Просмотр исходного кода

Closes #20924: Ready UI components for use by plugins (#21827)

* Misc cleanup

* Include permissions in TemplatedAttr context

* Introduce CircuitTerminationPanel to replace generic panel

* Replace all instantiations of Panel with TemplatePanel

* Misc cleanup for layouts

* Enable specifying column grid width

* Panel.render() should pass the request to render_to_string()

* CopyContent does not need to override render()

* Avoid setting mutable panel actions

* Catch exceptions raised when rendering embedded plugin content

* Handle panel title when object is not available

* Introduce should_render() method on Panel class

* Misc cleanup

* Pass the value returned by get_context() to should_render()

* Yet more cleanup

* Fix typos

* Clean up object attrs

* Replace candidate template panels with ObjectAttributesPanel subclasses

* Add tests for object attrs

* Remove beta warning

* PluginContentPanel should not call should_render()

* Clean up AddObject

* speed.html should reference value for port_speed

* Address PR feedback
Jeremy Stretch 8 часов назад
Родитель
Сommit
bcc410d99f

+ 69 - 28
docs/plugins/development/ui-components.md

@@ -1,12 +1,9 @@
 # UI Components
 
-!!! note "New in NetBox v4.5"
-    All UI components described here were introduced in NetBox v4.5. Be sure to set the minimum NetBox version to 4.5.0 for your plugin before incorporating any of these resources.
+!!! note "New in NetBox v4.6"
+    All UI components described here were introduced in NetBox v4.6. Be sure to set the minimum NetBox version to 4.6.0 for your plugin before incorporating any of these resources.
 
-!!! danger "Beta Feature"
-    UI components are considered a beta feature, and are still under active development. Please be aware that the API for resources on this page is subject to change in future releases.
-
-To simply the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
+To simplify the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
 
 ## Page Layout
 
@@ -75,9 +72,12 @@ class RecentChangesPanel(Panel):
             **super().get_context(context),
             'changes': get_changes()[:10],
         }
