Explorar o código

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

Closes #20204: Introduce modular template components
bctiemann hai 3 meses
pai
achega
1d2f6a82cb
Modificáronse 60 ficheiros con 2060 adicións e 1279 borrados
  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'
             - Getting Started: 'plugins/development/index.md'
             - Models: 'plugins/development/models.md'
             - Models: 'plugins/development/models.md'
             - Views: 'plugins/development/views.md'
             - Views: 'plugins/development/views.md'
+            - UI Components: 'plugins/development/ui-components.md'
             - Navigation: 'plugins/development/navigation.md'
             - Navigation: 'plugins/development/navigation.md'
             - Templates: 'plugins/development/templates.md'
             - Templates: 'plugins/development/templates.md'
             - Tables: 'plugins/development/tables.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 import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.paginator import EmptyPage, PageNotAnInteger
 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 django.views.generic import View
 
 
 from circuits.models import Circuit, CircuitTermination
 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 extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
 from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
 from netbox.object_actions import *
 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 netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -221,6 +229,27 @@ class RegionListView(generic.ObjectListView):
 @register_model_view(Region)
 @register_model_view(Region)
 class RegionView(GetRelatedModelsMixin, generic.ObjectView):
 class RegionView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Region.objects.all()
     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):
     def get_extra_context(self, request, instance):
         regions = instance.get_descendants(include_self=True)
         regions = instance.get_descendants(include_self=True)
@@ -332,6 +361,27 @@ class SiteGroupListView(generic.ObjectListView):
 @register_model_view(SiteGroup)
 @register_model_view(SiteGroup)
 class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
 class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = SiteGroup.objects.all()
     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):
     def get_extra_context(self, request, instance):
         groups = instance.get_descendants(include_self=True)
         groups = instance.get_descendants(include_self=True)
@@ -461,6 +511,39 @@ class SiteListView(generic.ObjectListView):
 @register_model_view(Site)
 @register_model_view(Site)
 class SiteView(GetRelatedModelsMixin, generic.ObjectView):
 class SiteView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Site.objects.prefetch_related('tenant__group')
     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):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -561,6 +644,52 @@ class LocationListView(generic.ObjectListView):
 @register_model_view(Location)
 @register_model_view(Location)
 class LocationView(GetRelatedModelsMixin, generic.ObjectView):
 class LocationView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Location.objects.all()
     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):
     def get_extra_context(self, request, instance):
         locations = instance.get_descendants(include_self=True)
         locations = instance.get_descendants(include_self=True)
@@ -661,6 +790,16 @@ class RackRoleListView(generic.ObjectListView):
 @register_model_view(RackRole)
 @register_model_view(RackRole)
 class RackRoleView(GetRelatedModelsMixin, generic.ObjectView):
 class RackRoleView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = RackRole.objects.all()
     queryset = RackRole.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.RackRolePanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -727,7 +866,22 @@ class RackTypeListView(generic.ObjectListView):
 
 
 @register_model_view(RackType)
 @register_model_view(RackType)
 class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
 class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = RackType.objects.all()
     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):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -845,6 +999,22 @@ class RackElevationListView(generic.ObjectListView):
 @register_model_view(Rack)
 @register_model_view(Rack)
 class RackView(GetRelatedModelsMixin, generic.ObjectView):
 class RackView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
     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):
     def get_extra_context(self, request, instance):
         peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
         peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
@@ -976,6 +1146,19 @@ class RackReservationListView(generic.ObjectListView):
 @register_model_view(RackReservation)
 @register_model_view(RackReservation)
 class RackReservationView(generic.ObjectView):
 class RackReservationView(generic.ObjectView):
     queryset = RackReservation.objects.all()
     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)
 @register_model_view(RackReservation, 'add', detail=False)
@@ -1049,6 +1232,10 @@ class ManufacturerListView(generic.ObjectListView):
 @register_model_view(Manufacturer)
 @register_model_view(Manufacturer)
 class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView):
 class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Manufacturer.objects.all()
     queryset = Manufacturer.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[OrganizationalObjectPanel(), TagsPanel()],
