This commit is contained in:
Jeremy Stretch
2025-10-30 13:57:49 -04:00
parent d4783b7fbd
commit 7d993cc141
13 changed files with 147 additions and 240 deletions

View File

@@ -5,21 +5,42 @@ from netbox.ui.components import ObjectPanel
class DevicePanel(ObjectPanel): class DevicePanel(ObjectPanel):
region = attrs.NestedObjectAttr('site.region', linkify=True) region = attrs.NestedObjectAttr('site.region', label=_('Region'), linkify=True)
site = attrs.ObjectAttr('site', linkify=True, grouped_by='group') site = attrs.ObjectAttr('site', label=_('Site'), linkify=True, grouped_by='group')
location = attrs.NestedObjectAttr('location', linkify=True) location = attrs.NestedObjectAttr('location', label=_('Location'), linkify=True)
rack = attrs.TemplatedAttr('rack', template_name='dcim/device/attrs/rack.html') rack = attrs.TemplatedAttr('rack', label=_('Rack'), template_name='dcim/device/attrs/rack.html')
virtual_chassis = attrs.NestedObjectAttr('virtual_chassis', linkify=True) virtual_chassis = attrs.NestedObjectAttr('virtual_chassis', label=_('Virtual chassis'), linkify=True)
parent_device = attrs.TemplatedAttr( parent_device = attrs.TemplatedAttr(
'parent_bay', 'parent_bay',
label=_('Parent device'),
template_name='dcim/device/attrs/parent_device.html', template_name='dcim/device/attrs/parent_device.html',
label=_('Parent Device'),
) )
gps_coordinates = attrs.GPSCoordinatesAttr() gps_coordinates = attrs.GPSCoordinatesAttr()
tenant = attrs.ObjectAttr('tenant', linkify=True, grouped_by='group') tenant = attrs.ObjectAttr('tenant', label=_('Tenant'), linkify=True, grouped_by='group')
device_type = attrs.ObjectAttr('device_type', linkify=True, grouped_by='manufacturer') device_type = attrs.ObjectAttr('device_type', label=_('Device type'), linkify=True, grouped_by='manufacturer')
description = attrs.TextAttr('description') description = attrs.TextAttr('description', label=_('Description'))
airflow = attrs.TextAttr('get_airflow_display') airflow = attrs.ChoiceAttr('airflow', label=_('Airflow'))
serial = attrs.TextAttr('serial', style='font-monospace') serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
asset_tag = attrs.TextAttr('asset_tag', style='font-monospace') asset_tag = attrs.TextAttr('asset_tag', label=_('Asset tag'), style='font-monospace', copy_button=True)
config_template = attrs.ObjectAttr('config_template', linkify=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',
)

View File

@@ -12,7 +12,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import View from django.views.generic import View
from circuits.models import Circuit, CircuitTermination from circuits.models import Circuit, CircuitTermination
from dcim.ui.panels import DevicePanel from dcim.ui import panels
from extras.views import ObjectConfigContextView, ObjectRenderConfigView from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
@@ -2227,7 +2227,8 @@ class DeviceView(generic.ObjectView):
return { return {
'vc_members': vc_members, 'vc_members': vc_members,
'svg_extra': f'highlight=id:{instance.pk}', 'svg_extra': f'highlight=id:{instance.pk}',
'device_panel': DevicePanel(instance, _('Device')), 'device_panel': panels.DevicePanel(instance, _('Device')),
'management_panel': panels.DeviceManagementPanel(instance, _('Management')),
} }

View File

