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 "Region" %} |
- {% nested_tree object.site.region %} |
-
-
- | {% trans "Site" %} |
- {{ object.site|linkify }} |
-
-
- | {% trans "Location" %} |
- {% nested_tree object.location %} |
-
- {% if object.virtual_chassis %}
-
- | {% trans "Virtual Chassis" %} |
- {{ object.virtual_chassis|linkify }} |
-
- {% endif %}
-
- | {% 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 %}
- {{ device_panel }}
-
-
-
-
- | {% 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 %}
- |
-
- {% if object.cluster %}
-
- | {% trans "Cluster" %} |
-
- {% if object.cluster.group %}
- {{ object.cluster.group|linkify }} /
- {% endif %}
- {{ object.cluster|linkify }}
- |
-
- {% endif %}
-
-
+ {{ management_panel }}
{% if object.powerports.exists and object.poweroutlets.exists %}
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 %}
-
- - {{ device.parent_bay.device|linkify }}
- - {{ device.parent_bay }}
-
-{% else %}
- {{ ''|placeholder }}
+{% load i18n %}
+
+ - {{ value.device|linkify }}
+ - {{ value }}
+
+{% 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 %}