+
+    def should_render(self, context):
+        return len(context['changes']) > 0
 ```
 
-NetBox also includes a set of panels suite for specific uses, such as display object details or embedding a table of related objects. These are listed below.
+NetBox also includes a set of panels suited for specific uses, such as displaying object details or embedding a table of related objects. These are listed below.
 
 ::: netbox.ui.panels.Panel
 
@@ -85,26 +85,6 @@ NetBox also includes a set of panels suite for specific uses, such as display ob
 
 ::: netbox.ui.panels.ObjectAttributesPanel
 
-#### Object Attributes
-
-The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
-
-| Class                                | Description                                      |
-|--------------------------------------|--------------------------------------------------|
-| `netbox.ui.attrs.AddressAttr`        | A physical or mailing address.                   |
-| `netbox.ui.attrs.BooleanAttr`        | A boolean value                                  |
-| `netbox.ui.attrs.ColorAttr`          | A color expressed in RGB                         |
-| `netbox.ui.attrs.ChoiceAttr`         | A selection from a set of choices                |
-| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude)         |
-| `netbox.ui.attrs.ImageAttr`          | An attached image (displays the image)           |
-| `netbox.ui.attrs.NestedObjectAttr`   | A related nested object                          |
-| `netbox.ui.attrs.NumericAttr`        | An integer or float value                        |
-| `netbox.ui.attrs.RelatedObjectAttr`  | A related object                                 |
-| `netbox.ui.attrs.TemplatedAttr`      | Renders an attribute using a custom template     |
-| `netbox.ui.attrs.TextAttr`           | A string (text) value                            |
-| `netbox.ui.attrs.TimezoneAttr`       | A timezone with annotated offset                 |
-| `netbox.ui.attrs.UtilizationAttr`    | A numeric value expressed as a utilization graph |
-
 ::: netbox.ui.panels.OrganizationalObjectPanel
 
 ::: netbox.ui.panels.NestedGroupObjectPanel
@@ -119,9 +99,13 @@ The following classes are available to represent object attributes within an Obj
 
 ::: netbox.ui.panels.TemplatePanel
 
+::: netbox.ui.panels.TextCodePanel
+
+::: netbox.ui.panels.ContextTablePanel
+
 ::: netbox.ui.panels.PluginContentPanel
 
-## Panel Actions
+### Panel Actions
 
 Each panel may have actions associated with it. These render as links or buttons within the panel header, opposite the panel's title. For example, a common use case is to include an "Add" action on a panel which displays a list of objects. Below is an example of this.
 
@@ -146,3 +130,60 @@ panels.ObjectsTablePanel(
 ::: netbox.ui.actions.AddObject
 
 ::: netbox.ui.actions.CopyContent
+
+## Object Attributes
+
+The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
+
+| Class                                    | Description                                      |
+|------------------------------------------|--------------------------------------------------|
+| `netbox.ui.attrs.AddressAttr`            | A physical or mailing address.                   |
+| `netbox.ui.attrs.BooleanAttr`            | A boolean value                                  |
+| `netbox.ui.attrs.ChoiceAttr`             | A selection from a set of choices                |
+| `netbox.ui.attrs.ColorAttr`              | A color expressed in RGB                         |
+| `netbox.ui.attrs.DateTimeAttr`           | A date or datetime value                         |
+| `netbox.ui.attrs.GenericForeignKeyAttr`  | A related object via a generic foreign key       |
+| `netbox.ui.attrs.GPSCoordinatesAttr`     | GPS coordinates (latitude and longitude)         |
+| `netbox.ui.attrs.ImageAttr`              | An attached image (displays the image)           |
+| `netbox.ui.attrs.NestedObjectAttr`       | A related nested object (includes ancestors)     |
+| `netbox.ui.attrs.NumericAttr`            | An integer or float value                        |
+| `netbox.ui.attrs.RelatedObjectAttr`      | A related object                                 |
+| `netbox.ui.attrs.RelatedObjectListAttr`  | A list of related objects                        |
+| `netbox.ui.attrs.TemplatedAttr`          | Renders an attribute using a custom template     |
+| `netbox.ui.attrs.TextAttr`               | A string (text) value                            |
+| `netbox.ui.attrs.TimezoneAttr`           | A timezone with annotated offset                 |
+| `netbox.ui.attrs.UtilizationAttr`        | A numeric value expressed as a utilization graph |
+
+::: netbox.ui.attrs.ObjectAttribute
+
+::: netbox.ui.attrs.AddressAttr
+
+::: netbox.ui.attrs.BooleanAttr
+
+::: netbox.ui.attrs.ChoiceAttr
+
+::: netbox.ui.attrs.ColorAttr
+
+::: netbox.ui.attrs.DateTimeAttr
+
+::: netbox.ui.attrs.GenericForeignKeyAttr
+
+::: netbox.ui.attrs.GPSCoordinatesAttr
+
+::: netbox.ui.attrs.ImageAttr
+
+::: netbox.ui.attrs.NestedObjectAttr
+
+::: netbox.ui.attrs.NumericAttr
+
+::: netbox.ui.attrs.RelatedObjectAttr
+
+::: netbox.ui.attrs.RelatedObjectListAttr
+
+::: netbox.ui.attrs.TemplatedAttr
+
+::: netbox.ui.attrs.TextAttr
+
+::: netbox.ui.attrs.TimezoneAttr
+
+::: netbox.ui.attrs.UtilizationAttr

+ 23 - 7
netbox/circuits/ui/panels.py

@@ -13,13 +13,9 @@ class CircuitCircuitTerminationPanel(panels.ObjectPanel):
     template_name = 'circuits/panels/circuit_circuit_termination.html'
     title = _('Termination')
 
-    def __init__(self, accessor=None, side=None, **kwargs):
-        super().__init__(**kwargs)
-
-        if accessor is not None:
-            self.accessor = accessor
-        if side is not None:
-            self.side = side
+    def __init__(self, side, accessor=None, **kwargs):
+        super().__init__(accessor=accessor, **kwargs)
+        self.side = side
 
     def get_context(self, context):
         return {
@@ -58,6 +54,26 @@ class CircuitGroupAssignmentsPanel(panels.ObjectsTablePanel):
         )
 
 
+class CircuitTerminationPanel(panels.ObjectAttributesPanel):
+    title = _('Circuit Termination')
+    circuit = attrs.RelatedObjectAttr('circuit', linkify=True)
+    provider = attrs.RelatedObjectAttr('circuit.provider', linkify=True)
+    termination = attrs.GenericForeignKeyAttr('termination', linkify=True, label=_('Termination point'))
+    connection = attrs.TemplatedAttr(
+        'pk',
+        template_name='circuits/circuit_termination/attrs/connection.html',
+        label=_('Connection'),
+    )
+    speed = attrs.TemplatedAttr(
+        'port_speed',
+        template_name='circuits/circuit_termination/attrs/speed.html',
+        label=_('Speed'),
+    )
+    xconnect_id = attrs.TextAttr('xconnect_id', label=_('Cross-Connect'), style='font-monospace')
+    pp_info = attrs.TextAttr('pp_info', label=_('Patch Panel/Port'))
+    description = attrs.TextAttr('description')
+
+
 class CircuitGroupPanel(panels.OrganizationalObjectPanel):
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
 

+ 1 - 5
netbox/circuits/views.py

@@ -8,7 +8,6 @@ from netbox.ui import actions, layout
 from netbox.ui.panels import (
     CommentsPanel,
     ObjectsTablePanel,
-    Panel,
     RelatedObjectsPanel,
 )
 from netbox.views import generic
@@ -512,10 +511,7 @@ class CircuitTerminationView(generic.ObjectView):
     queryset = CircuitTermination.objects.all()
     layout = layout.SimpleLayout(
         left_panels=[
-            Panel(
-                template_name='circuits/panels/circuit_termination.html',
-                title=_('Circuit Termination'),
-            )
+            panels.CircuitTerminationPanel(),
         ],
         right_panels=[
             CustomFieldsPanel(),

+ 6 - 0
netbox/core/views.py

@@ -193,8 +193,14 @@ class DataFileView(generic.ObjectView):
             layout.Column(
                 panels.DataFilePanel(),
                 panels.DataFileContentPanel(),
+                PluginContentPanel('left_page'),
             ),
         ),
+        layout.Row(
+            layout.Column(
+                PluginContentPanel('full_width_page'),
+            )
+        ),
     )
 
 

+ 35 - 43
netbox/dcim/ui/panels.py

@@ -1,5 +1,4 @@
 from django.contrib.contenttypes.models import ContentType
-from django.template.loader import render_to_string
 from django.utils.translation import gettext_lazy as _
 
 from netbox.ui import actions, attrs, panels
@@ -75,7 +74,7 @@ class RackReservationPanel(panels.ObjectAttributesPanel):
     unit_count = attrs.TextAttr('unit_count', label=_("Total U's"))
     status = attrs.ChoiceAttr('status')
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
-    user = attrs.RelatedObjectAttr('user')
+    user = attrs.RelatedObjectAttr('user', linkify=True)
     description = attrs.TextAttr('description')
 
 
@@ -220,8 +219,8 @@ class PowerPortPanel(panels.ObjectAttributesPanel):
     label = attrs.TextAttr('label')
     type = attrs.ChoiceAttr('type')
     description = attrs.TextAttr('description')
-    maximum_draw = attrs.TextAttr('maximum_draw')
-    allocated_draw = attrs.TextAttr('allocated_draw')
+    maximum_draw = attrs.TextAttr('maximum_draw', format_string='{}W')
+    allocated_draw = attrs.TextAttr('allocated_draw', format_string='{}W')
 
 
 class PowerOutletPanel(panels.ObjectAttributesPanel):
@@ -244,7 +243,7 @@ class FrontPortPanel(panels.ObjectAttributesPanel):
     label = attrs.TextAttr('label')
     type = attrs.ChoiceAttr('type')
     color = attrs.ColorAttr('color')
-    positions = attrs.TextAttr('positions')
+    positions = attrs.NumericAttr('positions')
     description = attrs.TextAttr('description')
 
 
@@ -255,7 +254,7 @@ class RearPortPanel(panels.ObjectAttributesPanel):
     label = attrs.TextAttr('label')
     type = attrs.ChoiceAttr('type')
     color = attrs.ColorAttr('color')
-    positions = attrs.TextAttr('positions')
+    positions = attrs.NumericAttr('positions')
     description = attrs.TextAttr('description')
 
 
@@ -268,6 +267,15 @@ class ModuleBayPanel(panels.ObjectAttributesPanel):
     description = attrs.TextAttr('description')
 
 
+class InstalledModulePanel(panels.ObjectAttributesPanel):
+    title = _('Installed Module')
+    module = attrs.RelatedObjectAttr('installed_module', linkify=True)
+    manufacturer = attrs.RelatedObjectAttr('installed_module.module_type.manufacturer', linkify=True)
+    module_type = attrs.RelatedObjectAttr('installed_module.module_type', linkify=True)
+    serial = attrs.TextAttr('installed_module.serial', label=_('Serial number'), style='font-monospace')
+    asset_tag = attrs.TextAttr('installed_module.asset_tag', style='font-monospace')
+
+
 class DeviceBayPanel(panels.ObjectAttributesPanel):
     device = attrs.RelatedObjectAttr('device', linkify=True)
     name = attrs.TextAttr('name')
@@ -275,6 +283,12 @@ class DeviceBayPanel(panels.ObjectAttributesPanel):
     description = attrs.TextAttr('description')
 
 
+class InstalledDevicePanel(panels.ObjectAttributesPanel):
+    title = _('Installed Device')
+    device = attrs.RelatedObjectAttr('installed_device', linkify=True)
+    device_type = attrs.RelatedObjectAttr('installed_device.device_type')
+
+
 class InventoryItemPanel(panels.ObjectAttributesPanel):
     device = attrs.RelatedObjectAttr('device', linkify=True)
     parent = attrs.RelatedObjectAttr('parent', linkify=True, label=_('Parent item'))
@@ -393,10 +407,6 @@ class ConnectionPanel(panels.ObjectPanel):
             'show_endpoints': self.show_endpoints,
         }
 
-    def render(self, context):
-        ctx = self.get_context(context)
-        return render_to_string(self.template_name, ctx, request=ctx.get('request'))
-
 
 class InventoryItemsPanel(panels.ObjectPanel):
     """
@@ -414,10 +424,6 @@ class InventoryItemsPanel(panels.ObjectPanel):
         ),
     ]
 
-    def render(self, context):
-        ctx = self.get_context(context)
-        return render_to_string(self.template_name, ctx, request=ctx.get('request'))
-
 
 class VirtualChassisMembersPanel(panels.ObjectPanel):
     """
@@ -451,10 +457,8 @@ class VirtualChassisMembersPanel(panels.ObjectPanel):
             'vc_members': context.get('vc_members'),
         }
 
-    def render(self, context):
-        if not context.get('vc_members'):
-            return ''
-        return super().render(context)
+    def should_render(self, context):
+        return bool(context.get('vc_members'))
 
 
 class PowerUtilizationPanel(panels.ObjectPanel):
@@ -470,11 +474,9 @@ class PowerUtilizationPanel(panels.ObjectPanel):
             'vc_members': context.get('vc_members'),
         }
 
-    def render(self, context):
+    def should_render(self, context):
         obj = context['object']
-        if not obj.powerports.exists() or not obj.poweroutlets.exists():
-            return ''
-        return super().render(context)
+        return obj.powerports.exists() and obj.poweroutlets.exists()
 
 
 class InterfacePanel(panels.ObjectAttributesPanel):
@@ -485,7 +487,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
     type = attrs.ChoiceAttr('type')
     speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
     duplex = attrs.ChoiceAttr('duplex')
-    mtu = attrs.TextAttr('mtu', label=_('MTU'))
+    mtu = attrs.NumericAttr('mtu', label=_('MTU'))
     enabled = attrs.BooleanAttr('enabled')
     mgmt_only = attrs.BooleanAttr('mgmt_only', label=_('Management only'))
     description = attrs.TextAttr('description')
@@ -494,7 +496,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
     mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode'))
     qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
     untagged_vlan = attrs.RelatedObjectAttr('untagged_vlan', linkify=True, label=_('Untagged VLAN'))
-    tx_power = attrs.TextAttr('tx_power', label=_('Transmit power (dBm)'))
+    tx_power = attrs.TextAttr('tx_power', label=_('Transmit power'), format_string='{} dBm')
     tunnel = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel'))
     l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN'))
 
@@ -527,12 +529,9 @@ class InterfaceConnectionPanel(panels.ObjectPanel):
     template_name = 'dcim/panels/interface_connection.html'
     title = _('Connection')
 
-    def render(self, context):
+    def should_render(self, context):
         obj = context.get('object')
-        if obj and obj.is_virtual:
-            return ''
-        ctx = self.get_context(context)
-        return render_to_string(self.template_name, ctx, request=ctx.get('request'))
+        return False if (obj is None or obj.is_virtual) else True
 
 
 class VirtualCircuitPanel(panels.ObjectPanel):
@@ -542,12 +541,11 @@ class VirtualCircuitPanel(panels.ObjectPanel):
     template_name = 'dcim/panels/interface_virtual_circuit.html'
     title = _('Virtual Circuit')
 
-    def render(self, context):
+    def should_render(self, context):
         obj = context.get('object')
         if not obj or not obj.is_virtual or not hasattr(obj, 'virtual_circuit_termination'):
-            return ''
-        ctx = self.get_context(context)
-        return render_to_string(self.template_name, ctx, request=ctx.get('request'))
+            return False
+        return True
 
 
 class InterfaceWirelessPanel(panels.ObjectPanel):
@@ -557,12 +555,9 @@ class InterfaceWirelessPanel(panels.ObjectPanel):
     template_name = 'dcim/panels/interface_wireless.html'
     title = _('Wireless')
 
-    def render(self, context):
+    def should_render(self, context):
         obj = context.get('object')
-        if not obj or not obj.is_wireless:
-            return ''
-        ctx = self.get_context(context)
-        return render_to_string(self.template_name, ctx, request=ctx.get('request'))
+        return False if (obj is None or not obj.is_wireless) else True
 
 
 class WirelessLANsPanel(panels.ObjectPanel):
@@ -572,9 +567,6 @@ class WirelessLANsPanel(panels.ObjectPanel):
     template_name = 'dcim/panels/interface_wireless_lans.html'
     title = _('Wireless LANs')
 
-    def render(self, context):
+    def should_render(self, context):
         obj = context.get('object')
-        if not obj or not obj.is_wireless:
-            return ''
-        ctx = self.get_context(context)
-        return render_to_string(self.template_name, ctx, request=ctx.get('request'))
+        return False if (obj is None or not obj.is_wireless) else True

+ 6 - 13
netbox/dcim/views.py

@@ -27,7 +27,6 @@ from netbox.ui.panels import (
     NestedGroupObjectPanel,
     ObjectsTablePanel,
     OrganizationalObjectPanel,
-    Panel,
     RelatedObjectsPanel,
     TemplatePanel,
 )
@@ -1771,7 +1770,7 @@ class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
             CommentsPanel(),
         ],
         right_panels=[
-            Panel(
+            TemplatePanel(
                 title=_('Attributes'),
                 template_name='dcim/panels/module_type_attributes.html',
             ),
@@ -2945,7 +2944,7 @@ class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
             CommentsPanel(),
         ],
         right_panels=[
-            Panel(
+            TemplatePanel(
                 title=_('Module Type'),
                 template_name='dcim/panels/module_type.html',
             ),
@@ -3753,10 +3752,7 @@ class ModuleBayView(generic.ObjectView):
         ],
         right_panels=[
             CustomFieldsPanel(),
-            Panel(
-                title=_('Installed Module'),
-                template_name='dcim/panels/installed_module.html',
-            ),
+            panels.InstalledModulePanel(),
         ],
     )
 
@@ -3828,10 +3824,7 @@ class DeviceBayView(generic.ObjectView):
             TagsPanel(),
         ],
         right_panels=[
-            Panel(
-                title=_('Installed Device'),
-                template_name='dcim/panels/installed_device.html',
-            ),
+            panels.InstalledDevicePanel(),
         ],
     )
 
@@ -4323,11 +4316,11 @@ class CableView(generic.ObjectView):
             CommentsPanel(),
         ],
         right_panels=[
-            Panel(
+            TemplatePanel(
                 title=_('Termination A'),
                 template_name='dcim/panels/cable_termination_a.html',
             ),
-            Panel(
+            TemplatePanel(
                 title=_('Termination B'),
                 template_name='dcim/panels/cable_termination_b.html',
             ),

+ 2 - 7
netbox/extras/ui/panels.py

@@ -1,5 +1,4 @@
 from django.contrib.contenttypes.models import ContentType
-from django.template.loader import render_to_string
 from django.utils.translation import gettext_lazy as _
 
 from netbox.ui import actions, attrs, panels
@@ -65,12 +64,8 @@ class CustomFieldsPanel(panels.ObjectPanel):
             'custom_fields': obj.get_custom_fields_by_group(),
         }
 
-    def render(self, context):
-        ctx = self.get_context(context)
-        # Hide the panel if no custom fields exist
-        if not ctx['custom_fields']:
-            return ''
-        return render_to_string(self.template_name, self.get_context(context))
+    def should_render(self, context):
+        return bool(context['custom_fields'])
 
 
 class ImageAttachmentsPanel(panels.ObjectsTablePanel):

+ 12 - 3
netbox/ipam/ui/attrs.py

@@ -7,6 +7,9 @@ class VRFDisplayAttr(attrs.ObjectAttribute):
     """
     Renders a VRF reference, displaying 'Global' when no VRF is assigned. Optionally includes
     the route distinguisher (RD).
+
+    Parameters:
+         show_rd (bool): If true, the VRF's RD will be included. (Default: False)
     """
     template_name = 'ipam/attrs/vrf.html'
 
@@ -14,11 +17,17 @@ class VRFDisplayAttr(attrs.ObjectAttribute):
         super().__init__(*args, **kwargs)
         self.show_rd = show_rd
 
+    def get_context(self, obj, attr, value, context):
+        return {
+            'show_rd': self.show_rd,
+        }
+
     def render(self, obj, context):
+        name = context['name']
         value = self.get_value(obj)
+
         return render_to_string(self.template_name, {
-            **self.get_context(obj, context),
-            'name': context['name'],
+            **self.get_context(obj, name, value, context),
+            'name': name,
             'value': value,
-            'show_rd': self.show_rd,
         })

+ 2 - 4
netbox/ipam/ui/panels.py

@@ -229,11 +229,9 @@ class VLANCustomerVLANsPanel(panels.ObjectsTablePanel):
             ],
         )
 
-    def render(self, context):
+    def should_render(self, context):
         obj = context.get('object')
-        if not obj or obj.qinq_role != 'svlan':
-            return ''
-        return super().render(context)
+        return False if (obj is None or obj.qinq_role != 'svlan') else True
 
 
 class ServiceTemplatePanel(panels.ObjectAttributesPanel):

+ 15 - 0
netbox/ipam/views.py

@@ -16,6 +16,7 @@ from netbox.ui.panels import (
     CommentsPanel,
     ContextTablePanel,
     ObjectsTablePanel,
+    PluginContentPanel,
     RelatedObjectsPanel,
     TemplatePanel,
 )
@@ -55,11 +56,13 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
             layout.Column(
                 panels.VRFPanel(),
                 TagsPanel(),
+                PluginContentPanel('left_page'),
             ),
             layout.Column(
                 RelatedObjectsPanel(),
                 CustomFieldsPanel(),
                 CommentsPanel(),
+                PluginContentPanel('right_page'),
             ),
         ),
         layout.Row(
@@ -70,6 +73,11 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
                 ContextTablePanel('export_targets_table', title=_('Export route targets')),
             ),
         ),
+        layout.Row(
+            layout.Column(
+                PluginContentPanel('full_width_page'),
+            ),
+        ),
     )
 
     def get_extra_context(self, request, instance):
@@ -169,10 +177,12 @@ class RouteTargetView(generic.ObjectView):
             layout.Column(
                 panels.RouteTargetPanel(),
                 TagsPanel(),
+                PluginContentPanel('left_page'),
             ),
             layout.Column(
                 CustomFieldsPanel(),
                 CommentsPanel(),
+                PluginContentPanel('right_page'),
             ),
         ),
         layout.Row(
@@ -207,6 +217,11 @@ class RouteTargetView(generic.ObjectView):
                 ),
             ),
         ),
+        layout.Row(
+            layout.Column(
+                PluginContentPanel('full_width_page'),
+            ),
+        ),
     )
 
 

+ 180 - 3
netbox/netbox/tests/test_ui.py

@@ -1,3 +1,5 @@
+from types import SimpleNamespace
+
 from django.test import TestCase
 
 from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
@@ -76,7 +78,7 @@ class ChoiceAttrTest(TestCase):
             self.termination.get_role_display(),
         )
         self.assertEqual(
-            attr.get_context(self.termination, {}),
+            attr.get_context(self.termination, 'role', attr.get_value(self.termination), {}),
             {'bg_color': self.termination.get_role_color()},
         )
 
@@ -88,7 +90,7 @@ class ChoiceAttrTest(TestCase):
             self.termination.interface.get_type_display(),
         )
         self.assertEqual(
-            attr.get_context(self.termination, {}),
+            attr.get_context(self.termination, 'interface.type', attr.get_value(self.termination), {}),
             {'bg_color': None},
         )
 
@@ -100,7 +102,9 @@ class ChoiceAttrTest(TestCase):
             self.termination.virtual_circuit.get_status_display(),
         )
         self.assertEqual(
-            attr.get_context(self.termination, {}),
+            attr.get_context(
+                self.termination, 'virtual_circuit.status', attr.get_value(self.termination), {}
+            ),
             {'bg_color': self.termination.virtual_circuit.get_status_color()},
         )
 
@@ -213,3 +217,176 @@ class RelatedObjectListAttrTest(TestCase):
         self.assertInHTML('<li>IKE Proposal 2</li>', rendered)
         self.assertNotIn('IKE Proposal 3', rendered)
         self.assertIn('…', rendered)
+
+
+class TextAttrTest(TestCase):
+
+    def test_get_value_with_format_string(self):
+        attr = attrs.TextAttr('asn', format_string='AS{}')
+        obj = SimpleNamespace(asn=65000)
+        self.assertEqual(attr.get_value(obj), 'AS65000')
+
+    def test_get_value_without_format_string(self):
+        attr = attrs.TextAttr('name')
+        obj = SimpleNamespace(name='foo')
+        self.assertEqual(attr.get_value(obj), 'foo')
+
+    def test_get_value_none_skips_format_string(self):
+        attr = attrs.TextAttr('name', format_string='prefix-{}')
+        obj = SimpleNamespace(name=None)
+        self.assertIsNone(attr.get_value(obj))
+
+    def test_get_context(self):
+        attr = attrs.TextAttr('name', style='text-monospace', copy_button=True)
+        obj = SimpleNamespace(name='bar')
+        context = attr.get_context(obj, 'name', 'bar', {})
+        self.assertEqual(context['style'], 'text-monospace')
+        self.assertTrue(context['copy_button'])
+
+
+class NumericAttrTest(TestCase):
+
+    def test_get_context_with_unit_accessor(self):
+        attr = attrs.NumericAttr('speed', unit_accessor='speed_unit')
+        obj = SimpleNamespace(speed=1000, speed_unit='Mbps')
+        context = attr.get_context(obj, 'speed', 1000, {})
+        self.assertEqual(context['unit'], 'Mbps')
+
+    def test_get_context_without_unit_accessor(self):
+        attr = attrs.NumericAttr('speed')
+        obj = SimpleNamespace(speed=1000)
+        context = attr.get_context(obj, 'speed', 1000, {})
+        self.assertIsNone(context['unit'])
+
+    def test_get_context_copy_button(self):
+        attr = attrs.NumericAttr('speed', copy_button=True)
+        obj = SimpleNamespace(speed=1000)
+        context = attr.get_context(obj, 'speed', 1000, {})
+        self.assertTrue(context['copy_button'])
+
+
+class BooleanAttrTest(TestCase):
+
+    def test_false_value_shown_by_default(self):
+        attr = attrs.BooleanAttr('enabled')
+        obj = SimpleNamespace(enabled=False)
+        self.assertIs(attr.get_value(obj), False)
+
+    def test_false_value_hidden_when_display_false_disabled(self):
+        attr = attrs.BooleanAttr('enabled', display_false=False)
+        obj = SimpleNamespace(enabled=False)
+        self.assertIsNone(attr.get_value(obj))
+
+    def test_true_value_always_shown(self):
+        attr = attrs.BooleanAttr('enabled', display_false=False)
+        obj = SimpleNamespace(enabled=True)
+        self.assertIs(attr.get_value(obj), True)
+
+
+class ImageAttrTest(TestCase):
+
+    def test_invalid_decoding_raises_value_error(self):
+        with self.assertRaises(ValueError):
+            attrs.ImageAttr('image', decoding='invalid')
+
+    def test_default_decoding_for_lazy_image(self):
+        attr = attrs.ImageAttr('image')
+        self.assertTrue(attr.load_lazy)
+        self.assertEqual(attr.decoding, 'async')
+
+    def test_default_decoding_for_non_lazy_image(self):
+        attr = attrs.ImageAttr('image', load_lazy=False)
+        self.assertFalse(attr.load_lazy)
+        self.assertIsNone(attr.decoding)
+
+    def test_explicit_decoding_value(self):
+        attr = attrs.ImageAttr('image', load_lazy=False, decoding='sync')
+        self.assertEqual(attr.decoding, 'sync')
+
+    def test_get_context(self):
+        attr = attrs.ImageAttr('image', load_lazy=False, decoding='async')
+        obj = SimpleNamespace(image='test.png')
+        context = attr.get_context(obj, 'image', 'test.png', {})
+        self.assertEqual(context['decoding'], 'async')
+        self.assertFalse(context['load_lazy'])
+
+
+class RelatedObjectAttrTest(TestCase):
+
+    def test_get_context_with_grouped_by(self):
+        region = SimpleNamespace(name='Region 1')
+        site = SimpleNamespace(name='Site 1', region=region)
+        obj = SimpleNamespace(site=site)
+        attr = attrs.RelatedObjectAttr('site', grouped_by='region')
+        context = attr.get_context(obj, 'site', site, {})
+        self.assertEqual(context['group'], region)
+
+    def test_get_context_without_grouped_by(self):
+        site = SimpleNamespace(name='Site 1')
+        obj = SimpleNamespace(site=site)
+        attr = attrs.RelatedObjectAttr('site')
+        context = attr.get_context(obj, 'site', site, {})
+        self.assertIsNone(context['group'])
+
+    def test_get_context_linkify(self):
+        site = SimpleNamespace(name='Site 1')
+        obj = SimpleNamespace(site=site)
+        attr = attrs.RelatedObjectAttr('site', linkify=True)
+        context = attr.get_context(obj, 'site', site, {})
+        self.assertTrue(context['linkify'])
+
+
+class GenericForeignKeyAttrTest(TestCase):
+
+    def test_get_context_content_type(self):
+        value = SimpleNamespace(_meta=SimpleNamespace(verbose_name='provider'))
+        obj = SimpleNamespace()
+        attr = attrs.GenericForeignKeyAttr('assigned_object')
+        context = attr.get_context(obj, 'assigned_object', value, {})
+        self.assertEqual(context['content_type'], 'provider')
+
+    def test_get_context_linkify(self):
+        value = SimpleNamespace(_meta=SimpleNamespace(verbose_name='provider'))
+        obj = SimpleNamespace()
+        attr = attrs.GenericForeignKeyAttr('assigned_object', linkify=True)
+        context = attr.get_context(obj, 'assigned_object', value, {})
+        self.assertTrue(context['linkify'])
+
+
+class GPSCoordinatesAttrTest(TestCase):
+
+    def test_missing_latitude_returns_placeholder(self):
+        attr = attrs.GPSCoordinatesAttr()
+        obj = SimpleNamespace(latitude=None, longitude=-74.006)
+        self.assertEqual(attr.render(obj, {'name': 'coordinates'}), attr.placeholder)
+
+    def test_missing_longitude_returns_placeholder(self):
+        attr = attrs.GPSCoordinatesAttr()
+        obj = SimpleNamespace(latitude=40.712, longitude=None)
+        self.assertEqual(attr.render(obj, {'name': 'coordinates'}), attr.placeholder)
+
+    def test_both_missing_returns_placeholder(self):
+        attr = attrs.GPSCoordinatesAttr()
+        obj = SimpleNamespace(latitude=None, longitude=None)
+        self.assertEqual(attr.render(obj, {'name': 'coordinates'}), attr.placeholder)
+
+
+class DateTimeAttrTest(TestCase):
+
+    def test_default_spec(self):
+        attr = attrs.DateTimeAttr('created')
+        obj = SimpleNamespace(created='2024-01-01')
+        context = attr.get_context(obj, 'created', '2024-01-01', {})
+        self.assertEqual(context['spec'], 'seconds')
+
+    def test_date_spec(self):
+        attr = attrs.DateTimeAttr('created', spec='date')
+        obj = SimpleNamespace(created='2024-01-01')
+        context = attr.get_context(obj, 'created', '2024-01-01', {})
+        self.assertEqual(context['spec'], 'date')
+
+    def test_minutes_spec(self):
+        attr = attrs.DateTimeAttr('created', spec='minutes')
+        obj = SimpleNamespace(created='2024-01-01')
+        context = attr.get_context(obj, 'created', '2024-01-01', {})
+        self.assertEqual(context['spec'], 'minutes')

+ 12 - 14
netbox/netbox/ui/actions.py

@@ -59,7 +59,7 @@ class PanelAction:
         """
         # Enforce permissions
         user = context['request'].user
-        if not user.has_perms(self.permissions):
+        if self.permissions and not user.has_perms(self.permissions):
             return ''
 
         return render_to_string(self.template_name, self.get_context(context))
@@ -118,19 +118,19 @@ class AddObject(LinkAction):
         url_params (dict): A dictionary of arbitrary URL parameters to append to the resolved URL
     """
     def __init__(self, model, url_params=None, **kwargs):
-        # Resolve the model class from its app.name label
+        # Resolve the model from its label
+        if '.' not in model:
+            raise ValueError(f"Invalid model label: {model}")
         try:
-            app_label, model_name = model.split('.')
-            model = apps.get_model(app_label, model_name)
-        except (ValueError, LookupError):
+            self.model = apps.get_model(model)
+        except LookupError:
             raise ValueError(f"Invalid model label: {model}")
-        view_name = get_viewname(model, 'add')
 
         kwargs.setdefault('label', _('Add'))
         kwargs.setdefault('button_icon', 'plus-thick')
-        kwargs.setdefault('permissions', [get_permission_for_model(model, 'add')])
+        kwargs.setdefault('permissions', [get_permission_for_model(self.model, 'add')])
 
-        super().__init__(view_name=view_name, url_params=url_params, **kwargs)
+        super().__init__(view_name=get_viewname(self.model, 'add'), url_params=url_params, **kwargs)
 
 
 class CopyContent(PanelAction):
@@ -148,10 +148,8 @@ class CopyContent(PanelAction):
         super().__init__(**kwargs)
         self.target_id = target_id
 
-    def render(self, context):
-        return render_to_string(self.template_name, {
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
             'target_id': self.target_id,
-            'label': self.label,
-            'button_class': self.button_class,
-            'button_icon': self.button_icon,
-        })
+        }

+ 59 - 50
netbox/netbox/ui/attrs.py

@@ -29,10 +29,26 @@ PLACEHOLDER_HTML = '<span class="text-muted">&mdash;</span>'
 
 IMAGE_DECODING_CHOICES = ('auto', 'async', 'sync')
 
+
 #
-# Attributes
+# Mixins
 #
 
+class MapURLMixin:
+    _map_url = None
+
+    @property
+    def map_url(self):
+        if self._map_url is True:
+            return get_config().MAPS_URL
+        if self._map_url:
+            return self._map_url
+        return None
+
+
+#
+# Attributes
+#
 
 class ObjectAttribute:
     """
@@ -64,17 +80,20 @@ class ObjectAttribute:
         """
         return resolve_attr_path(obj, self.accessor)
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         """
         Return any additional template context used to render the attribute value.
 
         Parameters:
             obj (object): The object for which the attribute is being rendered
-            context (dict): The root template context
+            attr (str): The name of the attribute being rendered
+            value: The value of the attribute on the object
+            context (dict): The panel template context
         """
         return {}
 
     def render(self, obj, context):
+        name = context['name']
         value = self.get_value(obj)
 
         # If the value is empty, render a placeholder
@@ -82,8 +101,8 @@ class ObjectAttribute:
             return self.placeholder
 
         return render_to_string(self.template_name, {
-            **self.get_context(obj, context),
-            'name': context['name'],
+            **self.get_context(obj, name, value, context),
+            'name': name,
             'value': value,
         })
 
@@ -112,7 +131,7 @@ class TextAttr(ObjectAttribute):
             return self.format_string.format(value)
         return value
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         return {
             'style': self.style,
             'copy_button': self.copy_button,
@@ -134,7 +153,7 @@ class NumericAttr(ObjectAttribute):
         self.unit_accessor = unit_accessor
         self.copy_button = copy_button
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None
         return {
             'unit': unit,
@@ -172,7 +191,7 @@ class ChoiceAttr(ObjectAttribute):
 
         return resolve_attr_path(target, field_name)
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         target, field_name = self._resolve_target(obj)
         if target is None:
             return {'bg_color': None}
@@ -241,7 +260,7 @@ class ImageAttr(ObjectAttribute):
             decoding = 'async'
         self.decoding = decoding
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         return {
             'decoding': self.decoding,
             'load_lazy': self.load_lazy,
@@ -264,8 +283,7 @@ class RelatedObjectAttr(ObjectAttribute):
         self.linkify = linkify
         self.grouped_by = grouped_by
 
-    def get_context(self, obj, context):
-        value = self.get_value(obj)
+    def get_context(self, obj, attr, value, context):
         group = getattr(value, self.grouped_by, None) if self.grouped_by else None
         return {
             'linkify': self.linkify,
@@ -300,14 +318,13 @@ class RelatedObjectListAttr(RelatedObjectAttr):
         self.max_items = max_items
         self.overflow_indicator = overflow_indicator
 
-    def _get_items(self, obj):
+    def _get_items(self, items):
         """
         Retrieve items from the given object using the accessor path.
 
         Returns a tuple of (items, has_more) where items is a list of resolved objects
         and has_more indicates whether additional items exist beyond the max_items limit.
         """
-        items = resolve_attr_path(obj, self.accessor)
         if items is None:
             return [], False
 
@@ -322,8 +339,8 @@ class RelatedObjectListAttr(RelatedObjectAttr):
 
         return items[:self.max_items], has_more
 
-    def get_context(self, obj, context):
-        items, has_more = self._get_items(obj)
+    def get_context(self, obj, attr, value, context):
+        items, has_more = self._get_items(value)
 
         return {
             'linkify': self.linkify,
@@ -338,14 +355,15 @@ class RelatedObjectListAttr(RelatedObjectAttr):
         }
 
     def render(self, obj, context):
-        context = context or {}
-        context_data = self.get_context(obj, context)
+        name = context['name']
+        value = self.get_value(obj)
+        context_data = self.get_context(obj, name, value, context)
 
         if not context_data['items']:
             return self.placeholder
 
         return render_to_string(self.template_name, {
-            'name': context.get('name'),
+            'name': name,
             **context_data,
         })
 
@@ -366,11 +384,12 @@ class NestedObjectAttr(ObjectAttribute):
         self.linkify = linkify
         self.max_depth = max_depth
 
-    def get_context(self, obj, context):
-        value = self.get_value(obj)
-        nodes = value.get_ancestors(include_self=True)
-        if self.max_depth:
-            nodes = list(nodes)[-self.max_depth:]
+    def get_context(self, obj, attr, value, context):
+        nodes = []
+        if value is not None:
+            nodes = value.get_ancestors(include_self=True)
+            if self.max_depth:
+                nodes = list(nodes)[-self.max_depth:]
         return {
             'nodes': nodes,
             'linkify': self.linkify,
@@ -394,40 +413,35 @@ class GenericForeignKeyAttr(ObjectAttribute):
         super().__init__(*args, **kwargs)
         self.linkify = linkify
 
-    def get_context(self, obj, context):
-        value = self.get_value(obj)
-        content_type = value._meta.verbose_name
+    def get_context(self, obj, attr, value, context):
+        content_type = value._meta.verbose_name if value is not None else None
         return {
             'content_type': content_type,
             'linkify': self.linkify,
         }
 
 
-class AddressAttr(ObjectAttribute):
+class AddressAttr(MapURLMixin, ObjectAttribute):
     """
     A physical or mailing address.
 
     Parameters:
-         map_url (bool): If true, the address will render as a hyperlink using settings.MAPS_URL
+         map_url (bool/str): The URL to use when rendering the address. If True, the address will render as a
+            hyperlink using settings.MAPS_URL.
     """
     template_name = 'ui/attrs/address.html'
 
     def __init__(self, *args, map_url=True, **kwargs):
         super().__init__(*args, **kwargs)
-        if map_url is True:
-            self.map_url = get_config().MAPS_URL
-        elif map_url:
-            self.map_url = map_url
-        else:
-            self.map_url = None
-
-    def get_context(self, obj, context):
+        self._map_url = map_url
+
+    def get_context(self, obj, attr, value, context):
         return {
             'map_url': self.map_url,
         }
 
 
-class GPSCoordinatesAttr(ObjectAttribute):
+class GPSCoordinatesAttr(MapURLMixin, ObjectAttribute):
     """
     A GPS coordinates pair comprising latitude and longitude values.
 
@@ -440,24 +454,18 @@ class GPSCoordinatesAttr(ObjectAttribute):
     label = _('GPS coordinates')
 
     def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
-        super().__init__(accessor=None, **kwargs)
+        super().__init__(accessor=latitude_attr, **kwargs)
         self.latitude_attr = latitude_attr
         self.longitude_attr = longitude_attr
-        if map_url is True:
-            self.map_url = get_config().MAPS_URL
-        elif map_url:
-            self.map_url = map_url
-        else:
-            self.map_url = None
-
-    def render(self, obj, context=None):
-        context = context or {}
+        self._map_url = map_url
+
+    def render(self, obj, context):
         latitude = resolve_attr_path(obj, self.latitude_attr)
         longitude = resolve_attr_path(obj, self.longitude_attr)
         if latitude is None or longitude is None:
             return self.placeholder
         return render_to_string(self.template_name, {
-            **context,
+            'name': context['name'],
             'latitude': latitude,
             'longitude': longitude,
             'map_url': self.map_url,
@@ -478,7 +486,7 @@ class DateTimeAttr(ObjectAttribute):
         super().__init__(*args, **kwargs)
         self.spec = spec
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         return {
             'spec': self.spec,
         }
@@ -504,8 +512,9 @@ class TemplatedAttr(ObjectAttribute):
         self.template_name = template_name
         self.context = context or {}
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         return {
+            **context,
             **self.context,
             'object': obj,
         }

+ 29 - 4
netbox/netbox/ui/layout.py

@@ -21,10 +21,16 @@ class Layout:
     """
     def __init__(self, *rows):
         for i, row in enumerate(rows):
-            if type(row) is not Row:
+            if not isinstance(row, Row):
                 raise TypeError(f"Row {i} must be a Row instance, not {type(row)}.")
         self.rows = rows
 
+    def __iter__(self):
+        return iter(self.rows)
+
+    def __repr__(self):
+        return f"Layout({len(self.rows)} rows)"
+
 
 class Row:
     """
@@ -35,10 +41,16 @@ class Row:
     """
     def __init__(self, *columns):
         for i, column in enumerate(columns):
-            if type(column) is not Column:
+            if not isinstance(column, Column):
                 raise TypeError(f"Column {i} must be a Column instance, not {type(column)}.")
         self.columns = columns
 
+    def __iter__(self):
+        return iter(self.columns)
+
+    def __repr__(self):
+        return f"Row({len(self.columns)} columns)"
+
 
 class Column:
     """
@@ -46,12 +58,25 @@ class Column:
 
     Parameters:
         *panels: One or more Panel instances
+        width: Bootstrap grid column width (1-12). If unset, the column will expand to fill available space.
     """
-    def __init__(self, *panels):
+    def __init__(self, *panels, width=None):
         for i, panel in enumerate(panels):
             if not isinstance(panel, Panel):
                 raise TypeError(f"Panel {i} must be an instance of a Panel, not {type(panel)}.")
+        if width is not None:
+            if type(width) is not int:
+                raise ValueError(f"Column width must be an integer, not {type(width)}")
+            if width not in range(1, 13):
+                raise ValueError(f"Column width must be an integer between 1 and 12 (got {width}).")
         self.panels = panels
+        self.width = width
+
+    def __iter__(self):
+        return iter(self.panels)
+
+    def __repr__(self):
+        return f"Column({len(self.panels)} panels)"
 
 
 #
@@ -62,7 +87,7 @@ class SimpleLayout(Layout):
     """
     A layout with one row of two columns and a second row with one column.
 
-    Plugin content registered for `left_page`, `right_page`, or `full_width_path` is included automatically. Most object
+    Plugin content registered for `left_page`, `right_page`, or `full_width_page` is included automatically. Most object
     views in NetBox utilize this layout.
 
     ```

