Compare commits

...

16 Commits

Author SHA1 Message Date
Jeremy Stretch
5db8c04c64 Fixes #21412: Defer monkey-patching until after settings have been loaded 2026-02-12 10:44:49 -05:00
Arthur Hanson
0bb22dee0c Allow REDIS KWARGS to be set in configuration.py (#21377)
* Allow REDIS KWARGS to be set in configuration.py

* cleanup

* cleanup

* cleanup

* Update netbox/netbox/settings.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* Update netbox/netbox/settings.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* document in REDIS config section

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2026-02-12 08:35:20 -05:00
Jason Novinger
6c383f293c Fixes #20435: Fix navigation margin issue when scrollbar appears (#21403)
Override Tabler's problematic margin-left: calc(100vw - 100%) rule that
causes a gap between the sidebar and main content when vertical scrollbar
is present on Windows/Linux browsers.

Uses scrollbar-gutter: stable to match the upstream fix in Tabler PR #2548.
2026-02-12 11:30:33 +01:00
github-actions
5bf516c63d Update source translation strings 2026-02-12 05:28:54 +00:00
Aditya Sharma
7df062d590 Fixes #21358: Prevent exception when sorting by Token column (#21391)
Mark the `token` TemplateColumn as non-orderable since it maps to a
Python property rather than a database field, causing a FieldError
when django-tables2 attempts to sort by it.

Add a regression test for TokenTable following the existing pattern
in circuits and vpn test suites.
2026-02-12 00:21:49 +01:00
Aditya Sharma
4b22be03a0 Fixes #21354: Fix Swagger-UI generating wrong URLs when BASE_PATH is set (#21392) 2026-02-11 11:35:13 -08:00
Dylan Lucci
24769ce127 Closes #21266: Add installed device table columns to DeviceBay table (#21348)
Expose additional properties of the device installed in each bay as
configurable table columns.

- Rename `role` → `installed_role`
- Rename `device_type` → `installed_device_type`
- Add `installed_description`, `installed_serial`, and
  `installed_asset_tag` columns to `DeviceBayTable`

---------

Co-authored-by: Martin Hauser <mhauser@netboxlabs.com>
2026-02-11 13:55:37 +01:00
github-actions
164e9db98d Update source translation strings 2026-02-11 05:29:43 +00:00
Martin Hauser
23f1c86e9c Closes #20211: Use thumbnails for ImageAttachment hover previews to improve page load performance (#21386) 2026-02-10 11:01:33 -06:00
Martin Hauser
02ffdd9d5d Closes #21268: Add Device Type details panel to Device view (#21368) 2026-02-10 10:37:35 -06:00
Martin Hauser
5013297326 feat(virtualization): Refactor VirtualMachine view to UI layout
Migrate the VirtualMachine detail view to SimpleLayout with standardized
panels for attributes, clusters, and resources. Modularize templates
to improve maintainability and reuse.

Fixes #21337
2026-02-10 10:22:18 -05:00
github-actions
584e0a9b8c Update source translation strings 2026-02-10 05:29:34 +00:00
Martin Hauser
3ac9d0b8bf Closes #20981: Enhance JSON rendering for Custom Validators and Protection Rules in Config Revision View (#21376)
* feat(config): Add extra context to ConfigRevisionView

Introduces `get_extra_context` method for `ConfigRevisionView` to
format JSON-based attributes like `CUSTOM_VALIDATORS`,
`DEFAULT_USER_PREFERENCES`, and `PROTECTION_RULES`.
This ensures clearer rendering of configuration data in the UI.

Fixes #20981

* Reduce padding on JSON blocks

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2026-02-09 09:48:39 -05:00
github-actions
b387ea5f58 Update source translation strings 2026-02-06 05:22:42 +00:00
bctiemann
ba9f6bf359 Fixes: #19129 - Richer display of MAC addresses in InterfaceTable when multiple MACs are present (#21270)
* Richer display of MAC addresses in InterfaceTable when multiple MACs are present

* Fix docstring

* Fix docstring

* Use mac_address_display in interface detail page

* Ensure "-" null placeholder still shows up on detail page

* Also include vminterface.html

* Simplify Multiple MAC addresses with additional selectable column for tables in list view and detail view

* Use ManyToManyColumn
2026-02-05 11:16:31 -05:00
Martin Hauser
ee6cbdcefe Fixes #21320: Prevent Rack validation errors when site or optional fields are missing during import (#21321) 2026-02-03 09:32:07 -06:00
26 changed files with 649 additions and 555 deletions

View File

@@ -200,6 +200,48 @@ REDIS = {
!!! note
It is permissible to use Sentinel for only one database and not the other.
### SSL Configuration
If you need to configure SSL/TLS for Redis beyond the basic `SSL`, `CA_CERT_PATH`, and `INSECURE_SKIP_TLS_VERIFY` options (for example, client certificates, a specific TLS version, or custom ciphers), you can pass additional parameters via the `KWARGS` key in either the `tasks` or `caching` subsection.
NetBox already maps `CA_CERT_PATH` to `ssl_ca_certs` and (for caching) `INSECURE_SKIP_TLS_VERIFY` to `ssl_cert_reqs`; only add `KWARGS` when you need to override or extend those settings (for example, to supply client certificates or restrict TLS version or ciphers).
* `KWARGS` - Optional dictionary of additional SSL/TLS (or other) parameters passed to the Redis client. These are passed directly to the underlying Redis client: for `tasks` to [redis-py](https://redis-py.readthedocs.io/en/stable/connections.html), and for `caching` to the [django-redis](https://github.com/jazzband/django-redis#configure-as-cache-backend) connection pool.
Example:
```python
REDIS = {
'tasks': {
'HOST': 'redis.example.com',
'PORT': 1234,
'SSL': True,
'CA_CERT_PATH': '/etc/ssl/certs/ca.crt',
'KWARGS': {
'ssl_certfile': '/path/to/client-cert.pem',
'ssl_keyfile': '/path/to/client-key.pem',
'ssl_min_version': ssl.TLSVersion.TLSv1_2,
'ssl_ciphers': 'HIGH:!aNULL',
},
},
'caching': {
'HOST': 'redis.example.com',
'PORT': 1234,
'SSL': True,
'CA_CERT_PATH': '/etc/ssl/certs/ca.crt',
'KWARGS': {
'ssl_certfile': '/path/to/client-cert.pem',
'ssl_keyfile': '/path/to/client-key.pem',
'ssl_min_version': ssl.TLSVersion.TLSv1_2,
'ssl_ciphers': 'HIGH:!aNULL',
},
}
}
```
!!! note
If you use `ssl.TLSVersion` in your configuration (e.g. `ssl_min_version`), add `import ssl` at the top of your configuration file.
---
## SECRET_KEY

View File

@@ -1,6 +1,7 @@
import json
import platform
from copy import deepcopy
from django import __version__ as django_version
from django.conf import settings
from django.contrib import messages
@@ -310,6 +311,22 @@ class ConfigRevisionListView(generic.ObjectListView):
class ConfigRevisionView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
def get_extra_context(self, request, instance):
"""
Retrieve additional context for a given request and instance.
"""
# Copy the revision data to avoid modifying the original
config = deepcopy(instance.data or {})
# Serialize any JSON-based classes
for attr in ['CUSTOM_VALIDATORS', 'DEFAULT_USER_PREFERENCES', 'PROTECTION_RULES']:
if attr in config:
config[attr] = json.dumps(config[attr], cls=ConfigJSONEncoder, indent=4)
return {
'config': config,
}
@register_model_view(ConfigRevision, 'add', detail=False)
class ConfigRevisionEditView(generic.ObjectEditView):
@@ -617,8 +634,8 @@ class SystemView(UserPassesTestMixin, View):
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
return response
# Serialize any CustomValidator classes
for attr in ['CUSTOM_VALIDATORS', 'PROTECTION_RULES']:
# Serialize any JSON-based classes
for attr in ['CUSTOM_VALIDATORS', 'DEFAULT_USER_PREFERENCES', 'PROTECTION_RULES']:
if hasattr(config, attr) and getattr(config, attr, None):
setattr(config, attr, json.dumps(getattr(config, attr), cls=ConfigJSONEncoder, indent=4))

View File

@@ -373,7 +373,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
super().clean()
# Validate location/site assignment
if self.site and self.location and self.location.site != self.site:
if self.site_id and self.location_id and self.location.site_id != self.site_id:
raise ValidationError(_("Assigned location must belong to parent site ({site}).").format(site=self.site))
# Validate outer dimensions and unit

View File

@@ -584,6 +584,15 @@ class BaseInterfaceTable(NetBoxTable):
orderable=False,
verbose_name=_('IP Addresses')
)
primary_mac_address = tables.Column(
verbose_name=_('Primary MAC'),
linkify=True
)
mac_addresses = columns.ManyToManyColumn(
orderable=False,
linkify_item=True,
verbose_name=_('MAC Addresses')
)
fhrp_groups = tables.TemplateColumn(
accessor=Accessor('fhrp_group_assignments'),
template_code=INTERFACE_FHRPGROUPS,
@@ -615,10 +624,6 @@ class BaseInterfaceTable(NetBoxTable):
verbose_name=_('Q-in-Q SVLAN'),
linkify=True
)
primary_mac_address = tables.Column(
verbose_name=_('MAC Address'),
linkify=True
)
def value_ip_addresses(self, value):
return ",".join([str(obj.address) for obj in value.all()])
@@ -681,11 +686,12 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
model = models.Interface
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'speed', 'speed_formatted', 'duplex', 'mode', 'primary_mac_address', 'wwn', 'poe_mode', 'poe_type',
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
'qinq_svlan', 'inventory_items', 'created', 'last_updated', 'vlan_translation_policy'
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses', 'primary_mac_address', 'wwn',
'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer',
'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups',
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
'vlan_translation_policy',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@@ -746,10 +752,11 @@ class DeviceInterfaceTable(InterfaceTable):
model = models.Interface
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
'mgmt_only', 'mtu', 'mode', 'primary_mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'actions',
'mgmt_only', 'mtu', 'mode', 'mac_addresses', 'primary_mac_address', 'wwn', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf',
'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'actions',
)
default_columns = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
@@ -880,24 +887,36 @@ class DeviceBayTable(DeviceComponentTable):
'args': [Accessor('device_id')],
}
)
role = columns.ColoredLabelColumn(
accessor=Accessor('installed_device__role'),
verbose_name=_('Role')
)
device_type = tables.Column(
accessor=Accessor('installed_device__device_type'),
linkify=True,
verbose_name=_('Type')
)
status = tables.TemplateColumn(
verbose_name=_('Status'),
template_code=DEVICEBAY_STATUS,
order_by=Accessor('installed_device__status')
)
installed_device = tables.Column(
verbose_name=_('Installed device'),
verbose_name=_('Installed Device'),
linkify=True
)
installed_role = columns.ColoredLabelColumn(
accessor=Accessor('installed_device__role'),
verbose_name=_('Installed Role')
)
installed_device_type = tables.Column(
accessor=Accessor('installed_device__device_type'),
linkify=True,
verbose_name=_('Installed Type')
)
installed_description = tables.Column(
accessor=Accessor('installed_device__description'),
verbose_name=_('Installed Description')
)
installed_serial = tables.Column(
accessor=Accessor('installed_device__serial'),
verbose_name=_('Installed Serial')
)
installed_asset_tag = tables.Column(
accessor=Accessor('installed_device__asset_tag'),
verbose_name=_('Installed Asset Tag')
)
tags = columns.TagColumn(
url_name='dcim:devicebay_list'
)
@@ -905,8 +924,9 @@ class DeviceBayTable(DeviceComponentTable):
class Meta(DeviceComponentTable.Meta):
model = models.DeviceBay
fields = (
'pk', 'id', 'name', 'device', 'label', 'status', 'role', 'device_type', 'installed_device', 'description',
'tags', 'created', 'last_updated',
'pk', 'id', 'name', 'device', 'label', 'status', 'description', 'installed_device', 'installed_role',
'installed_device_type', 'installed_description', 'installed_serial', 'installed_asset_tag', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
@@ -1199,4 +1219,6 @@ class MACAddressTable(PrimaryModelTable):
'pk', 'id', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description', 'is_primary',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'mac_address', 'assigned_object_parent', 'assigned_object', 'description')
default_columns = (
'pk', 'mac_address', 'is_primary', 'assigned_object_parent', 'assigned_object', 'description',
)

View File

@@ -90,7 +90,6 @@ class DevicePanel(panels.ObjectAttributesPanel):
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)
@@ -122,10 +121,19 @@ class DeviceManagementPanel(panels.ObjectAttributesPanel):
cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
class DeviceDeviceTypePanel(panels.ObjectAttributesPanel):
title = _('Device Type')
manufacturer = attrs.RelatedObjectAttr('device_type.manufacturer', linkify=True)
model = attrs.RelatedObjectAttr('device_type', linkify=True)
height = attrs.TextAttr('device_type.u_height', format_string='{}U')
front_image = attrs.ImageAttr('device_type.front_image')
rear_image = attrs.ImageAttr('device_type.rear_image')
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')

