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
This commit is contained in:
Jeremy Stretch 2023-02-24 16:04:00 -05:00 committed by GitHub
parent 36771e821c
commit 084a2cc52c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 754 additions and 217 deletions

View File

@ -34,6 +34,7 @@ __all__ = (
'ContentTypeSerializer', 'ContentTypeSerializer',
'CustomFieldSerializer', 'CustomFieldSerializer',
'CustomLinkSerializer', 'CustomLinkSerializer',
'DashboardSerializer',
'ExportTemplateSerializer', 'ExportTemplateSerializer',
'ImageAttachmentSerializer', 'ImageAttachmentSerializer',
'JobResultSerializer', 'JobResultSerializer',
@ -563,3 +564,13 @@ class ContentTypeSerializer(BaseModelSerializer):
class Meta: class Meta:
model = ContentType model = ContentType
fields = ['id', 'url', 'display', 'app_label', 'model'] fields = ['id', 'url', 'display', 'app_label', 'model']
#
# User dashboard
#
class DashboardSerializer(serializers.ModelSerializer):
class Meta:
model = Dashboard
fields = ('layout', 'config')

View File

@ -1,3 +1,5 @@
from django.urls import include, path
from netbox.api.routers import NetBoxRouter from netbox.api.routers import NetBoxRouter
from . import views from . import views
@ -22,4 +24,7 @@ router.register('job-results', views.JobResultViewSet)
router.register('content-types', views.ContentTypeViewSet) router.register('content-types', views.ContentTypeViewSet)
app_name = 'extras-api' app_name = 'extras-api'
urlpatterns = router.urls urlpatterns = [
path('dashboard/', views.DashboardView.as_view(), name='dashboard'),
path('', include(router.urls)),
]

View File

@ -4,6 +4,7 @@ from django_rq.queues import get_connection
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import RetrieveUpdateDestroyAPIView
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
@ -423,3 +424,15 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
queryset = ContentType.objects.order_by('app_label', 'model') queryset = ContentType.objects.order_by('app_label', 'model')
serializer_class = serializers.ContentTypeSerializer serializer_class = serializers.ContentTypeSerializer
filterset_class = filtersets.ContentTypeFilterSet 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()

View File

@ -5,4 +5,4 @@ class ExtrasConfig(AppConfig):
name = "extras" name = "extras"
def ready(self): def ready(self):
from . import lookups, search, signals from . import dashboard, lookups, search, signals

View File

@ -1,2 +1,47 @@
from django.contrib.contenttypes.models import ContentType
# Webhook content types # Webhook content types
HTTP_CONTENT_TYPE_JSON = 'application/json' 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,
},
]

View File

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

View File

@ -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')

View File

@ -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

View File

@ -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, {})

View File

@ -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)),
],
),
]

View File

@ -1,6 +1,7 @@
from .change_logging import ObjectChange from .change_logging import ObjectChange
from .configs import * from .configs import *
from .customfields import CustomField from .customfields import CustomField
from .dashboard import *
from .models import * from .models import *
from .search import * from .search import *
from .staging import * from .staging import *
@ -15,6 +16,7 @@ __all__ = (
'ConfigTemplate', 'ConfigTemplate',
'CustomField', 'CustomField',
'CustomLink', 'CustomLink',
'Dashboard',
'ExportTemplate', 'ExportTemplate',
'ImageAttachment', 'ImageAttachment',
'JobResult', 'JobResult',

View File

@ -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
]

View File

@ -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)

View File

@ -87,6 +87,11 @@ urlpatterns = [
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))), 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 # Reports
path('reports/', views.ReportListView.as_view(), name='report_list'), path('reports/', views.ReportListView.as_view(), name='report_list'),
path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'), path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),

View File

@ -1,14 +1,18 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q 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.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.views.generic import View from django.views.generic import View
from django_rq.queues import get_connection from django_rq.queues import get_connection
from rq import Worker 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 netbox.views import generic
from utilities.forms import ConfirmationForm, get_field_value
from utilities.htmx import is_htmx from utilities.htmx import is_htmx
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
@ -664,6 +668,130 @@ class JournalEntryBulkDeleteView(generic.BulkDeleteView):
table = tables.JournalEntryTable 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 # Reports
# #

View File

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

View File

@ -5,27 +5,17 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils.translation import gettext as _
from django.views.generic import View from django.views.generic import View
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from packaging import version from packaging import version
from circuits.models import Circuit, Provider from extras.dashboard.utils import get_dashboard
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 netbox.forms import SearchForm from netbox.forms import SearchForm
from netbox.search import LookupTypes from netbox.search import LookupTypes
from netbox.search.backends import search_backend from netbox.search.backends import search_backend
from netbox.tables import SearchTable from netbox.tables import SearchTable
from tenancy.models import Contact, Tenant
from utilities.htmx import is_htmx from utilities.htmx import is_htmx
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from virtualization.models import Cluster, VirtualMachine
from wireless.models import WirelessLAN, WirelessLink
__all__ = ( __all__ = (
'HomeView', 'HomeView',
@ -42,79 +32,8 @@ class HomeView(View):
if settings.LOGIN_REQUIRED and not request.user.is_authenticated: if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
return redirect('login') return redirect('login')
console_connections = ConsolePort.objects.restrict(request.user, 'view')\ # Construct the user's custom dashboard layout
.prefetch_related('_path').filter(_path__is_complete=True).count dashboard = get_dashboard(request.user).get_layout()
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)
# Check whether a new release is available. (Only for staff/superusers.) # Check whether a new release is available. (Only for staff/superusers.)
new_release = None new_release = None
@ -129,9 +48,7 @@ class HomeView(View):
} }
return render(request, self.template_name, { return render(request, self.template_name, {
'search_form': SearchForm(), 'dashboard': dashboard,
'stats': build_stats(),
'changelog_table': changelog_table,
'new_release': new_release, 'new_release': new_release,
}) })

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -29,9 +29,9 @@
"color2k": "^2.0.0", "color2k": "^2.0.0",
"dayjs": "^1.11.5", "dayjs": "^1.11.5",
"flatpickr": "4.6.13", "flatpickr": "4.6.13",
"gridstack": "^7.2.3",
"htmx.org": "^1.8.0", "htmx.org": "^1.8.0",
"just-debounce-it": "^3.1.1", "just-debounce-it": "^3.1.1",
"masonry-layout": "^4.2.2",
"query-string": "^7.1.1", "query-string": "^7.1.1",
"sass": "^1.55.0", "sass": "^1.55.0",
"simplebar": "^5.3.9", "simplebar": "^5.3.9",

