Skip to content

Commit 1d2f6a8

Browse files
authored
Merge pull request #20737 from netbox-community/20204-template-components
Closes #20204: Introduce modular template components
2 parents bcffc38 + 6e7bbfc commit 1d2f6a8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+2060
-1279
lines changed
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# UI Components
2+
3+
!!! note "New in NetBox v4.5"
4+
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.
5+
6+
!!! danger "Beta Feature"
7+
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.
8+
9+
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.
10+
11+
## Page Layout
12+
13+
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.
14+
15+
```
16+
+-------+-------+-------+
17+
| Col 1 | Col 2 | Col 3 |
18+
+-------+-------+-------+
19+
| Col 4 |
20+
+-----------+-----------+
21+
| Col 5 | Col 6 |
22+
+-----------+-----------+
23+
```
24+
25+
The above layout can be achieved with the following declaration under a view:
26+
27+
```python
28+
from netbox.ui import layout
29+
from netbox.views import generic
30+
31+
class MyView(generic.ObjectView):
32+
layout = layout.Layout(
33+
layout.Row(
34+
layout.Column(),
35+
layout.Column(),
36+
layout.Column(),
37+
),
38+
layout.Row(
39+
layout.Column(),
40+
),
41+
layout.Row(
42+
layout.Column(),
43+
layout.Column(),
44+
),
45+
)
46+
```
47+
48+
!!! note
49+
Currently, layouts are supported only for subclasses of [`generic.ObjectView`](./views.md#netbox.views.generic.ObjectView).
50+
51+
::: netbox.ui.layout.Layout
52+
53+
::: netbox.ui.layout.SimpleLayout
54+
55+
::: netbox.ui.layout.Row
56+
57+
::: netbox.ui.layout.Column
58+
59+
## Panels
60+
61+
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.
62+
63+
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.
64+
65+
```python
66+
from django.utils.translation import gettext_lazy as _
67+
from netbox.ui.panels import Panel
68+
69+
class RecentChangesPanel(Panel):
70+
template_name = 'my_plugin/panels/recent_changes.html'
71+
title = _('Recent Changes')
72+
73+
def get_context(self, context):
74+
return {
75+
**super().get_context(context),
76+
'changes': get_changes()[:10],
77+
}
78+
```
79+
80+
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.
81+
82+
::: netbox.ui.panels.Panel
83+
84+
::: netbox.ui.panels.ObjectPanel
85+
86+
::: netbox.ui.panels.ObjectAttributesPanel
87+
88+
#### Object Attributes
89+
90+
The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
91+
92+
| Class | Description |
93+
|--------------------------------------|--------------------------------------------------|
94+
| `netbox.ui.attrs.AddressAttr` | A physical or mailing address. |
95+
| `netbox.ui.attrs.BooleanAttr` | A boolean value |
96+
| `netbox.ui.attrs.ColorAttr` | A color expressed in RGB |
97+
| `netbox.ui.attrs.ChoiceAttr` | A selection from a set of choices |
98+
| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude) |
99+
| `netbox.ui.attrs.ImageAttr` | An attached image (displays the image) |
100+
| `netbox.ui.attrs.NestedObjectAttr` | A related nested object |
101+
| `netbox.ui.attrs.NumericAttr` | An integer or float value |
102+
| `netbox.ui.attrs.RelatedObjectAttr` | A related object |
103+
| `netbox.ui.attrs.TemplatedAttr` | Renders an attribute using a custom template |
104+
| `netbox.ui.attrs.TextAttr` | A string (text) value |
105+
| `netbox.ui.attrs.TimezoneAttr` | A timezone with annotated offset |
106+
| `netbox.ui.attrs.UtilizationAttr` | A numeric value expressed as a utilization graph |
107+
108+
::: netbox.ui.panels.OrganizationalObjectPanel
109+
110+
::: netbox.ui.panels.NestedGroupObjectPanel
111+
112+
::: netbox.ui.panels.CommentsPanel
113+
114+
::: netbox.ui.panels.JSONPanel
115+
116+
::: netbox.ui.panels.RelatedObjectsPanel
117+
118+
::: netbox.ui.panels.ObjectsTablePanel
119+
120+
::: netbox.ui.panels.TemplatePanel
121+
122+
::: netbox.ui.panels.PluginContentPanel
123+
124+
## Panel Actions
125+
126+
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.
127+
128+
```python
129+
from django.utils.translation import gettext_lazy as _
130+
from netbox.ui import actions, panels
131+
132+
panels.ObjectsTablePanel(
133+
model='dcim.Region',
134+
title=_('Child Regions'),
135+
filters={'parent_id': lambda ctx: ctx['object'].pk},
136+
actions=[
137+
actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
138+
],
139+
),
140+
```
141+
142+
::: netbox.ui.actions.PanelAction
143+
144+
::: netbox.ui.actions.LinkAction
145+
146+
::: netbox.ui.actions.AddObject
147+
148+
::: netbox.ui.actions.CopyContent

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ nav:
143143
- Getting Started: 'plugins/development/index.md'
144144
- Models: 'plugins/development/models.md'
145145
- Views: 'plugins/development/views.md'
146+
- UI Components: 'plugins/development/ui-components.md'
146147
- Navigation: 'plugins/development/navigation.md'
147148
- Templates: 'plugins/development/templates.md'
148149
- Tables: 'plugins/development/tables.md'