View File

@@ -13,7 +13,6 @@ 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, VLAN, VLANGroup
@@ -44,6 +43,7 @@ from .choices import DeviceFaceChoices, InterfaceModeChoices
from .models import *
from .models.device_components import PortMapping
from .object_actions import BulkAddComponents, BulkDisconnect
from .ui import panels
CABLE_TERMINATION_TYPES = {
'dcim.consoleport': ConsolePort,
@@ -2470,6 +2470,7 @@ class DeviceView(generic.ObjectView):
],
),
ImageAttachmentsPanel(),
panels.DeviceDeviceTypePanel(),
panels.DeviceDimensionsPanel(),
TemplatePanel('dcim/panels/device_rack_elevations.html'),
],

View File

@@ -39,9 +39,20 @@ __all__ = (
)
IMAGEATTACHMENT_IMAGE = """
{% load thumbnail %}
{% if record.image %}
<a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
<i class="mdi mdi-image"></i></a>
{% thumbnail record.image "400x400" as tn %}
<a href="{{ record.get_absolute_url }}"
class="image-preview"
data-preview-url="{{ tn.url }}"
data-bs-placement="left"
title="{{ record.filename }}"
rel="noopener noreferrer"
target="_blank"
aria-label="{{ record.filename }}">
<i class="mdi mdi-image"></i>
</a>
{% endthumbnail %}
{% endif %}
<a href="{{ record.get_absolute_url }}">{{ record.filename|truncate_middle:16 }}</a>
"""

