From 7d993cc1416497f3139edc7e9b8d33ab2d954047 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Oct 2025 13:57:49 -0400 Subject: [PATCH] WIP --- netbox/dcim/ui/panels.py | 47 +++-- netbox/dcim/views.py | 5 +- netbox/netbox/ui/attrs.py | 82 ++++++-- netbox/netbox/ui/components.py | 2 +- netbox/templates/components/attrs/choice.html | 5 + .../{ => attrs}/gps_coordinates.html | 0 .../components/{ => attrs}/nested_object.html | 0 .../components/{ => attrs}/object.html | 0 netbox/templates/components/attrs/text.html | 7 + netbox/templates/dcim/device.html | 188 +----------------- .../dcim/device/attrs/ipaddress.html | 11 + .../dcim/device/attrs/parent_device.html | 16 +- netbox/templates/dcim/device/attrs/rack.html | 24 +-- 13 files changed, 147 insertions(+), 240 deletions(-) create mode 100644 netbox/templates/components/attrs/choice.html rename netbox/templates/components/{ => attrs}/gps_coordinates.html (100%) rename netbox/templates/components/{ => attrs}/nested_object.html (100%) rename netbox/templates/components/{ => attrs}/object.html (100%) create mode 100644 netbox/templates/components/attrs/text.html create mode 100644 netbox/templates/dcim/device/attrs/ipaddress.html diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 3be080a1e..8f0d3b90d 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -5,21 +5,42 @@ from netbox.ui.components import ObjectPanel class DevicePanel(ObjectPanel): - region = attrs.NestedObjectAttr('site.region', linkify=True) - site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') - location = attrs.NestedObjectAttr('location', linkify=True) - rack = attrs.TemplatedAttr('rack', template_name='dcim/device/attrs/rack.html') - virtual_chassis = attrs.NestedObjectAttr('virtual_chassis', linkify=True) + region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True) + site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group') + location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True) + rack = attrs.TemplatedAttr('rack', label=_('Rack'), template_name='dcim/device/attrs/rack.html') + virtual_chassis = attrs.NestedObjectAttr('virtual_chassis', label=_('Virtual chassis'), linkify=True) parent_device = attrs.TemplatedAttr( 'parent_bay', + label=_('Parent device'), template_name='dcim/device/attrs/parent_device.html', - label=_('Parent Device'), ) gps_coordinates = attrs.GPSCoordinatesAttr() - tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') - device_type = attrs.ObjectAttr('device_type', linkify=True, grouped_by='manufacturer') - description = attrs.TextAttr('description') - airflow = attrs.TextAttr('get_airflow_display') - serial = attrs.TextAttr('serial', style='font-monospace') - asset_tag = attrs.TextAttr('asset_tag', style='font-monospace') - config_template = attrs.ObjectAttr('config_template', linkify=True) + tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group') + device_type = attrs.ObjectAttr('device_type', label=_('Device type'), linkify=True, grouped_by='manufacturer') + description = attrs.TextAttr('description', label=_('Description')) + airflow = attrs.ChoiceAttr('airflow', label=_('Airflow')) + serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True) + asset_tag = attrs.TextAttr('asset_tag', label=_('Asset tag'), style='font-monospace', copy_button=True) + config_template = attrs.ObjectAttr('config_template', label=_('Config template'), linkify=True) + + +class DeviceManagementPanel(ObjectPanel): + status = attrs.ChoiceAttr('status', label=_('Status')) + role = attrs.NestedObjectAttr('role', label=_('Role'), linkify=True, max_depth=3) + platform = attrs.NestedObjectAttr('platform', label=_('Platform'), linkify=True, max_depth=3) + primary_ip4 = attrs.TemplatedAttr( + 'primary_ip4', + label=_('Primary IPv4'), + template_name='dcim/device/attrs/ipaddress.html', + ) + primary_ip6 = attrs.TemplatedAttr( + 'primary_ip6', + label=_('Primary IPv6'), + template_name='dcim/device/attrs/ipaddress.html', + ) + oob_ip = attrs.TemplatedAttr( + 'oob_ip', + label=_('Out-of-band IP'), + template_name='dcim/device/attrs/ipaddress.html', + ) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0cb145f3b..d7ebeca11 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -12,7 +12,7 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import View from circuits.models import Circuit, CircuitTermination -from dcim.ui.panels import DevicePanel +from dcim.ui import panels from extras.views import ObjectConfigContextView, ObjectRenderConfigView from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable @@ -2227,7 +2227,8 @@ class DeviceView(generic.ObjectView): return { 'vc_members': vc_members, 'svg_extra': f'highlight=id:{instance.pk}', - 'device_panel': DevicePanel(instance, _('Device')), + 'device_panel': panels.DevicePanel(instance, _('Device')), + 'management_panel': panels.DeviceManagementPanel(instance, _('Management')), } diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 4dcd261bd..233beef60 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -1,5 +1,6 @@ +from abc import ABC, abstractmethod + from django.template.loader import render_to_string -from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -10,7 +11,7 @@ from netbox.config import get_config # Attributes # -class Attr: +class Attr(ABC): template_name = None placeholder = mark_safe('') @@ -19,6 +20,10 @@ class Attr: self.label = label self.template_name = template_name or self.template_name + @abstractmethod + def render(self, obj, context=None): + pass + @staticmethod def _resolve_attr(obj, path): cur = obj @@ -30,35 +35,65 @@ class Attr: class TextAttr(Attr): + template_name = 'components/attrs/text.html' - def __init__(self, *args, style=None, **kwargs): + def __init__(self, *args, style=None, copy_button=False, **kwargs): super().__init__(*args, **kwargs) self.style = style + self.copy_button = copy_button - def render(self, obj): + def render(self, obj, context=None): + context = context or {} value = self._resolve_attr(obj, self.accessor) if value in (None, ''): return self.placeholder - if self.style: - return mark_safe(f'{escape(value)}') - return value + return render_to_string(self.template_name, { + **context, + 'value': value, + 'style': self.style, + 'copy_button': self.copy_button, + }) + + +class ChoiceAttr(Attr): + template_name = 'components/attrs/choice.html' + + def render(self, obj, context=None): + context = context or {} + try: + value = getattr(obj, f'get_{self.accessor}_display')() + except AttributeError: + value = self._resolve_attr(obj, self.accessor) + if value in (None, ''): + return self.placeholder + try: + bg_color = getattr(obj, f'get_{self.accessor}_color')() + except AttributeError: + bg_color = None + return render_to_string(self.template_name, { + **context, + 'value': value, + 'bg_color': bg_color, + }) class ObjectAttr(Attr): - template_name = 'components/object.html' + template_name = 'components/attrs/object.html' def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): super().__init__(*args, **kwargs) self.linkify = linkify self.grouped_by = grouped_by - def render(self, obj): + def render(self, obj, context=None): + context = context or {} value = self._resolve_attr(obj, self.accessor) if value is None: return self.placeholder group = getattr(value, self.grouped_by, None) if self.grouped_by else None return render_to_string(self.template_name, { + **context, 'object': value, 'group': group, 'linkify': self.linkify, @@ -66,24 +101,30 @@ class ObjectAttr(Attr): class NestedObjectAttr(Attr): - template_name = 'components/nested_object.html' + template_name = 'components/attrs/nested_object.html' - def __init__(self, *args, linkify=None, **kwargs): + def __init__(self, *args, linkify=None, max_depth=None, **kwargs): super().__init__(*args, **kwargs) self.linkify = linkify + self.max_depth = max_depth - def render(self, obj): + def render(self, obj, context=None): + context = context or {} value = self._resolve_attr(obj, self.accessor) if value is None: return self.placeholder + nodes = value.get_ancestors(include_self=True) + if self.max_depth: + nodes = list(nodes)[-self.max_depth:] return render_to_string(self.template_name, { - 'nodes': value.get_ancestors(include_self=True), + **context, + 'nodes': nodes, 'linkify': self.linkify, }) class GPSCoordinatesAttr(Attr): - template_name = 'components/gps_coordinates.html' + template_name = 'components/attrs/gps_coordinates.html' def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs): kwargs.setdefault('label', _('GPS Coordinates')) @@ -97,12 +138,14 @@ class GPSCoordinatesAttr(Attr): else: self.map_url = None - def render(self, obj): + def render(self, obj, context=None): + context = context or {} latitude = self._resolve_attr(obj, self.latitude_attr) longitude = self._resolve_attr(obj, self.longitude_attr) if latitude is None or longitude is None: return self.placeholder return render_to_string(self.template_name, { + **context, 'latitude': latitude, 'longitude': longitude, 'map_url': self.map_url, @@ -115,12 +158,17 @@ class TemplatedAttr(Attr): super().__init__(*args, **kwargs) self.context = context or {} - def render(self, obj): + def render(self, obj, context=None): + context = context or {} + value = self._resolve_attr(obj, self.accessor) + if value is None: + return self.placeholder return render_to_string( self.template_name, { + **context, **self.context, 'object': obj, - 'value': self._resolve_attr(obj, self.accessor), + 'value': value, } ) diff --git a/netbox/netbox/ui/components.py b/netbox/netbox/ui/components.py index a2261b4f0..c3aa1ae66 100644 --- a/netbox/netbox/ui/components.py +++ b/netbox/netbox/ui/components.py @@ -40,7 +40,7 @@ class ObjectPanel(Component, metaclass=ObjectDetailsPanelMeta): return [ { 'label': attr.label or title(name), - 'value': attr.render(self.object), + 'value': attr.render(self.object, {'name': name}), } for name, attr in self._attrs.items() ] diff --git a/netbox/templates/components/attrs/choice.html b/netbox/templates/components/attrs/choice.html new file mode 100644 index 000000000..197d4c2fc --- /dev/null +++ b/netbox/templates/components/attrs/choice.html @@ -0,0 +1,5 @@ +{% if bg_color %} + {% badge value bg_color=bg_color %} +{% else %} + {{ value }} +{% endif %} diff --git a/netbox/templates/components/gps_coordinates.html b/netbox/templates/components/attrs/gps_coordinates.html similarity index 100% rename from netbox/templates/components/gps_coordinates.html rename to netbox/templates/components/attrs/gps_coordinates.html diff --git a/netbox/templates/components/nested_object.html b/netbox/templates/components/attrs/nested_object.html similarity index 100% rename from netbox/templates/components/nested_object.html rename to netbox/templates/components/attrs/nested_object.html diff --git a/netbox/templates/components/object.html b/netbox/templates/components/attrs/object.html similarity index 100% rename from netbox/templates/components/object.html rename to netbox/templates/components/attrs/object.html diff --git a/netbox/templates/components/attrs/text.html b/netbox/templates/components/attrs/text.html new file mode 100644 index 000000000..459c9ab8d --- /dev/null +++ b/netbox/templates/components/attrs/text.html @@ -0,0 +1,7 @@ +{% load i18n %} +{{ value }} +{% if copy_button %} + + + +{% endif %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 36700ccfe..1ef8a406d 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -11,115 +11,7 @@ {% block content %}
-
-

{% trans "Device" %}

- - - - - - - - - - - - - - {% if object.virtual_chassis %} - - - - - {% endif %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Region" %}{% nested_tree object.site.region %}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Virtual Chassis" %}{{ object.virtual_chassis|linkify }}
{% trans "Rack" %} - {% if object.rack %} - {{ object.rack|linkify }} - - - - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Position" %} - {% if object.parent_bay %} - {% with object.parent_bay.device as parent %} - {{ parent|linkify }} / {{ object.parent_bay }} - {% if parent.position %} - (U{{ parent.position|floatformat }} / {{ parent.get_face_display }}) - {% endif %} - {% endwith %} - {% elif object.rack and object.position %} - U{{ object.position|floatformat }} / {{ object.get_face_display }} - {% elif object.rack and object.device_type.u_height %} - {% trans "Not racked" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "GPS Coordinates" %} - {% if object.latitude and object.longitude %} - {% if config.MAPS_URL %} - - {% endif %} - {{ object.latitude }}, {{ object.longitude }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Device Type" %} - {{ object.device_type|linkify:"full_name" }} ({{ object.device_type.u_height|floatformat }}U) -
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Airflow" %} - {{ object.get_airflow_display|placeholder }} -
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Config Template" %}{{ object.config_template|linkify|placeholder }}
-
+ {{ device_panel }} {% if vc_members %}

