diff --git a/netbox/ipam/ui/__init__.py b/netbox/ipam/ui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/ipam/ui/panels.py b/netbox/ipam/ui/panels.py new file mode 100644 index 000000000..a2148057a --- /dev/null +++ b/netbox/ipam/ui/panels.py @@ -0,0 +1,37 @@ +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from netbox.ui import actions, panels + + +class FHRPGroupAssignmentsPanel(panels.ObjectPanel): + """ + A panel which lists all FHRP group assignments for a given object. + """ + + template_name = 'ipam/panels/fhrp_groups.html' + title = _('FHRP Groups') + actions = [ + actions.AddObject( + 'ipam.FHRPGroup', + url_params={ + 'return_url': lambda ctx: reverse( + 'ipam:fhrpgroupassignment_add', + query={ + 'interface_type': ContentType.objects.get_for_model(ctx['object']).pk, + 'interface_id': ctx['object'].pk, + }, + ), + }, + label=_('Create Group'), + ), + actions.AddObject( + 'ipam.FHRPGroupAssignment', + url_params={ + 'interface_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk, + 'interface_id': lambda ctx: ctx['object'].pk, + }, + label=_('Assign Group'), + ), + ] diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 37cc1ba12..c20c99b83 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -10,6 +10,7 @@ __all__ = ( 'BooleanAttr', 'ColorAttr', 'ChoiceAttr', + 'GenericForeignKeyAttr', 'GPSCoordinatesAttr', 'ImageAttr', 'NestedObjectAttr', @@ -279,6 +280,32 @@ class NestedObjectAttr(ObjectAttribute): } +class GenericForeignKeyAttr(ObjectAttribute): + """ + An attribute representing a related generic relation object. + + This attribute is similar to `RelatedObjectAttr` but uses the + ContentType of the related object to be displayed alongside the value. + + Parameters: + linkify (bool): If True, the rendered value will be hyperlinked + to the related object's detail view + """ + template_name = 'ui/attrs/generic_object.html' + + def __init__(self, *args, linkify=None, **kwargs): + super().__init__(*args, **kwargs) + self.linkify = linkify + + def get_context(self, obj, context): + value = self.get_value(obj) + content_type = value._meta.verbose_name + return { + 'content_type': content_type, + 'linkify': self.linkify, + } + + class AddressAttr(ObjectAttribute): """ A physical or mailing address. diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py index d87cd6c49..55e36b704 100644 --- a/netbox/netbox/ui/panels.py +++ b/netbox/netbox/ui/panels.py @@ -12,6 +12,7 @@ from utilities.views import get_viewname __all__ = ( 'CommentsPanel', + 'ContextTablePanel', 'JSONPanel', 'NestedGroupObjectPanel', 'ObjectAttributesPanel', @@ -339,3 +340,42 @@ class PluginContentPanel(Panel): def render(self, context): obj = context.get('object') return _get_registered_content(obj, self.method, context) + + +class ContextTablePanel(ObjectPanel): + """ + A panel which renders a django-tables2/NetBoxTable instance provided + via the view's extra context. + + This is useful when you already have a fully constructed table + (custom queryset, special columns, no list view) and just want to + render it inside a declarative layout panel. + + Parameters: + table (str | callable): Either the context key holding the table + (e.g. "vlan_table") or a callable which accepts the template + context and returns a table instance. + """ + template_name = 'ui/panels/context_table.html' + + def __init__(self, table, **kwargs): + super().__init__(**kwargs) + self.table = table + + def _resolve_table(self, context): + if callable(self.table): + return self.table(context) + return context.get(self.table) + + def get_context(self, context): + table = self._resolve_table(context) + return { + **super().get_context(context), + 'table': table, + } + + def render(self, context): + table = self._resolve_table(context) + if table is None: + return '' + return super().render(context) diff --git a/netbox/templates/ipam/panels/fhrp_groups.html b/netbox/templates/ipam/panels/fhrp_groups.html new file mode 100644 index 000000000..aa9a91dcc --- /dev/null +++ b/netbox/templates/ipam/panels/fhrp_groups.html @@ -0,0 +1,47 @@ +{% extends "ui/panels/_base.html" %} +{% load perms %} +{% load i18n %} + +{% block panel_content %} + + + + + + + + + + + + {% for assignment in object.fhrp_group_assignments.all %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Group" %}{% trans "Protocol" %}{% trans "Virtual IPs" %}{% trans "Priority" %}
{{ assignment.group|linkify:"group_id" }}{{ assignment.group.get_protocol_display }} + {% for ipaddress in assignment.group.ip_addresses.all %} + {{ ipaddress|linkify }}{% if not forloop.last %}
{% endif %} + {% endfor %} +
{{ assignment.priority }} + {% if request.user|can_change:assignment %} + + + + {% endif %} + {% if request.user|can_delete:assignment %} + + + + {% endif %} +
{% trans "None" %}
+{% endblock panel_content %} diff --git a/netbox/templates/ui/attrs/generic_object.html b/netbox/templates/ui/attrs/generic_object.html new file mode 100644 index 000000000..6ffabb94a --- /dev/null +++ b/netbox/templates/ui/attrs/generic_object.html @@ -0,0 +1,3 @@ + + {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %}{% if content_type %} ({{ content_type }}){% endif %} + diff --git a/netbox/templates/ui/panels/context_table.html b/netbox/templates/ui/panels/context_table.html new file mode 100644 index 000000000..1297defe5 --- /dev/null +++ b/netbox/templates/ui/panels/context_table.html @@ -0,0 +1,6 @@ +{% extends "ui/panels/_base.html" %} +{% load render_table from django_tables2 %} + +{% block panel_content %} + {% render_table table 'inc/table.html' %} +{% endblock panel_content %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 0dc7efe60..b2b7c3996 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -1,95 +1 @@ {% extends 'virtualization/cluster/base.html' %} -{% load helpers %} -{% load plugins %} -{% load i18n %} - -{% block content %} -
-
-
-

{% trans "Cluster" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - {% if object.scope %} - - {% else %} - - {% endif %} - -
{% trans "Name" %}{{ object.name }}
{% trans "Type" %}{{ object.type|linkify }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Group" %}{{ object.group|linkify|placeholder }}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Scope" %}{{ object.scope|linkify }} ({% trans object.scope_type.name %}){{ ''|placeholder }}
-
- {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
-
-

{% trans "Allocated Resources" %}

- - - - - - - - - - - - - -
{% trans "Virtual CPUs" %}{{ vcpus_sum|placeholder }}
{% trans "Memory" %} - {% if memory_sum %} - {{ memory_sum|humanize_ram_megabytes }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Disk Space" %} - {% if disk_sum %} - {{ disk_sum|humanize_disk_megabytes }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/virtualization/clustergroup.html b/netbox/templates/virtualization/clustergroup.html index b45ae60b4..fe5084b87 100644 --- a/netbox/templates/virtualization/clustergroup.html +++ b/netbox/templates/virtualization/clustergroup.html @@ -1,7 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} {% load i18n %} {% block extra_controls %} @@ -11,36 +8,3 @@ {% endif %} {% endblock extra_controls %} - -{% block content %} -
-
-
-

{% trans "Cluster Group" %}

- - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/comments.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/virtualization/clustertype.html b/netbox/templates/virtualization/clustertype.html index 016320f51..039d63be7 100644 --- a/netbox/templates/virtualization/clustertype.html +++ b/netbox/templates/virtualization/clustertype.html @@ -1,7 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} {% load i18n %} {% block extra_controls %} @@ -11,42 +8,3 @@ {% endif %} {% endblock extra_controls %} - -{% block content %} -
-
-
-

{% trans "Cluster Type" %}

- - - - - - - - - - - - - -
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Clusters" %} - {{ object.clusters.count }} -
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/comments.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/virtualization/panels/cluster_resources.html b/netbox/templates/virtualization/panels/cluster_resources.html new file mode 100644 index 000000000..bdaec3d89 --- /dev/null +++ b/netbox/templates/virtualization/panels/cluster_resources.html @@ -0,0 +1,34 @@ +{% load helpers %} +{% load i18n %} + +
+

{% trans "Allocated Resources" %}

+ + + + + + + + + + + + + +
{% trans "Virtual CPUs" %}{{ vcpus_sum|placeholder }}
{% trans "Memory" %} + {% if memory_sum %} + {{ memory_sum|humanize_ram_megabytes }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
+ {% trans "Disk Space" %} + + {% if disk_sum %} + {{ disk_sum|humanize_disk_megabytes }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
+
diff --git a/netbox/templates/virtualization/virtualdisk.html b/netbox/templates/virtualization/virtualdisk.html index 852863c00..720e410ae 100644 --- a/netbox/templates/virtualization/virtualdisk.html +++ b/netbox/templates/virtualization/virtualdisk.html @@ -10,48 +10,3 @@ {{ object.virtual_machine }} {% endblock %} - -{% block content %} -
-
-
-

{% trans "Virtual Disk" %}

- - - - - - - - - - - - - - - - - -
{% trans "Virtual Machine" %}{{ object.virtual_machine|linkify }}
{% trans "Name" %}{{ object.name }}
{% trans "Size" %} - {% if object.size %} - {{ object.size|humanize_disk_megabytes }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Description" %}{{ object.description|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/templates/virtualization/virtualdisk/attrs/size.html b/netbox/templates/virtualization/virtualdisk/attrs/size.html new file mode 100644 index 000000000..1185dbc20 --- /dev/null +++ b/netbox/templates/virtualization/virtualdisk/attrs/size.html @@ -0,0 +1,2 @@ +{% load helpers %} +{{ value|humanize_disk_megabytes }} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index b8ae28c5d..73127982d 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -1,8 +1,4 @@ {% extends 'generic/object.html' %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} -{% load i18n %} {% block breadcrumbs %} {{ block.super }} @@ -10,151 +6,3 @@ {{ object.virtual_machine }} {% endblock %} - -{% block content %} -
-
-
-

{% trans "Interface" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% if object.mode == 'q-in-q' %} - - - - - {% endif %} - - - - -
{% trans "Virtual Machine" %}{{ object.virtual_machine|linkify }}
{% trans "Name" %}{{ object.name }}
{% trans "Enabled" %} - {% if object.enabled %} - - {% else %} - - {% endif %} -
{% trans "Parent" %}{{ object.parent|linkify|placeholder }}
{% trans "Bridge" %}{{ object.bridge|linkify|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "MTU" %}{{ object.mtu|placeholder }}
{% trans "802.1Q Mode" %}{{ object.get_mode_display|placeholder }}
{% trans "Q-in-Q SVLAN" %}{{ object.qinq_svlan|linkify|placeholder }}
{% trans "Tunnel" %}{{ object.tunnel_termination.tunnel|linkify|placeholder }}
-
- {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/custom_fields.html' %} -
-

{% trans "Addressing" %}

- - - - - - - - - - - - - -
{% trans "MAC Address" %} - {% if object.primary_mac_address %} - {{ object.primary_mac_address|linkify }} - {% trans "Primary" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "VRF" %}{{ object.vrf|linkify|placeholder }}
{% trans "VLAN Translation" %}{{ object.vlan_translation_policy|linkify|placeholder }}
-
- {% include 'ipam/inc/panels/fhrp_groups.html' %} - {% plugin_right_page object %} -
-
-
-
-
-

- {% trans "IP Addresses" %} - {% if perms.ipam.add_ipaddress %} - - {% endif %} -

- {% htmx_table 'ipam:ipaddress_list' vminterface_id=object.pk %} -
-
-
-
-
-
-

- {% trans "MAC Addresses" %} - {% if perms.ipam.add_macaddress %} - - {% endif %} -

- {% htmx_table 'dcim:macaddress_list' vminterface_id=object.pk %} -
-
-
-
-
- {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %} -
-
-{% if object.vlan_translation_policy %} -
-
- {% include 'inc/panel_table.html' with table=vlan_translation_table heading="VLAN Translation" %} -
-
-{% endif %} -
-
- {% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %} -
-
-
-
- {% plugin_full_width_page object %} -
-
-{% endblock %} diff --git a/netbox/virtualization/ui/panels.py b/netbox/virtualization/ui/panels.py index bff967cb6..fef6de3f1 100644 --- a/netbox/virtualization/ui/panels.py +++ b/netbox/virtualization/ui/panels.py @@ -3,6 +3,16 @@ from django.utils.translation import gettext_lazy as _ from netbox.ui import attrs, panels +class ClusterPanel(panels.ObjectAttributesPanel): + name = attrs.TextAttr('name') + type = attrs.RelatedObjectAttr('type', linkify=True) + status = attrs.ChoiceAttr('status') + description = attrs.TextAttr('description') + group = attrs.RelatedObjectAttr('group', linkify=True) + tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group') + scope = attrs.GenericForeignKeyAttr('scope', linkify=True) + + class VirtualMachinePanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') status = attrs.ChoiceAttr('status') @@ -32,3 +42,35 @@ class VirtualMachineClusterPanel(panels.ObjectAttributesPanel): cluster = attrs.RelatedObjectAttr('cluster', linkify=True) cluster_type = attrs.RelatedObjectAttr('cluster.type', linkify=True) device = attrs.RelatedObjectAttr('device', linkify=True) + + +class VirtualDiskPanel(panels.ObjectAttributesPanel): + virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine')) + name = attrs.TextAttr('name') + size = attrs.TemplatedAttr('size', template_name='virtualization/virtualdisk/attrs/size.html') + description = attrs.TextAttr('description') + + +class VMInterfacePanel(panels.ObjectAttributesPanel): + virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine')) + name = attrs.TextAttr('name') + enabled = attrs.BooleanAttr('enabled') + parent = attrs.RelatedObjectAttr('parent_interface', linkify=True) + bridge = attrs.RelatedObjectAttr('bridge', linkify=True) + description = attrs.TextAttr('description') + mtu = attrs.TextAttr('mtu', label=_('MTU')) + mode = attrs.ChoiceAttr('mode', label=_('802.1Q Mode')) + qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN')) + tunnel_termination = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel')) + + +class VMInterfaceAddressingPanel(panels.ObjectAttributesPanel): + title = _('Addressing') + + primary_mac_address = attrs.TextAttr( + 'primary_mac_address', label=_('MAC Address'), style='font-monospace', copy_button=True + ) + vrf = attrs.RelatedObjectAttr('vrf', linkify=True, label=_('VRF')) + vlan_translation_policy = attrs.RelatedObjectAttr( + 'vlan_translation_policy', linkify=True, label=_('VLAN Translation') + ) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index eb4d31be4..1fca15c5e 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -14,6 +14,7 @@ from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel from extras.views import ObjectConfigContextView, ObjectRenderConfigView from ipam.models import IPAddress, VLANGroup from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable +from ipam.ui.panels import FHRPGroupAssignmentsPanel from netbox.object_actions import ( AddObject, BulkDelete, @@ -25,7 +26,14 @@ from netbox.object_actions import ( EditObject, ) from netbox.ui import actions, layout -from netbox.ui.panels import CommentsPanel, ObjectsTablePanel, TemplatePanel +from netbox.ui.panels import ( + CommentsPanel, + ContextTablePanel, + ObjectsTablePanel, + OrganizationalObjectPanel, + RelatedObjectsPanel, + TemplatePanel, +) from netbox.views import generic from utilities.query import count_related from utilities.query_functions import CollateAsChar @@ -54,6 +62,17 @@ class ClusterTypeListView(generic.ObjectListView): @register_model_view(ClusterType) class ClusterTypeView(GetRelatedModelsMixin, generic.ObjectView): queryset = ClusterType.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + OrganizationalObjectPanel(), + TagsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ], + ) def get_extra_context(self, request, instance): return { @@ -121,6 +140,17 @@ class ClusterGroupListView(generic.ObjectListView): @register_model_view(ClusterGroup) class ClusterGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = ClusterGroup.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + OrganizationalObjectPanel(), + TagsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + CustomFieldsPanel(), + CommentsPanel(), + ], + ) def get_extra_context(self, request, instance): return { @@ -202,6 +232,18 @@ class ClusterListView(generic.ObjectListView): @register_model_view(Cluster) class ClusterView(GetRelatedModelsMixin, generic.ObjectView): queryset = Cluster.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.ClusterPanel(), + CommentsPanel(), + ], + right_panels=[ + TemplatePanel('virtualization/panels/cluster_resources.html'), + RelatedObjectsPanel(), + CustomFieldsPanel(), + TagsPanel(), + ], + ) def get_extra_context(self, request, instance): return { @@ -507,6 +549,7 @@ class VirtualMachineBulkDeleteView(generic.BulkDeleteView): # VM interfaces # + @register_model_view(VMInterface, 'list', path='', detail=False) class VMInterfaceListView(generic.ObjectListView): queryset = VMInterface.objects.all() @@ -518,6 +561,44 @@ class VMInterfaceListView(generic.ObjectListView): @register_model_view(VMInterface) class VMInterfaceView(generic.ObjectView): queryset = VMInterface.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.VMInterfacePanel(), + TagsPanel(), + ], + right_panels=[ + CustomFieldsPanel(), + panels.VMInterfaceAddressingPanel(), + FHRPGroupAssignmentsPanel(), + ], + bottom_panels=[ + ObjectsTablePanel( + model='ipam.IPaddress', + filters={'vminterface_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject( + 'ipam.IPaddress', + url_params={ + 'virtual_machine': lambda ctx: ctx['object'].virtual_machine.pk, + 'vminterface': lambda ctx: ctx['object'].pk, + }, + ), + ], + ), + ObjectsTablePanel( + model='dcim.MACAddress', + filters={'vminterface_id': lambda ctx: ctx['object'].pk}, + actions=[ + actions.AddObject( + 'dcim.MACAddress', url_params={'vminterface': lambda ctx: ctx['object'].pk} + ), + ], + ), + ContextTablePanel('vlan_table', title=_('Assigned VLANs')), + ContextTablePanel('vlan_translation_table', title=_('VLAN Translation')), + ContextTablePanel('child_interfaces_table', title=_('Child Interfaces')), + ], + ) def get_extra_context(self, request, instance): @@ -623,6 +704,15 @@ class VirtualDiskListView(generic.ObjectListView): @register_model_view(VirtualDisk) class VirtualDiskView(generic.ObjectView): queryset = VirtualDisk.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.VirtualDiskPanel(), + TagsPanel(), + ], + right_panels=[ + CustomFieldsPanel(), + ], + ) @register_model_view(VirtualDisk, 'add', detail=False)