diff --git a/docs/plugins/development/ui-components.md b/docs/plugins/development/ui-components.md
new file mode 100644
index 00000000000..a8fe2eff0f2
--- /dev/null
+++ b/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
diff --git a/mkdocs.yml b/mkdocs.yml
index 078fc5e50cb..07628e775c6 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -143,6 +143,7 @@ nav:
- Getting Started: 'plugins/development/index.md'
- Models: 'plugins/development/models.md'
- Views: 'plugins/development/views.md'
+ - UI Components: 'plugins/development/ui-components.md'
- Navigation: 'plugins/development/navigation.md'
- Templates: 'plugins/development/templates.md'
- Tables: 'plugins/development/tables.md'
diff --git a/netbox/dcim/ui/__init__.py b/netbox/dcim/ui/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py
new file mode 100644
index 00000000000..87ceb9c4a05
--- /dev/null
+++ b/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)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index a41078a112a..508b7e6f2ea 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1,3 +1,4 @@
+from django.conf import settings
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger
@@ -12,10 +13,17 @@
from django.views.generic import View
from circuits.models import Circuit, CircuitTermination
+from dcim.ui import panels
+from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.object_actions import *
+from netbox.ui import actions, layout
+from netbox.ui.panels import (
+ CommentsPanel, JSONPanel, NestedGroupObjectPanel, ObjectsTablePanel, OrganizationalObjectPanel, RelatedObjectsPanel,
+ TemplatePanel,
+)
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -221,6 +229,27 @@ class RegionListView(generic.ObjectListView):
@register_model_view(Region)
class RegionView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Region.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ NestedGroupObjectPanel(),
+ TagsPanel(),
+ CustomFieldsPanel(),
+ CommentsPanel(),
+ ],
+ right_panels=[
+ RelatedObjectsPanel(),
+ ],
+ bottom_panels=[
+ ObjectsTablePanel(
+ model='dcim.Region',
+ title=_('Child Regions'),
+ filters={'parent_id': lambda ctx: ctx['object'].pk},
+ actions=[
+ actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
+ ],
+ ),
+ ]
+ )
def get_extra_context(self, request, instance):
regions = instance.get_descendants(include_self=True)
@@ -332,6 +361,27 @@ class SiteGroupListView(generic.ObjectListView):
@register_model_view(SiteGroup)
class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = SiteGroup.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ NestedGroupObjectPanel(),
+ TagsPanel(),
+ CustomFieldsPanel(),
+ CommentsPanel(),
+ ],
+ right_panels=[
+ RelatedObjectsPanel(),
+ ],
+ bottom_panels=[
+ ObjectsTablePanel(
+ model='dcim.SiteGroup',
+ title=_('Child Groups'),
+ filters={'parent_id': lambda ctx: ctx['object'].pk},
+ actions=[
+ actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
+ ],
+ ),
+ ]
+ )
def get_extra_context(self, request, instance):
groups = instance.get_descendants(include_self=True)
@@ -461,6 +511,39 @@ class SiteListView(generic.ObjectListView):
@register_model_view(Site)
class SiteView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Site.objects.prefetch_related('tenant__group')
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.SitePanel(),
+ CustomFieldsPanel(),
+ TagsPanel(),
+ CommentsPanel(),
+ ],
+ right_panels=[
+ RelatedObjectsPanel(),
+ ImageAttachmentsPanel(),
+ ],
+ bottom_panels=[
+ ObjectsTablePanel(
+ model='dcim.Location',
+ filters={'site_id': lambda ctx: ctx['object'].pk},
+ actions=[
+ actions.AddObject('dcim.Location', url_params={'site': lambda ctx: ctx['object'].pk}),
+ ],
+ ),
+ ObjectsTablePanel(
+ model='dcim.Device',
+ title=_('Non-Racked Devices'),
+ filters={
+ 'site_id': lambda ctx: ctx['object'].pk,
+ 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE,
+ 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE,
+ },
+ actions=[
+ actions.AddObject('dcim.Device', url_params={'site': lambda ctx: ctx['object'].pk}),
+ ],
+ ),
+ ]
+ )
def get_extra_context(self, request, instance):
return {
@@ -561,6 +644,52 @@ class LocationListView(generic.ObjectListView):
@register_model_view(Location)
class LocationView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Location.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.LocationPanel(),
+ TagsPanel(),
+ CustomFieldsPanel(),
+ CommentsPanel(),
+ ],
+ right_panels=[
+ RelatedObjectsPanel(),
+ ImageAttachmentsPanel(),
+ ],
+ bottom_panels=[
+ ObjectsTablePanel(
+ model='dcim.Location',
+ title=_('Child Locations'),
+ filters={'parent_id': lambda ctx: ctx['object'].pk},
+ actions=[
+ actions.AddObject(
+ 'dcim.Location',
+ url_params={
+ 'site': lambda ctx: ctx['object'].site_id,
+ 'parent': lambda ctx: ctx['object'].pk,
+ }
+ ),
+ ],
+ ),
+ ObjectsTablePanel(
+ model='dcim.Device',
+ title=_('Non-Racked Devices'),
+ filters={
+ 'location_id': lambda ctx: ctx['object'].pk,
+ 'rack_id': settings.FILTERS_NULL_CHOICE_VALUE,
+ 'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE,
+ },
+ actions=[
+ actions.AddObject(
+ 'dcim.Device',
+ url_params={
+ 'site': lambda ctx: ctx['object'].site_id,
+ 'parent': lambda ctx: ctx['object'].pk,
+ }
+ ),
+ ],
+ ),
+ ]
+ )
def get_extra_context(self, request, instance):
locations = instance.get_descendants(include_self=True)
@@ -661,6 +790,16 @@ class RackRoleListView(generic.ObjectListView):
@register_model_view(RackRole)
class RackRoleView(GetRelatedModelsMixin, generic.ObjectView):
queryset = RackRole.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.RackRolePanel(),
+ TagsPanel(),
+ ],
+ right_panels=[
+ RelatedObjectsPanel(),
+ CustomFieldsPanel(),
+ ],
+ )
def get_extra_context(self, request, instance):
return {
@@ -727,7 +866,22 @@ class RackTypeListView(generic.ObjectListView):
@register_model_view(RackType)
class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
+ template_name = 'generic/object.html'
queryset = RackType.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.RackTypePanel(),
+ panels.RackDimensionsPanel(title=_('Dimensions')),
+ TagsPanel(),
+ CommentsPanel(),
+ ],
+ right_panels=[
+ panels.RackNumberingPanel(title=_('Numbering')),
+ panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']),
+ CustomFieldsPanel(),
+ RelatedObjectsPanel(),
+ ],
+ )
def get_extra_context(self, request, instance):
return {
@@ -845,6 +999,22 @@ def get(self, request):
@register_model_view(Rack)
class RackView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.RackPanel(),
+ panels.RackDimensionsPanel(title=_('Dimensions')),
+ panels.RackNumberingPanel(title=_('Numbering')),
+ panels.RackWeightPanel(title=_('Weight')),
+ CustomFieldsPanel(),
+ TagsPanel(),
+ CommentsPanel(),
+ ImageAttachmentsPanel(),
+ ],
+ right_panels=[
+ TemplatePanel('dcim/panels/rack_elevations.html'),
+ RelatedObjectsPanel(),
+ ],
+ )
def get_extra_context(self, request, instance):
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
@@ -976,6 +1146,19 @@ class RackReservationListView(generic.ObjectListView):
@register_model_view(RackReservation)
class RackReservationView(generic.ObjectView):
queryset = RackReservation.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.RackPanel(accessor='object.rack', only=['region', 'site', 'location', 'name']),
+ panels.RackReservationPanel(title=_('Reservation')),
+ CustomFieldsPanel(),
+ TagsPanel(),
+ CommentsPanel(),
+ ],
+ right_panels=[
+ TemplatePanel(template_name='dcim/panels/rack_reservation_elevations.html'),
+ RelatedObjectsPanel(),
+ ],
+ )
@register_model_view(RackReservation, 'add', detail=False)
@@ -1049,6 +1232,10 @@ class ManufacturerListView(generic.ObjectListView):
@register_model_view(Manufacturer)
class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView):
queryset = Manufacturer.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[OrganizationalObjectPanel(), TagsPanel()],
+ right_panels=[RelatedObjectsPanel(), CustomFieldsPanel()],
+ )
def get_extra_context(self, request, instance):
return {
@@ -1122,6 +1309,18 @@ class DeviceTypeListView(generic.ObjectListView):
@register_model_view(DeviceType)
class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = DeviceType.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.DeviceTypePanel(),
+ TagsPanel(),
+ ],
+ right_panels=[
+ RelatedObjectsPanel(),
+ CustomFieldsPanel(),
+ CommentsPanel(),
+ ImageAttachmentsPanel(),
+ ],
+ )
def get_extra_context(self, request, instance):
return {
@@ -1372,7 +1571,36 @@ class ModuleTypeProfileListView(generic.ObjectListView):
@register_model_view(ModuleTypeProfile)
class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView):
+ template_name = 'generic/object.html'
queryset = ModuleTypeProfile.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.ModuleTypeProfilePanel(),
+ TagsPanel(),
+ CommentsPanel(),
+ ],
+ right_panels=[
+ JSONPanel(field_name='schema', title=_('Schema')),
+ CustomFieldsPanel(),
+ ],
+ bottom_panels=[
+ ObjectsTablePanel(
+ model='dcim.ModuleType',
+ title=_('Module Types'),
+ filters={
+ 'profile_id': lambda ctx: ctx['object'].pk,
+ },
+ actions=[
+ actions.AddObject(
+ 'dcim.ModuleType',
+ url_params={
+ 'profile': lambda ctx: ctx['object'].pk,
+ }
+ ),
+ ],
+ ),
+ ]
+ )
@register_model_view(ModuleTypeProfile, 'add', detail=False)
@@ -2213,6 +2441,43 @@ class DeviceListView(generic.ObjectListView):
@register_model_view(Device)
class DeviceView(generic.ObjectView):
queryset = Device.objects.all()
+ layout = layout.SimpleLayout(
+ left_panels=[
+ panels.DevicePanel(),
+ panels.VirtualChassisMembersPanel(),
+ CustomFieldsPanel(),
+ TagsPanel(),
+ CommentsPanel(),
+ ObjectsTablePanel(
+ model='dcim.VirtualDeviceContext',
+ filters={'device_id': lambda ctx: ctx['object'].pk},
+ actions=[
+ actions.AddObject('dcim.VirtualDeviceContext', url_params={'device': lambda ctx: ctx['object'].pk}),
+ ],
+ ),
+ ],
+ right_panels=[
+ panels.DeviceManagementPanel(),
+ panels.PowerUtilizationPanel(),
+ ObjectsTablePanel(
+ model='ipam.Service',
+ title=_('Application Services'),
+ filters={'device_id': lambda ctx: ctx['object'].pk},
+ actions=[
+ actions.AddObject(
+ 'ipam.Service',
+ url_params={
+ 'parent_object_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
+ 'parent': lambda ctx: ctx['object'].pk
+ }
+ ),
+ ],
+ ),
+ ImageAttachmentsPanel(),
+ panels.DeviceDimensionsPanel(),
+ TemplatePanel('dcim/panels/device_rack_elevations.html'),
+ ],
+ )
def get_extra_context(self, request, instance):
# VirtualChassis members
@@ -2225,7 +2490,7 @@ def get_extra_context(self, request, instance):
return {
'vc_members': vc_members,
- 'svg_extra': f'highlight=id:{instance.pk}'
+ 'svg_extra': f'highlight=id:{instance.pk}',
}
diff --git a/netbox/extras/ui/panels.py b/netbox/extras/ui/panels.py
new file mode 100644
index 00000000000..f2f9a5c9af3
--- /dev/null
+++ b/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),
+ }
diff --git a/netbox/netbox/ui/__init__.py b/netbox/netbox/ui/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/netbox/netbox/ui/actions.py b/netbox/netbox/ui/actions.py
new file mode 100644
index 00000000000..7579e7b9340
--- /dev/null
+++ b/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,
+ })
diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py
new file mode 100644
index 00000000000..bf55e3f3c4c
--- /dev/null
+++ b/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 = '— '
+
+
+#
+# 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'
diff --git a/netbox/netbox/ui/layout.py b/netbox/netbox/ui/layout.py
new file mode 100644
index 00000000000..b59fd7b3474
--- /dev/null
+++ b/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)
diff --git a/netbox/netbox/ui/panels.py b/netbox/netbox/ui/panels.py
new file mode 100644
index 00000000000..4d16cb8d348
--- /dev/null
+++ b/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)
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
index 89719159209..88a3456f746 100644
--- a/netbox/netbox/views/generic/object_views.py
+++ b/netbox/netbox/views/generic/object_views.py
@@ -44,9 +44,11 @@ class ObjectView(ActionsMixin, BaseObjectView):
Note: If `template_name` is not specified, it will be determined automatically based on the queryset model.
Attributes:
+ layout: An instance of `netbox.ui.layout.Layout` which defines the page layout (overrides HTML template)
tab: A ViewTab instance for the view
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
"""
+ layout = None
tab = None
actions = (CloneObject, EditObject, DeleteObject)
@@ -81,6 +83,7 @@ def get(self, request, **kwargs):
'object': instance,
'actions': actions,
'tab': self.tab,
+ 'layout': self.layout,
**self.get_extra_context(request, instance),
})
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html
index f8b8e95c2c1..9719d6df149 100644
--- a/netbox/templates/dcim/device.html
+++ b/netbox/templates/dcim/device.html
@@ -1,375 +1 @@
{% extends 'dcim/device/base.html' %}
-{% load render_table from django_tables2 %}
-{% load buttons %}
-{% load static %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
-{% load l10n %}
-{% load mptt %}
-
-{% block content %}
-
-
-
-
-
-
- {% trans "Region" %}
- {% nested_tree object.site.region %}
-
-
- {% trans "Site" %}
- {{ object.site|linkify }}
-
-
- {% trans "Location" %}
- {% nested_tree object.location %}
-
- {% if object.virtual_chassis %}
-
- {% trans "Virtual Chassis" %}
- {{ object.virtual_chassis|linkify }}
-
- {% endif %}
-
- {% trans "Rack" %}
-
- {% if object.rack %}
- {{ object.rack|linkify }}
-
-
-
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- {% trans "Position" %}
-
- {% 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 %}
- U{{ object.position|floatformat }} / {{ object.get_face_display }}
- {% elif object.rack and object.device_type.u_height %}
- {% trans "Not racked" %}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- {% trans "GPS Coordinates" %}
-
- {% if object.latitude and object.longitude %}
- {% if config.MAPS_URL %}
-
- {% endif %}
- {{ object.latitude }}, {{ object.longitude }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- {% trans "Tenant" %}
-
- {% if object.tenant.group %}
- {{ object.tenant.group|linkify }} /
- {% endif %}
- {{ object.tenant|linkify|placeholder }}
-
-
-
- {% trans "Device Type" %}
-
- {{ object.device_type|linkify:"full_name" }} ({{ object.device_type.u_height|floatformat }}U)
-
-
-
- {% trans "Description" %}
- {{ object.description|placeholder }}
-
-
- {% trans "Airflow" %}
-
- {{ object.get_airflow_display|placeholder }}
-
-
-
- {% trans "Serial Number" %}
- {{ object.serial|placeholder }}
-
-
- {% trans "Asset Tag" %}
- {{ object.asset_tag|placeholder }}
-
-
- {% trans "Config Template" %}
- {{ object.config_template|linkify|placeholder }}
-
-
-
- {% if vc_members %}
-
-
-
-
-
- {% trans "Device" %}
- {% trans "Position" %}
- {% trans "Master" %}
- {% trans "Priority" %}
-
-
-
- {% for vc_member in vc_members %}
-
- {{ vc_member|linkify }}
- {% badge vc_member.vc_position show_empty=True %}
-
- {% if object.virtual_chassis.master == vc_member %}
- {% checkmark True %}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
- {{ vc_member.vc_priority|placeholder }}
-
- {% endfor %}
-
-
-
- {% endif %}
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/tags.html' %}
- {% include 'inc/panels/comments.html' %}
-
-
- {% htmx_table 'dcim:virtualdevicecontext_list' device_id=object.pk %}
-
- {% plugin_left_page object %}
-
-
-
-
-
-
- {% trans "Status" %}
- {% badge object.get_status_display bg_color=object.get_status_color %}
-
-
- {% trans "Role" %}
- {{ object.role|linkify }}
-
-
- {% trans "Platform" %}
- {{ object.platform|linkify|placeholder }}
-
-
- {% trans "Primary IPv4" %}
-
- {% if object.primary_ip4 %}
- {{ object.primary_ip4.address.ip }}
- {% if object.primary_ip4.nat_inside %}
- ({% trans "NAT for" %} {{ object.primary_ip4.nat_inside.address.ip }} )
- {% elif object.primary_ip4.nat_outside.exists %}
- ({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}{{ nat.address.ip }} {% if not forloop.last %}, {% endif %}{% endfor %})
- {% endif %}
- {% copy_content "primary_ip4" %}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- {% trans "Primary IPv6" %}
-
- {% if object.primary_ip6 %}
- {{ object.primary_ip6.address.ip }}
- {% if object.primary_ip6.nat_inside %}
- ({% trans "NAT for" %} {{ object.primary_ip6.nat_inside.address.ip }} )
- {% elif object.primary_ip6.nat_outside.exists %}
- ({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}{{ nat.address.ip }} {% if not forloop.last %}, {% endif %}{% endfor %})
- {% endif %}
- {% copy_content "primary_ip6" %}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- Out-of-band IP
-
- {% if object.oob_ip %}
- {{ object.oob_ip.address.ip }}
- {% if object.oob_ip.nat_inside %}
- ({% trans "NAT for" %} {{ object.oob_ip.nat_inside.address.ip }} )
- {% elif object.oob_ip.nat_outside.exists %}
- ({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}{{ nat.address.ip }} {% if not forloop.last %}, {% endif %}{% endfor %})
- {% endif %}
- {% copy_content "oob_ip" %}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
- {% if object.cluster %}
-
- {% trans "Cluster" %}
-
- {% if object.cluster.group %}
- {{ object.cluster.group|linkify }} /
- {% endif %}
- {{ object.cluster|linkify }}
-
-
- {% endif %}
-
-
- {% if object.powerports.exists and object.poweroutlets.exists %}
-
-
-
-
-
- {% trans "Input" %}
- {% trans "Outlets" %}
- {% trans "Allocated" %}
- {% trans "Available" %}
- {% trans "Utilization" %}
-
-
- {% for powerport in object.powerports.all %}
- {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %}
-
- {{ powerport }}
- {{ utilization.outlet_count }}
- {{ utilization.allocated }}{% trans "VA" %}
- {% if powerfeed.available_power %}
- {{ powerfeed.available_power }}{% trans "VA" %}
- {% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
- {% else %}
- —
- —
- {% endif %}
-
- {% for leg in utilization.legs %}
-
-
- {% trans "Leg" context "Leg of a power feed" %} {{ leg.name }}
-
- {{ leg.outlet_count }}
- {{ leg.allocated }}
- {% if powerfeed.available_power %}
- {% with phase_available=powerfeed.available_power|divide:3 %}
- {{ phase_available }}{% trans "VA" %}
- {% utilization_graph leg.allocated|percentage:phase_available %}
- {% endwith %}
- {% else %}
- —
- —
- {% endif %}
-
- {% endfor %}
- {% endwith %}
- {% endfor %}
-
-
- {% endif %}
-
-
- {% htmx_table 'ipam:service_list' device_id=object.pk %}
-
- {% include 'inc/panels/image_attachments.html' %}
-
-
-
-
- {% trans "Height" %}
-
- {{ object.device_type.u_height }}U
-
-
-
- {% trans "Weight" %}
-
- {% if object.total_weight %}
- {{ object.total_weight|floatformat }} {% trans "Kilograms" %}
- ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %})
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
-
- {% if object.rack and object.position %}
-
-
-
{{ object.rack.name }}
- {% if object.rack.role %}
-
{{ object.rack.role }}
- {% endif %}
- {% if object.rack.facility_id %}
-
{{ object.rack.facility_id }}
- {% endif %}
-
-
-
-
{% trans "Front" %}
- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %}
-
-
-
-
-
{% trans "Rear" %}
- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %}
-
-
-
- {% endif %}
- {% plugin_right_page object %}
-
-
-
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/dcim/device/attrs/ipaddress.html b/netbox/templates/dcim/device/attrs/ipaddress.html
new file mode 100644
index 00000000000..7b434565760
--- /dev/null
+++ b/netbox/templates/dcim/device/attrs/ipaddress.html
@@ -0,0 +1,10 @@
+{% load i18n %}
+{{ value.address.ip }}
+{% if value.nat_inside %}
+ ({% trans "NAT for" %} {{ value.nat_inside.address.ip }} )
+{% elif value.nat_outside.exists %}
+ ({% trans "NAT" %}: {% for nat in value.nat_outside.all %}{{ nat.address.ip }} {% if not forloop.last %}, {% endif %}{% endfor %})
+{% endif %}
+
+
+
diff --git a/netbox/templates/dcim/device/attrs/parent_device.html b/netbox/templates/dcim/device/attrs/parent_device.html
new file mode 100644
index 00000000000..6351f792a2f
--- /dev/null
+++ b/netbox/templates/dcim/device/attrs/parent_device.html
@@ -0,0 +1,10 @@
+{% load i18n %}
+
+ {{ value.device|linkify }}
+ {{ value }}
+
+{% if value.device.position %}
+
+
+
+{% endif %}
diff --git a/netbox/templates/dcim/device/attrs/rack.html b/netbox/templates/dcim/device/attrs/rack.html
new file mode 100644
index 00000000000..d939e9ca3ba
--- /dev/null
+++ b/netbox/templates/dcim/device/attrs/rack.html
@@ -0,0 +1,14 @@
+{% load i18n %}
+
+ {{ value|linkify }}
+ {% if value and object.position %}
+ (U{{ object.position|floatformat }} / {{ object.get_face_display }})
+ {% elif value and object.device_type.u_height %}
+ {% trans "Not racked" %}
+ {% endif %}
+
+{% if object.position %}
+
+
+
+{% endif %}
diff --git a/netbox/templates/dcim/device/attrs/total_weight.html b/netbox/templates/dcim/device/attrs/total_weight.html
new file mode 100644
index 00000000000..73ac54ef551
--- /dev/null
+++ b/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" %})
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html
index 909b5276e73..4c9c678dcbf 100644
--- a/netbox/templates/dcim/devicetype.html
+++ b/netbox/templates/dcim/devicetype.html
@@ -1,112 +1 @@
{% extends 'dcim/devicetype/base.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
-
-{% block content %}
-
-
-
-
-
-
- {% trans "Manufacturer" %}
- {{ object.manufacturer|linkify }}
-
-
- {% trans "Model Name" %}
-
- {{ object.model }}
- {{ object.slug }}
-
-
-
- {% trans "Part Number" %}
- {{ object.part_number|placeholder }}
-
-
- {% trans "Default Platform" %}
- {{ object.default_platform|linkify }}
-
-
- {% trans "Description" %}
- {{ object.description|placeholder }}
-
-
- {% trans "Height (U)" %}
- {{ object.u_height|floatformat }}
-
-
- {% trans "Exclude From Utilization" %}
- {% checkmark object.exclude_from_utilization %}
-
-
- {% trans "Full Depth" %}
- {% checkmark object.is_full_depth %}
-
-
- {% trans "Weight" %}
-
- {% if object.weight %}
- {{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- {% trans "Parent/Child" %}
-
- {{ object.get_subdevice_role_display|placeholder }}
-
-
-
- {% trans "Airflow" %}
-
- {{ object.get_airflow_display|placeholder }}
-
-
-
- {% trans "Front Image" %}
-
- {% if object.front_image %}
-
-
-
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- {% trans "Rear Image" %}
-
- {% if object.rear_image %}
-
-
-
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
-
-
- {% 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 %}
-
-
-
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html
index dfd0c32b31a..07c64305a0b 100644
--- a/netbox/templates/dcim/location.html
+++ b/netbox/templates/dcim/location.html
@@ -1,8 +1,4 @@
{% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
@@ -10,96 +6,3 @@
{{ location|linkify }}
{% endfor %}
{% endblock %}
-
-{% block extra_controls %}
- {% if perms.dcim.add_location %}
-
- {% trans "Add Child Location" %}
-
- {% endif %}
-{% endblock extra_controls %}
-
-{% block content %}
-
-
-
-
-
-
- {% trans "Name" %}
- {{ object.name }}
-
-
- {% trans "Description" %}
- {{ object.description|placeholder }}
-
-
- {% trans "Site" %}
- {{ object.site|linkify }}
-
-
- {% trans "Parent" %}
- {{ object.parent|linkify|placeholder }}
-
-
- {% trans "Status" %}
- {% badge object.get_status_display bg_color=object.get_status_color %}
-
-
- {% trans "Tenant" %}
-
- {% if object.tenant.group %}
- {{ object.tenant.group|linkify }} /
- {% endif %}
- {{ object.tenant|linkify|placeholder }}
-
-
-
- {% trans "Facility" %}
- {{ object.facility|placeholder }}
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/comments.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/related_objects.html' %}
- {% include 'inc/panels/image_attachments.html' %}
- {% plugin_right_page object %}
-
-
-
-
-
-
- {% htmx_table 'dcim:location_list' parent_id=object.pk %}
-
-
-
- {% htmx_table 'dcim:device_list' location_id=object.pk rack_id='null' parent_bay_id='null' %}
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html
index cb6251f63e0..f6bd7dfc3b0 100644
--- a/netbox/templates/dcim/manufacturer.html
+++ b/netbox/templates/dcim/manufacturer.html
@@ -1,7 +1,4 @@
{% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
{% load i18n %}
{% block extra_controls %}
@@ -25,35 +22,3 @@
{% endif %}
{% endblock extra_controls %}
-
-{% block content %}
-
-
-
-
-
-
- {% trans "Name" %}
- {{ object.name }}
-
-
- {% trans "Description" %}
- {{ object.description|placeholder }}
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/related_objects.html' %}
- {% include 'inc/panels/custom_fields.html' %}
- {% plugin_right_page object %}
-
-
-
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/dcim/moduletypeprofile.html b/netbox/templates/dcim/moduletypeprofile.html
deleted file mode 100644
index 87e576bda9b..00000000000
--- a/netbox/templates/dcim/moduletypeprofile.html
+++ /dev/null
@@ -1,59 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
-
-{% block title %}{{ object.name }}{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
- {% trans "Name" %}
- {{ object.name }}
-
-
- {% trans "Description" %}
- {{ object.description|placeholder }}
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% include 'inc/panels/comments.html' %}
- {% plugin_left_page object %}
-
-
-
-
-
{{ object.schema|json }}
-
- {% include 'inc/panels/custom_fields.html' %}
- {% plugin_right_page object %}
-
-
-
-
-
-
- {% htmx_table 'dcim:moduletype_list' profile_id=object.pk %}
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/dcim/panels/device_rack_elevations.html b/netbox/templates/dcim/panels/device_rack_elevations.html
new file mode 100644
index 00000000000..1816be5c986
--- /dev/null
+++ b/netbox/templates/dcim/panels/device_rack_elevations.html
@@ -0,0 +1,26 @@
+{% load i18n %}
+{% if object.rack and object.position %}
+
+
+
{{ object.rack.name }}
+ {% if object.rack.role %}
+
{{ object.rack.role }}
+ {% endif %}
+ {% if object.rack.facility_id %}
+
{{ object.rack.facility_id }}
+ {% endif %}
+
+
+
+
{% trans "Front" %}
+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %}
+
+
+
+
+
{% trans "Rear" %}
+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %}
+
+
+
+{% endif %}
diff --git a/netbox/templates/dcim/panels/power_utilization.html b/netbox/templates/dcim/panels/power_utilization.html
new file mode 100644
index 00000000000..b716ed2c98d
--- /dev/null
+++ b/netbox/templates/dcim/panels/power_utilization.html
@@ -0,0 +1,50 @@
+{% extends "ui/panels/_base.html" %}
+{% load helpers i18n %}
+
+{% block panel_content %}
+
+
+
+ {% trans "Input" %}
+ {% trans "Outlets" %}
+ {% trans "Allocated" %}
+ {% trans "Available" %}
+ {% trans "Utilization" %}
+
+
+ {% for powerport in object.powerports.all %}
+ {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %}
+
+ {{ powerport }}
+ {{ utilization.outlet_count }}
+ {{ utilization.allocated }}{% trans "VA" %}
+ {% if powerfeed.available_power %}
+ {{ powerfeed.available_power }}{% trans "VA" %}
+ {% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
+ {% else %}
+ —
+ —
+ {% endif %}
+
+ {% for leg in utilization.legs %}
+
+
+ {% trans "Leg" context "Leg of a power feed" %} {{ leg.name }}
+
+ {{ leg.outlet_count }}
+ {{ leg.allocated }}
+ {% if powerfeed.available_power %}
+ {% with phase_available=powerfeed.available_power|divide:3 %}
+ {{ phase_available }}{% trans "VA" %}
+ {% utilization_graph leg.allocated|percentage:phase_available %}
+ {% endwith %}
+ {% else %}
+ —
+ —
+ {% endif %}
+
+ {% endfor %}
+ {% endwith %}
+ {% endfor %}
+
+{% endblock panel_content %}
diff --git a/netbox/templates/dcim/panels/rack_elevations.html b/netbox/templates/dcim/panels/rack_elevations.html
new file mode 100644
index 00000000000..550f54f3b34
--- /dev/null
+++ b/netbox/templates/dcim/panels/rack_elevations.html
@@ -0,0 +1,22 @@
+{% load i18n %}
+
+
+ {% trans "Images and Labels" %}
+ {% trans "Images only" %}
+ {% trans "Labels only" %}
+
+
+
+
+
+
{% trans "Front" %}
+ {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
+
+
+
+
+
{% trans "Rear" %}
+ {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
+
+
+
diff --git a/netbox/templates/dcim/panels/rack_reservation_elevations.html b/netbox/templates/dcim/panels/rack_reservation_elevations.html
new file mode 100644
index 00000000000..dab57f2426a
--- /dev/null
+++ b/netbox/templates/dcim/panels/rack_reservation_elevations.html
@@ -0,0 +1,15 @@
+{% load i18n %}
+
+
+
+
{% trans "Front" %}
+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %}
+
+
+
+
+
{% trans "Rear" %}
+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %}
+
+
+
diff --git a/netbox/templates/dcim/panels/virtual_chassis_members.html b/netbox/templates/dcim/panels/virtual_chassis_members.html
new file mode 100644
index 00000000000..29e422ea64e
--- /dev/null
+++ b/netbox/templates/dcim/panels/virtual_chassis_members.html
@@ -0,0 +1,31 @@
+{% extends "ui/panels/_base.html" %}
+{% load i18n %}
+
+{% block panel_content %}
+
+
+
+ {% trans "Device" %}
+ {% trans "Position" %}
+ {% trans "Master" %}
+ {% trans "Priority" %}
+
+
+
+ {% for vc_member in vc_members %}
+
+ {{ vc_member|linkify }}
+ {% badge vc_member.vc_position show_empty=True %}
+
+ {% if object.virtual_chassis.master == vc_member %}
+ {% checkmark True %}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+
+ {{ vc_member.vc_priority|placeholder }}
+
+ {% endfor %}
+
+
+{% endblock panel_content %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html
index eec4d63a583..a8f85e1f145 100644
--- a/netbox/templates/dcim/rack.html
+++ b/netbox/templates/dcim/rack.html
@@ -1,153 +1 @@
{% extends 'dcim/rack/base.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load static %}
-{% load plugins %}
-{% load i18n %}
-{% load mptt %}
-
-{% block content %}
-
-
-
-
-
-
- {% trans "Region" %}
- {% nested_tree object.site.region %}
-
-
- {% trans "Site" %}
- {{ object.site|linkify }}
-
-
- {% trans "Location" %}
- {% nested_tree object.location %}
-
-
- {% trans "Facility ID" %}
- {{ object.facility_id|placeholder }}
-
-
- {% trans "Tenant" %}
-
- {% if object.tenant.group %}
- {{ object.tenant.group|linkify }} /
- {% endif %}
- {{ object.tenant|linkify|placeholder }}
-
-
-
- {% trans "Status" %}
- {% badge object.get_status_display bg_color=object.get_status_color %}
-
-
- {% trans "Rack Type" %}
- {{ object.rack_type|linkify:"full_name"|placeholder }}
-
-
- {% trans "Role" %}
- {{ object.role|linkify|placeholder }}
-
-
- {% trans "Description" %}
- {{ object.description|placeholder }}
-
-
- {% trans "Serial Number" %}
- {{ object.serial|placeholder }}
-
-
- {% trans "Asset Tag" %}
- {{ object.asset_tag|placeholder }}
-
-
- {% trans "Airflow" %}
- {{ object.get_airflow_display|placeholder }}
-
-
- {% trans "Space Utilization" %}
- {% utilization_graph object.get_utilization %}
-
-
- {% trans "Power Utilization" %}
- {% utilization_graph object.get_power_utilization %}
-
-
-
- {% include 'dcim/inc/panels/racktype_dimensions.html' %}
- {% include 'dcim/inc/panels/racktype_numbering.html' %}
-
-
-
-
- {% trans "Rack Weight" %}
-
- {% if object.weight %}
- {{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- {% trans "Maximum Weight" %}
-
- {% if object.max_weight %}
- {{ object.max_weight }} {{ object.get_weight_unit_display }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- {% trans "Total Weight" %}
-
- {% if object.total_weight %}
- {{ object.total_weight|floatformat }} {% trans "Kilograms" %}
- ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %})
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
-
- {% 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 %}
-
-
-
-
- {% trans "Images and Labels" %}
- {% trans "Images only" %}
- {% trans "Labels only" %}
-
-
-
-
-
-
{% trans "Front" %}
- {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
-
-
-
-
-
{% trans "Rear" %}
- {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
-
-
-
- {% include 'inc/panels/related_objects.html' %}
- {% plugin_right_page object %}
-
-
-
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/dcim/rack/attrs/total_weight.html b/netbox/templates/dcim/rack/attrs/total_weight.html
new file mode 100644
index 00000000000..2db1d13e87a
--- /dev/null
+++ b/netbox/templates/dcim/rack/attrs/total_weight.html
@@ -0,0 +1,3 @@
+{% load i18n %}
+{{ value|floatformat }} {% trans "Kilograms" %}
+({{ value|kg_to_pounds|floatformat }} {% trans "Pounds" %})
diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html
index 87c4f7e4b68..405117041ef 100644
--- a/netbox/templates/dcim/rackreservation.html
+++ b/netbox/templates/dcim/rackreservation.html
@@ -1,99 +1,8 @@
{% extends 'generic/object.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load static %}
-{% load plugins %}
{% load i18n %}
-{% load mptt %}
{% block breadcrumbs %}
{{ block.super }}
{{ object.rack }}
{% trans "Units" %} {{ object.unit_list }}
{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
- {% trans "Region" %}
-
- {% nested_tree object.rack.site.region %}
-
-
-
- {% trans "Site" %}
- {{ object.rack.site|linkify }}
-
-
- {% trans "Location" %}
- {{ object.rack.location|linkify|placeholder }}
-
-
- {% trans "Rack" %}
- {{ object.rack|linkify }}
-
-
-
-
-
-
-
- {% trans "Units" %}
- {{ object.unit_list }}
-
-
- {% trans "Status" %}
- {% badge object.get_status_display bg_color=object.get_status_color %}
-
-
- {% trans "Tenant" %}
-
- {% if object.tenant.group %}
- {{ object.tenant.group|linkify }} /
- {% endif %}
- {{ object.tenant|linkify|placeholder }}
-
-
-
- {% trans "User" %}
- {{ object.user }}
-
-
- {% trans "Description" %}
- {{ object.description }}
-
-
-
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/tags.html' %}
- {% include 'inc/panels/comments.html' %}
- {% plugin_left_page object %}
-
-
-
-
-
-
{% trans "Front" %}
- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' %}
-
-
-
-
-
{% trans "Rear" %}
- {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' %}
-
-
-
- {% plugin_right_page object %}
-
-
-
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html
index 87341d90e0b..5466eea2165 100644
--- a/netbox/templates/dcim/rackrole.html
+++ b/netbox/templates/dcim/rackrole.html
@@ -1,7 +1,4 @@
{% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
{% load i18n %}
{% block extra_controls %}
@@ -11,41 +8,3 @@
{% endif %}
{% endblock extra_controls %}
-
-{% block content %}
-
-
-
-
-
-
- {% trans "Name" %}
- {{ object.name }}
-
-
- {% trans "Description" %}
- {{ object.description|placeholder }}
-
-
- {% trans "Color" %}
-
-
-
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/related_objects.html' %}
- {% include 'inc/panels/custom_fields.html' %}
- {% plugin_right_page object %}
-
-
-
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/dcim/racktype.html b/netbox/templates/dcim/racktype.html
deleted file mode 100644
index cfeba02fea9..00000000000
--- a/netbox/templates/dcim/racktype.html
+++ /dev/null
@@ -1,75 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load static %}
-{% load plugins %}
-{% load i18n %}
-{% load mptt %}
-
-{% block content %}
-
-
-
-
-
-
- {% trans "Manufacturer" %}
- {{ object.manufacturer|linkify }}
-
-
- {% trans "Model" %}
- {{ object.model }}
-
-
- {% trans "Description" %}
- {{ object.description|placeholder }}
-
-
- {% trans "Airflow" %}
- {{ object.get_airflow_display|placeholder }}
-
-
-
- {% include 'dcim/inc/panels/racktype_dimensions.html' %}
- {% include 'inc/panels/tags.html' %}
- {% include 'inc/panels/comments.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'dcim/inc/panels/racktype_numbering.html' %}
-
-
-
-
- {% trans "Rack Weight" %}
-
- {% if object.weight %}
- {{ object.weight|floatformat }} {{ object.get_weight_unit_display }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- {% trans "Maximum Weight" %}
-
- {% if object.max_weight %}
- {{ object.max_weight }} {{ object.get_weight_unit_display }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
-
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/related_objects.html' %}
- {% plugin_right_page object %}
-
-
-
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html
index f11868b0a29..8fdf314ae98 100644
--- a/netbox/templates/dcim/region.html
+++ b/netbox/templates/dcim/region.html
@@ -1,7 +1,4 @@
{% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
@@ -18,53 +15,3 @@
{% endif %}
{% endblock extra_controls %}
-
-{% block content %}
-
-
-
-
-
-
- {% trans "Name" %}
- {{ object.name }}
-
-
- {% trans "Description" %}
- {{ object.description|placeholder }}
-
-
- {% trans "Parent" %}
- {{ object.parent|linkify|placeholder }}
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/comments.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/related_objects.html' %}
- {% plugin_right_page object %}
-
-
-
-
-
-
- {% htmx_table 'dcim:region_list' parent_id=object.pk %}
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html
index cf65961d966..3cefe59b15a 100644
--- a/netbox/templates/dcim/site.html
+++ b/netbox/templates/dcim/site.html
@@ -1,10 +1,4 @@
{% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load tz %}
-{% load i18n %}
-{% load l10n %}
-{% load mptt %}
{% block breadcrumbs %}
{{ block.super }}
@@ -20,135 +14,3 @@
{{ object.group }}
{% endif %}
{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
- {% trans "Region" %}
-
- {% nested_tree object.region %}
-
-
-
- {% trans "Group" %}
-
- {% nested_tree object.group %}
-
-
-
- {% trans "Status" %}
- {% badge object.get_status_display bg_color=object.get_status_color %}
-
-
- {% trans "Tenant" %}
-
- {% if object.tenant.group %}
- {{ object.tenant.group|linkify }} /
- {% endif %}
- {{ object.tenant|linkify|placeholder }}
-
-
-
- {% trans "Facility" %}
- {{ object.facility|placeholder }}
-
-
- {% trans "Description" %}
- {{ object.description|placeholder }}
-
-
- {% trans "Time Zone" %}
-
- {% if object.time_zone %}
- {{ object.time_zone }} ({% trans "UTC" %} {{ object.time_zone|tzoffset }})
- {% trans "Site time" %}: {% timezone object.time_zone %}{% now 'Y-m-d H:i' %}{% endtimezone %}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- {% trans "Physical Address" %}
-
- {% if object.physical_address %}
- {{ object.physical_address|linebreaksbr }}
- {% if config.MAPS_URL %}
-
- {% trans "Map" %}
-
- {% endif %}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
- {% trans "Shipping Address" %}
- {{ object.shipping_address|linebreaksbr|placeholder }}
-
-
- {% trans "GPS Coordinates" %}
-
- {% if object.latitude and object.longitude %}
- {% if config.MAPS_URL %}
-
- {% endif %}
- {{ object.latitude }}, {{ object.longitude }}
- {% else %}
- {{ ''|placeholder }}
- {% endif %}
-
-
-
-
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/tags.html' %}
- {% include 'inc/panels/comments.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
- {% include 'inc/panels/image_attachments.html' %}
- {% plugin_right_page object %}
-
-
-
-
-
-
- {% htmx_table 'dcim:location_list' site_id=object.pk %}
-
-
-
- {% htmx_table 'dcim:device_list' site_id=object.pk rack_id='null' parent_bay_id='null' %}
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html
index dc9aca6f5ae..b25156ea208 100644
--- a/netbox/templates/dcim/sitegroup.html
+++ b/netbox/templates/dcim/sitegroup.html
@@ -1,7 +1,4 @@
{% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
@@ -18,53 +15,3 @@
{% endif %}
{% endblock extra_controls %}
-
-{% block content %}
-
-
-
-
-
-
- {% trans "Name" %}
- {{ object.name }}
-
-
- {% trans "Description" %}
- {{ object.description|placeholder }}
-
-
- {% trans "Parent" %}
- {{ object.parent|linkify|placeholder }}
-
-
-
- {% include 'inc/panels/tags.html' %}
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/comments.html' %}
- {% plugin_left_page object %}
-
-
- {% include 'inc/panels/related_objects.html' %}
- {% plugin_right_page object %}
-
-
-
-
-
-
- {% htmx_table 'dcim:sitegroup_list' parent_id=object.pk %}
-
- {% plugin_full_width_page object %}
-
-
-{% endblock %}
diff --git a/netbox/templates/extras/panels/custom_fields.html b/netbox/templates/extras/panels/custom_fields.html
new file mode 100644
index 00000000000..d0b1c56861f
--- /dev/null
+++ b/netbox/templates/extras/panels/custom_fields.html
@@ -0,0 +1,31 @@
+{% extends "ui/panels/_base.html" %}
+{% load i18n %}
+
+{% block panel_content %}
+
+ {% for group_name, fields in custom_fields.items %}
+ {% if group_name %}
+
+ {{ group_name }}
+
+ {% endif %}
+ {% for field, value in fields.items %}
+
+ {{ field }}
+ {% if field.description %}
+
+ {% endif %}
+
+
+ {% customfield_value field value %}
+
+
+ {% endfor %}
+ {% endfor %}
+
+{% endblock panel_content %}
diff --git a/netbox/templates/extras/panels/tags.html b/netbox/templates/extras/panels/tags.html
new file mode 100644
index 00000000000..d505dc48de6
--- /dev/null
+++ b/netbox/templates/extras/panels/tags.html
@@ -0,0 +1,15 @@
+{% extends "ui/panels/_base.html" %}
+{% load helpers %}
+{% load i18n %}
+
+{% block panel_content %}
+
+ {% with url=object|validated_viewname:"list" %}
+ {% for tag in object.tags.all %}
+ {% tag tag url %}
+ {% empty %}
+ {% trans "No tags assigned" %}
+ {% endfor %}
+ {% endwith %}
+
+{% endblock panel_content %}
diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html
index df95a4a42b4..100d5bde775 100644
--- a/netbox/templates/generic/object.html
+++ b/netbox/templates/generic/object.html
@@ -122,7 +122,20 @@
{% plugin_alerts object %}
{% endblock alerts %}
-{% block content %}{% endblock %}
+{% block content %}
+ {# Render panel layout declared on view class #}
+ {% for row in layout.rows %}
+
+ {% for column in row.columns %}
+
+ {% for panel in column.panels %}
+ {% render panel %}
+ {% endfor %}
+
+ {% endfor %}
+
+ {% endfor %}
+{% endblock %}
{% block modals %}
{% include 'inc/htmx_modal.html' %}
diff --git a/netbox/templates/ui/actions/copy_content.html b/netbox/templates/ui/actions/copy_content.html
new file mode 100644
index 00000000000..67f54354b09
--- /dev/null
+++ b/netbox/templates/ui/actions/copy_content.html
@@ -0,0 +1,7 @@
+{% load i18n %}
+
+ {% if button_icon %}
+
+ {% endif %}
+ {{ label }}
+
diff --git a/netbox/templates/ui/actions/link.html b/netbox/templates/ui/actions/link.html
new file mode 100644
index 00000000000..11c6b6da9ad
--- /dev/null
+++ b/netbox/templates/ui/actions/link.html
@@ -0,0 +1,6 @@
+
+ {% if button_icon %}
+
+ {% endif %}
+ {{ label }}
+
diff --git a/netbox/templates/ui/attrs/address.html b/netbox/templates/ui/attrs/address.html
new file mode 100644
index 00000000000..08f46fc43cd
--- /dev/null
+++ b/netbox/templates/ui/attrs/address.html
@@ -0,0 +1,8 @@
+{% load i18n %}
+{% load l10n %}
+{{ value|linebreaksbr }}
+{% if map_url %}
+
+ {% trans "Map" %}
+
+{% endif %}
diff --git a/netbox/templates/ui/attrs/boolean.html b/netbox/templates/ui/attrs/boolean.html
new file mode 100644
index 00000000000..a7087c94f7c
--- /dev/null
+++ b/netbox/templates/ui/attrs/boolean.html
@@ -0,0 +1 @@
+{% checkmark value %}
diff --git a/netbox/templates/ui/attrs/choice.html b/netbox/templates/ui/attrs/choice.html
new file mode 100644
index 00000000000..197d4c2fc1d
--- /dev/null
+++ b/netbox/templates/ui/attrs/choice.html
@@ -0,0 +1,5 @@
+{% if bg_color %}
+ {% badge value bg_color=bg_color %}
+{% else %}
+ {{ value }}
+{% endif %}
diff --git a/netbox/templates/ui/attrs/color.html b/netbox/templates/ui/attrs/color.html
new file mode 100644
index 00000000000..78e1cfff3cc
--- /dev/null
+++ b/netbox/templates/ui/attrs/color.html
@@ -0,0 +1 @@
+
diff --git a/netbox/templates/ui/attrs/gps_coordinates.html b/netbox/templates/ui/attrs/gps_coordinates.html
new file mode 100644
index 00000000000..8e72f08bb6d
--- /dev/null
+++ b/netbox/templates/ui/attrs/gps_coordinates.html
@@ -0,0 +1,8 @@
+{% load i18n %}
+{% load l10n %}
+{{ latitude }}, {{ longitude }}
+{% if map_url %}
+
+ {% trans "Map" %}
+
+{% endif %}
diff --git a/netbox/templates/ui/attrs/image.html b/netbox/templates/ui/attrs/image.html
new file mode 100644
index 00000000000..3c10113c4ed
--- /dev/null
+++ b/netbox/templates/ui/attrs/image.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/netbox/templates/ui/attrs/nested_object.html b/netbox/templates/ui/attrs/nested_object.html
new file mode 100644
index 00000000000..8cae0818979
--- /dev/null
+++ b/netbox/templates/ui/attrs/nested_object.html
@@ -0,0 +1,11 @@
+
+ {% for node in nodes %}
+
+ {% if linkify %}
+ {{ node }}
+ {% else %}
+ {{ node }}
+ {% endif %}
+
+ {% endfor %}
+
diff --git a/netbox/templates/ui/attrs/numeric.html b/netbox/templates/ui/attrs/numeric.html
new file mode 100644
index 00000000000..5c54f2979dc
--- /dev/null
+++ b/netbox/templates/ui/attrs/numeric.html
@@ -0,0 +1,12 @@
+{% load i18n %}
+
+ {{ value }}
+ {% if unit %}
+ {{ unit|lower }}
+ {% endif %}
+
+{% if copy_button %}
+
+
+
+{% endif %}
diff --git a/netbox/templates/ui/attrs/object.html b/netbox/templates/ui/attrs/object.html
new file mode 100644
index 00000000000..58fce231652
--- /dev/null
+++ b/netbox/templates/ui/attrs/object.html
@@ -0,0 +1,14 @@
+{% if group %}
+ {# Display an object with its parent group #}
+
+
+ {% if linkify %}{{ group|linkify }}{% else %}{{ group }}{% endif %}
+
+
+ {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %}
+
+
+{% else %}
+ {# Display only the object #}
+ {% if linkify %}{{ value|linkify }}{% else %}{{ value }}{% endif %}
+{% endif %}
diff --git a/netbox/templates/ui/attrs/text.html b/netbox/templates/ui/attrs/text.html
new file mode 100644
index 00000000000..459c9ab8d4f
--- /dev/null
+++ b/netbox/templates/ui/attrs/text.html
@@ -0,0 +1,7 @@
+{% load i18n %}
+{{ value }}
+{% if copy_button %}
+
+
+
+{% endif %}
diff --git a/netbox/templates/ui/attrs/timezone.html b/netbox/templates/ui/attrs/timezone.html
new file mode 100644
index 00000000000..7492d72ada9
--- /dev/null
+++ b/netbox/templates/ui/attrs/timezone.html
@@ -0,0 +1,6 @@
+{% load i18n %}
+{% load tz %}
+
+ {{ value }} ({% trans "UTC" %} {{ value|tzoffset }})
+ {% trans "Local time" %}: {% timezone value %}{% now 'Y-m-d H:i' %}{% endtimezone %}
+
diff --git a/netbox/templates/ui/attrs/utilization.html b/netbox/templates/ui/attrs/utilization.html
new file mode 100644
index 00000000000..6e1db73f1b2
--- /dev/null
+++ b/netbox/templates/ui/attrs/utilization.html
@@ -0,0 +1,2 @@
+{% load helpers %}
+{% utilization_graph value %}
diff --git a/netbox/templates/ui/panels/_base.html b/netbox/templates/ui/panels/_base.html
new file mode 100644
index 00000000000..5128e6428d1
--- /dev/null
+++ b/netbox/templates/ui/panels/_base.html
@@ -0,0 +1,15 @@
+
+
+
+ {% block panel_content %}{% endblock %}
+
+
diff --git a/netbox/templates/ui/panels/comments.html b/netbox/templates/ui/panels/comments.html
new file mode 100644
index 00000000000..de32162ce55
--- /dev/null
+++ b/netbox/templates/ui/panels/comments.html
@@ -0,0 +1,12 @@
+{% extends "ui/panels/_base.html" %}
+{% load i18n %}
+
+{% block panel_content %}
+
+ {% if comments %}
+ {{ comments|markdown }}
+ {% else %}
+ {% trans "None" %}
+ {% endif %}
+
+{% endblock panel_content %}
diff --git a/netbox/templates/ui/panels/embedded_table.html b/netbox/templates/ui/panels/embedded_table.html
new file mode 100644
index 00000000000..64579705f0c
--- /dev/null
+++ b/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 %}
diff --git a/netbox/templates/ui/panels/json.html b/netbox/templates/ui/panels/json.html
new file mode 100644
index 00000000000..36d3d4d1a42
--- /dev/null
+++ b/netbox/templates/ui/panels/json.html
@@ -0,0 +1,5 @@
+{% extends "ui/panels/_base.html" %}
+
+{% block panel_content %}
+ {{ data|json }}
+{% endblock panel_content %}
diff --git a/netbox/templates/ui/panels/object_attributes.html b/netbox/templates/ui/panels/object_attributes.html
new file mode 100644
index 00000000000..399a0081e8a
--- /dev/null
+++ b/netbox/templates/ui/panels/object_attributes.html
@@ -0,0 +1,14 @@
+{% extends "ui/panels/_base.html" %}
+
+{% block panel_content %}
+
+ {% for attr in attrs %}
+
+ {{ attr.label }}
+
+ {{ attr.value }}
+
+
+ {% endfor %}
+
+{% endblock panel_content %}
diff --git a/netbox/templates/ui/panels/objects_table.html b/netbox/templates/ui/panels/objects_table.html
new file mode 100644
index 00000000000..64579705f0c
--- /dev/null
+++ b/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 %}
diff --git a/netbox/templates/ui/panels/related_objects.html b/netbox/templates/ui/panels/related_objects.html
new file mode 100644
index 00000000000..29d6dc6c4a4
--- /dev/null
+++ b/netbox/templates/ui/panels/related_objects.html
@@ -0,0 +1,25 @@
+{% extends "ui/panels/_base.html" %}
+{% load helpers %}
+{% load i18n %}
+
+{% block panel_content %}
+
+{% endblock panel_content %}
diff --git a/netbox/utilities/data.py b/netbox/utilities/data.py
index 617a31cd6e6..36fd0f7fc4f 100644
--- a/netbox/utilities/data.py
+++ b/netbox/utilities/data.py
@@ -12,6 +12,7 @@
'flatten_dict',
'ranges_to_string',
'ranges_to_string_list',
+ 'resolve_attr_path',
'shallow_compare_dict',
'string_to_ranges',
)
@@ -213,3 +214,26 @@ def string_to_ranges(value):
return None
values.append(NumericRange(int(lower), int(upper) + 1, bounds='[)'))
return values
+
+
+#
+# Attribute resolution
+#
+
+def resolve_attr_path(obj, path):
+ """
+ Follow a dotted path across attributes and/or dictionary keys and return the final value.
+
+ Parameters:
+ obj: The starting object
+ path: The dotted path to follow (e.g. "foo.bar.baz")
+ """
+ cur = obj
+ for part in path.split('.'):
+ if cur is None:
+ return None
+ try:
+ cur = getattr(cur, part) if hasattr(cur, part) else cur.get(part)
+ except AttributeError:
+ cur = None
+ return cur
diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py
index 8a275f44bc9..663bf564702 100644
--- a/netbox/utilities/templatetags/builtins/tags.py
+++ b/netbox/utilities/templatetags/builtins/tags.py
@@ -3,6 +3,7 @@
from django import template
from django.templatetags.static import static
+from django.utils.safestring import mark_safe
from extras.choices import CustomFieldTypeChoices
from utilities.querydict import dict_to_querydict
@@ -179,3 +180,11 @@ def static_with_params(path, **params):
# Reconstruct the URL with the new query string
new_parsed = parsed._replace(query=new_query)
return urlunparse(new_parsed)
+
+
+@register.simple_tag(takes_context=True)
+def render(context, component):
+ """
+ Render a UI component (e.g. a Panel) by calling its render() method and passing the current template context.
+ """
+ return mark_safe(component.render(context))