Jeremy Stretch 3 месяцев назад
Родитель
Сommit
7d993cc141

+ 34 - 13
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',
+    )

+ 3 - 2
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')),
         }
 
 

+ 65 - 17
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('<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,
             }
         )

+ 1 - 1
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()
         ]
 

+ 5 - 0
netbox/templates/components/attrs/choice.html

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

+ 0 - 0
netbox/templates/components/gps_coordinates.html → netbox/templates/components/attrs/gps_coordinates.html


+ 0 - 0
netbox/templates/components/nested_object.html → netbox/templates/components/attrs/nested_object.html


+ 0 - 0
netbox/templates/components/object.html → netbox/templates/components/attrs/object.html


+ 7 - 0
netbox/templates/components/attrs/text.html

@@ -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 %}

+ 2 - 186
netbox/templates/dcim/device.html

@@ -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>

+ 11 - 0
netbox/templates/dcim/device/attrs/ipaddress.html

@@ -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>

+ 9 - 7
netbox/templates/dcim/device/attrs/parent_device.html

@@ -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 %}

+ 10 - 14
netbox/templates/dcim/device/attrs/rack.html

@@ -1,18 +1,14 @@
 {% load i18n %}
-{% if value %}
-  <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>
+<span>
+  {{ value|linkify }}
   {% 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" %}">
-      <i class="mdi mdi-view-day-outline"></i>
-    </a>
+    (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 %}
-{% else %}
-  {{ ''|placeholder }}
+</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 %}