@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -10,7 +11,7 @@ from netbox.config import get_config
# Attributes # Attributes
# #
class Attr: class Attr(ABC):
template_name = None template_name = None
placeholder = mark_safe('<span class="text-muted">&mdash;</span>') placeholder = mark_safe('<span class="text-muted">&mdash;</span>')
@@ -19,6 +20,10 @@ class Attr:
self.label = label self.label = label
self.template_name = template_name or self.template_name self.template_name = template_name or self.template_name
@abstractmethod
def render(self, obj, context=None):
pass
@staticmethod @staticmethod
def _resolve_attr(obj, path): def _resolve_attr(obj, path):
cur = obj cur = obj
@@ -30,35 +35,65 @@ class Attr:
class TextAttr(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) super().__init__(*args, **kwargs)
self.style = style 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) value = self._resolve_attr(obj, self.accessor)
if value in (None, ''): if value in (None, ''):
return self.placeholder return self.placeholder
if self.style: return render_to_string(self.template_name, {
return mark_safe(f'<span class="{self.style}">{escape(value)}</span>') **context,
return value '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): class ObjectAttr(Attr):
template_name = 'components/object.html' template_name = 'components/attrs/object.html'
def __init__(self, *args, linkify=None, grouped_by=None, **kwargs): def __init__(self, *args, linkify=None, grouped_by=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.linkify = linkify self.linkify = linkify
self.grouped_by = grouped_by 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) value = self._resolve_attr(obj, self.accessor)
if value is None: if value is None:
return self.placeholder return self.placeholder
group = getattr(value, self.grouped_by, None) if self.grouped_by else None group = getattr(value, self.grouped_by, None) if self.grouped_by else None
return render_to_string(self.template_name, { return render_to_string(self.template_name, {
**context,
'object': value, 'object': value,
'group': group, 'group': group,
'linkify': self.linkify, 'linkify': self.linkify,
@@ -66,24 +101,30 @@ class ObjectAttr(Attr):
class NestedObjectAttr(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) super().__init__(*args, **kwargs)
self.linkify = linkify 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) value = self._resolve_attr(obj, self.accessor)
if value is None: if value is None:
return self.placeholder 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, { return render_to_string(self.template_name, {
'nodes': value.get_ancestors(include_self=True), **context,
'nodes': nodes,
'linkify': self.linkify, 'linkify': self.linkify,
}) })
class GPSCoordinatesAttr(Attr): 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): def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
kwargs.setdefault('label', _('GPS Coordinates')) kwargs.setdefault('label', _('GPS Coordinates'))
@@ -97,12 +138,14 @@ class GPSCoordinatesAttr(Attr):
else: else:
self.map_url = None 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) latitude = self._resolve_attr(obj, self.latitude_attr)
longitude = self._resolve_attr(obj, self.longitude_attr) longitude = self._resolve_attr(obj, self.longitude_attr)
if latitude is None or longitude is None: if latitude is None or longitude is None:
return self.placeholder return self.placeholder
return render_to_string(self.template_name, { return render_to_string(self.template_name, {
**context,
'latitude': latitude, 'latitude': latitude,
'longitude': longitude, 'longitude': longitude,
'map_url': self.map_url, 'map_url': self.map_url,
@@ -115,12 +158,17 @@ class TemplatedAttr(Attr):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.context = context or {} 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( return render_to_string(
self.template_name, self.template_name,
{ {
**context,
**self.context, **self.context,
'object': obj, 'object': obj,
'value': self._resolve_attr(obj, self.accessor), 'value': value,
} }
) )

View File

@@ -40,7 +40,7 @@ class ObjectPanel(Component, metaclass=ObjectDetailsPanelMeta):
return [ return [
{ {
'label': attr.label or title(name), '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() } for name, attr in self._attrs.items()
] ]

View File

@@ -0,0 +1,5 @@
{% if bg_color %}
{% badge value bg_color=bg_color %}
{% else %}
{{ value }}
{% endif %}

View File

@@ -0,0 +1,7 @@
{% load i18n %}
<span{% if name %} id="attr_{{ name }}"{% endif %}{% if style %} class="{{ style }}"{% endif %}>{{ value }}</span>
{% if copy_button %}
<a class="btn btn-sm btn-primary copy-content" data-clipboard-target="#attr_{{ name }}" title="{% trans "Copy to clipboard" %}">
<i class="mdi mdi-content-copy"></i>
</a>
{% endif %}

View File

@@ -11,115 +11,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-12 col-xl-6"> <div class="col col-12 col-xl-6">
<div class="card"> {{ device_panel }}
<h2 class="card-header">{% trans "Device" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Region" %}</th>
<td>{% nested_tree object.site.region %}</td>
</tr>
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>{{ object.site|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Location" %}</th>
<td>{% nested_tree object.location %}</td>
</tr>
{% if object.virtual_chassis %}
<tr>
<th scope="row">{% trans "Virtual Chassis" %}</th>
<td>{{ object.virtual_chassis|linkify }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Rack" %}</th>
<td class="d-flex justify-content-between align-items-start">
{% if object.rack %}
{{ object.rack|linkify }}
<a href="{{ object.rack.get_absolute_url }}?device={% firstof object.parent_bay.device.pk object.pk %}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}">
<i class="mdi mdi-view-day-outline"></i>
</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Position" %}</th>
<td>
{% 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 %}
<span>U{{ object.position|floatformat }} / {{ object.get_face_display }}</span>
{% elif object.rack and object.device_type.u_height %}
<span class="badge text-bg-warning">{% trans "Not racked" %}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "GPS Coordinates" %}</th>
<td class="position-relative">
{% if object.latitude and object.longitude %}
{% if config.MAPS_URL %}
<div class="position-absolute top-50 end-0 me-2 translate-middle-y d-print-none">
<a href="{{ config.MAPS_URL }}{{ object.latitude|unlocalize }},{{ object.longitude|unlocalize }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> {% trans "Map" %}
</a>
</div>
{% endif %}
<span>{{ object.latitude }}, {{ object.longitude }}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Device Type" %}</th>
<td>
{{ object.device_type|linkify:"full_name" }} ({{ object.device_type.u_height|floatformat }}U)
</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Airflow" %}</th>
<td>
{{ object.get_airflow_display|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Serial Number" %}</th>
<td class="font-monospace">{{ object.serial|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Asset Tag" %}</th>
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Config Template" %}</th>
<td>{{ object.config_template|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% if vc_members %} {% if vc_members %}
<div class="card"> <div class="card">
<h2 class="card-header"> <h2 class="card-header">
@@ -177,83 +69,7 @@
{% plugin_left_page object %} {% plugin_left_page object %}
</div> </div>
<div class="col col-12 col-xl-6"> <div class="col col-12 col-xl-6">
{{ device_panel }} {{ management_panel }}
<div class="card">
<h2 class="card-header">{% trans "Management" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{{ object.role|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Platform" %}</th>
<td>{{ object.platform|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Primary IPv4" %}</th>
<td>
{% if object.primary_ip4 %}
<a href="{{ object.primary_ip4.get_absolute_url }}" id="primary_ip4">{{ object.primary_ip4.address.ip }}</a>
{% if object.primary_ip4.nat_inside %}
({% trans "NAT for" %} <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
{% elif object.primary_ip4.nat_outside.exists %}
({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% copy_content "primary_ip4" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Primary IPv6" %}</th>
<td>
{% if object.primary_ip6 %}
<a href="{{ object.primary_ip6.get_absolute_url }}" id="primary_ip6">{{ object.primary_ip6.address.ip }}</a>
{% if object.primary_ip6.nat_inside %}
({% trans "NAT for" %} <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
{% elif object.primary_ip6.nat_outside.exists %}
({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% copy_content "primary_ip6" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Out-of-band IP</th>
<td>
{% if object.oob_ip %}
<a href="{{ object.oob_ip.get_absolute_url }}" id="oob_ip">{{ object.oob_ip.address.ip }}</a>
{% if object.oob_ip.nat_inside %}
({% trans "NAT for" %} <a href="{{ object.oob_ip.nat_inside.get_absolute_url }}">{{ object.oob_ip.nat_inside.address.ip }}</a>)
{% elif object.oob_ip.nat_outside.exists %}
({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% copy_content "oob_ip" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
{% if object.cluster %}
<tr>
<th>{% trans "Cluster" %}</th>
<td>
{% if object.cluster.group %}
{{ object.cluster.group|linkify }} /
{% endif %}
{{ object.cluster|linkify }}
</td>
</tr>
{% endif %}
</table>
</div>
{% if object.powerports.exists and object.poweroutlets.exists %} {% if object.powerports.exists and object.poweroutlets.exists %}
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Power Utilization" %}</h2> <h2 class="card-header">{% trans "Power Utilization" %}</h2>

View File

@@ -0,0 +1,11 @@
{# TODO: Add copy-to-clipboard button #}
{% load i18n %}
<a href="{{ value.get_absolute_url }}"{% if name %} id="attr_{{ name }}"{% endif %}>{{ value.address.ip }}</a>
{% if value.nat_inside %}
({% trans "NAT for" %} <a href="{{ value.nat_inside.get_absolute_url }}">{{ value.nat_inside.address.ip }}</a>)
{% elif value.nat_outside.exists %}
({% trans "NAT" %}: {% for nat in value.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
<a class="btn btn-sm btn-primary copy-content" data-clipboard-target="#attr_{{ name }}" title="{% trans "Copy to clipboard" %}">
<i class="mdi mdi-content-copy"></i>
</a>

View File

@@ -1,8 +1,10 @@
{% if value %} {% load i18n %}
<ol class="breadcrumb" aria-label="breadcrumbs"> <ol class="breadcrumb" aria-label="breadcrumbs">
<li class="breadcrumb-item">{{ device.parent_bay.device|linkify }}</li> <li class="breadcrumb-item">{{ value.device|linkify }}</li>
<li class="breadcrumb-item">{{ device.parent_bay }}</li> <li class="breadcrumb-item">{{ value }}</li>
</ol> </ol>
{% else %} {% if value.device.position %}
{{ ''|placeholder }} <a href="{{ value.device.rack.get_absolute_url }}?device={{ value.device.pk }}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}">
<i class="mdi mdi-view-day-outline"></i>
</a>
{% endif %} {% endif %}

View File

@@ -1,18 +1,14 @@
{% load i18n %} {% load i18n %}
{% if value %} <span>
<span> {{ value|linkify }}
{{ value|linkify }}
{% if value and object.position %}
(U{{ object.position|floatformat }} / {{ object.get_face_display }})
{% elif value and object.device_type.u_height %}
<span class="badge text-bg-warning">{% trans "Not racked" %}</span>
{% endif %}
</span>
{% if value and object.position %} {% if value and object.position %}
<a href="{{ value.get_absolute_url }}?device={{ object.pk }}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}"> (U{{ object.position|floatformat }} / {{ object.get_face_display }})
<i class="mdi mdi-view-day-outline"></i> {% elif value and object.device_type.u_height %}
</a> <span class="badge text-bg-warning">{% trans "Not racked" %}</span>
{% endif %} {% endif %}
{% else %} </span>
{{ ''|placeholder }} {% if object.position %}
<a href="{{ value.get_absolute_url }}?device={{ object.pk }}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}">
<i class="mdi mdi-view-day-outline"></i>
</a>
{% endif %} {% endif %}