+ 64 - 36
netbox/netbox/ui/panels.py

@@ -45,18 +45,17 @@ class Panel:
     Parameters:
         title (str): The human-friendly title of the panel
         actions (list): An iterable of PanelActions to include in the panel header
-        template_name (str): Overrides the default template name, if defined
     """
     template_name = None
     title = None
     actions = None
 
-    def __init__(self, title=None, actions=None, template_name=None):
+    def __init__(self, title=None, actions=None):
         if title is not None:
             self.title = title
-        self.actions = actions or self.actions or []
-        if template_name is not None:
-            self.template_name = template_name
+        if actions is not None:
+            self.actions = actions
+        self.actions = list(self.actions) if self.actions else []
 
     def get_context(self, context):
         """
@@ -74,6 +73,15 @@ class Panel:
             'panel_class': self.__class__.__name__,
         }
 
+    def should_render(self, context):
+        """
+        Determines whether the panel should render on the page. (Default: True)
+
+        Parameters:
+            context (dict): The panel's prepared context (the return value of get_context())
+        """
+        return True
+
     def render(self, context):
         """
         Render the panel as HTML.
@@ -81,7 +89,10 @@ class Panel:
         Parameters:
             context (dict): The template context
         """
-        return render_to_string(self.template_name, self.get_context(context))
+        ctx = self.get_context(context)
+        if not self.should_render(ctx):
+            return ''
+        return render_to_string(self.template_name, ctx, request=ctx.get('request'))
 
 
 #
