Merge pull request #20737 from netbox-community/20204-template-components
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled

Closes #20204: Introduce modular template components
This commit is contained in:
bctiemann 2025-11-10 09:07:23 -05:00 committed by GitHub
commit 1d2f6a82cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 2060 additions and 1279 deletions

View File

@ -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

View File

@ -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'

View File

189
netbox/dcim/ui/panels.py Normal file
View File

@ -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)

View File

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

View File

@ -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),
}

View File

157
netbox/netbox/ui/actions.py Normal file
View File

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

344
netbox/netbox/ui/attrs.py Normal file
View File

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

View File

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

341
netbox/netbox/ui/panels.py Normal file
View File

@ -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)

View File

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

View File

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

View File

@ -0,0 +1,10 @@
{% load i18n %}
<a href="{{ value.get_absolute_url }}"{% if name %} id="attr_{{ name }}"{% endif %}>{{ value.address.ip }}</a>
{% if value.nat_inside %}
({% trans "NAT for" %} <a href="{{ value.nat_inside.get_absolute_url }}">{{ value.nat_inside.address.ip }}</a>)
{% elif value.nat_outside.exists %}
({% trans "NAT" %}: {% for nat in value.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
<a class="btn btn-sm btn-primary copy-content" data-clipboard-target="#attr_{{ name }}" title="{% trans "Copy to clipboard" %}">
<i class="mdi mdi-content-copy"></i>
</a>

View File

@ -0,0 +1,10 @@
{% load i18n %}
<ol class="breadcrumb" aria-label="breadcrumbs">
<li class="breadcrumb-item">{{ value.device|linkify }}</li>
<li class="breadcrumb-item">{{ value }}</li>
</ol>
{% if value.device.position %}
<a href="{{ value.device.rack.get_absolute_url }}?device={{ value.device.pk }}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}">
<i class="mdi mdi-view-day-outline"></i>
</a>
{% endif %}

View File

@ -0,0 +1,14 @@
{% load i18n %}
<span>
{{ value|linkify }}
{% if value and object.position %}
(U{{ object.position|floatformat }} / {{ object.get_face_display }})
{% elif value and object.device_type.u_height %}
<span class="badge text-bg-warning">{% trans "Not racked" %}</span>
{% endif %}
</span>
{% if object.position %}
<a href="{{ value.get_absolute_url }}?device={{ object.pk }}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}">
<i class="mdi mdi-view-day-outline"></i>
</a>
{% endif %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
{% load i18n %}
<span{% if style %} class="{{ style }}"{% endif %}>
<span{% if name %} id="attr_{{ name }}"{% endif %}>{{ value }}</span>
{% if unit %}
{{ unit|lower }}
{% endif %}
</span>
{% if copy_button %}
<a class="btn btn-sm btn-primary copy-content" data-clipboard-target="#attr_{{ name }}" title="{% trans "Copy to clipboard" %}">
<i class="mdi mdi-content-copy"></i>
</a>
{% endif %}

View File

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

View File

@ -0,0 +1,7 @@
{% load i18n %}
<span{% if name %} id="attr_{{ name }}"{% endif %}{% if style %} class="{{ style }}"{% endif %}>{{ value }}</span>
{% if copy_button %}
<a class="btn btn-sm btn-primary copy-content" data-clipboard-target="#attr_{{ name }}" title="{% trans "Copy to clipboard" %}">
<i class="mdi mdi-content-copy"></i>
</a>
{% endif %}

View File

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

View File

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

View File

@ -0,0 +1,15 @@
<!-- begin {{ panel_class|default:"panel" }} -->
<div class="card">
<h2 class="card-header">
{{ title }}
{% if actions %}
<div class="card-actions">
{% for action in actions %}
{% render action %}
{% endfor %}
</div>
{% endif %}
</h2>
{% block panel_content %}{% endblock %}
</div>
<!-- end {{ panel_class|default:"panel" }} -->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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