fix: restore visible rack integration widgets

This commit is contained in:
2026-08-13 11:21:48 +02:00
parent e42580d651
commit 79a071597c
11 changed files with 152 additions and 68 deletions
+6
View File
@@ -132,6 +132,12 @@ autorisierten Rack-Datensatz. Die Topology-Rack-Ansicht leitet ihre Breiten
ausschließlich aus den Geräten ab, die der Topology-View tatsächlich in seine ausschließlich aus den Geräten ab, die der Topology-View tatsächlich in seine
HTML-Antwort geschrieben hat. Zusätzliche Berechtigungsabfragen können die HTML-Antwort geschrieben hat. Zusätzliche Berechtigungsabfragen können die
beiden Ansichten dadurch nicht mehr fälschlich leeren. beiden Ansichten dadurch nicht mehr fälschlich leeren.
Ab Version `0.9.7` enthält Reorder eigene 12-Spalten-CSS-Regeln. Diese beheben
die Inkompatibilität zwischen GridStack 10 aus Reorder Rack `1.1.4` und dem
GridStack-11-Stylesheet von NetBox 4.6.7, durch die Gerätekacheln mit einer
berechneten Breite von `0px` unsichtbar waren. Topology-Breiten werden außerdem
nach dem vollständigen Rendern zentral auf die HTTP-Antwort angewendet und
zusätzlich direkt in die vorhandenen Geräte-Styles geschrieben.
Bereits vorhandene Geräte, die dieselbe HE und Rackseite belegen, aber noch Bereits vorhandene Geräte, die dieselbe HE und Rackseite belegen, aber noch
keine Plugin-Platzierungszeile besitzen, werden in Rack-SVG, Reorder und der keine Plugin-Platzierungszeile besitzen, werden in Rack-SVG, Reorder und der
Topology-Rack-Ansicht ohne Datenbankänderung gleichmäßig nebeneinander Topology-Rack-Ansicht ohne Datenbankänderung gleichmäßig nebeneinander
+1 -5
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.9.6" __version__ = "0.9.7"
class NetBoxUtilitiesConfig(PluginConfig): class NetBoxUtilitiesConfig(PluginConfig):
@@ -38,10 +38,6 @@ class NetBoxUtilitiesConfig(PluginConfig):
from .reorder_rack import install_reorder_rack_bulk_save from .reorder_rack import install_reorder_rack_bulk_save
install_reorder_rack_bulk_save() install_reorder_rack_bulk_save()
if get_plugin_config("netbox_utilities", "topology_views_rack_width_enabled"):
from .topology_views import install_topology_rack_width_support
install_topology_rack_width_support()
config = NetBoxUtilitiesConfig config = NetBoxUtilitiesConfig
+4 -1
View File
@@ -22,7 +22,10 @@ class GlobalTenantFilterMiddleware:
try: try:
if scope is not None and request.method in {"GET", "HEAD"}: if scope is not None and request.method in {"GET", "HEAD"}:
self._inject_filter_parameter(request, scope) self._inject_filter_parameter(request, scope)
return self.get_response(request) response = self.get_response(request)
from .topology_views import apply_topology_rack_widths
return apply_topology_rack_widths(request, response)
finally: finally:
active_tenant_scope.reset(token) active_tenant_scope.reset(token)
@@ -36,6 +36,41 @@
width: calc(var(--netbox-utilities-rack-device-width) - 6px) !important; width: calc(var(--netbox-utilities-rack-device-width) - 6px) !important;
} }
/*
* netbox-reorder-rack 1.1.4 bundles GridStack 10, while NetBox 4.6.7
* provides GridStack 11's base stylesheet. GridStack 11 calculates item
* widths through a CSS variable which the older runtime never sets. Without
* these explicit rules every 12-column widget therefore has width: 0px.
*/
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item {
width: calc(100% / 12) !important;
}
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="2"] { width: calc(200% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="3"] { width: 25% !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="4"] { width: calc(400% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="5"] { width: calc(500% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="6"] { width: 50% !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="7"] { width: calc(700% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="8"] { width: calc(800% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="9"] { width: 75% !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="10"] { width: calc(1000% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="11"] { width: calc(1100% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-w="12"] { width: 100% !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="0"] { left: 0 !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="1"] { left: calc(100% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="2"] { left: calc(200% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="3"] { left: 25% !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="4"] { left: calc(400% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="5"] { left: calc(500% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="6"] { left: 50% !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="7"] { left: calc(700% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="8"] { left: calc(800% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="9"] { left: 75% !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="10"] { left: calc(1000% / 12) !important; }
.netbox-utilities-reorder-grid[gs-column="12"] > .grid-stack-item[gs-x="11"] { left: calc(1100% / 12) !important; }
.netbox-utilities-sidebar-resizer { .netbox-utilities-sidebar-resizer {
display: none; display: none;
} }
@@ -34,7 +34,7 @@
<div class="col-lg-4 col-md-6 col-sm-12 text-center"> <div class="col-lg-4 col-md-6 col-sm-12 text-center">
<h4>Non-Racked Devices</h4> <h4>Non-Racked Devices</h4>
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
<div class="grid-stack rack" id="grid-other" gs-column="12" gs-max-row="150" <div class="grid-stack rack netbox-utilities-reorder-grid" id="grid-other" gs-column="12" gs-max-row="150"
gs-min-row="{{ object.u_height|mul:2 }}" data-grid-id="2"> gs-min-row="{{ object.u_height|mul:2 }}" data-grid-id="2">
{% for device in non_racked %} {% for device in non_racked %}
<div class="grid-stack-item" gs-w="12" gs-h="{{ device.device_type.u_height|mul:2 }}" <div class="grid-stack-item" gs-w="12" gs-h="{{ device.device_type.u_height|mul:2 }}"
@@ -6,7 +6,7 @@
{% if unit|rack_unit %}<li>{{ unit|rack_unit_to_int }}</li>{% else %}<li></li>{% endif %} {% if unit|rack_unit %}<li>{{ unit|rack_unit_to_int }}</li>{% else %}<li></li>{% endif %}
{% endfor %} {% endfor %}
</ul> </ul>
<div class="grid-stack rack" id="grid-{{ face }}" gs-column="12" <div class="grid-stack rack netbox-utilities-reorder-grid" id="grid-{{ face }}" gs-column="12"
gs-max-row="{{ object.u_height|mul:2 }}" gs-min-row="{{ object.u_height|mul:2 }}" gs-max-row="{{ object.u_height|mul:2 }}" gs-min-row="{{ object.u_height|mul:2 }}"
data-grid-id="{{ grid_id }}"> data-grid-id="{{ grid_id }}">
{% for device in reorder_devices %} {% for device in reorder_devices %}
+16
View File
@@ -1,6 +1,7 @@
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from django.http import HttpResponse
from django.test import RequestFactory, SimpleTestCase from django.test import RequestFactory, SimpleTestCase
from netbox_utilities.middleware import GlobalTenantFilterMiddleware from netbox_utilities.middleware import GlobalTenantFilterMiddleware
@@ -28,6 +29,21 @@ class GlobalTenantFilterMiddlewareTest(SimpleTestCase):
def setUp(self): def setUp(self):
self.factory = RequestFactory() self.factory = RequestFactory()
@patch("netbox_utilities.topology_views.apply_topology_rack_widths")
@patch("netbox_utilities.middleware.GlobalTenantFilterMiddleware._get_selected_scope", return_value=None)
def test_processes_topology_widths_after_the_view_response(self, _scope, apply_widths):
native_response = HttpResponse("native topology")
final_response = HttpResponse("width-aware topology")
apply_widths.return_value = final_response
middleware = GlobalTenantFilterMiddleware(lambda _request: native_response)
request = self.factory.get("/plugins/netbox_topology_views/rack-elevation/?rack_id=3")
request.session = {}
result = middleware(request)
self.assertIs(result, final_response)
apply_widths.assert_called_once_with(request, native_response)
@patch("netbox_utilities.middleware.resolve") @patch("netbox_utilities.middleware.resolve")
def test_injects_and_overrides_tenant_id(self, mocked_resolve): def test_injects_and_overrides_tenant_id(self, mocked_resolve):
mocked_resolve.return_value = resolver_match(TenantFilterSet) mocked_resolve.return_value = resolver_match(TenantFilterSet)
+10 -1
View File
@@ -310,6 +310,7 @@ class ReorderRackFrontendTest(SimpleTestCase):
self.assertIn("{% for device in reorder_devices %}", grid) self.assertIn("{% for device in reorder_devices %}", grid)
self.assertIn('gs-w="{{ device.grid_width }}"', grid) self.assertIn('gs-w="{{ device.grid_width }}"', grid)
self.assertIn('gs-x="{{ device.grid_x }}"', grid) self.assertIn('gs-x="{{ device.grid_x }}"', grid)
self.assertIn("netbox-utilities-reorder-grid", grid)
self.assertIn('json_script:"netbox-utilities-reorder-rack-width-data"', page) self.assertIn('json_script:"netbox-utilities-reorder-rack-width-data"', page)
self.assertIn("netbox_utilities/reorder-rack-width.js", page) self.assertIn("netbox_utilities/reorder-rack-width.js", page)
@@ -343,6 +344,14 @@ class ReorderRackFrontendTest(SimpleTestCase):
self.assertIn('gs-id="10"', rendered_grid) self.assertIn('gs-id="10"', rendered_grid)
self.assertIn("LEO-Fritzbox", rendered_grid) self.assertIn("LEO-Fritzbox", rendered_grid)
stylesheet = (Path(__file__).parents[1] / "static" / "netbox_utilities" / "netbox_utilities.css").read_text(
encoding="utf-8"
)
self.assertIn('.netbox-utilities-reorder-grid[gs-column="12"]', stylesheet)
self.assertIn('.grid-stack-item[gs-w="6"] { width: 50% !important; }', stylesheet)
self.assertIn('.grid-stack-item[gs-w="12"] { width: 100% !important; }', stylesheet)
self.assertIn('.grid-stack-item[gs-x="6"] { left: 50% !important; }', stylesheet)
def test_resolved_reorder_page_uses_server_side_width_view(self): def test_resolved_reorder_page_uses_server_side_width_view(self):
view_class = resolve(reverse("dcim:rack_reorder", kwargs={"pk": 3})).func.view_class view_class = resolve(reverse("dcim:rack_reorder", kwargs={"pk": 3})).func.view_class
@@ -405,7 +414,7 @@ class ReorderRackFrontendTest(SimpleTestCase):
self.assertEqual(template_name, "netbox_utilities/reorder_rack.html") self.assertEqual(template_name, "netbox_utilities/reorder_rack.html")
self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox") self.assertEqual(context["reorder_devices"][0]["label"], "LEO-Fritzbox")
self.assertEqual(context["reorder_devices"][0]["grid_width"], 6) self.assertEqual(context["reorder_devices"][0]["grid_width"], 6)
self.assertEqual(context["asset_version"], "0.9.6") self.assertEqual(context["asset_version"], "0.9.7")
self.assertIs(context["reorder_rack_width_data"], get_width_data.return_value) self.assertIs(context["reorder_rack_width_data"], get_width_data.return_value)
get_width_data.assert_called_once() get_width_data.assert_called_once()
self.assertIs(get_width_data.call_args.kwargs["rack"], rack) self.assertIs(get_width_data.call_args.kwargs["rack"], rack)
+35 -18
View File
@@ -6,11 +6,12 @@ from django.http import HttpResponse
from django.test import RequestFactory, SimpleTestCase from django.test import RequestFactory, SimpleTestCase
from netbox_utilities.topology_views import ( from netbox_utilities.topology_views import (
_apply_topology_widths_to_device_tags,
_inject_topology_widths, _inject_topology_widths,
_topology_data_for_visible_devices, _topology_data_for_visible_devices,
_visible_topology_device_urls, _visible_topology_device_urls,
apply_topology_rack_widths,
get_topology_rack_width_data, get_topology_rack_width_data,
install_topology_rack_width_support,
is_topology_rack_elevation_request, is_topology_rack_elevation_request,
serialize_topology_placement, serialize_topology_placement,
topology_rack_width_enabled, topology_rack_width_enabled,
@@ -245,24 +246,40 @@ class TopologyViewsRackWidthTest(SimpleTestCase):
self.assertIn('id="netbox-utilities-topology-rack-width-styles"', html) self.assertIn('id="netbox-utilities-topology-rack-width-styles"', html)
self.assertIn('.rack-device[href="/dcim/devices/334/"]', html) self.assertIn('.rack-device[href="/dcim/devices/334/"]', html)
self.assertIn("left: calc(50% + 3px) !important", html) self.assertIn("left: calc(50% + 3px) !important", html)
self.assertIn("netbox_utilities/topology-rack-width.js?v=0.9.6", html) self.assertIn("netbox_utilities/topology-rack-width.js?v=0.9.7", html)
self.assertIn("left:calc(0% + 3px)!important", html)
self.assertIn("left:calc(50% + 3px)!important", html)
self.assertIn("width:calc(50% - 6px)!important", html)
def test_patches_the_optional_topology_view_only_once(self): def test_preserves_existing_topology_style_while_applying_width(self):
def original_get(self, request): data = {
return self, request "devices": [
{
"url": "/dcim/devices/334/",
"left_percent": 0,
"width_percent": 50,
}
]
}
html = (
'<a class="rack-device" href="/dcim/devices/334/" '
'style="bottom: 10%; height: 2%; border-color: #f00">Fritzbox</a>'
)
class RackElevationView: result = _apply_topology_widths_to_device_tags(html, data)
get = original_get
module = SimpleNamespace(RackElevationView=RackElevationView) self.assertIn("bottom: 10%", result)
with ( self.assertIn("height: 2%", result)
patch("netbox_utilities.topology_views.apps.is_installed", return_value=True), self.assertIn("left:calc(0% + 3px)!important", result)
patch("netbox_utilities.topology_views.get_plugin_config", return_value=True), self.assertIn("width:calc(50% - 6px)!important", result)
patch("netbox_utilities.topology_views.import_module", return_value=module),
):
self.assertTrue(install_topology_rack_width_support())
patched_get = RackElevationView.get
self.assertTrue(install_topology_rack_width_support())
self.assertIs(RackElevationView.get, patched_get) @patch("netbox_utilities.topology_views._inject_topology_widths")
self.assertIs(RackElevationView._netbox_utilities_original_get, original_get) @patch("netbox_utilities.topology_views.topology_rack_width_enabled", return_value=True)
def test_response_processor_runs_after_the_topology_view(self, _enabled, inject):
response = HttpResponse("<html></html>")
inject.return_value = response
result = apply_topology_rack_widths(self.request, response)
self.assertIs(result, response)
inject.assert_called_once_with(response)
+42 -40
View File
@@ -1,8 +1,6 @@
import logging import logging
import re import re
from functools import wraps
from html import unescape from html import unescape
from importlib import import_module
from django.apps import apps from django.apps import apps
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
@@ -17,12 +15,12 @@ from .rack_width import effective_width_positions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TOPOLOGY_RACK_ELEVATION_VIEW = "plugins:netbox_topology_views:rack_elevation" TOPOLOGY_RACK_ELEVATION_VIEW = "plugins:netbox_topology_views:rack_elevation"
TOPOLOGY_VIEW_PATCH_MARKER = "_netbox_utilities_rack_width_view"
RACK_DEVICE_TAG_RE = re.compile( RACK_DEVICE_TAG_RE = re.compile(
r"<a\b[^>]*\bclass=[\"'][^\"']*\brack-device\b[^\"']*[\"'][^>]*>", r"<a\b[^>]*\bclass=[\"'][^\"']*\brack-device\b[^\"']*[\"'][^>]*>",
re.IGNORECASE, re.IGNORECASE,
) )
HREF_RE = re.compile(r"\bhref=[\"'](?P<url>[^\"']+)[\"']", re.IGNORECASE) HREF_RE = re.compile(r"\bhref=[\"'](?P<url>[^\"']+)[\"']", re.IGNORECASE)
STYLE_RE = re.compile(r"\bstyle=(?P<quote>[\"'])(?P<style>.*?)(?P=quote)", re.IGNORECASE)
DEVICE_ID_RE = re.compile(r"(?:^|/)dcim/devices/(?P<device_id>\d+)/?(?:$|[?#])") DEVICE_ID_RE = re.compile(r"(?:^|/)dcim/devices/(?P<device_id>\d+)/?(?:$|[?#])")
@@ -179,6 +177,34 @@ def _topology_width_head(data):
return f'{stylesheet}{payload}<script src="{script_url}" defer></script>' return f'{stylesheet}{payload}<script src="{script_url}" defer></script>'
def _apply_topology_widths_to_device_tags(html, data):
descriptors = {device["url"]: device for device in data["devices"]}
def replace_tag(match):
tag = match.group(0)
href_match = HREF_RE.search(tag)
if href_match is None:
return tag
descriptor = descriptors.get(unescape(href_match.group("url")))
if descriptor is None:
return tag
geometry = (
"right:auto!important;"
f"left:calc({descriptor['left_percent']}% + 3px)!important;"
f"width:calc({descriptor['width_percent']}% - 6px)!important"
)
style_match = STYLE_RE.search(tag)
if style_match is not None:
quote = style_match.group("quote")
existing = style_match.group("style").rstrip().rstrip(";")
replacement = f"style={quote}{existing};{geometry}{quote}"
return f"{tag[: style_match.start()]}{replacement}{tag[style_match.end() :]}"
return f'{tag[:-1]} style="{geometry}">'
return RACK_DEVICE_TAG_RE.sub(replace_tag, html)
def _inject_topology_widths(response): def _inject_topology_widths(response):
if ( if (
getattr(response, "streaming", False) getattr(response, "streaming", False)
@@ -192,6 +218,7 @@ def _inject_topology_widths(response):
return response return response
visible_urls = _visible_topology_device_urls(html) visible_urls = _visible_topology_device_urls(html)
data = _topology_data_for_visible_devices(visible_urls) data = _topology_data_for_visible_devices(visible_urls)
html = _apply_topology_widths_to_device_tags(html, data)
insertion = _topology_width_head(data) insertion = _topology_width_head(data)
head_end = html.lower().index("</head>") head_end = html.lower().index("</head>")
html = f"{html[:head_end]}{insertion}{html[head_end:]}" html = f"{html[:head_end]}{insertion}{html[head_end:]}"
@@ -202,41 +229,16 @@ def _inject_topology_widths(response):
return response return response
def install_topology_rack_width_support(): def apply_topology_rack_widths(request, response):
"""Patch the optional view and derive widths from its authorized HTML.""" """Apply widths after the optional view has rendered its authorized devices."""
if not ( if not topology_rack_width_enabled(request):
apps.is_installed("netbox_topology_views") return response
and get_plugin_config("netbox_utilities", "topology_views_rack_width_enabled")
):
return False
try: try:
topology_views = import_module("netbox_topology_views.views") return _inject_topology_widths(response)
view = topology_views.RackElevationView except Exception:
except (AttributeError, ImportError): logger.warning(
logger.warning("NetBox Utilities could not find the optional Topology rack elevation view") "Could not inject partial rack widths into NetBox Topology Views path %s",
return False request.get_full_path(),
exc_info=True,
if getattr(view, TOPOLOGY_VIEW_PATCH_MARKER, False): )
return True return response
original_get = view.get
@wraps(original_get)
def width_aware_topology_get(self, request, *args, **kwargs):
response = original_get(self, request, *args, **kwargs)
try:
return _inject_topology_widths(response)
except (AttributeError, ObjectDoesNotExist, OperationalError, ProgrammingError, TypeError, ValueError):
logger.warning(
"Could not inject partial rack widths into NetBox Topology Views path %s",
request.get_full_path(),
exc_info=True,
)
return response
view._netbox_utilities_original_get = original_get
view.get = width_aware_topology_get
setattr(view, TOPOLOGY_VIEW_PATCH_MARKER, True)
logger.info("Enabled partial rack widths for the optional Topology rack elevation view")
return True
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "netbox-utilities" name = "netbox-utilities"
version = "0.9.6" version = "0.9.7"
description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6" description = "Navigation, tenant utilities, partial-width rack devices, bulk uploads, and rack reordering for NetBox 4.6"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"