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 --- a/netbox/project-static/dist/config.js +++ b/netbox/project-static/dist/config.js @@ -1,24 +1,7 @@ -(()=>{var yr=Object.create;var he=Object.defineProperty,Tr=Object.defineProperties,br=Object.getOwnPropertyDescriptor,Ar=Object.getOwnPropertyDescriptors,Sr=Object.getOwnPropertyNames,vn=Object.getOwnPropertySymbols,Or=Object.getPrototypeOf,yn=Object.prototype.hasOwnProperty,wr=Object.prototype.propertyIsEnumerable;var Tn=(i,t,e)=>t in i?he(i,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):i[t]=e,O=(i,t)=>{for(var e in t||(t={}))yn.call(t,e)&&Tn(i,e,t[e]);if(vn)for(var e of vn(t))wr.call(t,e)&&Tn(i,e,t[e]);return i},Ve=(i,t)=>Tr(i,Ar(t)),bn=i=>he(i,"__esModule",{value:!0});var Nt=(i,t)=>()=>(t||i((t={exports:{}}).exports,t),t.exports),Cr=(i,t)=>{bn(i);for(var e in t)he(i,e,{get:t[e],enumerable:!0})},Lr=(i,t,e)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of Sr(t))!yn.call(i,n)&&n!=="default"&&he(i,n,{get:()=>t[n],enumerable:!(e=br(t,n))||e.enumerable});return i},Nr=i=>Lr(bn(he(i!=null?yr(Or(i)):{},"default",i&&i.__esModule&&"default"in i?{get:()=>i.default,enumerable:!0}:{value:i,enumerable:!0})),i);var Ai=(i,t,e)=>new Promise((n,o)=>{var r=u=>{try{l(e.next(u))}catch(p){o(p)}},s=u=>{try{l(e.throw(u))}catch(p){o(p)}},l=u=>u.done?n(u.value):Promise.resolve(u.value).then(r,s);l((e=e.apply(i,t)).next())});var rn=Nt((rr,fi)=>{(function(i,t){typeof define=="function"&&define.amd?define(t):typeof fi=="object"&&fi.exports?fi.exports=t():i.EvEmitter=t()})(typeof window!="undefined"?window:rr,function(){"use strict";function i(){}var t=i.prototype;return t.on=function(e,n){if(!(!e||!n)){var o=this._events=this._events||{},r=o[e]=o[e]||[];return r.indexOf(n)==-1&&r.push(n),this}},t.once=function(e,n){if(!(!e||!n)){this.on(e,n);var o=this._onceEvents=this._onceEvents||{},r=o[e]=o[e]||{};return r[n]=!0,this}},t.off=function(e,n){var o=this._events&&this._events[e];if(!(!o||!o.length)){var r=o.indexOf(n);return r!=-1&&o.splice(r,1),this}},t.emitEvent=function(e,n){var o=this._events&&this._events[e];if(!(!o||!o.length)){o=o.slice(0),n=n||[];for(var r=this._onceEvents&&this._onceEvents[e],s=0;s{(function(i,t){typeof define=="function"&&define.amd?define(t):typeof di=="object"&&di.exports?di.exports=t():i.getSize=t()})(window,function(){"use strict";function t(d){var E=parseFloat(d),v=d.indexOf("%")==-1&&!isNaN(E);return v&&E}function e(){}var n=typeof console=="undefined"?e:function(d){console.error(d)},o=["paddingLeft","paddingRight","paddingTop","paddingBottom","marginLeft","marginRight","marginTop","marginBottom","borderLeftWidth","borderRightWidth","borderTopWidth","borderBottomWidth"],r=o.length;function s(){for(var d={width:0,height:0,innerWidth:0,innerHeight:0,outerWidth:0,outerHeight:0},E=0;E