Просмотр исходного кода

Closes #9416: Dashboard widgets (#11823)

* Replace masonry with gridstack

* Initial work on dashboard widgets

* Implement function to save dashboard layout

* Define a default dashboard

* Clean up widgets

* Implement widget configuration views & forms

* Permit merging dict value with existing dict in user config

* Add widget deletion view

* Enable HTMX for widget configuration

* Implement view to add dashboard widgets

* ObjectCountsWidget: Identify models by app_label & name

* Add color customization to dashboard widgets

* Introduce Dashboard model to store user dashboard layout & config

* Clean up utility functions

* Remove hard-coded API URL

* Use fixed grid cell height

* Add modal close button

* Clean up dashboard views

* Rebuild JS
Jeremy Stretch 3 лет назад
Родитель
Сommit
084a2cc52c
40 измененных файлов с 754 добавлено и 217 удалено
  1. 11 0
      netbox/extras/api/serializers.py
  2. 6 1
      netbox/extras/api/urls.py
  3. 13 0
      netbox/extras/api/views.py
  4. 1 1
      netbox/extras/apps.py
  5. 45 0
      netbox/extras/constants.py
  6. 2 0
      netbox/extras/dashboard/__init__.py
  7. 38 0
      netbox/extras/dashboard/forms.py
  8. 76 0
      netbox/extras/dashboard/utils.py
  9. 119 0
      netbox/extras/dashboard/widgets.py
  10. 25 0
      netbox/extras/migrations/0087_dashboard.py
  11. 2 0
      netbox/extras/models/__init__.py
  12. 70 0
      netbox/extras/models/dashboard.py
  13. 11 0
      netbox/extras/templatetags/dashboard.py
  14. 5 0
      netbox/extras/urls.py
  15. 129 1
      netbox/extras/views.py
  16. 1 0
      netbox/netbox/registry.py
  17. 4 87
      netbox/netbox/views/misc.py
  18. 0 0
      netbox/project-static/dist/config.js
  19. 0 0
      netbox/project-static/dist/config.js.map
  20. 0 0
      netbox/project-static/dist/lldp.js
  21. 0 0
      netbox/project-static/dist/lldp.js.map
  22. 0 0
      netbox/project-static/dist/netbox-external.css
  23. 0 0
      netbox/project-static/dist/netbox.js
  24. 0 0
      netbox/project-static/dist/netbox.js.map
  25. 0 0
      netbox/project-static/dist/status.js
  26. 0 0
      netbox/project-static/dist/status.js.map
  27. 2 2
      netbox/project-static/package.json
  28. 0 14
      netbox/project-static/src/bs.ts
  29. 41 0
      netbox/project-static/src/dashboard.ts
  30. 2 0
      netbox/project-static/src/netbox.ts
  31. 1 0
      netbox/project-static/styles/_external.scss
  32. 5 39
      netbox/project-static/yarn.lock
  33. 37 0
      netbox/templates/extras/dashboard/widget.html
  34. 27 0
      netbox/templates/extras/dashboard/widget_add.html
  35. 20 0
      netbox/templates/extras/dashboard/widget_config.html
  36. 4 0
      netbox/templates/extras/dashboard/widgets/changelog.html
  37. 14 0
      netbox/templates/extras/dashboard/widgets/objectcounts.html
  38. 38 70
      netbox/templates/home.html
  39. 1 1
      netbox/templates/inc/htmx_modal.html
  40. 4 1
      netbox/users/models.py

+ 11 - 0
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')

+ 6 - 1
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)),
+]

+ 13 - 0
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()

+ 1 - 1
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

+ 45 - 0
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,
+    },
+]

+ 2 - 0
netbox/extras/dashboard/__init__.py

@@ -0,0 +1,2 @@
+from .utils import *
+from .widgets import *

+ 38 - 0
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')

+ 76 - 0
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

+ 119 - 0
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, {})

+ 25 - 0
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)),
+            ],
+        ),
+    ]

+ 2 - 0
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',

+ 70 - 0
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
+        ]

+ 11 - 0
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)

+ 5 - 0
netbox/extras/urls.py

@@ -87,6 +87,11 @@ urlpatterns = [
     path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
     path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
 
+    # User dashboard
+    path('dashboard/widgets/add/', views.DashboardWidgetAddView.as_view(), name='dashboardwidget_add'),
+    path('dashboard/widgets/<uuid:id>/configure/', views.DashboardWidgetConfigView.as_view(), name='dashboardwidget_config'),
+    path('dashboard/widgets/<uuid:id>/delete/', views.DashboardWidgetDeleteView.as_view(), name='dashboardwidget_delete'),
+
     # Reports
     path('reports/', views.ReportListView.as_view(), name='report_list'),
     path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),

+ 129 - 1
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
 #

+ 1 - 0
netbox/netbox/registry.py

@@ -27,4 +27,5 @@ registry = Registry({
     'plugins': dict(),
     'search': dict(),
     'views': collections.defaultdict(dict),
+    'widgets': dict(),
 })

