diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 5e0a484f8..5764c66ee 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -34,6 +34,7 @@ __all__ = ( 'ContentTypeSerializer', 'CustomFieldSerializer', 'CustomLinkSerializer', + 'DashboardSerializer', 'ExportTemplateSerializer', 'ImageAttachmentSerializer', 'JobResultSerializer', @@ -563,3 +564,13 @@ class ContentTypeSerializer(BaseModelSerializer): class Meta: model = ContentType fields = ['id', 'url', 'display', 'app_label', 'model'] + + +# +# User dashboard +# + +class DashboardSerializer(serializers.ModelSerializer): + class Meta: + model = Dashboard + fields = ('layout', 'config') diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index f01cdcd00..e796f0fdb 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -1,3 +1,5 @@ +from django.urls import include, path + from netbox.api.routers import NetBoxRouter from . import views @@ -22,4 +24,7 @@ router.register('job-results', views.JobResultViewSet) router.register('content-types', views.ContentTypeViewSet) app_name = 'extras-api' -urlpatterns = router.urls +urlpatterns = [ + path('dashboard/', views.DashboardView.as_view(), name='dashboard'), + path('', include(router.urls)), +] diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 75f0eb464..7665e949d 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -4,6 +4,7 @@ from django_rq.queues import get_connection from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied +from rest_framework.generics import RetrieveUpdateDestroyAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer from rest_framework.response import Response @@ -423,3 +424,15 @@ class ContentTypeViewSet(ReadOnlyModelViewSet): queryset = ContentType.objects.order_by('app_label', 'model') serializer_class = serializers.ContentTypeSerializer filterset_class = filtersets.ContentTypeFilterSet + + +# +# User dashboard +# + +class DashboardView(RetrieveUpdateDestroyAPIView): + queryset = Dashboard.objects.all() + serializer_class = serializers.DashboardSerializer + + def get_object(self): + return Dashboard.objects.filter(user=self.request.user).first() diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index 965eb033e..f23e62dd2 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -5,4 +5,4 @@ class ExtrasConfig(AppConfig): name = "extras" def ready(self): - from . import lookups, search, signals + from . import dashboard, lookups, search, signals diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index d65fb9612..12ff21b31 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -1,2 +1,47 @@ +from django.contrib.contenttypes.models import ContentType + # Webhook content types HTTP_CONTENT_TYPE_JSON = 'application/json' + +# Dashboard +DEFAULT_DASHBOARD = [ + { + 'widget': 'extras.ObjectCountsWidget', + 'width': 4, + 'height': 3, + 'title': 'IPAM', + 'config': { + 'models': [ + 'ipam.aggregate', + 'ipam.prefix', + 'ipam.ipaddress', + ] + } + }, + { + 'widget': 'extras.ObjectCountsWidget', + 'width': 4, + 'height': 3, + 'title': 'DCIM', + 'config': { + 'models': [ + 'dcim.site', + 'dcim.rack', + 'dcim.device', + ] + } + }, + { + 'widget': 'extras.NoteWidget', + 'width': 4, + 'height': 3, + 'config': { + 'content': 'Welcome to **NetBox**!' + } + }, + { + 'widget': 'extras.ChangeLogWidget', + 'width': 12, + 'height': 6, + }, +] diff --git a/netbox/extras/dashboard/__init__.py b/netbox/extras/dashboard/__init__.py new file mode 100644 index 000000000..2539f0cbe --- /dev/null +++ b/netbox/extras/dashboard/__init__.py @@ -0,0 +1,2 @@ +from .utils import * +from .widgets import * diff --git a/netbox/extras/dashboard/forms.py b/netbox/extras/dashboard/forms.py new file mode 100644 index 000000000..ba07be4b1 --- /dev/null +++ b/netbox/extras/dashboard/forms.py @@ -0,0 +1,38 @@ +from django import forms +from django.urls import reverse_lazy + +from netbox.registry import registry +from utilities.forms import BootstrapMixin, add_blank_choice +from utilities.choices import ButtonColorChoices + +__all__ = ( + 'DashboardWidgetAddForm', + 'DashboardWidgetForm', +) + + +def get_widget_choices(): + return registry['widgets'].items() + + +class DashboardWidgetForm(BootstrapMixin, forms.Form): + title = forms.CharField( + required=False + ) + color = forms.ChoiceField( + choices=add_blank_choice(ButtonColorChoices), + required=False, + ) + + +class DashboardWidgetAddForm(DashboardWidgetForm): + widget_class = forms.ChoiceField( + choices=get_widget_choices, + widget=forms.Select( + attrs={ + 'hx-get': reverse_lazy('extras:dashboardwidget_add'), + 'hx-target': '#widget_add_form', + } + ) + ) + field_order = ('widget_class', 'title', 'color') diff --git a/netbox/extras/dashboard/utils.py b/netbox/extras/dashboard/utils.py new file mode 100644 index 000000000..8281cc522 --- /dev/null +++ b/netbox/extras/dashboard/utils.py @@ -0,0 +1,76 @@ +import uuid + +from django.core.exceptions import ObjectDoesNotExist + +from netbox.registry import registry +from extras.constants import DEFAULT_DASHBOARD + +__all__ = ( + 'get_dashboard', + 'get_default_dashboard', + 'get_widget_class', + 'register_widget', +) + + +def register_widget(cls): + """ + Decorator for registering a DashboardWidget class. + """ + app_label = cls.__module__.split('.', maxsplit=1)[0] + label = f'{app_label}.{cls.__name__}' + registry['widgets'][label] = cls + + return cls + + +def get_widget_class(name): + """ + Return a registered DashboardWidget class identified by its name. + """ + try: + return registry['widgets'][name] + except KeyError: + raise ValueError(f"Unregistered widget class: {name}") + + +def get_dashboard(user): + """ + Return the Dashboard for a given User if one exists, or generate a default dashboard. + """ + if user.is_anonymous: + dashboard = get_default_dashboard() + else: + try: + dashboard = user.dashboard + except ObjectDoesNotExist: + # Create a dashboard for this user + dashboard = get_default_dashboard() + dashboard.user = user + dashboard.save() + + return dashboard + + +def get_default_dashboard(): + from extras.models import Dashboard + dashboard = Dashboard( + layout=[], + config={} + ) + for widget in DEFAULT_DASHBOARD: + id = str(uuid.uuid4()) + dashboard.layout.append({ + 'id': id, + 'w': widget['width'], + 'h': widget['height'], + 'x': widget.get('x'), + 'y': widget.get('y'), + }) + dashboard.config[id] = { + 'class': widget['widget'], + 'title': widget.get('title'), + 'config': widget.get('config', {}), + } + + return dashboard diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py new file mode 100644 index 000000000..cee8f5f67 --- /dev/null +++ b/netbox/extras/dashboard/widgets.py @@ -0,0 +1,119 @@ +import uuid + +from django import forms +from django.contrib.contenttypes.models import ContentType +from django.template.loader import render_to_string +from django.utils.translation import gettext as _ + +from utilities.forms import BootstrapMixin +from utilities.templatetags.builtins.filters import render_markdown +from utilities.utils import content_type_identifier, content_type_name +from .utils import register_widget + +__all__ = ( + 'ChangeLogWidget', + 'DashboardWidget', + 'NoteWidget', + 'ObjectCountsWidget', +) + + +def get_content_type_labels(): + return [ + (content_type_identifier(ct), content_type_name(ct)) + for ct in ContentType.objects.order_by('app_label', 'model') + ] + + +class DashboardWidget: + default_title = None + description = None + width = 4 + height = 3 + + class ConfigForm(forms.Form): + pass + + def __init__(self, id=None, title=None, color=None, config=None, width=None, height=None, x=None, y=None): + self.id = id or str(uuid.uuid4()) + self.config = config or {} + self.title = title or self.default_title + self.color = color + if width: + self.width = width + if height: + self.height = height + self.x, self.y = x, y + + def __str__(self): + return self.title or self.__class__.__name__ + + def set_layout(self, grid_item): + self.width = grid_item['w'] + self.height = grid_item['h'] + self.x = grid_item.get('x') + self.y = grid_item.get('y') + + def render(self, request): + raise NotImplementedError(f"{self.__class__} must define a render() method.") + + @property + def name(self): + return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}' + + @property + def form_data(self): + return { + 'title': self.title, + 'color': self.color, + 'config': self.config, + } + + +@register_widget +class NoteWidget(DashboardWidget): + description = _('Display some arbitrary custom content. Markdown is supported.') + + class ConfigForm(BootstrapMixin, forms.Form): + content = forms.CharField( + widget=forms.Textarea() + ) + + def render(self, request): + return render_markdown(self.config.get('content')) + + +@register_widget +class ObjectCountsWidget(DashboardWidget): + default_title = _('Objects') + description = _('Display a set of NetBox models and the number of objects created for each type.') + template_name = 'extras/dashboard/widgets/objectcounts.html' + + class ConfigForm(BootstrapMixin, forms.Form): + models = forms.MultipleChoiceField( + choices=get_content_type_labels + ) + + def render(self, request): + counts = [] + for content_type_id in self.config['models']: + app_label, model_name = content_type_id.split('.') + model = ContentType.objects.get_by_natural_key(app_label, model_name).model_class() + object_count = model.objects.restrict(request.user, 'view').count + counts.append((model, object_count)) + + return render_to_string(self.template_name, { + 'counts': counts, + }) + + +@register_widget +class ChangeLogWidget(DashboardWidget): + default_title = _('Change Log') + description = _('Display the most recent records from the global change log.') + template_name = 'extras/dashboard/widgets/changelog.html' + width = 12 + height = 4 + + def render(self, request): + return render_to_string(self.template_name, {}) diff --git a/netbox/extras/migrations/0087_dashboard.py b/netbox/extras/migrations/0087_dashboard.py new file mode 100644 index 000000000..e64843e0e --- /dev/null +++ b/netbox/extras/migrations/0087_dashboard.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.7 on 2023-02-24 00:56 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('extras', '0086_configtemplate'), + ] + + operations = [ + migrations.CreateModel( + name='Dashboard', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('layout', models.JSONField()), + ('config', models.JSONField()), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='dashboard', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 33936cc4f..14e23366f 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,6 +1,7 @@ from .change_logging import ObjectChange from .configs import * from .customfields import CustomField +from .dashboard import * from .models import * from .search import * from .staging import * @@ -15,6 +16,7 @@ __all__ = ( 'ConfigTemplate', 'CustomField', 'CustomLink', + 'Dashboard', 'ExportTemplate', 'ImageAttachment', 'JobResult', diff --git a/netbox/extras/models/dashboard.py b/netbox/extras/models/dashboard.py new file mode 100644 index 000000000..cdbf85b60 --- /dev/null +++ b/netbox/extras/models/dashboard.py @@ -0,0 +1,70 @@ +from django.contrib.auth import get_user_model +from django.db import models + +from extras.dashboard.utils import get_widget_class + +__all__ = ( + 'Dashboard', +) + + +class Dashboard(models.Model): + user = models.OneToOneField( + to=get_user_model(), + on_delete=models.CASCADE, + related_name='dashboard' + ) + layout = models.JSONField() + config = models.JSONField() + + class Meta: + pass + + def get_widget(self, id): + """ + Instantiate and return a widget by its ID + """ + id = str(id) + config = dict(self.config[id]) # Copy to avoid mutating instance data + widget_class = get_widget_class(config.pop('class')) + return widget_class(id=id, **config) + + def get_layout(self): + """ + Return the dashboard's configured layout, suitable for rendering with gridstack.js. + """ + widgets = [] + for grid_item in self.layout: + widget = self.get_widget(grid_item['id']) + widget.set_layout(grid_item) + widgets.append(widget) + return widgets + + def add_widget(self, widget, x=None, y=None): + """ + Add a widget to the dashboard, optionally specifying its X & Y coordinates. + """ + id = str(widget.id) + self.config[id] = { + 'class': widget.name, + 'title': widget.title, + 'color': widget.color, + 'config': widget.config, + } + self.layout.append({ + 'id': id, + 'h': widget.height, + 'w': widget.width, + 'x': x, + 'y': y, + }) + + def delete_widget(self, id): + """ + Delete a widget from the dashboard. + """ + id = str(id) + del self.config[id] + self.layout = [ + item for item in self.layout if item['id'] != id + ] diff --git a/netbox/extras/templatetags/dashboard.py b/netbox/extras/templatetags/dashboard.py new file mode 100644 index 000000000..4ac31abcf --- /dev/null +++ b/netbox/extras/templatetags/dashboard.py @@ -0,0 +1,11 @@ +from django import template + + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def render_widget(context, widget): + request = context['request'] + + return widget.render(request) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index dfbaa1bc6..e127e164a 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -87,6 +87,11 @@ urlpatterns = [ path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog//', include(get_model_urls('extras', 'objectchange'))), + # User dashboard + path('dashboard/widgets/add/', views.DashboardWidgetAddView.as_view(), name='dashboardwidget_add'), + path('dashboard/widgets//configure/', views.DashboardWidgetConfigView.as_view(), name='dashboardwidget_config'), + path('dashboard/widgets//delete/', views.DashboardWidgetDeleteView.as_view(), name='dashboardwidget_delete'), + # Reports path('reports/', views.ReportListView.as_view(), name='report_list'), path('reports/results//', views.ReportResultView.as_view(), name='report_result'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 3edb70cf1..62cb8db36 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,14 +1,18 @@ from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType from django.db.models import Count, Q -from django.http import Http404, HttpResponseForbidden +from django.http import Http404, HttpResponseForbidden, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.generic import View from django_rq.queues import get_connection from rq import Worker +from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm +from extras.dashboard.utils import get_widget_class from netbox.views import generic +from utilities.forms import ConfirmationForm, get_field_value from utilities.htmx import is_htmx from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin, register_model_view @@ -664,6 +668,130 @@ class JournalEntryBulkDeleteView(generic.BulkDeleteView): table = tables.JournalEntryTable +# +# Dashboard widgets +# + +class DashboardWidgetAddView(LoginRequiredMixin, View): + template_name = 'extras/dashboard/widget_add.html' + + def get(self, request): + if not is_htmx(request): + return redirect('home') + + initial = request.GET or { + 'widget_class': 'extras.NoteWidget', + } + widget_form = DashboardWidgetAddForm(initial=initial) + widget_name = get_field_value(widget_form, 'widget_class') + widget_class = get_widget_class(widget_name) + config_form = widget_class.ConfigForm(prefix='config') + + return render(request, self.template_name, { + 'widget_class': widget_class, + 'widget_form': widget_form, + 'config_form': config_form, + }) + + def post(self, request): + widget_form = DashboardWidgetAddForm(request.POST) + config_form = None + widget_class = None + + if widget_form.is_valid(): + widget_class = get_widget_class(widget_form.cleaned_data['widget_class']) + config_form = widget_class.ConfigForm(request.POST, prefix='config') + + if config_form.is_valid(): + data = widget_form.cleaned_data + data.pop('widget_class') + data['config'] = config_form.cleaned_data + widget = widget_class(**data) + request.user.dashboard.add_widget(widget) + request.user.dashboard.save() + messages.success(request, f'Added widget {widget.id}') + + return HttpResponse(headers={ + 'HX-Redirect': reverse('home'), + }) + + return render(request, self.template_name, { + 'widget_class': widget_class, + 'widget_form': widget_form, + 'config_form': config_form, + }) + + +class DashboardWidgetConfigView(LoginRequiredMixin, View): + template_name = 'extras/dashboard/widget_config.html' + + def get(self, request, id): + if not is_htmx(request): + return redirect('home') + + widget = request.user.dashboard.get_widget(id) + widget_form = DashboardWidgetForm(initial=widget.form_data) + config_form = widget.ConfigForm(initial=widget.form_data.get('config'), prefix='config') + + return render(request, self.template_name, { + 'widget_form': widget_form, + 'config_form': config_form, + 'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id}) + }) + + def post(self, request, id): + widget = request.user.dashboard.get_widget(id) + widget_form = DashboardWidgetForm(request.POST) + config_form = widget.ConfigForm(request.POST, prefix='config') + + if widget_form.is_valid() and config_form.is_valid(): + data = widget_form.cleaned_data + data['config'] = config_form.cleaned_data + request.user.dashboard.config[str(id)].update(data) + request.user.dashboard.save() + messages.success(request, f'Updated widget {widget.id}') + + return HttpResponse(headers={ + 'HX-Redirect': reverse('home'), + }) + + return render(request, self.template_name, { + 'widget_form': widget_form, + 'config_form': config_form, + 'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id}) + }) + + +class DashboardWidgetDeleteView(LoginRequiredMixin, View): + template_name = 'generic/object_delete.html' + + def get(self, request, id): + if not is_htmx(request): + return redirect('home') + + widget = request.user.dashboard.get_widget(id) + form = ConfirmationForm(initial=request.GET) + + return render(request, 'htmx/delete_form.html', { + 'object_type': widget.__class__.__name__, + 'object': widget, + 'form': form, + 'form_url': reverse('extras:dashboardwidget_delete', kwargs={'id': id}) + }) + + def post(self, request, id): + form = ConfirmationForm(request.POST) + + if form.is_valid(): + request.user.dashboard.delete_widget(id) + request.user.dashboard.save() + messages.success(request, f'Deleted widget {id}') + else: + messages.error(request, f'Error deleting widget: {form.errors[0]}') + + return redirect(reverse('home')) + + # # Reports # diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index e37ee0d0c..23b9ad4cb 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -27,4 +27,5 @@ registry = Registry({ 'plugins': dict(), 'search': dict(), 'views': collections.defaultdict(dict), + 'widgets': dict(), }) diff --git a/netbox/netbox/views/misc.py b/netbox/netbox/views/misc.py index 3c8c93f84..c7255916c 100644 --- a/netbox/netbox/views/misc.py +++ b/netbox/netbox/views/misc.py @@ -5,27 +5,17 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.shortcuts import redirect, render -from django.utils.translation import gettext as _ from django.views.generic import View from django_tables2 import RequestConfig from packaging import version -from circuits.models import Circuit, Provider -from dcim.models import ( - Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site, -) -from extras.models import ObjectChange -from extras.tables import ObjectChangeTable -from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF +from extras.dashboard.utils import get_dashboard from netbox.forms import SearchForm from netbox.search import LookupTypes from netbox.search.backends import search_backend from netbox.tables import SearchTable -from tenancy.models import Contact, Tenant from utilities.htmx import is_htmx from utilities.paginator import EnhancedPaginator, get_paginate_count -from virtualization.models import Cluster, VirtualMachine -from wireless.models import WirelessLAN, WirelessLink __all__ = ( 'HomeView', @@ -42,79 +32,8 @@ class HomeView(View): if settings.LOGIN_REQUIRED and not request.user.is_authenticated: return redirect('login') - console_connections = ConsolePort.objects.restrict(request.user, 'view')\ - .prefetch_related('_path').filter(_path__is_complete=True).count - power_connections = PowerPort.objects.restrict(request.user, 'view')\ - .prefetch_related('_path').filter(_path__is_complete=True).count - interface_connections = Interface.objects.restrict(request.user, 'view')\ - .prefetch_related('_path').filter(_path__is_complete=True).count - - def get_count_queryset(model): - return model.objects.restrict(request.user, 'view').count - - def build_stats(): - org = ( - Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)), - Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)), - Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)), - ) - dcim = ( - Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)), - Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)), - Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)), - ) - ipam = ( - Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)), - Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)), - Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)), - Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)), - Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)), - Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)), - ) - circuits = ( - Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)), - Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit)) - ) - virtualization = ( - Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster', - get_count_queryset(Cluster)), - Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine', - get_count_queryset(VirtualMachine)), - ) - connections = ( - Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)), - Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections), - Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections), - Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections), - ) - power = ( - Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)), - Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)), - ) - wireless = ( - Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan', - get_count_queryset(WirelessLAN)), - Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink', - get_count_queryset(WirelessLink)), - ) - stats = ( - (_('Organization'), org, 'domain'), - (_('IPAM'), ipam, 'counter'), - (_('Virtualization'), virtualization, 'monitor'), - (_('Inventory'), dcim, 'server'), - (_('Circuits'), circuits, 'transit-connection-variant'), - (_('Connections'), connections, 'cable-data'), - (_('Power'), power, 'flash'), - (_('Wireless'), wireless, 'wifi'), - ) - - return stats - - # Compile changelog table - changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related( - 'user', 'changed_object_type' - )[:10] - changelog_table = ObjectChangeTable(changelog, user=request.user) + # Construct the user's custom dashboard layout + dashboard = get_dashboard(request.user).get_layout() # Check whether a new release is available. (Only for staff/superusers.) new_release = None @@ -129,9 +48,7 @@ class HomeView(View): } return render(request, self.template_name, { - 'search_form': SearchForm(), - 'stats': build_stats(), - 'changelog_table': changelog_table, + 'dashboard': dashboard, 'new_release': new_release, }) diff --git a/netbox/project-static/dist/config.js b/netbox/project-static/dist/config.js index cda30523c..02e2c5518 100644 Binary files a/netbox/project-static/dist/config.js and b/netbox/project-static/dist/config.js differ diff --git a/netbox/project-static/dist/config.js.map b/netbox/project-static/dist/config.js.map index 0ca9fb89e..65dcddcf2 100644 Binary files a/netbox/project-static/dist/config.js.map and b/netbox/project-static/dist/config.js.map differ diff --git a/netbox/project-static/dist/lldp.js b/netbox/project-static/dist/lldp.js index da3c3bd46..77430ea57 100644 Binary files a/netbox/project-static/dist/lldp.js and b/netbox/project-static/dist/lldp.js differ diff --git a/netbox/project-static/dist/lldp.js.map b/netbox/project-static/dist/lldp.js.map index a36df817f..d7a46d320 100644 Binary files a/netbox/project-static/dist/lldp.js.map and b/netbox/project-static/dist/lldp.js.map differ diff --git a/netbox/project-static/dist/netbox-external.css b/netbox/project-static/dist/netbox-external.css index 8164a7fa8..edbb6aec5 100644 Binary files a/netbox/project-static/dist/netbox-external.css and b/netbox/project-static/dist/netbox-external.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index d0058eae9..fdd9dc943 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 5481e38a3..6b7879775 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/dist/status.js b/netbox/project-static/dist/status.js index a6a5534ec..cf9cd63ab 100644 Binary files a/netbox/project-static/dist/status.js and b/netbox/project-static/dist/status.js differ diff --git a/netbox/project-static/dist/status.js.map b/netbox/project-static/dist/status.js.map index a773c5600..6073a6bd7 100644 Binary files a/netbox/project-static/dist/status.js.map and b/netbox/project-static/dist/status.js.map differ diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json index 8258c2be4..f10b5b7ac 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -29,9 +29,9 @@ "color2k": "^2.0.0", "dayjs": "^1.11.5", "flatpickr": "4.6.13", + "gridstack": "^7.2.3", "htmx.org": "^1.8.0", "just-debounce-it": "^3.1.1", - "masonry-layout": "^4.2.2", "query-string": "^7.1.1", "sass": "^1.55.0", "simplebar": "^5.3.9", @@ -56,4 +56,4 @@ "resolutions": { "@types/bootstrap/**/@popperjs/core": "^2.11.6" } -} \ No newline at end of file +} diff --git a/netbox/project-static/src/bs.ts b/netbox/project-static/src/bs.ts index e819b7cb1..ecc99ba1a 100644 --- a/netbox/project-static/src/bs.ts +++ b/netbox/project-static/src/bs.ts @@ -1,5 +1,4 @@ import { Collapse, Modal, Popover, Tab, Toast, Tooltip } from 'bootstrap'; -import Masonry from 'masonry-layout'; import { createElement, getElements } from './util'; type ToastLevel = 'danger' | 'warning' | 'success' | 'info'; @@ -12,18 +11,6 @@ window.Popover = Popover; window.Toast = Toast; window.Tooltip = Tooltip; -/** - * Initialize masonry-layout for homepage (or any other masonry layout cards). - */ -function initMasonry(): void { - for (const grid of getElements('.masonry')) { - new Masonry(grid, { - itemSelector: '.masonry-item', - percentPosition: true, - }); - } -} - function initTooltips() { for (const tooltip of getElements('[data-bs-toggle="tooltip"]')) { new Tooltip(tooltip, { container: 'body' }); @@ -194,7 +181,6 @@ export function initBootstrap(): void { for (const func of [ initTooltips, initModals, - initMasonry, initTabs, initImagePreview, initSidebarAccordions, diff --git a/netbox/project-static/src/dashboard.ts b/netbox/project-static/src/dashboard.ts new file mode 100644 index 000000000..4efc3899c --- /dev/null +++ b/netbox/project-static/src/dashboard.ts @@ -0,0 +1,41 @@ +import { GridStack, GridStackOptions, GridStackWidget } from 'gridstack'; +import { createToast } from './bs'; +import { apiPatch, hasError } from './util'; + +async function saveDashboardLayout( + url: string, + gridData: GridStackWidget[] | GridStackOptions, +): Promise> { + let data = { + layout: gridData + } + return await apiPatch(url, data); +} + +export function initDashboard(): void { + // Initialize the grid + let grid = GridStack.init({ + cellHeight: 100, + }); + + // Create a listener for the dashboard save button + const gridSaveButton = document.getElementById('save_dashboard') as HTMLButtonElement; + if (gridSaveButton === null) { + return; + } + gridSaveButton.addEventListener('click', () => { + const url = gridSaveButton.getAttribute('data-url'); + if (url == null) { + return; + } + let gridData = grid.save(false); + saveDashboardLayout(url, gridData).then(res => { + if (hasError(res)) { + const toast = createToast('danger', 'Error Saving Dashboard Config', res.error); + toast.show(); + } else { + location.reload(); + } + }); + }); +} diff --git a/netbox/project-static/src/netbox.ts b/netbox/project-static/src/netbox.ts index f19b879fe..ed294e655 100644 --- a/netbox/project-static/src/netbox.ts +++ b/netbox/project-static/src/netbox.ts @@ -10,6 +10,7 @@ import { initDateSelector } from './dateSelector'; import { initTableConfig } from './tableConfig'; import { initInterfaceTable } from './tables'; import { initSideNav } from './sidenav'; +import { initDashboard } from './dashboard'; import { initRackElevation } from './racks'; import { initLinks } from './links'; import { initHtmx } from './htmx'; @@ -28,6 +29,7 @@ function initDocument(): void { initTableConfig, initInterfaceTable, initSideNav, + initDashboard, initRackElevation, initLinks, initHtmx, diff --git a/netbox/project-static/styles/_external.scss b/netbox/project-static/styles/_external.scss index aee6aa95d..a44238653 100644 --- a/netbox/project-static/styles/_external.scss +++ b/netbox/project-static/styles/_external.scss @@ -2,3 +2,4 @@ @import '../node_modules/@mdi/font/css/materialdesignicons.min.css'; @import '../node_modules/flatpickr/dist/flatpickr.css'; @import '../node_modules/simplebar/dist/simplebar.css'; +@import 'gridstack/dist/gridstack.min.css'; diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index 9dca72d25..c4bee7557 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -875,11 +875,6 @@ delegate@^3.1.2: resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== -desandro-matches-selector@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/desandro-matches-selector/-/desandro-matches-selector-2.0.2.tgz#717beed4dc13e7d8f3762f707a6d58a6774218e1" - integrity sha1-cXvu1NwT59jzdi9wem1YpndCGOE= - diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -1411,11 +1406,6 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -ev-emitter@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ev-emitter/-/ev-emitter-1.1.1.tgz#8f18b0ce5c76a5d18017f71c0a795c65b9138f2a" - integrity sha512-ipiDYhdQSCZ4hSbX4rMW+XzNKMD1prg/sTvoVmSLkuQ1MVlwjJQQA+sW8tMYR3BLUr9KjodFV4pvzunvRhd33Q== - event-target-shim@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" @@ -1496,13 +1486,6 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -fizzy-ui-utils@^2.0.0: - version "2.0.7" - resolved "https://registry.yarnpkg.com/fizzy-ui-utils/-/fizzy-ui-utils-2.0.7.tgz#7df45dcc4eb374a08b65d39bb9a4beedf7330505" - integrity sha512-CZXDVXQ1If3/r8s0T+v+qVeMshhfcuq0rqIFgJnrtd+Bu8GmDmqMjntjUePypVtjHXKJ6V4sw9zeyox34n9aCg== - dependencies: - desandro-matches-selector "^2.0.0" - flat-cache@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" @@ -1582,11 +1565,6 @@ get-intrinsic@^1.1.3: has "^1.0.3" has-symbols "^1.0.3" -get-size@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/get-size/-/get-size-2.0.3.tgz#54a1d0256b20ea7ac646516756202769941ad2ef" - integrity sha512-lXNzT/h/dTjTxRbm9BXb+SGxxzkm97h/PCIKtlN/CBCxxmkkIVV21udumMS93MuVTDX583gqc94v3RjuHmI+2Q== - get-symbol-description@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" @@ -1784,6 +1762,11 @@ graphql-ws@^5.4.1: resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5" integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA== +gridstack@^7.2.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-7.2.3.tgz#bc04d3588eb5f2b7edd910e31fdac5bea8069ff2" + integrity sha512-1s4Fx+Hr4nKl064q/ygrd41XiZaC2gG6R+yz5nbOibP9vODJ6mOtjIM5x8qKN12FknakaMpVBnCa1T6V7H15hQ== + has-bigints@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" @@ -2163,14 +2146,6 @@ markdown-it@^10.0.0: mdurl "^1.0.1" uc.micro "^1.0.5" -masonry-layout@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/masonry-layout/-/masonry-layout-4.2.2.tgz#d57b44af13e601bfcdc423f1dd8348b5524de348" - integrity sha512-iGtAlrpHNyxaR19CvKC3npnEcAwszXoyJiI8ARV2ePi7fmYhIud25MHK8Zx4P0LCC4d3TNO9+rFa1KoK1OEOaA== - dependencies: - get-size "^2.0.2" - outlayer "^2.1.0" - mdurl@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" @@ -2341,15 +2316,6 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" -outlayer@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/outlayer/-/outlayer-2.1.1.tgz#29863b6de10ea5dadfffcadfa0d728907387e9a2" - integrity sha1-KYY7beEOpdrf/8rfoNcokHOH6aI= - dependencies: - ev-emitter "^1.0.0" - fizzy-ui-utils "^2.0.0" - get-size "^2.0.2" - p-limit@3.1.0, p-limit@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" diff --git a/netbox/templates/extras/dashboard/widget.html b/netbox/templates/extras/dashboard/widget.html new file mode 100644 index 000000000..4ed84f067 --- /dev/null +++ b/netbox/templates/extras/dashboard/widget.html @@ -0,0 +1,37 @@ +{% load dashboard %} + +
+
+
+
+ +
+
+ +
+ {% if widget.title %} + {{ widget.title }} + {% endif %} +
+
+ {% render_widget widget %} +
+
+
diff --git a/netbox/templates/extras/dashboard/widget_add.html b/netbox/templates/extras/dashboard/widget_add.html new file mode 100644 index 000000000..e752a393d --- /dev/null +++ b/netbox/templates/extras/dashboard/widget_add.html @@ -0,0 +1,27 @@ +{% load form_helpers %} + +
+ {% csrf_token %} + + + +
diff --git a/netbox/templates/extras/dashboard/widget_config.html b/netbox/templates/extras/dashboard/widget_config.html new file mode 100644 index 000000000..6f8f8cc20 --- /dev/null +++ b/netbox/templates/extras/dashboard/widget_config.html @@ -0,0 +1,20 @@ +{% load form_helpers %} + +
+ {% csrf_token %} + + + +
diff --git a/netbox/templates/extras/dashboard/widgets/changelog.html b/netbox/templates/extras/dashboard/widgets/changelog.html new file mode 100644 index 000000000..dfa4dba3f --- /dev/null +++ b/netbox/templates/extras/dashboard/widgets/changelog.html @@ -0,0 +1,4 @@ +
diff --git a/netbox/templates/extras/dashboard/widgets/objectcounts.html b/netbox/templates/extras/dashboard/widgets/objectcounts.html new file mode 100644 index 000000000..d75d88218 --- /dev/null +++ b/netbox/templates/extras/dashboard/widgets/objectcounts.html @@ -0,0 +1,14 @@ +{% load helpers %} + +{% if counts %} +
+ {% for model, count in counts %} + +
+ {{ model|meta:"verbose_name_plural"|bettertitle }} +
{{ count }}
+
+
+ {% endfor %} +
+{% endif %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index cef797f40..5bed19d9d 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -3,80 +3,48 @@ {% load render_table from django_tables2 %} {% block header %} - {% if new_release %} - {# new_release is set only if the current user is a superuser or staff member #} -
- -
- {% endif %} + {% if new_release %} + {# new_release is set only if the current user is a superuser or staff member #} +
+ +
+ {% endif %} {% endblock %} {% block title %}Home{% endblock %} {% block content-wrapper %} -
- {# General stats #} -
- {% for section, items, icon in stats %} -
-
-
- - {{ section }} -
-
-
- {% for item in items %} - {% if item.permission in perms %} - -
- {{ item.label }} -

{{ item.count }}

-
-
- {% else %} -
  • -
    - {{ item.label }} -

    - -

    -
    -
  • - {% endif %} - {% endfor %} -
    -
    -
    -
    - {% endfor %} -
    - - {# Changelog #} - {% if perms.extras.view_objectchange %} -
    -
    -
    -
    - - Change Log -
    -
    -
    -
    -
    - {% endif %} + {# Render the user's customized dashboard #} +
    + {% for widget in dashboard %} + {% include 'extras/dashboard/widget.html' %} + {% endfor %} +
    +
    + + Add Widget + +
    {% endblock content-wrapper %} + +{% block modals %} + {% include 'inc/htmx_modal.html' %} +{% endblock modals %} diff --git a/netbox/templates/inc/htmx_modal.html b/netbox/templates/inc/htmx_modal.html index d15e5b799..771f5d595 100644 --- a/netbox/templates/inc/htmx_modal.html +++ b/netbox/templates/inc/htmx_modal.html @@ -1,5 +1,5 @@