View File

@@ -11,14 +11,10 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from rest_framework.utils import field_mapping
from strawberry_django import pagination
from strawberry_django.fields.field import StrawberryDjangoField
from core.exceptions import IncompatiblePluginError
from netbox.config import PARAMS as CONFIG_PARAMS
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
from netbox.graphql.pagination import OffsetPaginationInput, apply_pagination
from netbox.plugins import PluginConfig
from netbox.registry import registry
import storages.utils # type: ignore
@@ -28,21 +24,6 @@ from utilities.string import trailing_slash
from .monkey import get_unique_validators
#
# Monkey-patching
#
# TODO: Remove this once #20547 has been implemented
# Override DRF's get_unique_validators() function with our own (see bug #19302)
field_mapping.get_unique_validators = get_unique_validators
# Override strawberry-django's OffsetPaginationInput class to add the `start` parameter
pagination.OffsetPaginationInput = OffsetPaginationInput
# Patch StrawberryDjangoField to use our custom `apply_pagination()` method with support for cursor-based pagination
StrawberryDjangoField.apply_pagination = apply_pagination
#
# Environment setup
#
@@ -408,6 +389,11 @@ if CACHING_REDIS_CA_CERT_PATH:
CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_ca_certs'] = CACHING_REDIS_CA_CERT_PATH
# Merge in KWARGS for additional parameters
if caching_redis_kwargs := REDIS['caching'].get('KWARGS'):
CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS'].update(caching_redis_kwargs)
#
# Sessions
@@ -773,7 +759,7 @@ SPECTACULAR_SETTINGS = {
'COMPONENT_SPLIT_REQUEST': True,
'REDOC_DIST': 'SIDECAR',
'SERVERS': [{
'url': BASE_PATH,
'url': '',
'description': 'NetBox',
}],
'SWAGGER_UI_DIST': 'SIDECAR',
@@ -817,6 +803,11 @@ if TASKS_REDIS_CA_CERT_PATH:
RQ_PARAMS.setdefault('REDIS_CLIENT_KWARGS', {})
RQ_PARAMS['REDIS_CLIENT_KWARGS']['ssl_ca_certs'] = TASKS_REDIS_CA_CERT_PATH
# Merge in KWARGS for additional parameters
if tasks_redis_kwargs := TASKS_REDIS.get('KWARGS'):
RQ_PARAMS.setdefault('REDIS_CLIENT_KWARGS', {})
RQ_PARAMS['REDIS_CLIENT_KWARGS'].update(tasks_redis_kwargs)
# Define named RQ queues
RQ_QUEUES = {
RQ_QUEUE_HIGH: RQ_PARAMS,
@@ -959,6 +950,26 @@ for plugin_name in PLUGINS:
raise ImproperlyConfigured(f"events_pipline in plugin: {plugin_name} must be a list or tuple")
#
# Monkey-patching
#
from rest_framework.utils import field_mapping # noqa: E402
from strawberry_django import pagination # noqa: E402
from strawberry_django.fields.field import StrawberryDjangoField # noqa: E402
from netbox.graphql.pagination import OffsetPaginationInput, apply_pagination # noqa: E402
# TODO: Remove this once #20547 has been implemented
# Override DRF's get_unique_validators() function with our own (see bug #19302)
field_mapping.get_unique_validators = get_unique_validators
# Override strawberry-django's OffsetPaginationInput class to add the `start` parameter
pagination.OffsetPaginationInput = OffsetPaginationInput
# Patch StrawberryDjangoField to use our custom `apply_pagination()` method with support for cursor-based pagination
StrawberryDjangoField.apply_pagination = apply_pagination
# UNSUPPORTED FUNCTIONALITY: Import any local overrides.
try:
from .local_settings import *

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -150,20 +150,22 @@ function initSidebarAccordions(): void {
*/
function initImagePreview(): void {
for (const element of getElements<HTMLAnchorElement>('a.image-preview')) {
// Generate a max-width that's a quarter of the screen's width (note - the actual element
// width will be slightly larger due to the popover body's padding).
const maxWidth = `${Math.round(window.innerWidth / 4)}px`;
// Prefer a thumbnail URL for the popover (so we don't preload full-size images),
// but fall back to the link target if no thumbnail was provided.
const previewUrl = element.dataset.previewUrl ?? element.href;
const image = createElement('img', { src: previewUrl });
// Create an image element that uses the linked image as its `src`.
const image = createElement('img', { src: element.href });
image.style.maxWidth = maxWidth;
// Ensure lazy loading and async decoding
image.loading = 'lazy';
image.decoding = 'async';
// Create a container for the image.
const content = createElement('div', null, null, [image]);
// Initialize the Bootstrap Popper instance.
new Popover(element, {
// Attach this custom class to the popover so that it styling can be controlled via CSS.
// Attach this custom class to the popover so that its styling
// can be controlled via CSS.
customClass: 'image-preview-popover',
trigger: 'hover',
html: true,

View File

@@ -89,6 +89,29 @@ img.plugin-icon {
}
}
// Image preview popover (rendered for <a.image-preview> by initImagePreview())
.image-preview-popover {
--bs-popover-max-width: clamp(240px, 25vw, 640px);
.popover-header {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popover-body {
display: flex;
justify-content: center;
align-items: center;
}
img {
display: block;
max-width: 100%;
max-height: clamp(160px, 33vh, 640px);
height: auto;
}
}
body[data-bs-theme=dark] {
// Assuming icon is black/white line art, invert it and tone down brightness
img.plugin-icon {

View File

@@ -5,6 +5,16 @@
font-variant-ligatures: none;
}
// TODO: Remove when Tabler releases fix for https://github.com/tabler/tabler/issues/2271
// and NetBox upgrades to that version. Fix merged to Tabler dev branch in PR #2548.
:root,
:host {
@include media-breakpoint-up(lg) {
margin-left: 0;
scrollbar-gutter: stable;
}
}
// Restore default foreground & background colors for <pre> blocks
pre {
background-color: transparent;

View File

@@ -33,7 +33,7 @@
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Configuration Data" %}</h2>
{% include 'core/inc/config_data.html' with config=object.data %}
{% include 'core/inc/config_data.html' %}
</div>
<div class="card">

View File

@@ -95,7 +95,7 @@
<tr>
<th scope="row" class="ps-3">{% trans "Custom validators" %}</th>
{% if config.CUSTOM_VALIDATORS %}
<td><pre>{{ config.CUSTOM_VALIDATORS }}</pre></td>
<td><pre class="p-0">{{ config.CUSTOM_VALIDATORS }}</pre></td>
{% else %}
<td>{{ ''|placeholder }}</td>
{% endif %}
@@ -103,7 +103,7 @@
<tr>
<th scope="row" class="border-0 ps-3">{% trans "Protection rules" %}</th>
{% if config.PROTECTION_RULES %}
<td class="border-0"><pre>{{ config.PROTECTION_RULES }}</pre></td>
<td class="border-0"><pre class="p-0">{{ config.PROTECTION_RULES }}</pre></td>
{% else %}
<td class="border-0">{{ ''|placeholder }}</td>
{% endif %}
@@ -116,7 +116,7 @@
<tr>
<th scope="row" class="border-0 ps-3">{% trans "Default preferences" %}</th>
{% if config.DEFAULT_USER_PREFERENCES %}
<td class="border-0"><pre>{{ config.DEFAULT_USER_PREFERENCES|json }}</pre></td>
<td class="border-0"><pre class="p-0">{{ config.DEFAULT_USER_PREFERENCES }}</pre></td>
{% else %}
<td class="border-0">{{ ''|placeholder }}</td>
{% endif %}

View File

@@ -0,0 +1,34 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h2 class="card-header">{% trans "Resources" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
<td>{{ object.vcpus|placeholder }}</td>
</tr>
<tr>
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
<td>
{% if object.memory %}
<span title={{ object.memory }}>{{ object.memory|humanize_ram_megabytes }}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">
<i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
</th>
<td>
{% if object.disk %}
{{ object.disk|humanize_disk_megabytes }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>

View File

@@ -1,199 +1 @@
{% extends 'virtualization/virtualmachine/base.html' %}
{% load buttons %}
{% load static %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row my-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Virtual Machine" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object }}</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 "Start on boot" %}</th>
<td>{% badge object.get_start_on_boot_display bg_color=object.get_start_on_boot_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{{ object.role|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Platform" %}</th>
<td>{{ object.platform|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>{{ object.serial|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 "Config Template" %}</th>
<td>{{ object.config_template|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Primary IPv4" %}</th>
<td>
{% if object.primary_ip4 %}
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}" 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="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}" 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>
</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">
<div class="card">
<h2 class="card-header">{% trans "Cluster" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>
{{ object.site|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Cluster" %}</th>
<td>
{% if object.cluster.group %}
{{ object.cluster.group|linkify }} /
{% endif %}
{{ object.cluster|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Cluster Type" %}</th>
<td>
{{ object.cluster.type|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>
{{ object.device|linkify|placeholder }}
</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Resources" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row"><i class="mdi mdi-gauge"></i> {% trans "Virtual CPUs" %}</th>
<td>{{ object.vcpus|placeholder }}</td>
</tr>
<tr>
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
<td>
{% if object.memory %}
<span title={{ object.memory }}>{{ object.memory|humanize_ram_megabytes }}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">
<i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
</th>
<td>
{% if object.disk %}
{{ object.disk|humanize_disk_megabytes }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
<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' virtual_machine_id=object.pk %}
</div>
{% 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 "Virtual Disks" %}
{% if perms.virtualization.add_virtualdisk %}
<div class="card-actions">
<a href="{% url 'virtualization:virtualdisk_add' %}?device={{ object.device.pk }}&virtual_machine={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Virtual Disk" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'virtualization:virtualdisk_list' virtual_machine_id=object.pk %}
</div>
</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

@@ -78,8 +78,8 @@
<tr>
<th scope="row">{% trans "MAC Address" %}</th>
<td>
{% if object.mac_address %}
<span class="font-monospace">{{ object.mac_address }}</span>
{% if object.primary_mac_address %}
<span class="font-monospace">{{ object.primary_mac_address|linkify }}</span>
<span class="badge text-bg-primary">{% trans "Primary" %}</span>
{% else %}
{{ ''|placeholder }}

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ class TokenTable(NetBoxTable):
token = columns.TemplateColumn(
verbose_name=_('token'),
template_code=TOKEN,
orderable=False,
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled')

View File

@@ -0,0 +1,24 @@
from django.test import RequestFactory, tag, TestCase
from users.models import Token
from users.tables import TokenTable
class TokenTableTest(TestCase):
@tag('regression')
def test_every_orderable_field_does_not_throw_exception(self):
tokens = Token.objects.all()
disallowed = {'actions'}
orderable_columns = [
column.name for column in TokenTable(tokens).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get("/")
for col in orderable_columns:
for direction in ('-', ''):
with self.subTest(col=col, direction=direction):
table = TokenTable(tokens)
table.order_by = f'{direction}{col}'
table.as_html(fake_request)

View File

View File

@@ -0,0 +1,34 @@
from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs, panels
class VirtualMachinePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
status = attrs.ChoiceAttr('status')
start_on_boot = attrs.ChoiceAttr('start_on_boot')
role = attrs.RelatedObjectAttr('role', linkify=True)
platform = attrs.NestedObjectAttr('platform', linkify=True, max_depth=3)
description = attrs.TextAttr('description')
serial = attrs.TextAttr('serial', label=_('Serial number'), style='font-monospace', copy_button=True)
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
primary_ip4 = attrs.TemplatedAttr(
'primary_ip4',
label=_('Primary IPv4'),
template_name='virtualization/virtualmachine/attrs/ipaddress.html',
)
primary_ip6 = attrs.TemplatedAttr(
'primary_ip6',
label=_('Primary IPv6'),
template_name='virtualization/virtualmachine/attrs/ipaddress.html',
)
class VirtualMachineClusterPanel(panels.ObjectAttributesPanel):
title = _('Cluster')
site = attrs.RelatedObjectAttr('site', linkify=True, grouped_by='group')
cluster = attrs.RelatedObjectAttr('cluster', linkify=True)
cluster_type = attrs.RelatedObjectAttr('cluster.type', linkify=True)
device = attrs.RelatedObjectAttr('device', linkify=True)

View File

@@ -10,12 +10,15 @@ from dcim.filtersets import DeviceFilterSet
from dcim.forms import DeviceFilterForm
from dcim.models import Device
from dcim.tables import DeviceTable
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import IPAddress, VLANGroup
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.object_actions import (
AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename, DeleteObject, EditObject,
)
from netbox.ui import actions, layout
from netbox.ui.panels import CommentsPanel, ObjectsTablePanel, TemplatePanel
from netbox.views import generic
from utilities.query import count_related
from utilities.query_functions import CollateAsChar
@@ -23,6 +26,7 @@ from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
from . import filtersets, forms, tables
from .models import *
from .object_actions import BulkAddComponents
from .ui import panels
#
@@ -336,6 +340,7 @@ class ClusterAddDevicesView(generic.ObjectEditView):
# Virtual machines
#
@register_model_view(VirtualMachine, 'list', path='', detail=False)
class VirtualMachineListView(generic.ObjectListView):
queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6')
@@ -348,6 +353,44 @@ class VirtualMachineListView(generic.ObjectListView):
@register_model_view(VirtualMachine)
class VirtualMachineView(generic.ObjectView):
queryset = VirtualMachine.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.VirtualMachinePanel(),
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
panels.VirtualMachineClusterPanel(),
TemplatePanel('virtualization/panels/virtual_machine_resources.html'),
ObjectsTablePanel(
model='ipam.Service',
title=_('Application Services'),
filters={'virtual_machine_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(),
],
bottom_panels=[
ObjectsTablePanel(
model='virtualization.VirtualDisk',
filters={'virtual_machine_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'virtualization.VirtualDisk', url_params={'virtual_machine': lambda ctx: ctx['object'].pk}
),
],
),
],
)
@register_model_view(VirtualMachine, 'interfaces')