+ 4 - 87
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,
         })
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-external.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js.map


+ 2 - 2
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"
   }
-}
+}

+ 0 - 14
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<HTMLDivElement>('.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,

+ 41 - 0
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<APIResponse<APIUserConfig>> {
+  let data = {
+    layout: gridData
+  }
+  return await apiPatch<APIUserConfig>(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();
+      }
+    });
+  });
+}

+ 2 - 0
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,

+ 1 - 0
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';

+ 5 - 39
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"

+ 37 - 0
netbox/templates/extras/dashboard/widget.html

@@ -0,0 +1,37 @@
+{% load dashboard %}
+
+<div
+    class="grid-stack-item"
+    gs-w="{{ widget.width }}"
+    gs-h="{{ widget.height }}"
+    gs-x="{{ widget.x }}"
+    gs-y="{{ widget.y }}"
+    gs-id="{{ widget.id }}"
+>
+  <div class="card grid-stack-item-content">
+    <div class="card-header text-center text-light bg-{% if widget.color %}{{ widget.color }}{% else %}secondary{% endif %} p-1">
+      <div class="float-start ps-1">
+        <a href="#"
+          hx-get="{% url 'extras:dashboardwidget_config' id=widget.id %}"
+          hx-target="#htmx-modal-content"
+          data-bs-toggle="modal"
+          data-bs-target="#htmx-modal"
+        ><i class="mdi mdi-cog text-gray"></i></a>
+      </div>
+      <div class="float-end pe-1">
+        <a href="#"
+          hx-get="{% url 'extras:dashboardwidget_delete' id=widget.id %}"
+          hx-target="#htmx-modal-content"
+          data-bs-toggle="modal"
+          data-bs-target="#htmx-modal"
+        ><i class="mdi mdi-close text-gray"></i></a>
+      </div>
+      {% if widget.title %}
+        <strong>{{ widget.title }}</strong>
+      {% endif %}
+    </div>
+    <div class="card-body p-2">
+      {% render_widget widget %}
+    </div>
+  </div>
+</div>

+ 27 - 0
netbox/templates/extras/dashboard/widget_add.html

@@ -0,0 +1,27 @@
+{% load form_helpers %}
+
+<form hx-post="{% url 'extras:dashboardwidget_add' %}" id="widget_add_form">
+  {% csrf_token %}
+  <div class="modal-header">
+    <h5 class="modal-title">Add a Widget</h5>
+  </div>
+  <div class="modal-body">
+    {% block form %}
+      {% render_field widget_form.widget_class %}
+      <div class="row mb-3">
+        <label class="col-sm-3 col-form-label text-lg-end">Description</label>
+        <div class="col">
+          <div class="form-control-plaintext">{{ widget_class.description|placeholder }}</div>
+        </div>
+      </div>
+      {% render_field widget_form.color %}
+      {% render_field widget_form.title %}
+      {% render_form config_form %}
+    {% endblock form %}
+  </div>
+  <div class="modal-footer">
+    {% block buttons %}
+      <button class="btn btn-primary">Save</button>
+    {% endblock buttons %}
+  </div>
+</form>

+ 20 - 0
netbox/templates/extras/dashboard/widget_config.html

@@ -0,0 +1,20 @@
+{% load form_helpers %}
+
+<form hx-post="{{ form_url }}">
+  {% csrf_token %}
+  <div class="modal-header">
+    <h5 class="modal-title">Widget Configuration</h5>
+    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+  </div>
+  <div class="modal-body">
+    {% block form %}
+      {% render_form widget_form %}
+      {% render_form config_form %}
+    {% endblock form %}
+  </div>
+  <div class="modal-footer">
+    {% block buttons %}
+      <button class="btn btn-primary">Save</button>
+    {% endblock buttons %}
+  </div>
+</form>

+ 4 - 0
netbox/templates/extras/dashboard/widgets/changelog.html

@@ -0,0 +1,4 @@
+<div class="htmx-container"
+  hx-get="{% url 'extras:objectchange_list' %}?sort=-time"
+  hx-trigger="load"
+></div>

+ 14 - 0
netbox/templates/extras/dashboard/widgets/objectcounts.html

@@ -0,0 +1,14 @@
+{% load helpers %}
+
+{% if counts %}
+  <div class="list-group list-group-flush">
+    {% for model, count in counts %}
+      <a href="{% url model|viewname:"list" %}" class="list-group-item list-group-item-action">
+        <div class="d-flex w-100 justify-content-between align-items-center">
+          {{ model|meta:"verbose_name_plural"|bettertitle }}
+          <h6 class="mb-1">{{ count }}</h6>
+        </div>
+      </a>
+    {% endfor %}
+  </div>
+{% endif %}

