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

Merge pull request #20737 from netbox-community/20204-template-components

Closes #20204: Introduce modular template components
bctiemann 3 месяцев назад
Родитель
Сommit
1d2f6a82cb
60 измененных файлов с 2060 добавлено и 1279 удалено
  1. 148 0
      docs/plugins/development/ui-components.md
  2. 1 0
      mkdocs.yml
  3. 0 0
      netbox/dcim/ui/__init__.py
  4. 189 0
      netbox/dcim/ui/panels.py
  5. 266 1
      netbox/dcim/views.py
  6. 68 0
      netbox/extras/ui/panels.py
  7. 0 0
      netbox/netbox/ui/__init__.py
  8. 157 0
      netbox/netbox/ui/actions.py
  9. 344 0
      netbox/netbox/ui/attrs.py
  10. 94 0
      netbox/netbox/ui/layout.py
  11. 341 0
      netbox/netbox/ui/panels.py
  12. 3 0
      netbox/netbox/views/generic/object_views.py
  13. 0 374
      netbox/templates/dcim/device.html
  14. 10 0
      netbox/templates/dcim/device/attrs/ipaddress.html
  15. 10 0
      netbox/templates/dcim/device/attrs/parent_device.html
  16. 14 0
      netbox/templates/dcim/device/attrs/rack.html
  17. 3 0
      netbox/templates/dcim/device/attrs/total_weight.html
  18. 0 111
      netbox/templates/dcim/devicetype.html
  19. 0 97
      netbox/templates/dcim/location.html
  20. 0 35
      netbox/templates/dcim/manufacturer.html
  21. 0 58
      netbox/templates/dcim/moduletypeprofile.html
  22. 26 0
      netbox/templates/dcim/panels/device_rack_elevations.html
  23. 50 0
      netbox/templates/dcim/panels/power_utilization.html
  24. 22 0
      netbox/templates/dcim/panels/rack_elevations.html
  25. 15 0
      netbox/templates/dcim/panels/rack_reservation_elevations.html
  26. 31 0
      netbox/templates/dcim/panels/virtual_chassis_members.html
  27. 0 152
      netbox/templates/dcim/rack.html
  28. 3 0
      netbox/templates/dcim/rack/attrs/total_weight.html
  29. 0 91
      netbox/templates/dcim/rackreservation.html
  30. 0 41
      netbox/templates/dcim/rackrole.html
  31. 0 74
      netbox/templates/dcim/racktype.html
  32. 0 53
      netbox/templates/dcim/region.html
  33. 0 138
      netbox/templates/dcim/site.html
  34. 0 53
      netbox/templates/dcim/sitegroup.html
  35. 31 0
      netbox/templates/extras/panels/custom_fields.html
  36. 15 0
      netbox/templates/extras/panels/tags.html
  37. 14 1
      netbox/templates/generic/object.html
  38. 7 0
      netbox/templates/ui/actions/copy_content.html
  39. 6 0
      netbox/templates/ui/actions/link.html
  40. 8 0
      netbox/templates/ui/attrs/address.html
  41. 1 0
      netbox/templates/ui/attrs/boolean.html
  42. 5 0
      netbox/templates/ui/attrs/choice.html
  43. 1 0
      netbox/templates/ui/attrs/color.html
  44. 8 0
      netbox/templates/ui/attrs/gps_coordinates.html
  45. 3 0
      netbox/templates/ui/attrs/image.html
  46. 11 0
      netbox/templates/ui/attrs/nested_object.html
  47. 12 0
      netbox/templates/ui/attrs/numeric.html
  48. 14 0
      netbox/templates/ui/attrs/object.html
  49. 7 0
      netbox/templates/ui/attrs/text.html
  50. 6 0
      netbox/templates/ui/attrs/timezone.html
  51. 2 0
      netbox/templates/ui/attrs/utilization.html
  52. 15 0
      netbox/templates/ui/panels/_base.html
  53. 12 0
      netbox/templates/ui/panels/comments.html
  54. 5 0
      netbox/templates/ui/panels/embedded_table.html
  55. 5 0
      netbox/templates/ui/panels/json.html
  56. 14 0
      netbox/templates/ui/panels/object_attributes.html
  57. 5 0
      netbox/templates/ui/panels/objects_table.html
  58. 25 0
      netbox/templates/ui/panels/related_objects.html
  59. 24 0
      netbox/utilities/data.py
  60. 9 0
      netbox/utilities/templatetags/builtins/tags.py

+ 148 - 0
docs/plugins/development/ui-components.md

@@ -0,0 +1,148 @@
+# 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.
+
+!!! 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.
+
+## Page Layout
+
+A layout defines the general arrangement of content on a page into rows and columns. The layout is defined under the [view](./views.md) and declares a set of rows, each of which may have one or more columns. Below is an example layout.
+
+```
++-------+-------+-------+
+| Col 1 | Col 2 | Col 3 |
++-------+-------+-------+
+|         Col 4         |
++-----------+-----------+
+|   Col 5   |   Col 6   |
++-----------+-----------+
+```
+
+The above layout can be achieved with the following declaration under a view:
+
+```python
+from netbox.ui import layout
+from netbox.views import generic
+
+class MyView(generic.ObjectView):
+    layout = layout.Layout(
+        layout.Row(
+            layout.Column(),
+            layout.Column(),
+            layout.Column(),
+        ),
+        layout.Row(
+            layout.Column(),
+        ),
+        layout.Row(
+            layout.Column(),
+            layout.Column(),
+        ),
+    )
+```
+
+!!! note
+    Currently, layouts are supported only for subclasses of [`generic.ObjectView`](./views.md#netbox.views.generic.ObjectView).
+
+::: netbox.ui.layout.Layout
+
+::: netbox.ui.layout.SimpleLayout
+
+::: netbox.ui.layout.Row
+
+::: netbox.ui.layout.Column
+
+## Panels
+
+Within each column, related blocks of content are arranged into panels. Each panel has a title and may have a set of associated actions, but the content within is otherwise arbitrary.
+
+Plugins can define their own panels by inheriting from the base class `netbox.ui.panels.Panel`. Override the `get_context()` method to pass additional context to your custom panel template. An example is provided below.
+
+```python
+from django.utils.translation import gettext_lazy as _
+from netbox.ui.panels import Panel
+
+class RecentChangesPanel(Panel):
+    template_name = 'my_plugin/panels/recent_changes.html'
+    title = _('Recent Changes')
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'changes': get_changes()[:10],
+        }
+```
+
+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.ui.panels.Panel
+
+::: netbox.ui.panels.ObjectPanel
+
+::: 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
+
+::: netbox.ui.panels.CommentsPanel
+
+::: netbox.ui.panels.JSONPanel
+
+::: netbox.ui.panels.RelatedObjectsPanel
+
+::: netbox.ui.panels.ObjectsTablePanel
+
+::: netbox.ui.panels.TemplatePanel
+
+::: netbox.ui.panels.PluginContentPanel
+
+## 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.
+
+```python
+from django.utils.translation import gettext_lazy as _
+from netbox.ui import actions, panels
+
+panels.ObjectsTablePanel(
+    model='dcim.Region',
+    title=_('Child Regions'),
+    filters={'parent_id': lambda ctx: ctx['object'].pk},
+    actions=[
+        actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
+    ],
+),
+```
+
+::: netbox.ui.actions.PanelAction
+
+::: netbox.ui.actions.LinkAction
+
+::: netbox.ui.actions.AddObject
+
+::: netbox.ui.actions.CopyContent

+ 1 - 0
mkdocs.yml

@@ -143,6 +143,7 @@ nav:
             - Getting Started: 'plugins/development/index.md'
             - Models: 'plugins/development/models.md'
             - Views: 'plugins/development/views.md'
+            - UI Components: 'plugins/development/ui-components.md'
             - Navigation: 'plugins/development/navigation.md'
             - Templates: 'plugins/development/templates.md'
             - Tables: 'plugins/development/tables.md'

+ 0 - 0
netbox/dcim/ui/__init__.py


+ 189 - 0
netbox/dcim/ui/panels.py

@@ -0,0 +1,189 @@
+from django.utils.translation import gettext_lazy as _
+
+from netbox.ui import attrs, panels
+
+
+class SitePanel(panels.ObjectAttributesPanel):
+    region = attrs.NestedObjectAttr('region', linkify=True)
+    group = attrs.NestedObjectAttr('group', linkify=True)
+    name = attrs.TextAttr('name')
+    status = attrs.ChoiceAttr('status')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    facility = attrs.TextAttr('facility')
+    description = attrs.TextAttr('description')
+    timezone = attrs.TimezoneAttr('time_zone')
+    physical_address = attrs.AddressAttr('physical_address', map_url=True)
+    shipping_address = attrs.AddressAttr('shipping_address', map_url=True)
+    gps_coordinates = attrs.GPSCoordinatesAttr()
+
+
+class LocationPanel(panels.NestedGroupObjectPanel):
+    site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
+    status = attrs.ChoiceAttr('status')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    facility = attrs.TextAttr('facility')
+
+
+class RackDimensionsPanel(panels.ObjectAttributesPanel):
+    form_factor = attrs.ChoiceAttr('form_factor')
+    width = attrs.ChoiceAttr('width')
+    height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height'))
+    outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display')
+    outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display')
+    outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display')
+    mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm')
+
+
+class RackNumberingPanel(panels.ObjectAttributesPanel):
+    starting_unit = attrs.TextAttr('starting_unit')
+    desc_units = attrs.BooleanAttr('desc_units', label=_('Descending units'))
+
+
+class RackPanel(panels.ObjectAttributesPanel):
+    region = attrs.NestedObjectAttr('site.region', linkify=True)
+    site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
+    location = attrs.NestedObjectAttr('location', linkify=True)
+    name = attrs.TextAttr('name')
+    facility = attrs.TextAttr('facility', label=_('Facility ID'))
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    status = attrs.ChoiceAttr('status')
+    rack_type = attrs.RelatedObjectAttr('rack_type', linkify=True, grouped_by='manufacturer')
+    role = attrs.RelatedObjectAttr('role', linkify=True)
+    description = attrs.TextAttr('description')
+    serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
+    asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
+    airflow = attrs.ChoiceAttr('airflow')
+    space_utilization = attrs.UtilizationAttr('get_utilization')
+    power_utilization = attrs.UtilizationAttr('get_power_utilization')
+
+
+class RackWeightPanel(panels.ObjectAttributesPanel):
+    weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
+    max_weight = attrs.NumericAttr('max_weight', unit_accessor='get_weight_unit_display', label=_('Maximum weight'))
+    total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/rack/attrs/total_weight.html')
+
+
+class RackRolePanel(panels.OrganizationalObjectPanel):
+    color = attrs.ColorAttr('color')
+
+
+class RackReservationPanel(panels.ObjectAttributesPanel):
+    units = attrs.TextAttr('unit_list')
+    status = attrs.ChoiceAttr('status')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    user = attrs.RelatedObjectAttr('user')
+    description = attrs.TextAttr('description')
+
+
+class RackTypePanel(panels.ObjectAttributesPanel):
+    manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
+    model = attrs.TextAttr('model')
+    description = attrs.TextAttr('description')
+
+
+class DevicePanel(panels.ObjectAttributesPanel):
+    region = attrs.NestedObjectAttr('site.region', linkify=True)
+    site = attrs.RelatedObjectAttr('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.RelatedObjectAttr('virtual_chassis', linkify=True)
+    parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html')
+    gps_coordinates = attrs.GPSCoordinatesAttr()
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    device_type = attrs.RelatedObjectAttr('device_type', linkify=True, grouped_by='manufacturer')
+    description = attrs.TextAttr('description')
+    airflow = attrs.ChoiceAttr('airflow')
+    serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
+    asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
+    config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
+
+
+class DeviceManagementPanel(panels.ObjectAttributesPanel):
+    title = _('Management')
+
+    status = attrs.ChoiceAttr('status')
+    role = attrs.NestedObjectAttr('role', linkify=True, max_depth=3)
+    platform = attrs.NestedObjectAttr('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',
+    )
+    cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
+
+
+class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
+    title = _('Dimensions')
+
+    height = attrs.TextAttr('device_type.u_height', format_string='{}U')
+    total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html')
+
+
+class DeviceTypePanel(panels.ObjectAttributesPanel):
+    manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
+    model = attrs.TextAttr('model')
+    part_number = attrs.TextAttr('part_number')
+    default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True)
+    description = attrs.TextAttr('description')
+    height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height'))
+    exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization')
+    full_depth = attrs.BooleanAttr('is_full_depth')
+    weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
+    subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child'))
+    airflow = attrs.ChoiceAttr('airflow')
+    front_image = attrs.ImageAttr('front_image')
+    rear_image = attrs.ImageAttr('rear_image')
+
+
+class ModuleTypeProfilePanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+
+
+class VirtualChassisMembersPanel(panels.ObjectPanel):
+    """
+    A panel which lists all members of a virtual chassis.
+    """
+    template_name = 'dcim/panels/virtual_chassis_members.html'
+    title = _('Virtual Chassis Members')
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'vc_members': context.get('vc_members'),
+        }
+
+    def render(self, context):
+        if not context.get('vc_members'):
+            return ''
+        return super().render(context)
+
+
+class PowerUtilizationPanel(panels.ObjectPanel):
+    """
+    A panel which displays the power utilization statistics for a device.
+    """
+    template_name = 'dcim/panels/power_utilization.html'
+    title = _('Power Utilization')
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'vc_members': context.get('vc_members'),
+        }
+
+    def render(self, context):
+        obj = context['object']
+        if not obj.powerports.exists() or not obj.poweroutlets.exists():
+            return ''
+        return super().render(context)

+ 266 - 1
netbox/dcim/views.py

@@ -1,3 +1,4 @@
+from django.conf import settings
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.core.paginator import EmptyPage, PageNotAnInteger
@@ -12,10 +13,17 @@ from django.utils.translation import gettext_lazy as _
 from django.views.generic import View
 
 from circuits.models import Circuit, CircuitTermination
+from dcim.ui import panels
+from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
 from netbox.object_actions import *
+from netbox.ui import actions, layout
+from netbox.ui.panels import (
+    CommentsPanel, JSONPanel, NestedGroupObjectPanel, ObjectsTablePanel, OrganizationalObjectPanel, RelatedObjectsPanel,
+    TemplatePanel,
+)
 from netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -221,6 +229,27 @@ class RegionListView(generic.ObjectListView):
 @register_model_view(Region)
 class RegionView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Region.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            NestedGroupObjectPanel(),
+            TagsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='dcim.Region',
+                title=_('Child Regions'),
+                filters={'parent_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
+                ],
+            ),
+        ]
+    )
 
     def get_extra_context(self, request, instance):
         regions = instance.get_descendants(include_self=True)
@@ -332,6 +361,27 @@ class SiteGroupListView(generic.ObjectListView):
 @register_model_view(SiteGroup)
 class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = SiteGroup.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            NestedGroupObjectPanel(),
+            TagsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='dcim.SiteGroup',
+                title=_('Child Groups'),
+                filters={'parent_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
+                ],
+            ),
+        ]
+    )
 
     def get_extra_context(self, request, instance):
         groups = instance.get_descendants(include_self=True)
@@ -461,6 +511,39 @@ class SiteListView(generic.ObjectListView):
 @register_model_view(Site)
 class SiteView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Site.objects.prefetch_related('tenant__group')
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.SitePanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            ImageAttachmentsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='dcim.Location',
+                filters={'site_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject('dcim.Location', url_params={'site': lambda ctx: ctx['object'].pk}),
+                ],
+            ),
+            ObjectsTablePanel(
+                model='dcim.Device',
+                title=_('Non-Racked Devices'),
+                filters={
+                    'site_id': lambda ctx: ctx['object'].pk,
+                    'rack_id': settings.FILTERS_NULL_CHOICE_VALUE,
+                    'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE,
+                },
+                actions=[
+                    actions.AddObject('dcim.Device', url_params={'site': lambda ctx: ctx['object'].pk}),
+                ],
+            ),
+        ]
+    )
 
     def get_extra_context(self, request, instance):
         return {
@@ -561,6 +644,52 @@ class LocationListView(generic.ObjectListView):
 @register_model_view(Location)
 class LocationView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Location.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.LocationPanel(),
+            TagsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            ImageAttachmentsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='dcim.Location',
+                title=_('Child Locations'),
+                filters={'parent_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'dcim.Location',
+                        url_params={
+                            'site': lambda ctx: ctx['object'].site_id,
+                            'parent': lambda ctx: ctx['object'].pk,
+                        }
+                    ),
+                ],
+            ),
+            ObjectsTablePanel(
+                model='dcim.Device',
+                title=_('Non-Racked Devices'),
+                filters={
+                    'location_id': lambda ctx: ctx['object'].pk,
+                    'rack_id': settings.FILTERS_NULL_CHOICE_VALUE,
+                    'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE,
+                },
+                actions=[
+                    actions.AddObject(
+                        'dcim.Device',
+                        url_params={
+                            'site': lambda ctx: ctx['object'].site_id,
+                            'parent': lambda ctx: ctx['object'].pk,
+                        }
+                    ),
+                ],
+            ),
+        ]
+    )
 
     def get_extra_context(self, request, instance):
         locations = instance.get_descendants(include_self=True)
@@ -661,6 +790,16 @@ class RackRoleListView(generic.ObjectListView):
 @register_model_view(RackRole)
 class RackRoleView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = RackRole.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.RackRolePanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+        ],
+    )
 
     def get_extra_context(self, request, instance):
         return {
@@ -727,7 +866,22 @@ class RackTypeListView(generic.ObjectListView):
 
 @register_model_view(RackType)
 class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = RackType.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.RackTypePanel(),
+            panels.RackDimensionsPanel(title=_('Dimensions')),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            panels.RackNumberingPanel(title=_('Numbering')),
+            panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']),
+            CustomFieldsPanel(),
+            RelatedObjectsPanel(),
+        ],
+    )
 
     def get_extra_context(self, request, instance):
         return {
@@ -845,6 +999,22 @@ class RackElevationListView(generic.ObjectListView):
 @register_model_view(Rack)
 class RackView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.RackPanel(),
+            panels.RackDimensionsPanel(title=_('Dimensions')),
+            panels.RackNumberingPanel(title=_('Numbering')),
+            panels.RackWeightPanel(title=_('Weight')),
+            CustomFieldsPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+            ImageAttachmentsPanel(),
+        ],
+        right_panels=[
+            TemplatePanel('dcim/panels/rack_elevations.html'),
+            RelatedObjectsPanel(),
+        ],
+    )
 
     def get_extra_context(self, request, instance):
         peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
@@ -976,6 +1146,19 @@ class RackReservationListView(generic.ObjectListView):
 @register_model_view(RackReservation)
 class RackReservationView(generic.ObjectView):
     queryset = RackReservation.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location', 'name']),
+            panels.RackReservationPanel(title=_('Reservation')),
+            CustomFieldsPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            TemplatePanel(template_name='dcim/panels/rack_reservation_elevations.html'),
+            RelatedObjectsPanel(),
+        ],
+    )
 
 
 @register_model_view(RackReservation, 'add', detail=False)
@@ -1049,6 +1232,10 @@ class ManufacturerListView(generic.ObjectListView):
 @register_model_view(Manufacturer)
 class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Manufacturer.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[OrganizationalObjectPanel(), TagsPanel()],
+        right_panels=[RelatedObjectsPanel(), CustomFieldsPanel()],
+    )
 
     def get_extra_context(self, request, instance):
         return {
@@ -1122,6 +1309,18 @@ class DeviceTypeListView(generic.ObjectListView):
 @register_model_view(DeviceType)
 class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = DeviceType.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.DeviceTypePanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+            ImageAttachmentsPanel(),
+        ],
+    )
 
     def get_extra_context(self, request, instance):
         return {
@@ -1372,7 +1571,36 @@ class ModuleTypeProfileListView(generic.ObjectListView):
 
 @register_model_view(ModuleTypeProfile)
 class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = ModuleTypeProfile.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ModuleTypeProfilePanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            JSONPanel(field_name='schema', title=_('Schema')),
+            CustomFieldsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='dcim.ModuleType',
+                title=_('Module Types'),
+                filters={
+                    'profile_id': lambda ctx: ctx['object'].pk,
+                },
+                actions=[
+                    actions.AddObject(
+                        'dcim.ModuleType',
+                        url_params={
+                            'profile': lambda ctx: ctx['object'].pk,
+                        }
+                    ),
+                ],
+            ),
+        ]
+    )
 
 
 @register_model_view(ModuleTypeProfile, 'add', detail=False)
@@ -2213,6 +2441,43 @@ class DeviceListView(generic.ObjectListView):
 @register_model_view(Device)
 class DeviceView(generic.ObjectView):
     queryset = Device.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.DevicePanel(),
+            panels.VirtualChassisMembersPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+            ObjectsTablePanel(
+                model='dcim.VirtualDeviceContext',
+                filters={'device_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject('dcim.VirtualDeviceContext', url_params={'device': lambda ctx: ctx['object'].pk}),
+                ],
+            ),
+        ],
+        right_panels=[
+            panels.DeviceManagementPanel(),
+            panels.PowerUtilizationPanel(),
+            ObjectsTablePanel(
+                model='ipam.Service',
+                title=_('Application Services'),
+                filters={'device_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'ipam.Service',
+                        url_params={
+                            'parent_object_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
+                            'parent': lambda ctx: ctx['object'].pk
+                        }
+                    ),
+                ],
+            ),
+            ImageAttachmentsPanel(),
+            panels.DeviceDimensionsPanel(),
+            TemplatePanel('dcim/panels/device_rack_elevations.html'),
+        ],
+    )
 
     def get_extra_context(self, request, instance):
         # VirtualChassis members
@@ -2225,7 +2490,7 @@ class DeviceView(generic.ObjectView):
 
         return {
             'vc_members': vc_members,
-            'svg_extra': f'highlight=id:{instance.pk}'
+            'svg_extra': f'highlight=id:{instance.pk}',
         }
 
 

+ 68 - 0
netbox/extras/ui/panels.py

@@ -0,0 +1,68 @@
+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, panels
+from utilities.data import resolve_attr_path
+
+__all__ = (
+    'CustomFieldsPanel',
+    'ImageAttachmentsPanel',
+    'TagsPanel',
+)
+
+
+class CustomFieldsPanel(panels.ObjectPanel):
+    """
+    A panel showing the value of all custom fields defined on an object.
+    """
+    template_name = 'extras/panels/custom_fields.html'
+    title = _('Custom Fields')
+
+    def get_context(self, context):
+        obj = resolve_attr_path(context, self.accessor)
+        return {
+            **super().get_context(context),
+            '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))
+
+
+class ImageAttachmentsPanel(panels.ObjectsTablePanel):
+    """
+    A panel showing all images attached to the object.
+    """
+    actions = [
+        actions.AddObject(
+            'extras.imageattachment',
+            url_params={
+                'object_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
+                'object_id': lambda ctx: ctx['object'].pk,
+                'return_url': lambda ctx: ctx['object'].get_absolute_url(),
+            },
+            label=_('Attach an image'),
+        ),
+    ]
+
+    def __init__(self, **kwargs):
+        super().__init__('extras.imageattachment', **kwargs)
+
+
+class TagsPanel(panels.ObjectPanel):
+    """
+    A panel showing the tags assigned to the object.
+    """
+    template_name = 'extras/panels/tags.html'
+    title = _('Tags')
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'object': resolve_attr_path(context, self.accessor),
+        }

+ 0 - 0
netbox/netbox/ui/__init__.py


+ 157 - 0
netbox/netbox/ui/actions.py

@@ -0,0 +1,157 @@
+from urllib.parse import urlencode
+
+from django.apps import apps
+from django.template.loader import render_to_string
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from utilities.permissions import get_permission_for_model
+from utilities.views import get_viewname
+
+__all__ = (
+    'AddObject',
+    'CopyContent',
+    'LinkAction',
+    'PanelAction',
+)
+
+
+class PanelAction:
+    """
+    A link (typically a button) within a panel to perform some associated action, such as adding an object.
+
+    Attributes:
+        template_name (str): The name of the template to render
+
+    Parameters:
+        label (str): The human-friendly button text
+        permissions (list): An iterable of permissions required to display the action
+        button_class (str): Bootstrap CSS class for the button
+        button_icon (str): Name of the button's MDI icon
+    """
+    template_name = None
+
+    def __init__(self, label, permissions=None, button_class='primary', button_icon=None):
+        self.label = label
+        self.permissions = permissions
+        self.button_class = button_class
+        self.button_icon = button_icon
+
+    def get_context(self, context):
+        """
+        Return the template context used to render the action element.
+
+        Parameters:
+            context (dict): The template context
+        """
+        return {
+            'label': self.label,
+            'button_class': self.button_class,
+            'button_icon': self.button_icon,
+        }
+
+    def render(self, context):
+        """
+        Render the action as HTML.
+
+        Parameters:
+            context (dict): The template context
+        """
+        # Enforce permissions
+        user = context['request'].user
+        if not user.has_perms(self.permissions):
+            return ''
+
+        return render_to_string(self.template_name, self.get_context(context))
+
+
+class LinkAction(PanelAction):
+    """
+    A hyperlink (typically a button) within a panel to perform some associated action, such as adding an object.
+
+    Parameters:
+        view_name (str): Name of the view to which the action will link
+        view_kwargs (dict): Additional keyword arguments to pass to `reverse()` when resolving the URL
+        url_params (dict): A dictionary of arbitrary URL parameters to append to the action's URL. If the value of a key
+            is a callable, it will be passed the current template context.
+    """
+    template_name = 'ui/actions/link.html'
+
+    def __init__(self, view_name, view_kwargs=None, url_params=None, **kwargs):
+        super().__init__(**kwargs)
+        self.view_name = view_name
+        self.view_kwargs = view_kwargs or {}
+        self.url_params = url_params or {}
+
+    def get_url(self, context):
+        """
+        Resolve the URL for the action from its view name and kwargs. Append any additional URL parameters.
+
+        Parameters:
+            context (dict): The template context
+        """
+        url = reverse(self.view_name, kwargs=self.view_kwargs)
+        if self.url_params:
+            # If the param value is callable, call it with the context and save the result.
+            url_params = {
+                k: v(context) if callable(v) else v for k, v in self.url_params.items()
+            }
+            # Set the return URL if not already set and an object is available.
+            if 'return_url' not in url_params and 'object' in context:
+                url_params['return_url'] = context['object'].get_absolute_url()
+            url = f'{url}?{urlencode(url_params)}'
+        return url
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'url': self.get_url(context),
+        }
+
+
+class AddObject(LinkAction):
+    """
+    An action to add a new object.
+
+    Parameters:
+        model (str): The dotted label of the model to be added (e.g. "dcim.site")
+        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
+        try:
+            app_label, model_name = model.split('.')
+            model = apps.get_model(app_label, model_name)
+        except (ValueError, 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')])
+
+        super().__init__(view_name=view_name, url_params=url_params, **kwargs)
+
+
+class CopyContent(PanelAction):
+    """
+    An action to copy the contents of a panel to the clipboard.
+
+    Parameters:
+        target_id (str): The ID of the target element containing the content to be copied
+    """
+    template_name = 'ui/actions/copy_content.html'
+
+    def __init__(self, target_id, **kwargs):
+        kwargs.setdefault('label', _('Copy'))
+        kwargs.setdefault('button_icon', 'content-copy')
+        super().__init__(**kwargs)
+        self.target_id = target_id
+
+    def render(self, context):
+        return render_to_string(self.template_name, {
+            'target_id': self.target_id,
+            'label': self.label,
+            'button_class': self.button_class,
+            'button_icon': self.button_icon,
+        })

+ 344 - 0
netbox/netbox/ui/attrs.py

@@ -0,0 +1,344 @@
+from django.template.loader import render_to_string
+from django.utils.safestring import mark_safe
+from django.utils.translation import gettext_lazy as _
+
+from netbox.config import get_config
+from utilities.data import resolve_attr_path
+
+__all__ = (
+    'AddressAttr',
+    'BooleanAttr',
+    'ColorAttr',
+    'ChoiceAttr',
+    'GPSCoordinatesAttr',
+    'ImageAttr',
+    'NestedObjectAttr',
+    'NumericAttr',
+    'ObjectAttribute',
+    'RelatedObjectAttr',
+    'TemplatedAttr',
+    'TextAttr',
+    'TimezoneAttr',
+    'UtilizationAttr',
+)
+
+PLACEHOLDER_HTML = '<span class="text-muted">&mdash;</span>'
+
+
+#
+# Attributes
+#
+
+class ObjectAttribute:
+    """
+    Base class for representing an attribute of an object.
+
+    Attributes:
+        template_name (str): The name of the template to render
+        placeholder (str): HTML to render for empty/null values
+
+    Parameters:
+        accessor (str): The dotted path to the attribute being rendered (e.g. "site.region.name")
+        label (str): Human-friendly label for the rendered attribute
+    """
+    template_name = None
+    label = None
+    placeholder = mark_safe(PLACEHOLDER_HTML)
+
+    def __init__(self, accessor, label=None):
+        self.accessor = accessor
+        if label is not None:
+            self.label = label
+
+    def get_value(self, obj):
+        """
+        Return the value of the attribute.
+
+        Parameters:
+            obj (object): The object for which the attribute is being rendered
+        """
+        return resolve_attr_path(obj, self.accessor)
+
+    def get_context(self, obj, 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
+        """
+        return {}
+
+    def render(self, obj, context):
+        value = self.get_value(obj)
+
+        # If the value is empty, render a placeholder
+        if value in (None, ''):
+            return self.placeholder
+
+        return render_to_string(self.template_name, {
+            **self.get_context(obj, context),
+            'name': context['name'],
+            'value': value,
+        })
+
+
+class TextAttr(ObjectAttribute):
+    """
+    A text attribute.
+
+    Parameters:
+         style (str): CSS class to apply to the rendered attribute
+         format_string (str): If specified, the value will be formatted using this string when rendering
+         copy_button (bool): Set to True to include a copy-to-clipboard button
+    """
+    template_name = 'ui/attrs/text.html'
+
+    def __init__(self, *args, style=None, format_string=None, copy_button=False, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.style = style
+        self.format_string = format_string
+        self.copy_button = copy_button
+
+    def get_value(self, obj):
+        value = resolve_attr_path(obj, self.accessor)
+        # Apply format string (if any)
+        if value and self.format_string:
+            return self.format_string.format(value)
+        return value
+
+    def get_context(self, obj, context):
+        return {
+            'style': self.style,
+            'copy_button': self.copy_button,
+        }
+
+
+class NumericAttr(ObjectAttribute):
+    """
+    An integer or float attribute.
+
+    Parameters:
+         unit_accessor (str): Accessor for the unit of measurement to display alongside the value (if any)
+         copy_button (bool): Set to True to include a copy-to-clipboard button
+    """
+    template_name = 'ui/attrs/numeric.html'
+
+    def __init__(self, *args, unit_accessor=None, copy_button=False, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.unit_accessor = unit_accessor
+        self.copy_button = copy_button
+
+    def get_context(self, obj, context):
+        unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None
+        return {
+            'unit': unit,
+            'copy_button': self.copy_button,
+        }
+
+
+class ChoiceAttr(ObjectAttribute):
+    """
+    A selection from a set of choices.
+
+    The class calls get_FOO_display() on the object to retrieve the human-friendly choice label. If a get_FOO_color()
+    method exists on the object, it will be used to render a background color for the attribute value.
+    """
+    template_name = 'ui/attrs/choice.html'
+
+    def get_value(self, obj):
+        try:
+            return getattr(obj, f'get_{self.accessor}_display')()
+        except AttributeError:
+            return resolve_attr_path(obj, self.accessor)
+
+    def get_context(self, obj, context):
+        try:
+            bg_color = getattr(obj, f'get_{self.accessor}_color')()
+        except AttributeError:
+            bg_color = None
+        return {
+            'bg_color': bg_color,
+        }
+
+
+class BooleanAttr(ObjectAttribute):
+    """
+    A boolean attribute.
+
+    Parameters:
+         display_false (bool): If False, a placeholder will be rendered instead of the "False" indication
+    """
+    template_name = 'ui/attrs/boolean.html'
+
+    def __init__(self, *args, display_false=True, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.display_false = display_false
+
+    def get_value(self, obj):
+        value = super().get_value(obj)
+        if value is False and self.display_false is False:
+            return None
+        return value
+
+
+class ColorAttr(ObjectAttribute):
+    """
+    An RGB color value.
+    """
+    template_name = 'ui/attrs/color.html'
+    label = _('Color')
+
+
+class ImageAttr(ObjectAttribute):
+    """
+    An attribute representing an image field on the model. Displays the uploaded image.
+    """
+    template_name = 'ui/attrs/image.html'
+
+
+class RelatedObjectAttr(ObjectAttribute):
+    """
+    An attribute representing a related object.
+
+    Parameters:
+         linkify (bool): If True, the rendered value will be hyperlinked to the related object's detail view
+         grouped_by (str): A second-order object to annotate alongside the related object; for example, an attribute
+            representing the dcim.Site model might specify grouped_by="region"
+    """
+    template_name = 'ui/attrs/object.html'
+
+    def __init__(self, *args, linkify=None, grouped_by=None, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.linkify = linkify
+        self.grouped_by = grouped_by
+
+    def get_context(self, obj, context):
+        value = self.get_value(obj)
+        group = getattr(value, self.grouped_by, None) if self.grouped_by else None
+        return {
+            'linkify': self.linkify,
+            'group': group,
+        }
+
+
+class NestedObjectAttr(ObjectAttribute):
+    """
+    An attribute representing a related nested object. Similar to `RelatedObjectAttr`, but includes the ancestors of the
+    related object in the rendered output.
+
+    Parameters:
+         linkify (bool): If True, the rendered value will be hyperlinked to the related object's detail view
+         max_depth (int): Maximum number of ancestors to display (default: all)
+    """
+    template_name = 'ui/attrs/nested_object.html'
+
+    def __init__(self, *args, linkify=None, max_depth=None, **kwargs):
+        super().__init__(*args, **kwargs)
+        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:]
+        return {
+            'nodes': nodes,
+            'linkify': self.linkify,
+        }
+
+
+class AddressAttr(ObjectAttribute):
+    """
+    A physical or mailing address.
+
+    Parameters:
+         map_url (bool): 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):
+        return {
+            'map_url': self.map_url,
+        }
+
+
+class GPSCoordinatesAttr(ObjectAttribute):
+    """
+    A GPS coordinates pair comprising latitude and longitude values.
+
+    Parameters:
+         latitude_attr (float): The name of the field containing the latitude value
+         longitude_attr (float): The name of the field containing the longitude value
+         map_url (bool): If true, the address will render as a hyperlink using settings.MAPS_URL
+    """
+    template_name = 'ui/attrs/gps_coordinates.html'
+    label = _('GPS coordinates')
+
+    def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
+        super().__init__(accessor=None, **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 {}
+        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,
+            'latitude': latitude,
+            'longitude': longitude,
+            'map_url': self.map_url,
+        })
+
+
+class TimezoneAttr(ObjectAttribute):
+    """
+    A timezone value. Includes the numeric offset from UTC.
+    """
+    template_name = 'ui/attrs/timezone.html'
+
+
+class TemplatedAttr(ObjectAttribute):
+    """
+    Renders an attribute using a custom template.
+
+    Parameters:
+         template_name (str): The name of the template to render
+         context (dict): Additional context to pass to the template when rendering
+    """
+    def __init__(self, *args, template_name, context=None, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.template_name = template_name
+        self.context = context or {}
+
+    def get_context(self, obj, context):
+        return {
+            **self.context,
+            'object': obj,
+        }
+
+
+class UtilizationAttr(ObjectAttribute):
+    """
+    Renders the value of an attribute as a utilization graph.
+    """
+    template_name = 'ui/attrs/utilization.html'

+ 94 - 0
netbox/netbox/ui/layout.py

@@ -0,0 +1,94 @@
+from netbox.ui.panels import Panel, PluginContentPanel
+
+__all__ = (
+    'Column',
+    'Layout',
+    'Row',
+    'SimpleLayout',
+)
+
+
+#
+# Base classes
+#
+
+class Layout:
+    """
+    A collection of rows and columns comprising the layout of content within the user interface.
+
+    Parameters:
+        *rows: One or more Row instances
+    """
+    def __init__(self, *rows):
+        for i, row in enumerate(rows):
+            if type(row) is not Row:
+                raise TypeError(f"Row {i} must be a Row instance, not {type(row)}.")
+        self.rows = rows
+
+
+class Row:
+    """
+    A collection of columns arranged horizontally.
+
+    Parameters:
+        *columns: One or more Column instances
+    """
+    def __init__(self, *columns):
+        for i, column in enumerate(columns):
+            if type(column) is not Column:
+                raise TypeError(f"Column {i} must be a Column instance, not {type(column)}.")
+        self.columns = columns
+
+
+class Column:
+    """
+    A collection of panels arranged vertically.
+
+    Parameters:
+        *panels: One or more Panel instances
+    """
+    def __init__(self, *panels):
+        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)}.")
+        self.panels = panels
+
+
+#
+# Common layouts
+#
+
+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
+    views in NetBox utilize this layout.
+
+    ```
+    +-------+-------+
+    | Col 1 | Col 2 |
+    +-------+-------+
+    |     Col 3     |
+    +---------------+
+    ```
+
+    Parameters:
+        left_panels: Panel instances to be rendered in the top lefthand column
+        right_panels: Panel instances to be rendered in the top righthand column
+        bottom_panels: Panel instances to be rendered in the bottom row
+    """
+    def __init__(self, left_panels=None, right_panels=None, bottom_panels=None):
+        left_panels = left_panels or []
+        right_panels = right_panels or []
+        bottom_panels = bottom_panels or []
+        rows = [
+            Row(
+                Column(*left_panels, PluginContentPanel('left_page')),
+                Column(*right_panels, PluginContentPanel('right_page')),
+            ),
+            Row(
+                Column(*bottom_panels, PluginContentPanel('full_width_page'))
+            )
+        ]
+        super().__init__(*rows)

+ 341 - 0
netbox/netbox/ui/panels.py

@@ -0,0 +1,341 @@
+from django.apps import apps
+from django.template.loader import render_to_string
+from django.utils.translation import gettext_lazy as _
+
+from netbox.ui import attrs
+from netbox.ui.actions import CopyContent
+from utilities.data import resolve_attr_path
+from utilities.querydict import dict_to_querydict
+from utilities.string import title
+from utilities.templatetags.plugins import _get_registered_content
+from utilities.views import get_viewname
+
+__all__ = (
+    'CommentsPanel',
+    'JSONPanel',
+    'NestedGroupObjectPanel',
+    'ObjectAttributesPanel',
+    'ObjectPanel',
+    'ObjectsTablePanel',
+    'OrganizationalObjectPanel',
+    'Panel',
+    'PluginContentPanel',
+    'RelatedObjectsPanel',
+    'TemplatePanel',
+)
+
+
+#
+# Base classes
+#
+
+class Panel:
+    """
+    A block of content rendered within an HTML template.
+
+    Panels are arranged within rows and columns, (generally) render as discrete "cards" within the user interface. Each
+    panel has a title and may have one or more actions associated with it, which will be rendered as hyperlinks in the
+    top right corner of the card.
+
+    Attributes:
+        template_name (str): The name of the template used to render the 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 = None
+    title = None
+    actions = None
+
+    def __init__(self, title=None, actions=None):
+        if title is not None:
+            self.title = title
+        self.actions = actions or self.actions or []
+
+    def get_context(self, context):
+        """
+        Return the context data to be used when rendering the panel.
+
+        Parameters:
+            context (dict): The template context
+        """
+        return {
+            'request': context.get('request'),
+            'object': context.get('object'),
+            'title': self.title,
+            'actions': self.actions,
+            'panel_class': self.__class__.__name__,
+        }
+
+    def render(self, context):
+        """
+        Render the panel as HTML.
+
+        Parameters:
+            context (dict): The template context
+        """
+        return render_to_string(self.template_name, self.get_context(context))
+
+
+#
+# Object-specific panels
+#
+
+class ObjectPanel(Panel):
+    """
+    Base class for object-specific panels.
+
+    Parameters:
+        accessor (str): The dotted path in context data to the object being rendered (default: "object")
+    """
+    accessor = 'object'
+
+    def __init__(self, accessor=None, **kwargs):
+        super().__init__(**kwargs)
+
+        if accessor is not None:
+            self.accessor = accessor
+
+    def get_context(self, context):
+        obj = resolve_attr_path(context, self.accessor)
+        return {
+            **super().get_context(context),
+            'title': self.title or title(obj._meta.verbose_name),
+            'object': obj,
+        }
+
+
+class ObjectAttributesPanelMeta(type):
+
+    def __new__(mcls, name, bases, namespace, **kwargs):
+        declared = {}
+
+        # Walk MRO parents (excluding `object`) for declared attributes
+        for base in reversed([b for b in bases if hasattr(b, "_attrs")]):
+            for key, attr in getattr(base, '_attrs', {}).items():
+                if key not in declared:
+                    declared[key] = attr
+
+        # Add local declarations in the order they appear in the class body
+        for key, attr in namespace.items():
+            if isinstance(attr, attrs.ObjectAttribute):
+                declared[key] = attr
+
+        namespace['_attrs'] = declared
+
+        # Remove Attrs from the class namespace to keep things tidy
+        local_items = [key for key, attr in namespace.items() if isinstance(attr, attrs.ObjectAttribute)]
+        for key in local_items:
+            namespace.pop(key)
+
+        cls = super().__new__(mcls, name, bases, namespace, **kwargs)
+        return cls
+
+
+class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
+    """
+    A panel which displays selected attributes of an object.
+
+    Attributes are added to the panel by declaring ObjectAttribute instances in the class body (similar to fields on
+    a Django form). Attributes are displayed in the order they are declared.
+
+    Note that the `only` and `exclude` parameters are mutually exclusive.
+
+    Parameters:
+            only (list): If specified, only attributes in this list will be displayed
+            exclude (list): If specified, attributes in this list will be excluded from display
+    """
+    template_name = 'ui/panels/object_attributes.html'
+
+    def __init__(self, only=None, exclude=None, **kwargs):
+        super().__init__(**kwargs)
+
+        # Set included/excluded attributes
+        if only is not None and exclude is not None:
+            raise ValueError("only and exclude cannot both be specified.")
+        self.only = only or []
+        self.exclude = exclude or []
+
+    @staticmethod
+    def _name_to_label(name):
+        """
+        Format an attribute's name to be presented as a human-friendly label.
+        """
+        label = name[:1].upper() + name[1:]
+        label = label.replace('_', ' ')
+        return label
+
+    def get_context(self, context):
+        # Determine which attributes to display in the panel based on only/exclude args
+        attr_names = set(self._attrs.keys())
+        if self.only:
+            attr_names &= set(self.only)
+        elif self.exclude:
+            attr_names -= set(self.exclude)
+
+        ctx = super().get_context(context)
+
+        return {
+            **ctx,
+            'attrs': [
+                {
+                    'label': attr.label or self._name_to_label(name),
+                    'value': attr.render(ctx['object'], {'name': name}),
+                } for name, attr in self._attrs.items() if name in attr_names
+            ],
+        }
+
+
+class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta):
+    """
+    An ObjectPanel with attributes common to OrganizationalModels. Includes `name` and `description` attributes.
+    """
+    name = attrs.TextAttr('name', label=_('Name'))
+    description = attrs.TextAttr('description', label=_('Description'))
+
+
+class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta):
+    """
+    An ObjectPanel with attributes common to NestedGroupObjects. Includes the `parent` attribute.
+    """
+    parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True)
+    name = attrs.TextAttr('name', label=_('Name'))
+    description = attrs.TextAttr('description', label=_('Description'))
+
+
+class CommentsPanel(ObjectPanel):
+    """
+    A panel which displays comments associated with an object.
+
+    Parameters:
+        field_name (str): The name of the comment field on the object (default: "comments")
+    """
+    template_name = 'ui/panels/comments.html'
+    title = _('Comments')
+
+    def __init__(self, field_name='comments', **kwargs):
+        super().__init__(**kwargs)
+        self.field_name = field_name
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'comments': getattr(context['object'], self.field_name),
+        }
+
+
+class JSONPanel(ObjectPanel):
+    """
+    A panel which renders formatted JSON data from an object's JSONField.
+
+    Parameters:
+        field_name (str): The name of the JSON field on the object
+        copy_button (bool): Set to True (default) to include a copy-to-clipboard button
+    """
+    template_name = 'ui/panels/json.html'
+
+    def __init__(self, field_name, copy_button=True, **kwargs):
+        super().__init__(**kwargs)
+        self.field_name = field_name
+
+        if copy_button:
+            self.actions.append(CopyContent(f'panel_{field_name}'))
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'data': getattr(context['object'], self.field_name),
+            'field_name': self.field_name,
+        }
+
+
+#
+# Miscellaneous panels
+#
+
+class RelatedObjectsPanel(Panel):
+    """
+    A panel which displays the types and counts of related objects.
+    """
+    template_name = 'ui/panels/related_objects.html'
+    title = _('Related Objects')
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'related_models': context.get('related_models'),
+        }
+
+
+class ObjectsTablePanel(Panel):
+    """
+    A panel which displays a table of objects (rendered via HTMX).
+
+    Parameters:
+        model (str): The dotted label of the model to be added (e.g. "dcim.site")
+        filters (dict): A dictionary of arbitrary URL parameters to append to the table's URL. If the value of a key is
+            a callable, it will be passed the current template context.
+    """
+    template_name = 'ui/panels/objects_table.html'
+    title = None
+
+    def __init__(self, model, filters=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):
+            raise ValueError(f"Invalid model label: {model}")
+
+        self.filters = filters 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)
+
+    def get_context(self, context):
+        url_params = {
+            k: v(context) if callable(v) else v for k, v in self.filters.items()
+        }
+        if 'return_url' not in url_params and 'object' in context:
+            url_params['return_url'] = context['object'].get_absolute_url()
+        return {
+            **super().get_context(context),
+            'viewname': get_viewname(self.model, 'list'),
+            'url_params': dict_to_querydict(url_params),
+        }
+
+
+class TemplatePanel(Panel):
+    """
+    A panel which renders custom content using an HTML template.
+
+    Parameters:
+        template_name (str): The name of the template to render
+    """
+    def __init__(self, template_name, **kwargs):
+        super().__init__(**kwargs)
+        self.template_name = template_name
+
+    def render(self, context):
+        # Pass the entire context to the template
+        return render_to_string(self.template_name, context.flatten())
+
+
+class PluginContentPanel(Panel):
+    """
+    A panel which displays embedded plugin content.
+
+    Parameters:
+        method (str): The name of the plugin method to render (e.g. "left_page")
+    """
+    def __init__(self, method, **kwargs):
+        super().__init__(**kwargs)
+        self.method = method
+
+    def render(self, context):
+        obj = context.get('object')
+        return _get_registered_content(obj, self.method, context)

+ 3 - 0
netbox/netbox/views/generic/object_views.py

@@ -44,9 +44,11 @@ class ObjectView(ActionsMixin, BaseObjectView):
     Note: If `template_name` is not specified, it will be determined automatically based on the queryset model.
 
     Attributes:
+        layout: An instance of `netbox.ui.layout.Layout` which defines the page layout (overrides HTML template)
         tab: A ViewTab instance for the view
         actions: An iterable of ObjectAction subclasses (see ActionsMixin)
     """
+    layout = None
     tab = None
     actions = (CloneObject, EditObject, DeleteObject)
 
@@ -81,6 +83,7 @@ class ObjectView(ActionsMixin, BaseObjectView):
             'object': instance,
             'actions': actions,
             'tab': self.tab,
+            'layout': self.layout,
             **self.get_extra_context(request, instance),
         })
 

+ 0 - 374
netbox/templates/dcim/device.html

@@ -1,375 +1 @@
 {% extends 'dcim/device/base.html' %}
-{% load render_table from django_tables2 %}
-{% load buttons %}
-{% load static %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
-{% load l10n %}
-{% load mptt %}
-
-{% block content %}
-    <div class="row">
-        <div class="col col-12 col-xl-6">
-            <div class="card">
-                <h2 class="card-header">{% trans "Device" %}</h2>
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <th scope="row">{% trans "Region" %}</th>
-                        <td>{% nested_tree object.site.region %}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Site" %}</th>
-                        <td>{{ object.site|linkify }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Location" %}</th>
-                        <td>{% nested_tree object.location %}</td>
-                    </tr>
-                    {% if object.virtual_chassis %}
-                      <tr>
-                        <th scope="row">{% trans "Virtual Chassis" %}</th>
-                        <td>{{ object.virtual_chassis|linkify }}</td>
-                      </tr>
-                    {% endif %}
-                    <tr>
-                        <th scope="row">{% trans "Rack" %}</th>
-                        <td class="d-flex justify-content-between align-items-start">
-                            {% if object.rack %}
-                                {{ object.rack|linkify }}
-                                <a href="{{ object.rack.get_absolute_url }}?device={% firstof object.parent_bay.device.pk object.pk %}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}">
-                                  <i class="mdi mdi-view-day-outline"></i>
-                                </a>
-                            {% else %}
-                                {{ ''|placeholder }}
-                            {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Position" %}</th>
-                        <td>
-                            {% if object.parent_bay %}
-                                {% with object.parent_bay.device as parent %}
-                                    {{ parent|linkify }} / {{ object.parent_bay }}
-                                    {% if parent.position %}
-                                        (U{{ parent.position|floatformat }} / {{ parent.get_face_display }})
-                                    {% endif %}
-                                {% endwith %}
-                            {% elif object.rack and object.position %}
-                                <span>U{{ object.position|floatformat }} / {{ object.get_face_display }}</span>
-                            {% elif object.rack and object.device_type.u_height %}
-                                <span class="badge text-bg-warning">{% trans "Not racked" %}</span>
-                            {% else %}
-                                {{ ''|placeholder }}
-                            {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                      <th scope="row">{% trans "GPS Coordinates" %}</th>
-                      <td class="position-relative">
-                        {% if object.latitude and object.longitude %}
-                          {% if config.MAPS_URL %}
-                            <div class="position-absolute top-50 end-0 me-2 translate-middle-y d-print-none">
-                              <a href="{{ config.MAPS_URL }}{{ object.latitude|unlocalize }},{{ object.longitude|unlocalize }}" target="_blank" class="btn btn-primary btn-sm">
-                                <i class="mdi mdi-map-marker"></i> {% trans "Map" %}
-                              </a>
-                            </div>
-                            {% endif %}
-                          <span>{{ object.latitude }}, {{ object.longitude }}</span>
-                        {% else %}
-                          {{ ''|placeholder }}
-                        {% endif %}
-                      </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Tenant" %}</th>
-                        <td>
-                            {% if object.tenant.group %}
-                                {{ object.tenant.group|linkify }} /
-                            {% endif %}
-                            {{ object.tenant|linkify|placeholder }}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Device Type" %}</th>
-                        <td>
-                            {{ object.device_type|linkify:"full_name" }} ({{ object.device_type.u_height|floatformat }}U)
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Description" %}</th>
-                        <td>{{ object.description|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Airflow" %}</th>
-                        <td>
-                            {{ object.get_airflow_display|placeholder }}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Serial Number" %}</th>
-                        <td class="font-monospace">{{ object.serial|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Asset Tag" %}</th>
-                        <td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Config Template" %}</th>
-                        <td>{{ object.config_template|linkify|placeholder }}</td>
-                    </tr>
-                </table>
-            </div>
-            {% if vc_members %}
-                <div class="card">
-                    <h2 class="card-header">
-                      {% trans "Virtual Chassis" %}
-                      <div class="card-actions">
-                        <a href="{{ object.virtual_chassis.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
-                          <span class="mdi mdi-arrow-right-bold" aria-hidden="true"></span> {% trans "View Virtual Chassis" %}
-                        </a>
-                      </div>
-                    </h2>
-                    <table class="table table-hover attr-table">
-                      <thead>
-                        <tr class="border-bottom">
-                          <th>{% trans "Device" %}</th>
-                          <th>{% trans "Position" %}</th>
-                          <th>{% trans "Master" %}</th>
-                          <th>{% trans "Priority" %}</th>
-                        </tr>
-                      </thead>
-                      <tbody>
-                        {% for vc_member in vc_members %}
-                          <tr{% if vc_member == object %} class="table-primary"{% endif %}>
-                            <td>{{ vc_member|linkify }}</td>
-                            <td>{% badge vc_member.vc_position show_empty=True %}</td>
-                            <td>
-                              {% if object.virtual_chassis.master == vc_member %}
-                                {% checkmark True %}
-                              {% else %}
-                                {{ ''|placeholder }}
-                              {% endif %}
-                            </td>
-                            <td>{{ vc_member.vc_priority|placeholder }}</td>
-                          </tr>
-                        {% endfor %}
-                      </tbody>
-                    </table>
-                </div>
-            {% endif %}
-            {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' %}
-            {% include 'inc/panels/comments.html' %}
-            <div class="card">
-              <h2 class="card-header">
-                {% trans "Virtual Device Contexts" %}
-                {% if perms.dcim.add_virtualdevicecontext %}
-                  <div class="card-actions">
-                    <a href="{% url 'dcim:virtualdevicecontext_add' %}?device={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
-                      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Create VDC" %}
-                    </a>
-                  </div>
-                {% endif %}
-              </h2>
-              {% htmx_table 'dcim:virtualdevicecontext_list' device_id=object.pk %}
-            </div>
-            {% plugin_left_page object %}
-        </div>
-        <div class="col col-12 col-xl-6">
-            <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>
-            {% if object.powerports.exists and object.poweroutlets.exists %}
-                <div class="card">
-                    <h2 class="card-header">{% trans "Power Utilization" %}</h2>
-                    <table class="table table-hover">
-                        <thead>
-                          <tr>
-                            <th>{% trans "Input" %}</th>
-                            <th>{% trans "Outlets" %}</th>
-                            <th>{% trans "Allocated" %}</th>
-                            <th>{% trans "Available" %}</th>
-                            <th>{% trans "Utilization" %}</th>
-                          </tr>
-                        </thead>
-                        {% for powerport in object.powerports.all %}
-                            {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %}
-                                <tr>
-                                    <td>{{ powerport }}</td>
-                                    <td>{{ utilization.outlet_count }}</td>
-                                    <td>{{ utilization.allocated }}{% trans "VA" %}</td>
-                                    {% if powerfeed.available_power %}
-                                        <td>{{ powerfeed.available_power }}{% trans "VA" %}</td>
-                                        <td>{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}</td>
-                                    {% else %}
-                                        <td class="text-muted">&mdash;</td>
-                                        <td class="text-muted">&mdash;</td>
-                                    {% endif %}
-                                </tr>
-                                {% for leg in utilization.legs %}
-                                    <tr>
-                                        <td style="padding-left: 20px">
-                                          {% trans "Leg" context "Leg of a power feed" %} {{ leg.name }}
-                                        </td>
-                                        <td>{{ leg.outlet_count }}</td>
-                                        <td>{{ leg.allocated }}</td>
-                                        {% if powerfeed.available_power %}
-                                            {% with phase_available=powerfeed.available_power|divide:3 %}
-                                                <td>{{ phase_available }}{% trans "VA" %}</td>
-                                                <td>{% utilization_graph leg.allocated|percentage:phase_available %}</td>
-                                            {% endwith %}
-                                        {% else %}
-                                            <td class="text-muted">&mdash;</td>
-                                            <td class="text-muted">&mdash;</td>
-                                        {% endif %}
-                                    </tr>
-                                {% endfor %}
-                            {% endwith %}
-                        {% endfor %}
-                    </table>
-                </div>
-            {% endif %}
-            <div class="card">
-              <h2 class="card-header">
-                {% trans "Application Services" %}
-                {% if perms.ipam.add_service %}
-                  <div class="card-actions">
-                    <a href="{% url 'ipam:service_add' %}?parent_object_type={{ object|content_type_id }}&parent={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
-                      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add an application service" %}
-                    </a>
-                  </div>
-                {% endif %}
-              </h2>
-              {% htmx_table 'ipam:service_list' device_id=object.pk %}
-            </div>
-            {% include 'inc/panels/image_attachments.html' %}
-            <div class="card">
-                <h2 class="card-header">{% trans "Dimensions" %}</h2>
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <th scope="row">{% trans "Height" %}</th>
-                        <td>
-                            {{ object.device_type.u_height }}U
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Weight" %}</th>
-                        <td>
-                            {% if object.total_weight %}
-                                {{ object.total_weight|floatformat }} {% trans "Kilograms" %}
-                                ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %})
-                            {% else %}
-                                {{ ''|placeholder }}
-                            {% endif %}
-                        </td>
-                    </tr>
-                </table>
-            </div>
-            {% if object.rack and object.position %}
-              <div class="row" style="margin-bottom: 20px">
-                <div class="text-center">
-                  <strong><a href="{% url 'dcim:rack' pk=object.rack.pk %}">{{ object.rack.name }}</a></strong>
-                  {% if object.rack.role %}
-                    <br /><span class="badge my-3" style="color: {{ object.rack.role.color|fgcolor }}; background-color: #{{ object.rack.role.color }}">{{ object.rack.role }}</span>
-                  {% endif %}
-                  {% if object.rack.facility_id %}
-                    <br /><small class="text-muted">{{ object.rack.facility_id }}</small>
-                  {% endif %}
-                </div>
-                <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
-                  <div style="margin-left: 30px">
-                    <h2 class="h4">{% trans "Front" %}</h2>
-                    {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %}
-                  </div>
-                </div>
-                <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
-                  <div style="margin-left: 30px">
-                    <h2 class="h4">{% trans "Rear" %}</h2>
-                    {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %}
-                  </div>
-                </div>
-              </div>
-            {% endif %}
-            {% plugin_right_page object %}
-        </div>
-    </div>
-    <div class="row">
-        <div class="col col-md-12">
-            {% plugin_full_width_page object %}
-        </div>
-    </div>
-{% endblock %}

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

@@ -0,0 +1,10 @@
+{% 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>

+ 10 - 0
netbox/templates/dcim/device/attrs/parent_device.html

@@ -0,0 +1,10 @@
+{% 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 %}

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

@@ -0,0 +1,14 @@
+{% load i18n %}
+<span>
+  {{ value|linkify }}
+  {% if value and object.position %}
+    (U{{ object.position|floatformat }} / {{ object.get_face_display }})
+  {% elif value and object.device_type.u_height %}
+    <span class="badge text-bg-warning">{% trans "Not racked" %}</span>
+  {% endif %}
+</span>
+{% if 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 %}

+ 3 - 0
netbox/templates/dcim/device/attrs/total_weight.html

@@ -0,0 +1,3 @@
+{% load helpers i18n %}
+{{ value|floatformat }} {% trans "Kilograms" %}
+({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %})

+ 0 - 111
netbox/templates/dcim/devicetype.html

@@ -1,112 +1 @@
 {% extends 'dcim/devicetype/base.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
-
-{% block content %}
-    <div class="row">
-        <div class="col col-12 col-md-6">
-            <div class="card">
-                <h2 class="card-header">{% trans "Chassis" %}</h2>
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <th scope="row">{% trans "Manufacturer" %}</th>
-                        <td>{{ object.manufacturer|linkify }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Model Name" %}</th>
-                        <td>
-                            {{ object.model }}<br/>
-                            <small class="text-muted">{{ object.slug }}</small>
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Part Number" %}</th>
-                        <td>{{ object.part_number|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Default Platform" %}</th>
-                        <td>{{ object.default_platform|linkify }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Description" %}</th>
-                        <td>{{ object.description|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Height (U)" %}</th>
-                        <td>{{ object.u_height|floatformat }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Exclude From Utilization" %}</th>
-                        <td>{% checkmark object.exclude_from_utilization %}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Full Depth" %}</th>
-                        <td>{% checkmark object.is_full_depth %}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Weight" %}</th>
-                        <td>
-                        {% if object.weight %}
-                          {{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
-                        {% else %}
-                          {{ ''|placeholder }}
-                        {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Parent/Child" %}</th>
-                        <td>
-                            {{ object.get_subdevice_role_display|placeholder }}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Airflow" %}</th>
-                        <td>
-                            {{ object.get_airflow_display|placeholder }}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Front Image" %}</th>
-                        <td>
-                            {% if object.front_image %}
-                                <a href="{{ object.front_image.url }}">
-                                    <img src="{{ object.front_image.url }}" alt="{{ object.front_image.name }}" class="img-fluid" />
-                                </a>
-                            {% else %}
-                                {{ ''|placeholder }}
-                            {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Rear Image" %}</th>
-                        <td>
-                            {% if object.rear_image %}
-                                <a href="{{ object.rear_image.url }}">
-                                    <img src="{{ object.rear_image.url }}" alt="{{ object.rear_image.name }}" class="img-fluid" />
-                                </a>
-                            {% else %}
-                                {{ ''|placeholder }}
-                            {% endif %}
-                        </td>
-                    </tr>
-                </table>
-            </div>
-            {% include 'inc/panels/tags.html' %}
-            {% plugin_left_page object %}
-        </div>
-        <div class="col col-12 col-md-6">
-            {% include 'inc/panels/related_objects.html' %}
-            {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/comments.html' %}
-            {% include 'inc/panels/image_attachments.html' %}
-            {% plugin_right_page object %}
-        </div>
-    </div>
-    <div class="row">
-        <div class="col col-md-12">
-            {% plugin_full_width_page object %}
-        </div>
-    </div>
-{% endblock %}

+ 0 - 97
netbox/templates/dcim/location.html

@@ -1,8 +1,4 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
 
 {% block breadcrumbs %}
   {{ block.super }}
@@ -10,96 +6,3 @@
     <li class="breadcrumb-item">{{ location|linkify }}</li>
   {% endfor %}
 {% endblock %}
-
-{% block extra_controls %}
-  {% if perms.dcim.add_location %}
-    <a href="{% url 'dcim:location_add' %}?site={{ object.site.pk }}&parent={{ object.pk }}" class="btn btn-primary">
-      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Child Location" %}
-    </a>
-  {% endif %}
-{% endblock extra_controls %}
-
-{% block content %}
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Location" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Name" %}</th>
-          <td>{{ object.name }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Site" %}</th>
-          <td>{{ object.site|linkify }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Parent" %}</th>
-          <td>{{ object.parent|linkify|placeholder }}</td>
-        </tr>
-        <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 "Tenant" %}</th>
-          <td>
-            {% if object.tenant.group %}
-              {{ object.tenant.group|linkify }} /
-            {% endif %}
-            {{ object.tenant|linkify|placeholder }}
-          </td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Facility" %}</th>
-          <td>{{ object.facility|placeholder }}</td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/comments.html' %}
-    {% plugin_left_page object %}
-  </div>
-	<div class="col col-12 col-md-6">
-    {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/image_attachments.html' %}
-    {% plugin_right_page object %}
-	</div>
-</div>
-<div class="row mb-3">
-	<div class="col col-md-12">
-    <div class="card">
-      <h2 class="card-header">
-        {% trans "Child Locations" %}
-        {% if perms.dcim.add_location %}
-          <div class="card-actions">
-            <a href="{% url 'dcim:location_add' %}?site={{ object.site.pk }}&parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
-              <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Location" %}
-            </a>
-          </div>
-        {% endif %}
-      </h2>
-      {% htmx_table 'dcim:location_list' parent_id=object.pk %}
-    </div>
-    <div class="card">
-      <h2 class="card-header">
-        {% trans "Non-Racked Devices" %}
-        {% if perms.dcim.add_device %}
-          <div class="card-actions">
-            <a href="{% url 'dcim:device_add' %}?site={{ object.site.pk }}&location={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
-              <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Device" %}
-            </a>
-          </div>
-        {% endif %}
-      </h2>
-      {% htmx_table 'dcim:device_list' location_id=object.pk rack_id='null' parent_bay_id='null' %}
-    </div>
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 35
netbox/templates/dcim/manufacturer.html

@@ -1,7 +1,4 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 
 {% block extra_controls %}
@@ -25,35 +22,3 @@
     </div>
   {% endif %}
 {% endblock extra_controls %}
-
-{% block content %}
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Manufacturer" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Name" %}</th>
-          <td>{{ object.name }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' %}
-    {% plugin_left_page object %}
-	</div>
-	<div class="col col-12 col-md-6">
-    {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% plugin_right_page object %}
-  </div>
-</div>
-<div class="row mb-3">
-	<div class="col col-md-12">
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 58
netbox/templates/dcim/moduletypeprofile.html

@@ -1,59 +1 @@
 {% extends 'generic/object.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
-
-{% block title %}{{ object.name }}{% endblock %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Module Type Profile" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.name }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/tags.html' %}
-      {% include 'inc/panels/comments.html' %}
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-md-6">
-      <div class="card">
-        <h2 class="card-header d-flex justify-content-between">
-          {% trans "Schema" %}
-          {% copy_content 'profile_schema' %}
-        </h2>
-        <pre id="profile_schema">{{ object.schema|json }}</pre>
-      </div>
-      {% include 'inc/panels/custom_fields.html' %}
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row mb-3">
-    <div class="col col-md-12">
-      <div class="card">
-        <h2 class="card-header">
-          {% trans "Module Types" %}
-          {% if perms.dcim.add_moduletype %}
-            <div class="card-actions">
-              <a href="{% url 'dcim:moduletype_add' %}?profile={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
-                <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Module Type" %}
-              </a>
-            </div>
-          {% endif %}
-        </h2>
-        {% htmx_table 'dcim:moduletype_list' profile_id=object.pk %}
-      </div>
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 26 - 0
netbox/templates/dcim/panels/device_rack_elevations.html

@@ -0,0 +1,26 @@
+{% load i18n %}
+{% if object.rack and object.position %}
+  <div class="row" style="margin-bottom: 20px">
+    <div class="text-center">
+      <strong><a href="{% url 'dcim:rack' pk=object.rack.pk %}">{{ object.rack.name }}</a></strong>
+      {% if object.rack.role %}
+        <br /><span class="badge my-3" style="color: {{ object.rack.role.color|fgcolor }}; background-color: #{{ object.rack.role.color }}">{{ object.rack.role }}</span>
+      {% endif %}
+      {% if object.rack.facility_id %}
+        <br /><small class="text-muted">{{ object.rack.facility_id }}</small>
+      {% endif %}
+    </div>
+    <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
+      <div style="margin-left: 30px">
+        <h2 class="h4">{% trans "Front" %}</h2>
+        {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %}
+      </div>
+    </div>
+    <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
+      <div style="margin-left: 30px">
+        <h2 class="h4">{% trans "Rear" %}</h2>
+        {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %}
+      </div>
+    </div>
+  </div>
+{% endif %}

+ 50 - 0
netbox/templates/dcim/panels/power_utilization.html

@@ -0,0 +1,50 @@
+{% extends "ui/panels/_base.html" %}
+{% load helpers i18n %}
+
+{% block panel_content %}
+  <table class="table table-hover">
+    <thead>
+      <tr>
+        <th>{% trans "Input" %}</th>
+        <th>{% trans "Outlets" %}</th>
+        <th>{% trans "Allocated" %}</th>
+        <th>{% trans "Available" %}</th>
+        <th>{% trans "Utilization" %}</th>
+      </tr>
+    </thead>
+    {% for powerport in object.powerports.all %}
+      {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %}
+        <tr>
+          <td>{{ powerport }}</td>
+          <td>{{ utilization.outlet_count }}</td>
+          <td>{{ utilization.allocated }}{% trans "VA" %}</td>
+          {% if powerfeed.available_power %}
+            <td>{{ powerfeed.available_power }}{% trans "VA" %}</td>
+            <td>{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}</td>
+          {% else %}
+            <td class="text-muted">&mdash;</td>
+            <td class="text-muted">&mdash;</td>
+          {% endif %}
+        </tr>
+        {% for leg in utilization.legs %}
+          <tr>
+            <td style="padding-left: 20px">
+              {% trans "Leg" context "Leg of a power feed" %} {{ leg.name }}
+            </td>
+            <td>{{ leg.outlet_count }}</td>
+            <td>{{ leg.allocated }}</td>
+            {% if powerfeed.available_power %}
+              {% with phase_available=powerfeed.available_power|divide:3 %}
+                <td>{{ phase_available }}{% trans "VA" %}</td>
+                <td>{% utilization_graph leg.allocated|percentage:phase_available %}</td>
+              {% endwith %}
+            {% else %}
+              <td class="text-muted">&mdash;</td>
+              <td class="text-muted">&mdash;</td>
+            {% endif %}
+          </tr>
+        {% endfor %}
+      {% endwith %}
+    {% endfor %}
+  </table>
+{% endblock panel_content %}

+ 22 - 0
netbox/templates/dcim/panels/rack_elevations.html

@@ -0,0 +1,22 @@
+{% load i18n %}
+<div class="text-end mb-4">
+  <select class="btn btn-outline-secondary no-ts rack-view">
+    <option value="images-and-labels" selected="selected">{% trans "Images and Labels" %}</option>
+    <option value="images-only">{% trans "Images only" %}</option>
+    <option value="labels-only">{% trans "Labels only" %}</option>
+  </select>
+</div>
+<div class="row" style="margin-bottom: 20px">
+  <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
+    <div style="margin-left: 30px">
+      <h2 class="h4">{% trans "Front" %}</h2>
+      {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
+    </div>
+  </div>
+  <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
+    <div style="margin-left: 30px">
+      <h2 class="h4">{% trans "Rear" %}</h2>
+      {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
+    </div>
+  </div>
+</div>

+ 15 - 0
netbox/templates/dcim/panels/rack_reservation_elevations.html

@@ -0,0 +1,15 @@
+{% load i18n %}
+<div class="row" style="margin-bottom: 20px">
+  <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
+    <div style="margin-left: 30px">
+      <h2 class="h4">{% trans "Front" %}</h2>
+      {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %}
+    </div>
+  </div>
+  <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
+    <div style="margin-left: 30px">
+      <h2 class="h4">{% trans "Rear" %}</h2>
+      {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %}
+    </div>
+  </div>
+</div>

+ 31 - 0
netbox/templates/dcim/panels/virtual_chassis_members.html

@@ -0,0 +1,31 @@
+{% extends "ui/panels/_base.html" %}
+{% load i18n %}
+
+{% block panel_content %}
+  <table class="table table-hover attr-table">
+    <thead>
+      <tr class="border-bottom">
+        <th>{% trans "Device" %}</th>
+        <th>{% trans "Position" %}</th>
+        <th>{% trans "Master" %}</th>
+        <th>{% trans "Priority" %}</th>
+      </tr>
+    </thead>
+    <tbody>
+      {% for vc_member in vc_members %}
+        <tr{% if vc_member == object %} class="table-primary"{% endif %}>
+          <td>{{ vc_member|linkify }}</td>
+          <td>{% badge vc_member.vc_position show_empty=True %}</td>
+          <td>
+            {% if object.virtual_chassis.master == vc_member %}
+              {% checkmark True %}
+            {% else %}
+              {{ ''|placeholder }}
+            {% endif %}
+          </td>
+          <td>{{ vc_member.vc_priority|placeholder }}</td>
+        </tr>
+      {% endfor %}
+    </tbody>
+  </table>
+{% endblock panel_content %}

+ 0 - 152
netbox/templates/dcim/rack.html

@@ -1,153 +1 @@
 {% extends 'dcim/rack/base.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load static %}
-{% load plugins %}
-{% load i18n %}
-{% load mptt %}
-
-{% block content %}
-  <div class="row">
-	  <div class="col col-12 col-xl-5">
-      <div class="card">
-        <h2 class="card-header">{% trans "Rack" %}</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>
-          <tr>
-            <th scope="row">{% trans "Facility ID" %}</th>
-            <td>{{ object.facility_id|placeholder }}</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 "Status" %}</th>
-            <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Rack Type" %}</th>
-            <td>{{ object.rack_type|linkify:"full_name"|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Role" %}</th>
-            <td>{{ object.role|linkify|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|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 "Airflow" %}</th>
-            <td>{{ object.get_airflow_display|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Space Utilization" %}</th>
-            <td>{% utilization_graph object.get_utilization %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Power Utilization" %}</th>
-            <td>{% utilization_graph object.get_power_utilization %}</td>
-          </tr>
-        </table>
-      </div>
-      {% include 'dcim/inc/panels/racktype_dimensions.html' %}
-      {% include 'dcim/inc/panels/racktype_numbering.html' %}
-      <div class="card">
-        <h2 class="card-header">{% trans "Weight" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Rack Weight" %}</th>
-            <td>
-              {% if object.weight %}
-                {{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
-              {% else %}
-                {{ ''|placeholder }}
-              {% endif %}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Maximum Weight" %}</th>
-            <td>
-              {% if object.max_weight %}
-                {{ object.max_weight }} {{ object.get_weight_unit_display }}
-              {% else %}
-                {{ ''|placeholder }}
-              {% endif %}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Total Weight" %}</th>
-            <td>
-                {% if object.total_weight %}
-                    {{ object.total_weight|floatformat }} {% trans "Kilograms" %}
-                    ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %})
-                {% else %}
-                    {{ ''|placeholder }}
-                {% endif %}
-            </td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/tags.html' %}
-      {% include 'inc/panels/comments.html' %}
-      {% include 'inc/panels/image_attachments.html' %}
-      {% plugin_left_page object %}
-	  </div>
-    <div class="col col-12 col-xl-7">
-      <div class="text-end mb-4">
-        <select class="btn btn-outline-secondary no-ts rack-view">
-          <option value="images-and-labels" selected="selected">{% trans "Images and Labels" %}</option>
-          <option value="images-only">{% trans "Images only" %}</option>
-          <option value="labels-only">{% trans "Labels only" %}</option>
-        </select>
-      </div>
-        <div class="row" style="margin-bottom: 20px">
-          <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
-            <div style="margin-left: 30px">
-              <h2 class="h4">{% trans "Front" %}</h2>
-              {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
-            </div>
-          </div>
-          <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
-            <div style="margin-left: 30px">
-              <h2 class="h4">{% trans "Rear" %}</h2>
-              {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
-            </div>
-          </div>
-        </div>
-        {% include 'inc/panels/related_objects.html' %}
-        {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row">
-    <div class="col col-md-12">
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 3 - 0
netbox/templates/dcim/rack/attrs/total_weight.html

@@ -0,0 +1,3 @@
+{% load helpers i18n %}
+{{ value|floatformat }} {% trans "Kilograms" %}
+({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %})

+ 0 - 91
netbox/templates/dcim/rackreservation.html

@@ -1,99 +1,8 @@
 {% extends 'generic/object.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load static %}
-{% load plugins %}
 {% load i18n %}
-{% load mptt %}
 
 {% block breadcrumbs %}
   {{ block.super }}
   <li class="breadcrumb-item"><a href="{% url 'dcim:rackreservation_list' %}?rack_id={{ object.rack.pk }}">{{ object.rack }}</a></li>
   <li class="breadcrumb-item">{% trans "Units" %} {{ object.unit_list }}</li>
 {% endblock %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col col-12 col-xl-5">
-      <div class="card">
-        <h2 class="card-header">{% trans "Rack" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Region" %}</th>
-            <td>
-              {% nested_tree object.rack.site.region %}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Site" %}</th>
-            <td>{{ object.rack.site|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Location" %}</th>
-            <td>{{ object.rack.location|linkify|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Rack" %}</th>
-            <td>{{ object.rack|linkify }}</td>
-          </tr>
-        </table>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Reservation Details" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Units" %}</th>
-            <td>{{ object.unit_list }}</td>
-          </tr>
-           <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 "Tenant" %}</th>
-            <td>
-              {% if object.tenant.group %}
-                {{ object.tenant.group|linkify }} /
-              {% endif %}
-              {{ object.tenant|linkify|placeholder }}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "User" %}</th>
-            <td>{{ object.user }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description }}</td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/tags.html' %}
-      {% include 'inc/panels/comments.html' %}
-      {% plugin_left_page object %}
-    </div>
-      <div class="col col-12 col-xl-7">
-        <div class="row" style="margin-bottom: 20px">
-          <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
-            <div style="margin-left: 30px">
-              <h2 class="h4">{% trans "Front" %}</h2>
-              {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' %}
-            </div>
-          </div>
-          <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
-            <div style="margin-left: -30px">
-              <h2 class="h4">{% trans "Rear" %}</h2>
-              {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' %}
-            </div>
-          </div>
-        </div>
-        {% plugin_right_page object %}
-      </div>
-  </div>
-  <div class="row">
-    <div class="col col-md-12">
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 41
netbox/templates/dcim/rackrole.html

@@ -1,7 +1,4 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 
 {% block extra_controls %}
@@ -11,41 +8,3 @@
     </a>
   {% endif %}
 {% endblock extra_controls %}
-
-{% block content %}
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Rack Role" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Name" %}</th>
-          <td>{{ object.name }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Color" %}</th>
-          <td>
-            <span class="badge color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
-          </td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' %}
-    {% plugin_left_page object %}
-	</div>
-	<div class="col col-12 col-md-6">
-    {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% plugin_right_page object %}
-  </div>
-</div>
-<div class="row mb-3">
-	<div class="col col-md-12">
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 74
netbox/templates/dcim/racktype.html

@@ -1,75 +1 @@
 {% extends 'generic/object.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load static %}
-{% load plugins %}
-{% load i18n %}
-{% load mptt %}
-
-{% block content %}
-  <div class="row">
-	  <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Rack Type" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Manufacturer" %}</th>
-            <td>{{ object.manufacturer|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Model" %}</th>
-            <td>{{ object.model }}</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>
-        </table>
-      </div>
-      {% include 'dcim/inc/panels/racktype_dimensions.html' %}
-      {% include 'inc/panels/tags.html' %}
-      {% include 'inc/panels/comments.html' %}
-      {% plugin_left_page object %}
-	  </div>
-    <div class="col col-12 col-md-6">
-      {% include 'dcim/inc/panels/racktype_numbering.html' %}
-      <div class="card">
-        <h2 class="card-header">{% trans "Weight" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Rack Weight" %}</th>
-            <td>
-              {% if object.weight %}
-                {{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
-              {% else %}
-                {{ ''|placeholder }}
-              {% endif %}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Maximum Weight" %}</th>
-            <td>
-              {% if object.max_weight %}
-                {{ object.max_weight }} {{ object.get_weight_unit_display }}
-              {% else %}
-                {{ ''|placeholder }}
-              {% endif %}
-            </td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/related_objects.html' %}
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row">
-    <div class="col col-md-12">
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 53
netbox/templates/dcim/region.html

@@ -1,7 +1,4 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 
 {% block breadcrumbs %}
@@ -18,53 +15,3 @@
     </a>
   {% endif %}
 {% endblock extra_controls %}
-
-{% block content %}
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Region" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Name" %}</th>
-          <td>{{ object.name }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Parent" %}</th>
-          <td>{{ object.parent|linkify|placeholder }}</td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/comments.html' %}
-    {% plugin_left_page object %}
-  </div>
-	<div class="col col-12 col-md-6">
-    {% include 'inc/panels/related_objects.html' %}
-    {% plugin_right_page object %}
-	</div>
-</div>
-<div class="row mb-3">
-	<div class="col col-md-12">
-    <div class="card">
-      <h2 class="card-header">
-        {% trans "Child Regions" %}
-        {% if perms.dcim.add_region %}
-          <div class="card-actions">
-            <a href="{% url 'dcim:region_add' %}?parent={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
-              <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Region" %}
-            </a>
-          </div>
-        {% endif %}
-      </h2>
-      {% htmx_table 'dcim:region_list' parent_id=object.pk %}
-    </div>
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 138
netbox/templates/dcim/site.html

@@ -1,10 +1,4 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load tz %}
-{% load i18n %}
-{% load l10n %}
-{% load mptt %}
 
 {% block breadcrumbs %}
   {{ block.super }}
@@ -20,135 +14,3 @@
     <li class="breadcrumb-item"><a href="{% url 'dcim:site_list' %}?group_id={{ object.group.pk }}">{{ object.group }}</a></li>
   {% endif %}
 {% endblock %}
-
-{% block content %}
-<div class="row">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Site" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Region" %}</th>
-          <td>
-            {% nested_tree object.region %}
-          </td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Group" %}</th>
-          <td>
-            {% nested_tree object.group %}
-          </td>
-        </tr>
-        <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 "Tenant" %}</th>
-          <td>
-            {% if object.tenant.group %}
-              {{ object.tenant.group|linkify }} /
-            {% endif %}
-            {{ object.tenant|linkify|placeholder }}
-          </td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Facility" %}</th>
-          <td>{{ object.facility|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Time Zone" %}</th>
-          <td>
-            {% if object.time_zone %}
-              {{ object.time_zone }} ({% trans "UTC" %} {{ object.time_zone|tzoffset }})<br />
-              <small class="text-muted">{% trans "Site time" %}: {% timezone object.time_zone %}{% now 'Y-m-d H:i' %}{% endtimezone %}</small>
-            {% else %}
-              {{ ''|placeholder }}
-            {% endif %}
-          </td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Physical Address" %}</th>
-          <td class="d-flex justify-content-between align-items-start">
-            {% if object.physical_address %}
-              <span>{{ object.physical_address|linebreaksbr }}</span>
-              {% if config.MAPS_URL %}
-                <a href="{{ config.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm d-print-none">
-                  <i class="mdi mdi-map-marker"></i> {% trans "Map" %}
-                </a>
-              {% endif %}
-            {% else %}
-              {{ ''|placeholder }}
-            {% endif %}
-          </td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Shipping Address" %}</th>
-          <td>{{ object.shipping_address|linebreaksbr|placeholder }}</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>
-      </table>
-    </div>
-    {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/tags.html' %}
-    {% include 'inc/panels/comments.html' %}
-    {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
-      {% include 'inc/panels/image_attachments.html' %}
-      {% plugin_right_page object %}
-	</div>
-</div>
-<div class="row">
-  <div class="col col-md-12">
-    <div class="card">
-      <h2 class="card-header">
-        {% trans "Locations" %}
-        {% if perms.dcim.add_location %}
-          <div class="card-actions">
-            <a href="{% url 'dcim:location_add' %}?site={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
-              <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Location" %}
-            </a>
-          </div>
-        {% endif %}
-      </h2>
-      {% htmx_table 'dcim:location_list' site_id=object.pk %}
-    </div>
-    <div class="card">
-      <h2 class="card-header">
-        {% trans "Non-Racked Devices" %}
-        {% if perms.dcim.add_device %}
-          <div class="card-actions">
-            <a href="{% url 'dcim:device_add' %}?site={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
-              <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Device" %}
-            </a>
-          </div>
-        {% endif %}
-      </h2>
-      {% htmx_table 'dcim:device_list' site_id=object.pk rack_id='null' parent_bay_id='null' %}
-    </div>
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 53
netbox/templates/dcim/sitegroup.html

@@ -1,7 +1,4 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 
 {% block breadcrumbs %}
@@ -18,53 +15,3 @@
     </a>
   {% endif %}
 {% endblock extra_controls %}
-
-{% block content %}
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Site Group" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Name" %}</th>
-          <td>{{ object.name }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Parent" %}</th>
-          <td>{{ object.parent|linkify|placeholder }}</td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% include 'inc/panels/comments.html' %}
-    {% plugin_left_page object %}
-  </div>
-	<div class="col col-12 col-md-6">
-    {% include 'inc/panels/related_objects.html' %}
-    {% plugin_right_page object %}
-	</div>
-</div>
-<div class="row mb-3">
-	<div class="col col-md-12">
-    <div class="card">
-      <h2 class="card-header">
-        {% trans "Child Groups" %}
-        {% if perms.dcim.add_sitegroup %}
-          <div class="card-actions">
-            <a href="{% url 'dcim:sitegroup_add' %}?parent={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
-              <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Site Group" %}
-            </a>
-          </div>
-        {% endif %}
-      </h2>
-      {% htmx_table 'dcim:sitegroup_list' parent_id=object.pk %}
-    </div>
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 31 - 0
netbox/templates/extras/panels/custom_fields.html

@@ -0,0 +1,31 @@
+{% extends "ui/panels/_base.html" %}
+{% load i18n %}
+
+{% block panel_content %}
+  <table class="table table-hover attr-table">
+    {% for group_name, fields in custom_fields.items %}
+      {% if group_name %}
+        <tr>
+          <th scope="row" colspan="2" class="fw-bold">{{ group_name }}</th>
+        </tr>
+      {% endif %}
+      {% for field, value in fields.items %}
+        <tr>
+          <th scope="row"{% if group_name %} class="ps-3"{% endif %}>{{ field }}
+            {% if field.description %}
+              <i
+                class="mdi mdi-information text-primary"
+                data-bs-toggle="tooltip"
+                data-bs-placement="right"
+                title="{{ field.description|escape }}"
+              ></i>
+            {% endif %}
+          </th>
+          <td>
+            {% customfield_value field value %}
+          </td>
+        </tr>
+      {% endfor %}
+    {% endfor %}
+  </table>
+{% endblock panel_content %}

+ 15 - 0
netbox/templates/extras/panels/tags.html

@@ -0,0 +1,15 @@
+{% extends "ui/panels/_base.html" %}
+{% load helpers %}
+{% load i18n %}
+
+{% block panel_content %}
+  <div class="card-body">
+    {% with url=object|validated_viewname:"list" %}
+      {% for tag in object.tags.all %}
+        {% tag tag url %}
+      {% empty %}
+        <span class="text-muted">{% trans "No tags assigned" %}</span>
+      {% endfor %}
+    {% endwith %}
+  </div>
+{% endblock panel_content %}

+ 14 - 1
netbox/templates/generic/object.html

@@ -122,7 +122,20 @@ Context:
   {% plugin_alerts object %}
 {% endblock alerts %}
 
-{% block content %}{% endblock %}
+{% block content %}
+  {# Render panel layout declared on view class #}
+  {% for row in layout.rows %}
+    <div class="row">
+      {% for column in row.columns %}
+        <div class="col">
+          {% for panel in column.panels %}
+            {% render panel %}
+          {% endfor %}
+        </div>
+      {% endfor %}
+    </div>
+  {% endfor %}
+{% endblock %}
 
 {% block modals %}
   {% include 'inc/htmx_modal.html' %}

+ 7 - 0
netbox/templates/ui/actions/copy_content.html

@@ -0,0 +1,7 @@
+{% load i18n %}
+<a class="btn btn-ghost-{{ button_class }} btn-sm copy-content" data-clipboard-target="#{{ target_id }}" title="{% trans "Copy to clipboard" %}">
+  {% if button_icon %}
+    <i class="mdi mdi-{{ button_icon }}" aria-hidden="true"></i>
+  {% endif %}
+  {{ label }}
+</a>

+ 6 - 0
netbox/templates/ui/actions/link.html

@@ -0,0 +1,6 @@
+<a {% if url %}href="{{ url }}" {% endif %}class="btn btn-ghost-{{ button_class }} btn-sm">
+  {% if button_icon %}
+    <i class="mdi mdi-{{ button_icon }}" aria-hidden="true"></i>
+  {% endif %}
+  {{ label }}
+</a>

+ 8 - 0
netbox/templates/ui/attrs/address.html

@@ -0,0 +1,8 @@
+{% load i18n %}
+{% load l10n %}
+<span>{{ value|linebreaksbr }}</span>
+{% if map_url %}
+  <a href="{{ map_url }}{{ value }}" target="_blank" class="btn btn-primary btn-sm print-none">
+    <i class="mdi mdi-map-marker"></i> {% trans "Map" %}
+  </a>
+{% endif %}

+ 1 - 0
netbox/templates/ui/attrs/boolean.html

@@ -0,0 +1 @@
+{% checkmark value %}

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

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

+ 1 - 0
netbox/templates/ui/attrs/color.html

@@ -0,0 +1 @@
+<span class="badge color-label" style="background-color: #{{ value }}">&nbsp;</span>

+ 8 - 0
netbox/templates/ui/attrs/gps_coordinates.html

@@ -0,0 +1,8 @@
+{% load i18n %}
+{% load l10n %}
+<span>{{ latitude }}, {{ longitude }}</span>
+{% if map_url %}
+  <a href="{{ map_url }}{{ latitude|unlocalize }},{{ longitude|unlocalize }}" target="_blank" class="btn btn-primary btn-sm print-none">
+    <i class="mdi mdi-map-marker"></i> {% trans "Map" %}
+  </a>
+{% endif %}

+ 3 - 0
netbox/templates/ui/attrs/image.html

@@ -0,0 +1,3 @@
+<a href="{{ value.url }}">
+  <img src="{{ value.url }}" alt="{{ value.name }}" class="img-fluid" />
+</a>

+ 11 - 0
netbox/templates/ui/attrs/nested_object.html

@@ -0,0 +1,11 @@
+<ol class="breadcrumb" aria-label="breadcrumbs">
+  {% for node in nodes %}
+    <li class="breadcrumb-item">
+      {% if linkify %}
+        <a href="{{ node.get_absolute_url }}">{{ node }}</a>
+      {% else %}
+        {{ node }}
+      {% endif %}
+    </li>
+  {% endfor %}
+</ol>

+ 12 - 0
netbox/templates/ui/attrs/numeric.html

@@ -0,0 +1,12 @@
+{% load i18n %}
+<span{% if style %} class="{{ style }}"{% endif %}>
+  <span{% if name %} id="attr_{{ name }}"{% endif %}>{{ value }}</span>
+  {% if unit %}
+    {{ unit|lower }}
+  {% endif %}
+</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 %}

+ 14 - 0
netbox/templates/ui/attrs/object.html

@@ -0,0 +1,14 @@
+{% if group %}
+  {# Display an object with its parent group #}
+  <ol class="breadcrumb" aria-label="breadcrumbs">
+    <li class="breadcrumb-item">
+      {% if linkify %}{{ group|linkify }}{% else %}{{ group }}{% endif %}
+    </li>
+    <li class="breadcrumb-item">
+      {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %}
+    </li>
+  </ol>
+{% else %}
+  {# Display only the object #}
+  {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %}
+{% endif %}

+ 7 - 0
netbox/templates/ui/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 %}

+ 6 - 0
netbox/templates/ui/attrs/timezone.html

@@ -0,0 +1,6 @@
+{% load i18n %}
+{% load tz %}
+<div>
+  {{ value }} ({% trans "UTC" %} {{ value|tzoffset }})<br />
+  <small class="text-muted">{% trans "Local time" %}: {% timezone value %}{% now 'Y-m-d H:i' %}{% endtimezone %}</small>
+</div>

+ 2 - 0
netbox/templates/ui/attrs/utilization.html

@@ -0,0 +1,2 @@
+{% load helpers %}
+{% utilization_graph value %}

+ 15 - 0
netbox/templates/ui/panels/_base.html

@@ -0,0 +1,15 @@
+<!-- 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>
+  {% block panel_content %}{% endblock %}
+</div>
+<!-- end {{ panel_class|default:"panel" }} -->

+ 12 - 0
netbox/templates/ui/panels/comments.html

@@ -0,0 +1,12 @@
+{% extends "ui/panels/_base.html" %}
+{% load i18n %}
+
+{% block panel_content %}
+  <div class="card-body">
+    {% if comments %}
+      {{ comments|markdown }}
+    {% else %}
+      <span class="text-muted">{% trans "None" %}</span>
+    {% endif %}
+  </div>
+{% endblock panel_content %}

+ 5 - 0
netbox/templates/ui/panels/embedded_table.html

@@ -0,0 +1,5 @@
+{% extends "ui/panels/_base.html" %}
+
+{% block panel_content %}
+  {% include 'builtins/htmx_table.html' %}
+{% endblock panel_content %}

+ 5 - 0
netbox/templates/ui/panels/json.html

@@ -0,0 +1,5 @@
+{% extends "ui/panels/_base.html" %}
+
+{% block panel_content %}
+  <pre id="panel_{{ field_name }}">{{ data|json }}</pre>
+{% endblock panel_content %}

+ 14 - 0
netbox/templates/ui/panels/object_attributes.html

@@ -0,0 +1,14 @@
+{% extends "ui/panels/_base.html" %}
+
+{% block panel_content %}
+  <table class="table table-hover attr-table">
+    {% for attr in attrs %}
+      <tr>
+        <th scope="row">{{ attr.label }}</th>
+        <td>
+          <div class="d-flex justify-content-between align-items-start">{{ attr.value }}</div>
+        </td>
+      </tr>
+    {% endfor %}
+  </table>
+{% endblock panel_content %}

+ 5 - 0
netbox/templates/ui/panels/objects_table.html

@@ -0,0 +1,5 @@
+{% extends "ui/panels/_base.html" %}
+
+{% block panel_content %}
+  {% include 'builtins/htmx_table.html' %}
+{% endblock panel_content %}

+ 25 - 0
netbox/templates/ui/panels/related_objects.html

@@ -0,0 +1,25 @@
+{% extends "ui/panels/_base.html" %}
+{% load helpers %}
+{% load i18n %}
+
+{% block panel_content %}
+  <ul class="list-group list-group-flush" role="presentation">
+    {% for related_object_count in related_models %}
+      {% action_url related_object_count.queryset.model 'list' as list_url %}
+      {% if list_url %}
+        <a href="{{ list_url }}?{{ related_object_count.filter_param }}={{ object.pk }}" class="list-group-item list-group-item-action d-flex justify-content-between">
+          {{ related_object_count.name }}
+          {% with count=related_object_count.queryset.count %}
+            {% if count %}
+              <span class="badge text-bg-primary rounded-pill">{{ count }}</span>
+            {% else %}
+              <span class="badge text-bg-light rounded-pill">&mdash;</span>
+            {% endif %}
+          {% endwith %}
+        </a>
+      {% endif %}
+    {% empty %}
+      <span class="list-group-item text-muted">{% trans "None" %}</span>
+    {% endfor %}
+  </ul>
+{% endblock panel_content %}

+ 24 - 0
netbox/utilities/data.py

@@ -12,6 +12,7 @@ __all__ = (
     'flatten_dict',
     'ranges_to_string',
     'ranges_to_string_list',
+    'resolve_attr_path',
     'shallow_compare_dict',
     'string_to_ranges',
 )
@@ -213,3 +214,26 @@ def string_to_ranges(value):
             return None
         values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
     return values
+
+
+#
+# Attribute resolution
+#
+
+def resolve_attr_path(obj, path):
+    """
+    Follow a dotted path across attributes and/or dictionary keys and return the final value.
+
+    Parameters:
+        obj: The starting object
+        path: The dotted path to follow (e.g. "foo.bar.baz")
+    """
+    cur = obj
+    for part in path.split('.'):
+        if cur is None:
+            return None
+        try:
+            cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part)
+        except AttributeError:
+            cur = None
+    return cur

+ 9 - 0
netbox/utilities/templatetags/builtins/tags.py

@@ -3,6 +3,7 @@ from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
 
 from django import template
 from django.templatetags.static import static
+from django.utils.safestring import mark_safe
 
 from extras.choices import CustomFieldTypeChoices
 from utilities.querydict import dict_to_querydict
@@ -179,3 +180,11 @@ def static_with_params(path, **params):
     # Reconstruct the URL with the new query string
     new_parsed = parsed._replace(query=new_query)
     return urlunparse(new_parsed)
+
+
+@register.simple_tag(takes_context=True)
+def render(context, component):
+    """
+    Render a UI component (e.g. a Panel) by calling its render() method and passing the current template context.
+    """
+    return mark_safe(component.render(context))