@@ -177,83 +69,7 @@ {% plugin_left_page object %}

- {{ device_panel }} -
-

{% trans "Management" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - {% if object.cluster %} - - - - - {% endif %} -
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Role" %}{{ object.role|linkify }}
{% trans "Platform" %}{{ object.platform|linkify|placeholder }}
{% trans "Primary IPv4" %} - {% if object.primary_ip4 %} - {{ object.primary_ip4.address.ip }} - {% if object.primary_ip4.nat_inside %} - ({% trans "NAT for" %} {{ object.primary_ip4.nat_inside.address.ip }}) - {% elif object.primary_ip4.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "primary_ip4" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Primary IPv6" %} - {% if object.primary_ip6 %} - {{ object.primary_ip6.address.ip }} - {% if object.primary_ip6.nat_inside %} - ({% trans "NAT for" %} {{ object.primary_ip6.nat_inside.address.ip }}) - {% elif object.primary_ip6.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "primary_ip6" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
Out-of-band IP - {% if object.oob_ip %} - {{ object.oob_ip.address.ip }} - {% if object.oob_ip.nat_inside %} - ({% trans "NAT for" %} {{ object.oob_ip.nat_inside.address.ip }}) - {% elif object.oob_ip.nat_outside.exists %} - ({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) - {% endif %} - {% copy_content "oob_ip" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Cluster" %} - {% if object.cluster.group %} - {{ object.cluster.group|linkify }} / - {% endif %} - {{ object.cluster|linkify }} -
-
+ {{ management_panel }} {% if object.powerports.exists and object.poweroutlets.exists %}

{% trans "Power Utilization" %}

diff --git a/netbox/templates/dcim/device/attrs/ipaddress.html b/netbox/templates/dcim/device/attrs/ipaddress.html new file mode 100644 index 000000000..2af5dab6c --- /dev/null +++ b/netbox/templates/dcim/device/attrs/ipaddress.html @@ -0,0 +1,11 @@ +{# TODO: Add copy-to-clipboard button #} +{% load i18n %} +{{ value.address.ip }} +{% if value.nat_inside %} + ({% trans "NAT for" %} {{ value.nat_inside.address.ip }}) +{% elif value.nat_outside.exists %} + ({% trans "NAT" %}: {% for nat in value.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) +{% endif %} + + + diff --git a/netbox/templates/dcim/device/attrs/parent_device.html b/netbox/templates/dcim/device/attrs/parent_device.html index 375a511c4..6351f792a 100644 --- a/netbox/templates/dcim/device/attrs/parent_device.html +++ b/netbox/templates/dcim/device/attrs/parent_device.html @@ -1,8 +1,10 @@ -{% if value %} - -{% else %} - {{ ''|placeholder }} +{% load i18n %} + +{% if value.device.position %} + + + {% endif %} diff --git a/netbox/templates/dcim/device/attrs/rack.html b/netbox/templates/dcim/device/attrs/rack.html index b10780252..d939e9ca3 100644 --- a/netbox/templates/dcim/device/attrs/rack.html +++ b/netbox/templates/dcim/device/attrs/rack.html @@ -1,18 +1,14 @@ {% load i18n %} -{% if value %} - - {{ value|linkify }} - {% if value and object.position %} - (U{{ object.position|floatformat }} / {{ object.get_face_display }}) - {% elif value and object.device_type.u_height %} - {% trans "Not racked" %} - {% endif %} - + + {{ value|linkify }} {% if value and object.position %} - - - + (U{{ object.position|floatformat }} / {{ object.get_face_display }}) + {% elif value and object.device_type.u_height %} + {% trans "Not racked" %} {% endif %} -{% else %} - {{ ''|placeholder }} + +{% if object.position %} + + + {% endif %}