+ 38 - 70
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 #}
-        <div class="header-alert-container">
-          <div class="alert alert-info text-center mw-md-50" role="alert">
-            <h6 class="alert-heading">
-              <i class="mdi mdi-information-outline"></i><br/>New Release Available
-            </h6>
-            <small><a href="{{ new_release.url }}">NetBox v{{ new_release.version }}</a> is available.</small>
-            <hr class="my-2" />
-            <small class="mb-0">
-              <a href="https://docs.netbox.dev/en/stable/installation/upgrading/">Upgrade Instructions</a>
-            </small>
-          </div>
-        </div>
-    {% endif %}
+  {% if new_release %}
+    {# new_release is set only if the current user is a superuser or staff member #}
+    <div class="header-alert-container">
+      <div class="alert alert-info text-center mw-md-50" role="alert">
+        <h6 class="alert-heading">
+          <i class="mdi mdi-information-outline"></i><br/>New Release Available
+        </h6>
+        <small><a href="{{ new_release.url }}">NetBox v{{ new_release.version }}</a> is available.</small>
+        <hr class="my-2" />
+        <small class="mb-0">
+          <a href="https://docs.netbox.dev/en/stable/installation/upgrading/">Upgrade Instructions</a>
+        </small>
+      </div>
+    </div>
+  {% endif %}
 {% endblock %}
 
 {% block title %}Home{% endblock %}
 
 {% block content-wrapper %}
-  <div class="px-3">
-    {# General stats #}
-    <div class="row masonry">
-      {% for section, items, icon in stats %}
-        <div class="col col-sm-12 col-lg-6 col-xl-4 my-2 masonry-item">
-          <div class="card">
-            <h6 class="card-header text-center">
-              <i class="mdi mdi-{{ icon }}"></i>
-              <span class="ms-1">{{ section }}</span>
-            </h6>
-            <div class="card-body">
-              <div class="list-group list-group-flush">
-                {% for item in items %}
-                  {% if item.permission in perms %}
-                    <a href="{% url item.viewname %}" class="list-group-item list-group-item-action">
-                      <div class="d-flex w-100 justify-content-between align-items-center">
-                        {{ item.label }}
-                        <h4 class="mb-1">{{ item.count }}</h4>
-                      </div>
-                    </a>
-                  {% else %}
-                    <li class="list-group-item list-group-item-action disabled">
-                      <div class="d-flex w-100 justify-content-between align-items-center">
-                        {{ item.label }}
-                        <h4 class="mb-1">
-                          <i title="No permission" class="mdi mdi-lock"></i>
-                        </h4>
-                      </div>
-                    </li>
-                  {% endif %}
-                {% endfor %}
-              </div>
-            </div>
-          </div>
-        </div>
-      {% endfor %}
-    </div>
-
-    {# Changelog #}
-    {% if perms.extras.view_objectchange %}
-      <div class="row my-4 flex-grow-1 changelog-container">
-        <div class="col">
-          <div class="card">
-            <h6 class="card-header text-center">
-              <i class="mdi mdi-clipboard-clock"></i>
-              <span class="ms-1">Change Log</span>
-            </h6>
-            <div class="card-body htmx-container table-responsive"
-              hx-get="{% url 'extras:objectchange_list' %}?sort=-time"
-              hx-trigger="load"
-            ></div>
-          </div>
-        </div>
-      </div>
-    {% endif %}
+  {# Render the user's customized dashboard #}
+  <div class="grid-stack">
+    {% for widget in dashboard %}
+      {% include 'extras/dashboard/widget.html' %}
+    {% endfor %}
+  </div>
+  <div class="text-end px-2">
+    <a href="#"
+      hx-get="{% url 'extras:dashboardwidget_add' %}"
+      hx-target="#htmx-modal-content"
+      data-bs-toggle="modal"
+      data-bs-target="#htmx-modal"
+      class="btn btn-success btn-sm"
+    >
+      <i class="mdi mdi-plus"></i> Add Widget
+    </a>
+    <button id="save_dashboard" class="btn btn-primary btn-sm" data-url="{% url 'extras-api:dashboard' %}">
+      <i class="mdi mdi-content-save-outline"></i> Save
+    </button>
   </div>
 {% endblock content-wrapper %}
+
+{% block modals %}
+  {% include 'inc/htmx_modal.html' %}
+{% endblock modals %}

+ 1 - 1
netbox/templates/inc/htmx_modal.html

@@ -1,5 +1,5 @@
 <div class="modal fade" id="htmx-modal" tabindex="-1" aria-hidden="true">
-  <div class="modal-dialog modal-dialog-centered">
+  <div class="modal-dialog">
     <div class="modal-content" id="htmx-modal-content">
       {# Dynamic content goes here #}
     </div>

+ 4 - 1
netbox/users/models.py

@@ -140,7 +140,10 @@ class UserConfig(models.Model):
         # Set a key based on the last item in the path. Raise TypeError if attempting to overwrite a non-leaf node.
         key = keys[-1]
         if key in d and type(d[key]) is dict:
-            raise TypeError(f"Key '{path}' has child keys; cannot assign a value")
+            if type(value) is dict:
+                d[key].update(value)
+            else:
+                raise TypeError(f"Key '{path}' is a dictionary; cannot assign a non-dictionary value")
         else:
             d[key] = value
 

Некоторые файлы не были показаны из-за большого количества измененных файлов