@@ -105,9 +116,15 @@ class ObjectPanel(Panel):
 
     def get_context(self, context):
         obj = resolve_attr_path(context, self.accessor)
+        if self.title is not None:
+            title_ = self.title
+        elif obj is not None:
+            title_ = title(obj._meta.verbose_name)
+        else:
+            title_ = None
         return {
             **super().get_context(context),
-            'title': self.title or title(obj._meta.verbose_name),
+            'title': title_,
             'object': obj,
         }
 
@@ -187,7 +204,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
             'attrs': [
                 {
                     'label': attr.label or self._name_to_label(name),
-                    'value': attr.render(ctx['object'], {'name': name}),
+                    'value': attr.render(ctx['object'], {'name': name, 'perms': ctx['perms']}),
                 } for name, attr in self._attrs.items() if name in attr_names
             ],
         }
@@ -225,9 +242,10 @@ class CommentsPanel(ObjectPanel):
         self.field_name = field_name
 
     def get_context(self, context):
+        ctx = super().get_context(context)
         return {
-            **super().get_context(context),
-            'comments': getattr(context['object'], self.field_name),
+            **ctx,
+            'comments': getattr(ctx['object'], self.field_name, None),
         }
 
 
@@ -249,9 +267,10 @@ class JSONPanel(ObjectPanel):
             self.actions.append(CopyContent(f'panel_{field_name}'))
 
     def get_context(self, context):