View File

@ -1,5 +1,4 @@
import { Collapse, Modal, Popover, Tab, Toast, Tooltip } from 'bootstrap'; import { Collapse, Modal, Popover, Tab, Toast, Tooltip } from 'bootstrap';
import Masonry from 'masonry-layout';
import { createElement, getElements } from './util'; import { createElement, getElements } from './util';
type ToastLevel = 'danger' | 'warning' | 'success' | 'info'; type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
@ -12,18 +11,6 @@ window.Popover = Popover;
window.Toast = Toast; window.Toast = Toast;
window.Tooltip = Tooltip; 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() { function initTooltips() {
for (const tooltip of getElements('[data-bs-toggle="tooltip"]')) { for (const tooltip of getElements('[data-bs-toggle="tooltip"]')) {
new Tooltip(tooltip, { container: 'body' }); new Tooltip(tooltip, { container: 'body' });
@ -194,7 +181,6 @@ export function initBootstrap(): void {
for (const func of [ for (const func of [
initTooltips, initTooltips,
initModals, initModals,
initMasonry,
initTabs, initTabs,
initImagePreview, initImagePreview,
initSidebarAccordions, initSidebarAccordions,

View File

@ -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();
}
});
});
}

View File

@ -10,6 +10,7 @@ import { initDateSelector } from './dateSelector';
import { initTableConfig } from './tableConfig'; import { initTableConfig } from './tableConfig';
import { initInterfaceTable } from './tables'; import { initInterfaceTable } from './tables';
import { initSideNav } from './sidenav'; import { initSideNav } from './sidenav';
import { initDashboard } from './dashboard';
import { initRackElevation } from './racks'; import { initRackElevation } from './racks';
import { initLinks } from './links'; import { initLinks } from './links';
import { initHtmx } from './htmx'; import { initHtmx } from './htmx';
@ -28,6 +29,7 @@ function initDocument(): void {
initTableConfig, initTableConfig,
initInterfaceTable, initInterfaceTable,
initSideNav, initSideNav,
initDashboard,
initRackElevation, initRackElevation,
initLinks, initLinks,
initHtmx, initHtmx,

View File

@ -2,3 +2,4 @@
@import '../node_modules/@mdi/font/css/materialdesignicons.min.css'; @import '../node_modules/@mdi/font/css/materialdesignicons.min.css';
@import '../node_modules/flatpickr/dist/flatpickr.css'; @import '../node_modules/flatpickr/dist/flatpickr.css';
@import '../node_modules/simplebar/dist/simplebar.css'; @import '../node_modules/simplebar/dist/simplebar.css';
@import 'gridstack/dist/gridstack.min.css';

View File

@ -875,11 +875,6 @@ delegate@^3.1.2:
resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166"
integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== 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: diff@^4.0.1:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" 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" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== 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: event-target-shim@^5.0.0:
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" 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" locate-path "^6.0.0"
path-exists "^4.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: flat-cache@^3.0.4:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" 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 "^1.0.3"
has-symbols "^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: get-symbol-description@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" 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" resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5"
integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA== 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: has-bigints@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" 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" mdurl "^1.0.1"
uc.micro "^1.0.5" 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: mdurl@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" 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" type-check "^0.4.0"
word-wrap "^1.2.3" 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: p-limit@3.1.0, p-limit@^3.0.2:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

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

View File

@ -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 %}

View File

@ -23,60 +23,28 @@
{% block title %}Home{% endblock %} {% block title %}Home{% endblock %}
{% block content-wrapper %} {% block content-wrapper %}
<div class="px-3"> {# Render the user's customized dashboard #}
{# General stats #} <div class="grid-stack">
<div class="row masonry"> {% for widget in dashboard %}
{% for section, items, icon in stats %} {% include 'extras/dashboard/widget.html' %}
<div class="col col-sm-12 col-lg-6 col-xl-4 my-2 masonry-item"> {% endfor %}
<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> </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> </a>
{% else %} <button id="save_dashboard" class="btn btn-primary btn-sm" data-url="{% url 'extras-api:dashboard' %}">
<li class="list-group-item list-group-item-action disabled"> <i class="mdi mdi-content-save-outline"></i> Save
<div class="d-flex w-100 justify-content-between align-items-center"> </button>
{{ 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 %}
</div> </div>
{% endblock content-wrapper %} {% endblock content-wrapper %}
{% block modals %}
{% include 'inc/htmx_modal.html' %}
{% endblock modals %}

View File

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

View File

@ -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. # Set a key based on the last item in the path. Raise TypeError if attempting to overwrite a non-leaf node.
key = keys[-1] key = keys[-1]
if key in d and type(d[key]) is dict: 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: else:
d[key] = value d[key] = value