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):
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',
)

View File

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

View File

@@ -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('<span class="text-muted">&mdash;</span>')
@@ -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'<span class="{self.style}">{escape(value)}</span>')
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,
}
)

View File

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

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 %}
<div class="row">
<div class="col col-12 col-xl-6">
<div class="card">
<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>
{{ device_panel }}
{% if vc_members %}
<div class="card">
<h2 class="card-header">
@@ -177,83 +69,7 @@
{% plugin_left_page object %}
</div>
<div class="col col-12 col-xl-6">
{{ device_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>
{{ management_panel }}
{% if object.powerports.exists and object.poweroutlets.exists %}
<div class="card">
<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 %}
<ol class="breadcrumb" aria-label="breadcrumbs">
<li class="breadcrumb-item">{{ device.parent_bay.device|linkify }}</li>
<li class="breadcrumb-item">{{ device.parent_bay }}</li>
</ol>
{% else %}
{{ ''|placeholder }}
{% load i18n %}
<ol class="breadcrumb" aria-label="breadcrumbs">
<li class="breadcrumb-item">{{ value.device|linkify }}</li>
<li class="breadcrumb-item">{{ value }}</li>
</ol>
{% if value.device.position %}
<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 %}

View File

@@ -1,18 +1,14 @@
{% load i18n %}
{% if value %}
<span>
<span>
{{ 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 %}
</span>
{% 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 %}
{% else %}
{{ ''|placeholder }}
{% endif %}