netbox/dcim/ui/__init__.py

Whitespace-only changes.

netbox/dcim/ui/panels.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
from django.utils.translation import gettext_lazy as _
2+
3+
from netbox.ui import attrs, panels
4+
5+
6+
class SitePanel(panels.ObjectAttributesPanel):
7+
region = attrs.NestedObjectAttr('region', linkify=True)
8+
group = attrs.NestedObjectAttr('group', linkify=True)
9+
name = attrs.TextAttr('name')
10+
status = attrs.ChoiceAttr('status')
11+
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
12+
facility = attrs.TextAttr('facility')
13+
description = attrs.TextAttr('description')
14+
timezone = attrs.TimezoneAttr('time_zone')
15+
physical_address = attrs.AddressAttr('physical_address', map_url=True)
16+
shipping_address = attrs.AddressAttr('shipping_address', map_url=True)
17+
gps_coordinates = attrs.GPSCoordinatesAttr()
18+
19+
20+
class LocationPanel(panels.NestedGroupObjectPanel):
21+
site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
22+
status = attrs.ChoiceAttr('status')
23+
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
24+
facility = attrs.TextAttr('facility')
25+
26+
27+
class RackDimensionsPanel(panels.ObjectAttributesPanel):
28+
form_factor = attrs.ChoiceAttr('form_factor')
29+
width = attrs.ChoiceAttr('width')
30+
height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height'))
31+
outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display')
32+
outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display')
33+
outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display')
34+
mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm')
35+
36+
37+
class RackNumberingPanel(panels.ObjectAttributesPanel):
38+
starting_unit = attrs.TextAttr('starting_unit')
39+
desc_units = attrs.BooleanAttr('desc_units', label=_('Descending units'))
40+
41+
42+
class RackPanel(panels.ObjectAttributesPanel):
43+
region = attrs.NestedObjectAttr('site.region', linkify=True)
44+
site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
45+
location = attrs.NestedObjectAttr('location', linkify=True)
46+
name = attrs.TextAttr('name')
47+
facility = attrs.TextAttr('facility', label=_('Facility ID'))
48+
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
49+
status = attrs.ChoiceAttr('status')
50+
rack_type = attrs.RelatedObjectAttr('rack_type', linkify=True, grouped_by='manufacturer')
51+
role = attrs.RelatedObjectAttr('role', linkify=True)
52+
description = attrs.TextAttr('description')
53+
serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
54+
asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
55+
airflow = attrs.ChoiceAttr('airflow')
56+
space_utilization = attrs.UtilizationAttr('get_utilization')
57+
power_utilization = attrs.UtilizationAttr('get_power_utilization')
58+
59+
60+
class RackWeightPanel(panels.ObjectAttributesPanel):
61+
weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
62+
max_weight = attrs.NumericAttr('max_weight', unit_accessor='get_weight_unit_display', label=_('Maximum weight'))
63+
total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/rack/attrs/total_weight.html')
64+
65+
66+
class RackRolePanel(panels.OrganizationalObjectPanel):
67+
color = attrs.ColorAttr('color')
68+
69+
70+
class RackReservationPanel(panels.ObjectAttributesPanel):
71+
units = attrs.TextAttr('unit_list')
72+
status = attrs.ChoiceAttr('status')
73+
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
74+
user = attrs.RelatedObjectAttr('user')
75+
description = attrs.TextAttr('description')
76+
77+
78+
class RackTypePanel(panels.ObjectAttributesPanel):
79+
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
80+
model = attrs.TextAttr('model')
81+
description = attrs.TextAttr('description')
82+
83+
84+
class DevicePanel(panels.ObjectAttributesPanel):
85+
region = attrs.NestedObjectAttr('site.region', linkify=True)
86+
site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
87+
location = attrs.NestedObjectAttr('location', linkify=True)
88+
rack = attrs.TemplatedAttr('rack', template_name='dcim/device/attrs/rack.html')
89+
virtual_chassis = attrs.RelatedObjectAttr('virtual_chassis', linkify=True)
90+
parent_device = attrs.TemplatedAttr('parent_bay', template_name='dcim/device/attrs/parent_device.html')
91+
gps_coordinates = attrs.GPSCoordinatesAttr()
92+
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
93+
device_type = attrs.RelatedObjectAttr('device_type', linkify=True, grouped_by='manufacturer')
94+
description = attrs.TextAttr('description')
95+
airflow = attrs.ChoiceAttr('airflow')
96+
serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
97+
asset_tag = attrs.TextAttr('asset_tag', style='font-monospace', copy_button=True)
98+
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
99+
100+
101+
class DeviceManagementPanel(panels.ObjectAttributesPanel):
102+
title = _('Management')
103+
104+
status = attrs.ChoiceAttr('status')
105+
role = attrs.NestedObjectAttr('role', linkify=True, max_depth=3)
106+
platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3)
107+
primary_ip4 = attrs.TemplatedAttr(
108+
'primary_ip4',
109+
label=_('Primary IPv4'),
110+
template_name='dcim/device/attrs/ipaddress.html',
111+
)
112+
primary_ip6 = attrs.TemplatedAttr(
113+
'primary_ip6',
114+
label=_('Primary IPv6'),
115+
template_name='dcim/device/attrs/ipaddress.html',
116+
)
117+
oob_ip = attrs.TemplatedAttr(
118+
'oob_ip',
119+
label=_('Out-of-band IP'),
120+
template_name='dcim/device/attrs/ipaddress.html',
121+
)
122+
cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
123+
124+
125+
class DeviceDimensionsPanel(panels.ObjectAttributesPanel):
126+
title = _('Dimensions')
127+
128+
height = attrs.TextAttr('device_type.u_height', format_string='{}U')
129+
total_weight = attrs.TemplatedAttr('total_weight', template_name='dcim/device/attrs/total_weight.html')
130+
131+
132+
class DeviceTypePanel(panels.ObjectAttributesPanel):
133+
manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
134+
model = attrs.TextAttr('model')
135+
part_number = attrs.TextAttr('part_number')
136+
default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True)
137+
description = attrs.TextAttr('description')
138+
height = attrs.TextAttr('u_height', format_string='{}U', label=_('Height'))
139+
exclude_from_utilization = attrs.BooleanAttr('exclude_from_utilization')
140+
full_depth = attrs.BooleanAttr('is_full_depth')
141+
weight = attrs.NumericAttr('weight', unit_accessor='get_weight_unit_display')
142+
subdevice_role = attrs.ChoiceAttr('subdevice_role', label=_('Parent/child'))
143+
airflow = attrs.ChoiceAttr('airflow')
144+
front_image = attrs.ImageAttr('front_image')
145+
rear_image = attrs.ImageAttr('rear_image')
146+
147+
148+
class ModuleTypeProfilePanel(panels.ObjectAttributesPanel):
149+
name = attrs.TextAttr('name')
150+
description = attrs.TextAttr('description')
151+
152+
153+
class VirtualChassisMembersPanel(panels.ObjectPanel):
154+
"""
155+
A panel which lists all members of a virtual chassis.
156+
"""
157+
template_name = 'dcim/panels/virtual_chassis_members.html'
158+
title = _('Virtual Chassis Members')
159+
160+
def get_context(self, context):
161+
return {
162+
**super().get_context(context),
163+
'vc_members': context.get('vc_members'),
164+
}
165+
166+
def render(self, context):
167+
if not context.get('vc_members'):
168+
return ''
169+
return super().render(context)
170+
171+
172+
class PowerUtilizationPanel(panels.ObjectPanel):
173+
"""
174+
A panel which displays the power utilization statistics for a device.
175+
"""
176+
template_name = 'dcim/panels/power_utilization.html'
177+
title = _('Power Utilization')
178+
179+
def get_context(self, context):
180+
return {
181+
**super().get_context(context),
182+
'vc_members': context.get('vc_members'),
183+
}
184+
185+
def render(self, context):
186+
obj = context['object']
187+
if not obj.powerports.exists() or not obj.poweroutlets.exists():
188+
return ''
189+
return super().render(context)

0 commit comments

Comments
 (0)