+        ctx = super().get_context(context)
         return {
-            **super().get_context(context),
-            'data': getattr(context['object'], self.field_name),
+            **ctx,
+            'data': getattr(ctx['object'], self.field_name, None),
             'field_name': self.field_name,
         }
 
@@ -291,22 +310,27 @@ class ObjectsTablePanel(Panel):
     def __init__(self, model, filters=None, include_columns=None, exclude_columns=None, **kwargs):
         super().__init__(**kwargs)
 
-        # Resolve the model class from its app.name label
-        try:
-            app_label, model_name = model.split('.')
-            self.model = apps.get_model(app_label, model_name)
-        except (ValueError, LookupError):
+        # Validate the model label format
+        if '.' not in model:
             raise ValueError(f"Invalid model label: {model}")
-
+        self.model_label = model
         self.filters = filters or {}
         self.include_columns = include_columns or []
         self.exclude_columns = exclude_columns or []
 
-        # If no title is specified, derive one from the model name
-        if self.title is None:
-            self.title = title(self.model._meta.verbose_name_plural)
+    @property
+    def model(self):
+        try:
+            return apps.get_model(self.model_label)
+        except LookupError:
+            raise ValueError(f"Invalid model label: {self.model_label}")
 
     def get_context(self, context):
+        model = self.model
+
+        # If no title is specified, derive one from the model name
+        panel_title = self.title or title(model._meta.verbose_name_plural)
+
         url_params = {
             k: v(context) if callable(v) else v for k, v in self.filters.items()
         }
@@ -318,7 +342,8 @@ class ObjectsTablePanel(Panel):
             url_params['exclude_columns'] = ','.join(self.exclude_columns)
         return {
             **super().get_context(context),
-            'viewname': get_viewname(self.model, 'list'),
+            'title': panel_title,
+            'viewname': get_viewname(model, 'list'),
             'url_params': dict_to_querydict(url_params),
         }
 
@@ -330,12 +355,17 @@ class TemplatePanel(Panel):
     Parameters:
         template_name (str): The name of the template to render
     """
-    def __init__(self, template_name):
-        super().__init__(template_name=template_name)
+    def __init__(self, template_name, **kwargs):
+        self.template_name = template_name
+        super().__init__(**kwargs)
 
-    def render(self, context):
-        # Pass the entire context to the template
-        return render_to_string(self.template_name, context.flatten())
+    def get_context(self, context):
+        # Pass the entire context to the template, but let the panel's own context take precedence
+        # for panel-specific variables (title, actions, panel_class)
+        return {
+            **context.flatten(),
+            **super().get_context(context)
+        }
 
 
 class TextCodePanel(ObjectPanel):
@@ -350,10 +380,11 @@ class TextCodePanel(ObjectPanel):
         self.show_sync_warning = show_sync_warning
 
     def get_context(self, context):
+        ctx = super().get_context(context)
         return {
-            **super().get_context(context),
+            **ctx,
             'show_sync_warning': self.show_sync_warning,
-            'value': getattr(context.get('object'), self.field_name, None),
+            'value': getattr(ctx['object'], self.field_name, None),
         }
 
 
@@ -369,6 +400,7 @@ class PluginContentPanel(Panel):
         self.method = method
 
     def render(self, context):
+        # Override the default render() method to simply embed rendered plugin content
         obj = context.get('object')
         return _get_registered_content(obj, self.method, context)
 
@@ -399,14 +431,10 @@ class ContextTablePanel(ObjectPanel):
         return context.get(self.table)
 
     def get_context(self, context):
-        table = self._resolve_table(context)
         return {
             **super().get_context(context),
-            'table': table,
+            'table': self._resolve_table(context),
         }
 
-    def render(self, context):
-        table = self._resolve_table(context)
-        if table is None:
-            return ''
-        return super().render(context)
+    def should_render(self, context):
+        return context.get('table') is not None

+ 48 - 0
netbox/templates/circuits/circuit_termination/attrs/connection.html

@@ -0,0 +1,48 @@
+{% load helpers i18n %}
+{% if object.mark_connected %}
+  <div>
+    <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
+    <span class="text-muted">{% trans "Marked as connected" %}</span>
+  </div>
+{% elif object.cable %}
+  <div>
+    <a class="d-block d-md-inline" href="{{ object.cable.get_absolute_url }}">{{ object.cable }}</a> {% trans "to" %}
+    {% for peer in object.link_peers %}
+      {% if peer.device %}
+        {{ peer.device|linkify }}<br/>
+      {% elif peer.circuit %}
+        {{ peer.circuit|linkify }}<br/>
+      {% endif %}
+      {{ peer|linkify }}{% if not forloop.last %},{% endif %}
+    {% endfor %}
+  </div>
+  <div class="mt-1">
+    <a href="{% url 'circuits:circuittermination_trace' pk=object.pk %}" class="btn btn-sm btn-primary" title="{% trans "Trace" %}">
+      <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> {% trans "Trace" %}
+    </a>
+    {% if perms.dcim.change_cable %}
+      <a href="{% url 'dcim:cable_edit' pk=object.cable.pk %}?return_url={{ object.circuit.get_absolute_url }}" title="{% trans "Edit cable" %}" class="btn btn-sm btn-warning">
+        <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> {% trans "Edit" %}
+      </a>
+    {% endif %}
+    {% if perms.dcim.delete_cable %}
+      <a href="{% url 'dcim:cable_delete' pk=object.cable.pk %}?return_url={{ object.circuit.get_absolute_url }}" title="{% trans "Remove cable" %}" class="btn btn-sm btn-danger">
+        <i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {% trans "Disconnect" %}
+      </a>
+    {% endif %}
+  </div>
+{% elif perms.dcim.add_cable %}
+  <div class="dropdown">
+    <button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+      <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
+    </button>
+    <ul class="dropdown-menu">
+      <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">{% trans "Interface" %}</a></li>
+      <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">{% trans "Front Port" %}</a></li>
+      <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">{% trans "Rear Port" %}</a></li>
+      <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
+    </ul>
+  </div>
+{% else %}
+  {{ ''|placeholder }}
+{% endif %}

+ 8 - 0
netbox/templates/circuits/circuit_termination/attrs/speed.html

@@ -0,0 +1,8 @@
+{% load helpers i18n %}
+{% if object.upstream_speed %}
+  <i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ value|humanize_speed }}
+  <i class="mdi mdi-slash-forward"></i>
+  <i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ object.upstream_speed|humanize_speed }}
+{% else %}
+  {{ value|humanize_speed }}
+{% endif %}

+ 0 - 16
netbox/templates/circuits/panels/circuit_termination.html

@@ -1,16 +0,0 @@
-{% extends "ui/panels/_base.html" %}
-{% load helpers i18n %}
-
-{% block panel_content %}
-  <table class="table table-hover attr-table">
-    <tr>
-      <th scope="row">{% trans "Circuit" %}</th>
-      <td>{{ object.circuit|linkify|placeholder }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Provider" %}</th>
-      <td>{{ object.circuit.provider|linkify|placeholder }}</td>
-    </tr>
-    {% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
-  </table>
-{% endblock panel_content %}

+ 0 - 21
netbox/templates/dcim/panels/installed_device.html

@@ -1,21 +0,0 @@
-{% extends "ui/panels/_base.html" %}
-{% load helpers i18n %}
-
-{% block panel_content %}
-  {% if object.installed_device %}
-    {% with device=object.installed_device %}
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Device" %}</th>
-          <td>{{ device|linkify }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Device type" %}</th>
-          <td>{{ device.device_type }}</td>
-        </tr>
-      </table>
-    {% endwith %}
-  {% else %}
-    <div class="card-body text-muted">{% trans "None" %}</div>
-  {% endif %}
-{% endblock panel_content %}

+ 0 - 33
netbox/templates/dcim/panels/installed_module.html

@@ -1,33 +0,0 @@
-{% extends "ui/panels/_base.html" %}
-{% load helpers i18n %}
-
-{% block panel_content %}
-  {% if object.installed_module %}
-    {% with module=object.installed_module %}
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Module" %}</th>
-          <td>{{ module|linkify }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Manufacturer" %}</th>
-          <td>{{ module.module_type.manufacturer|linkify }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Module type" %}</th>
-          <td>{{ module.module_type|linkify }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Serial number" %}</th>
-          <td class="font-monospace">{{ module.serial|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Asset tag" %}</th>
-          <td class="font-monospace">{{ module.asset_tag|placeholder }}</td>
-        </tr>
-      </table>
-    {% endwith %}
-  {% else %}
-    <div class="card-body text-muted">{% trans "None" %}</div>
-  {% endif %}
-{% endblock panel_content %}

+ 4 - 4
netbox/templates/generic/object.html

@@ -124,11 +124,11 @@ Context:
 
 {% block content %}
   {# Render panel layout declared on view class #}
-  {% for row in layout.rows %}
+  {% for row in layout %}
     <div class="row">
-      {% for column in row.columns %}
-        <div class="col">
-          {% for panel in column.panels %}
+      {% for column in row %}
+        <div class="col-12 col-md{% if column.width %}-{{ column.width }}{% endif %}">
+          {% for panel in column %}
             {% render panel %}
           {% endfor %}
         </div>

+ 1 - 1
netbox/templates/ui/attrs/numeric.html

@@ -1,5 +1,5 @@
 {% load i18n %}
-<span{% if style %} class="{{ style }}"{% endif %}>
+<span>
   <span{% if name %} id="attr_{{ name }}"{% endif %}>{{ value }}</span>
   {% if unit %}
     {{ unit|lower }}

+ 12 - 0
netbox/templates/ui/exception.html

@@ -0,0 +1,12 @@
+{% load i18n %}
+<div class="alert alert-danger" role="alert">
+  <div class="alert-icon">
+    <i class="mdi mdi-alert"></i>
+  </div>
+  <div>
+    <p>
+      {% blocktrans %}An error occurred when loading content from plugin {{ plugin }}:{% endblocktrans %}
+    </p>
+    <pre class="p-0">{{ exception }}</pre>
+  </div>
+</div>

+ 12 - 10
netbox/templates/ui/panels/_base.html

@@ -1,15 +1,17 @@
 <!-- begin {{ panel_class|default:"panel" }} -->
 <div class="card">
-  <h2 class="card-header">
-    {{ title }}
-    {% if actions %}
-      <div class="card-actions">
-        {% for action in actions %}
-          {% render action %}
-        {% endfor %}
-      </div>
-    {% endif %}
-  </h2>
+  {% if title or actions %}
+    <h2 class="card-header">
+      {{ title|default:"" }}
+      {% if actions %}
+        <div class="card-actions">
+          {% for action in actions %}
+            {% render action %}
+          {% endfor %}
+        </div>
+      {% endif %}
+    </h2>
+  {% endif %}
   {% block panel_content %}{% endblock %}
 </div>
 <!-- end {{ panel_class|default:"panel" }} -->

+ 0 - 34
netbox/templates/vpn/panels/ipsecprofile_ike_policy.html

@@ -1,34 +0,0 @@
-{% load helpers %}
-{% load i18n %}
-
-<div class="card">
-  <h2 class="card-header">{% trans "IKE Policy" %}</h2>
-  <table class="table table-hover attr-table">
-    <tr>
-      <th scope="row">{% trans "Name" %}</th>
-      <td>{{ object.ike_policy|linkify }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Description" %}</th>
-      <td>{{ object.ike_policy.description|placeholder }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Version" %}</th>
-      <td>{{ object.ike_policy.get_version_display }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Mode" %}</th>
-      <td>{{ object.ike_policy.get_mode_display }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Proposals" %}</th>
-      <td>
-        <ul class="list-unstyled mb-0">
-          {% for proposal in object.ike_policy.proposals.all %}
-            <li><a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a></li>
-          {% endfor %}
-        </ul>
-      </td>
-    </tr>
-  </table>
-</div>

+ 0 - 30
netbox/templates/vpn/panels/ipsecprofile_ipsec_policy.html

@@ -1,30 +0,0 @@
-{% load helpers %}
-{% load i18n %}
-
-<div class="card">
-  <h2 class="card-header">{% trans "IPSec Policy" %}</h2>
-  <table class="table table-hover attr-table">
-    <tr>
-      <th scope="row">{% trans "Name" %}</th>
-      <td>{{ object.ipsec_policy|linkify }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Description" %}</th>
-      <td>{{ object.ipsec_policy.description|placeholder }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Proposals" %}</th>
-      <td>
-        <ul class="list-unstyled mb-0">
-          {% for proposal in object.ipsec_policy.proposals.all %}
-            <li><a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a></li>
-          {% endfor %}
-        </ul>
-      </td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "PFS Group" %}</th>
-      <td>{{ object.ipsec_policy.get_pfs_group_display }}</td>
-    </tr>
-  </table>
-</div>

+ 6 - 2
netbox/utilities/templatetags/plugins.py

@@ -1,5 +1,6 @@
 from django import template as template_
 from django.conf import settings
+from django.template.loader import render_to_string
 from django.utils.safestring import mark_safe
 
 from netbox.plugins import PluginTemplateExtension
@@ -38,8 +39,11 @@ def _get_registered_content(obj, method, template_context):
         context['config'] = settings.PLUGINS_CONFIG.get(plugin_name, {})
 
         # Call the method to render content
-        instance = template_extension(context)
-        content = getattr(instance, method)()
+        try:
+            instance = template_extension(context)
+            content = getattr(instance, method)()
+        except Exception as e:
+            content = render_to_string('ui/exception.html', {'plugin': plugin_name, 'exception': repr(e)})
         html += content
 
     return mark_safe(html)

+ 17 - 0
netbox/vpn/ui/panels.py

@@ -71,6 +71,23 @@ class IPSecProfilePanel(panels.ObjectAttributesPanel):
     mode = attrs.ChoiceAttr('mode')
 
 
+class IPSecProfileIKEPolicyPanel(panels.ObjectAttributesPanel):
+    title = _('IKE Policy')
+    name = attrs.RelatedObjectAttr('ike_policy', linkify=True)
+    description = attrs.TextAttr('ike_policy.description')
+    version = attrs.ChoiceAttr('ike_policy.version', label=_('IKE version'))
+    mode = attrs.ChoiceAttr('ike_policy.mode')
+    proposals = attrs.RelatedObjectListAttr('ike_policy.proposals', linkify=True)
+
+
+class IPSecProfileIPSecPolicyPanel(panels.ObjectAttributesPanel):
+    title = _('IPSec Policy')
+    name = attrs.RelatedObjectAttr('ipsec_policy', linkify=True)
+    description = attrs.TextAttr('ipsec_policy.description')
+    proposals = attrs.RelatedObjectListAttr('ipsec_policy.proposals', linkify=True)
+    pfs_group = attrs.ChoiceAttr('ipsec_policy.pfs_group', label=_('PFS group'))
+
+
 class L2VPNPanel(panels.ObjectAttributesPanel):
     name = attrs.TextAttr('name')
     identifier = attrs.TextAttr('identifier')

+ 2 - 3
netbox/vpn/views.py

@@ -10,7 +10,6 @@ from netbox.ui.panels import (
     ObjectsTablePanel,
     PluginContentPanel,
     RelatedObjectsPanel,
-    TemplatePanel,
 )
 from netbox.views import generic
 from utilities.query import count_related
@@ -591,8 +590,8 @@ class IPSecProfileView(generic.ObjectView):
             CommentsPanel(),
         ],
         right_panels=[
-            TemplatePanel('vpn/panels/ipsecprofile_ike_policy.html'),
-            TemplatePanel('vpn/panels/ipsecprofile_ipsec_policy.html'),
+            panels.IPSecProfileIKEPolicyPanel(),
+            panels.IPSecProfileIPSecPolicyPanel(),
         ],
     )
 

+ 3 - 3
netbox/wireless/ui/panels.py

@@ -34,10 +34,10 @@ class WirelessLinkInterfacePanel(panels.ObjectPanel):
         self.title = title
 
     def get_context(self, context):
-        obj = context['object']
+        ctx = super().get_context(context)
         return {
-            **super().get_context(context),
-            'interface': getattr(obj, self.interface_attr),
+            **ctx,
+            'interface': getattr(ctx['object'], self.interface_attr),
         }