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

+ 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.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 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):
 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,
             }
             }
         )
         )

+ 1 - 1
netbox/netbox/ui/components.py

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

+ 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 %}
 {% 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">
-                <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 %}
             {% 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 }}
-            <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 %}
             {% 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>

+ 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 %}
 {% endif %}

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

@@ -1,18 +1,14 @@
 {% load i18n %}
 {% 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 %}
   {% 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 %}
   {% 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 %}
 {% endif %}