Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
fd3a9a0
Initial work on #20204
jeremystretch Oct 29, 2025
3890043
Change approach for declaring object panels
jeremystretch Oct 30, 2025
d4783b7
Refactor
jeremystretch Oct 30, 2025
7d993cc
WIP
jeremystretch Oct 30, 2025
1acd567
Add site panel
jeremystretch Oct 30, 2025
83de784
Add region & site group panels
jeremystretch Oct 30, 2025
2a629d6
Enable panel inheritance; add location panel
jeremystretch Oct 30, 2025
90874ad
Add rack panel
jeremystretch Oct 30, 2025
eef9db5
Cleanup
jeremystretch Oct 31, 2025
3fd4664
Implement layout declaration under view
jeremystretch Oct 31, 2025
77613b3
Add panels for common inclusion templates
jeremystretch Oct 31, 2025
4d5f8e9
Add PluginContentPanel
jeremystretch Oct 31, 2025
e9b1543
Add EmbeddedTablePanel
jeremystretch Oct 31, 2025
da68503
Remove panels from get_extra_context()
jeremystretch Oct 31, 2025
37bea1e
Introduce panel actions
jeremystretch Nov 3, 2025
c392988
Replace EmbeddedTablePanel with ObjectsTablePanel
jeremystretch Nov 3, 2025
21bb734
Define layouts for regions, site groups, locations
jeremystretch Nov 3, 2025
17cffd7
Add rack role & type layouts
jeremystretch Nov 3, 2025
ed3dd01
Move some panels to extras
jeremystretch Nov 3, 2025
1cffbb2
Restore original object templates
jeremystretch Nov 3, 2025
40b114c
Add rack layout
jeremystretch Nov 3, 2025
17429c4
Clean up obsolete code
jeremystretch Nov 3, 2025
c05106f
Limit object assignment to object panels
jeremystretch Nov 3, 2025
59899d0
Lots of cleanup
jeremystretch Nov 4, 2025
d5cec37
Introduce SimpleLayout
jeremystretch Nov 4, 2025
1de41b4
Add layouts for DeviceType & ModuleTypeProfile
jeremystretch Nov 5, 2025
838794a
Derive attribute labels from name if not passed for instance
jeremystretch Nov 5, 2025
281cb4f
Split ObjectPanel into a base class and ObjectAttrsPanel; use base cl…
jeremystretch Nov 5, 2025
9d6522c
RackType has no airflow attribute
jeremystretch Nov 5, 2025
dfb08ff
Split PanelAction into a base class and LinkAction; CopyContent shoul…
jeremystretch Nov 5, 2025
4edaa48
Refactor render() on Attr to split out context and reduce boilerplate
jeremystretch Nov 5, 2025
1d2aef7
Hide custom fields panels if no custom fields exist on the model
jeremystretch Nov 5, 2025
e9777d3
Flesh out device layout
jeremystretch Nov 5, 2025
60cc009
Move templates for extras panels
jeremystretch Nov 6, 2025
e55a4ae
Finish layout for device view
jeremystretch Nov 6, 2025
6fc04bd
Fix accessor
jeremystretch Nov 6, 2025
a024012
Misc cleanup
jeremystretch Nov 6, 2025
917280d
Add plugin dev docs for UI components
jeremystretch Nov 7, 2025
7b0e8c1
Remove obsolete template HTML
jeremystretch Nov 7, 2025
3e43226
Annotate begin & end of panels in HTML
jeremystretch Nov 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions docs/plugins/development/ui-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# UI Components

!!! note "New in NetBox v4.5"
All UI components described here were introduced in NetBox v4.5. Be sure to set the minimum NetBox version to 4.5.0 for your plugin before incorporating any of these resources.

!!! danger "Beta Feature"
UI components are considered a beta feature, and are still under active development. Please be aware that the API for resources on this page is subject to change in future releases.

To simply the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.

## Page Layout

A layout defines the general arrangement of content on a page into rows and columns. The layout is defined under the [view](./views.md) and declares a set of rows, each of which may have one or more columns. Below is an example layout.