+        right_panels=[RelatedObjectsPanel(), CustomFieldsPanel()],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -1122,6 +1309,18 @@ class DeviceTypeListView(generic.ObjectListView):
 @register_model_view(DeviceType)
 @register_model_view(DeviceType)
 class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
 class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = DeviceType.objects.all()
     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):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -1372,7 +1571,36 @@ class ModuleTypeProfileListView(generic.ObjectListView):
 
 
 @register_model_view(ModuleTypeProfile)
 @register_model_view(ModuleTypeProfile)
 class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView):
 class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView):
+    template_name = 'generic/object.html'
     queryset = ModuleTypeProfile.objects.all()
     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)
 @register_model_view(ModuleTypeProfile, 'add', detail=False)
@@ -2213,6 +2441,43 @@ class DeviceListView(generic.ObjectListView):
 @register_model_view(Device)
 @register_model_view(Device)
 class DeviceView(generic.ObjectView):
 class DeviceView(generic.ObjectView):
     queryset = Device.objects.all()
     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):
     def get_extra_context(self, request, instance):
         # VirtualChassis members
         # VirtualChassis members
@@ -2225,7 +2490,7 @@ class DeviceView(generic.ObjectView):
 
 
         return {
         return {
             'vc_members': vc_members,
             'vc_members': vc_members,
-            'svg_extra': f'highlight=id:{instance.pk}'
+            'svg_extra': f'highlight=id:{instance.pk}',
         }
         }
 
 
 
 

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

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

@@ -1,375 +1 @@
 {% extends 'dcim/device/base.html' %}
 {% 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' %}
 {% 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' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   {{ block.super }}
   {{ block.super }}
@@ -10,96 +6,3 @@
     <li class="breadcrumb-item">{{ location|linkify }}</li>
     <li class="breadcrumb-item">{{ location|linkify }}</li>
   {% endfor %}
   {% endfor %}
 {% endblock %}
 {% 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' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block extra_controls %}
 {% block extra_controls %}
@@ -25,35 +22,3 @@
     </div>
     </div>
   {% endif %}
   {% endif %}
 {% endblock extra_controls %}
 {% 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' %}
 {% 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' %}
 {% 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' %}
 {% extends 'generic/object.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load static %}
-{% load plugins %}
 {% load i18n %}
 {% load i18n %}
-{% load mptt %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   {{ block.super }}
   {{ 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"><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>
   <li class="breadcrumb-item">{% trans "Units" %} {{ object.unit_list }}</li>
 {% endblock %}
 {% 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' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block extra_controls %}
 {% block extra_controls %}
@@ -11,41 +8,3 @@
     </a>
     </a>
   {% endif %}
   {% endif %}
 {% endblock extra_controls %}
 {% 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' %}
 {% 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' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
@@ -18,53 +15,3 @@
     </a>
     </a>
   {% endif %}
   {% endif %}
 {% endblock extra_controls %}
 {% 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' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load tz %}
-{% load i18n %}
-{% load l10n %}
-{% load mptt %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   {{ block.super }}
   {{ 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>
     <li class="breadcrumb-item"><a href="{% url 'dcim:site_list' %}?group_id={{ object.group.pk }}">{{ object.group }}</a></li>
   {% endif %}
   {% endif %}
 {% endblock %}
 {% 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' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
@@ -18,53 +15,3 @@
     </a>
     </a>
   {% endif %}
   {% endif %}
 {% endblock extra_controls %}
 {% 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 %}
   {% plugin_alerts object %}
 {% endblock alerts %}
 {% 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 %}
 {% block modals %}
   {% include 'inc/htmx_modal.html' %}
   {% 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',
     'flatten_dict',
     'ranges_to_string',
     'ranges_to_string',
     'ranges_to_string_list',
     'ranges_to_string_list',
+    'resolve_attr_path',
     'shallow_compare_dict',
     'shallow_compare_dict',
     'string_to_ranges',
     'string_to_ranges',
 )
 )
@@ -213,3 +214,26 @@ def string_to_ranges(value):
             return None
             return None
         values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
         values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
     return values
     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 import template
 from django.templatetags.static import static
 from django.templatetags.static import static
+from django.utils.safestring import mark_safe
 
 
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
 from utilities.querydict import dict_to_querydict
 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
     # Reconstruct the URL with the new query string
     new_parsed = parsed._replace(query=new_query)
     new_parsed = parsed._replace(query=new_query)
     return urlunparse(new_parsed)
     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))