```
+-------+-------+-------+
| Col 1 | Col 2 | Col 3 |
+-------+-------+-------+
| Col 4 |
+-----------+-----------+
| Col 5 | Col 6 |
+-----------+-----------+
```

The above layout can be achieved with the following declaration under a view:

```python
from netbox.ui import layout
from netbox.views import generic

class MyView(generic.ObjectView):
layout = layout.Layout(
layout.Row(
layout.Column(),
layout.Column(),
layout.Column(),
),
layout.Row(
layout.Column(),
),
layout.Row(
layout.Column(),
layout.Column(),
),
)
```

!!! note
Currently, layouts are supported only for subclasses of [`generic.ObjectView`](./views.md#netbox.views.generic.ObjectView).

::: netbox.ui.layout.Layout

::: netbox.ui.layout.SimpleLayout

::: netbox.ui.layout.Row

::: netbox.ui.layout.Column

## Panels

Within each column, related blocks of content are arranged into panels. Each panel has a title and may have a set of associated actions, but the content within is otherwise arbitrary.

Plugins can define their own panels by inheriting from the base class `netbox.ui.panels.Panel`. Override the `get_context()` method to pass additional context to your custom panel template. An example is provided below.

```python
from django.utils.translation import gettext_lazy as _
from netbox.ui.panels import Panel

class RecentChangesPanel(Panel):
template_name = 'my_plugin/panels/recent_changes.html'
title = _('Recent Changes')

def get_context(self, context):
return {
**super().get_context(context),
'changes': get_changes()[:10],
}
```

NetBox also includes a set of panels suite for specific uses, such as display object details or embedding a table of related objects. These are listed below.

::: netbox.ui.panels.Panel

::: netbox.ui.panels.ObjectPanel

::: netbox.ui.panels.ObjectAttributesPanel

#### Object Attributes

The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.

| Class | Description |
|--------------------------------------|--------------------------------------------------|
| `netbox.ui.attrs.AddressAttr` | A physical or mailing address. |
| `netbox.ui.attrs.BooleanAttr` | A boolean value |
| `netbox.ui.attrs.ColorAttr` | A color expressed in RGB |
| `netbox.ui.attrs.ChoiceAttr` | A selection from a set of choices |
| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude) |
| `netbox.ui.attrs.ImageAttr` | An attached image (displays the image) |
| `netbox.ui.attrs.NestedObjectAttr` | A related nested object |
| `netbox.ui.attrs.NumericAttr` | An integer or float value |
| `netbox.ui.attrs.RelatedObjectAttr` | A related object |
| `netbox.ui.attrs.TemplatedAttr` | Renders an attribute using a custom template |
| `netbox.ui.attrs.TextAttr` | A string (text) value |
| `netbox.ui.attrs.TimezoneAttr` | A timezone with annotated offset |
| `netbox.ui.attrs.UtilizationAttr` | A numeric value expressed as a utilization graph |

::: netbox.ui.panels.OrganizationalObjectPanel

::: netbox.ui.panels.NestedGroupObjectPanel

::: netbox.ui.panels.CommentsPanel

::: netbox.ui.panels.JSONPanel

::: netbox.ui.panels.RelatedObjectsPanel

::: netbox.ui.panels.ObjectsTablePanel

::: netbox.ui.panels.TemplatePanel

::: netbox.ui.panels.PluginContentPanel

## Panel Actions

Each panel may have actions associated with it. These render as links or buttons within the panel header, opposite the panel's title. For example, a common use case is to include an "Add" action on a panel which displays a list of objects. Below is an example of this.

```python
from django.utils.translation import gettext_lazy as _
from netbox.ui import actions, panels

panels.ObjectsTablePanel(
model='dcim.Region',
title=_('Child Regions'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
),
```

::: netbox.ui.actions.PanelAction

::: netbox.ui.actions.LinkAction

::: netbox.ui.actions.AddObject

::: netbox.ui.actions.CopyContent
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Empty file added netbox/dcim/ui/__init__.py
Empty file.
189 changes: 189 additions & 0 deletions netbox/dcim/ui/panels.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading