From f08df4fd524eb3afcd6c16d8e943edae6cc74899 Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 17 Sep 2026 10:45:27 +0200 Subject: [PATCH] fix: detect power cables by termination type and move toggle into topology card Power-cable detection previously relied on cable.type, which is usually unset for power patch cables (e.g. PDU-to-PDU), so they never got the distinct color. Power cables are now also detected by termination component type (PowerPort/PowerOutlet/PowerFeed). Wireless/power display is now toggled client-side via checkboxes in the Kabeltopologie card header (before the SVG/PNG/draw.io export buttons) instead of a server round-trip through the top filter form, so toggling is instant and affects exports too. Bump plugin version to 4.5.2. Co-Authored-By: Claude Sonnet 5 --- README.md | 2 +- netbox_topology_views/__init__.py | 2 +- netbox_topology_views/forms.py | 6 - .../netbox_topology_views/rack_elevation.html | 38 +++++- netbox_topology_views/views.py | 110 +++++++++--------- setup.py | 2 +- 6 files changed, 90 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index e323827..8706cec 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ sudo systemctl restart netbox netbox-rq ``` `--force-reinstall` ist derzeit sinnvoll, da der Fork intern noch die -Paketversion `4.5.1` verwendet. +Paketversion `4.5.2` verwendet. ## Verwendung diff --git a/netbox_topology_views/__init__.py b/netbox_topology_views/__init__.py index cd250a6..b3ccdd6 100644 --- a/netbox_topology_views/__init__.py +++ b/netbox_topology_views/__init__.py @@ -5,7 +5,7 @@ class TopologyViewsConfig(PluginConfig): name = "netbox_topology_views" verbose_name = "Topology views" description = "A plugin to render topology maps" - version = "4.5.1" + version = "4.5.2" author = "Mattijs Vanhaverbeke, Codex & Claude" author_email = "author@example.com" base_url = "netbox_topology_views" diff --git a/netbox_topology_views/forms.py b/netbox_topology_views/forms.py index 7b694d1..cca9f30 100644 --- a/netbox_topology_views/forms.py +++ b/netbox_topology_views/forms.py @@ -379,12 +379,6 @@ class RackElevationFilterForm(forms.Form): include_empty = forms.BooleanField( required=False, initial=False, label=_("Include empty racks") ) - show_wireless = forms.BooleanField( - required=False, initial=True, label=_("Show wireless links") - ) - show_power = forms.BooleanField( - required=False, initial=True, label=_("Show power cables") - ) class CoordinateGroupsForm(NetBoxModelForm): diff --git a/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html b/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html index 226feac..8c8232a 100644 --- a/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html +++ b/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html @@ -85,7 +85,17 @@
Kabeltopologie -
+
+
+
+ + +
+
+ + +
+
@@ -149,6 +159,22 @@ const data = JSON.parse(source.textContent); const isWirelessEdge = edge => edge.kind === 'wireless' || edge.kind === 'wireless-lan'; const isPowerEdge = edge => edge.kind === 'power'; + const showWirelessCheckbox = document.getElementById('rack-topology-show-wireless'); + const showPowerCheckbox = document.getElementById('rack-topology-show-power'); + const isEdgeVisible = edge => { + if (isWirelessEdge(edge)) return showWirelessCheckbox ? showWirelessCheckbox.checked : true; + if (isPowerEdge(edge)) return showPowerCheckbox ? showPowerCheckbox.checked : true; + return true; + }; + const applyKindVisibility = () => { + const showWireless = showWirelessCheckbox ? showWirelessCheckbox.checked : true; + root.querySelectorAll('.rack-topology-node').forEach(node => { + if (node.dataset.kind === 'wireless-lan') { + node.style.display = showWireless ? '' : 'none'; + } + }); + drawEdges(); + }; const rackData = JSON.parse(document.getElementById('rack-export-data').textContent); const columns = root.querySelector('.rack-topology-columns'); const svg = root.querySelector('svg'); @@ -224,6 +250,7 @@ item.id = `topology-${node.node_id}`; item.style.setProperty('--node-color', `#${node.color}`); item.dataset.nodeId = node.node_id; + if (node.kind) item.dataset.kind = node.kind; if (node.url) item.href = node.url; const nameElement = document.createElement('strong'); nameElement.textContent = node.device_name; @@ -271,6 +298,7 @@ svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.replaceChildren(); data.edges.forEach(edge => { + if (!isEdgeVisible(edge)) return; const from = document.getElementById(`topology-${edge.from}`); const to = document.getElementById(`topology-${edge.to}`); if (!from || !to) return; @@ -309,7 +337,7 @@ }); }; - const connectedEdges = nodeId => data.edges.filter(edge => edge.from === nodeId || edge.to === nodeId); + const connectedEdges = nodeId => data.edges.filter(edge => isEdgeVisible(edge) && (edge.from === nodeId || edge.to === nodeId)); root.querySelectorAll('.rack-topology-node').forEach(node => { const nodeId = node.dataset.nodeId; node.addEventListener('mouseenter', () => { @@ -538,6 +566,7 @@ drawioCells += ``; }); root.querySelectorAll('.rack-topology-node').forEach(node => { + if (node.style.display === 'none') return; const bounds = node.getBoundingClientRect(); const x = bounds.left - rootBounds.left + root.scrollLeft; const y = bounds.top - rootBounds.top + root.scrollTop; @@ -554,6 +583,7 @@ }); let edgeContent = ''; data.edges.forEach(edge => { + if (!isEdgeVisible(edge)) return; const a = positions.get(edge.from), b = positions.get(edge.to); if (!a || !b) return; const bend = Math.max(30, Math.abs(b.x - a.x) * .35); @@ -613,7 +643,9 @@ document.querySelectorAll('.topology-export').forEach(button => button.addEventListener('click', () => { exportGraphic(buildTopologySvg(), button.dataset.format, 'rack-topologie'); })); - requestAnimationFrame(drawEdges); + showWirelessCheckbox?.addEventListener('change', applyKindVisibility); + showPowerCheckbox?.addEventListener('change', applyKindVisibility); + requestAnimationFrame(applyKindVisibility); window.addEventListener('resize', drawEdges); root.addEventListener('scroll', drawEdges, {passive: true}); })(); diff --git a/netbox_topology_views/views.py b/netbox_topology_views/views.py index 0d89f23..dfab69a 100644 --- a/netbox_topology_views/views.py +++ b/netbox_topology_views/views.py @@ -1009,13 +1009,7 @@ class RackElevationView(PermissionRequiredMixin, View): } for device in rack.elevation_devices], }) - def _flag_enabled(name): - if not request.GET: - return True - return request.GET.get(name) in ("on", "True", "true", "1") - - show_wireless = _flag_enabled("show_wireless") - show_power = _flag_enabled("show_power") + power_component_models = {"powerport", "poweroutlet", "powerfeed"} cable_rows = [] topology_nodes = {} @@ -1026,10 +1020,10 @@ class RackElevationView(PermissionRequiredMixin, View): ).values_list("cable_id", flat=True).distinct() cables = Cable.objects.restrict(request.user, "view").filter(pk__in=cable_ids) for cable in cables.order_by("label", "pk"): - is_power_cable = cable.type == CableTypeChoices.TYPE_POWER - if is_power_cable and not show_power: - continue a_terms, b_terms = list(cable.a_terminations), list(cable.b_terminations) + is_power_cable = cable.type == CableTypeChoices.TYPE_POWER or any( + term._meta.model_name in power_component_models for term in a_terms + b_terms + ) for index in range(max(len(a_terms), len(b_terms), 1)): a = _rack_endpoint(a_terms[index]) if index < len(a_terms) else None b = _rack_endpoint(b_terms[index]) if index < len(b_terms) else None @@ -1050,55 +1044,55 @@ class RackElevationView(PermissionRequiredMixin, View): "kind": "power" if is_power_cable else "cable", }) - if show_wireless: - wireless_links = WirelessLink.objects.restrict(request.user, "view").filter( - Q(interface_a__device_id__in=device_ids) | - Q(interface_b__device_id__in=device_ids) - ).select_related( - "interface_a__device__rack", "interface_a__device__role", "interface_a__device__device_type", - "interface_b__device__rack", "interface_b__device__role", "interface_b__device__device_type", - ) - for wireless_link in wireless_links: - a = _rack_endpoint(wireless_link.interface_a) - b = _rack_endpoint(wireless_link.interface_b) - topology_nodes[a["node_id"]] = a - topology_nodes[b["node_id"]] = b - topology_edges.append({ - "from": a["node_id"], "to": b["node_id"], - "from_device": a["device_name"], "to_device": b["device_name"], - "from_port": a["name"], "to_port": b["name"], - "label": wireless_link.ssid or str(wireless_link), - "color": "#8b5cf6", "url": wireless_link.get_absolute_url(), - "kind": "wireless", - }) + wireless_links = WirelessLink.objects.restrict(request.user, "view").filter( + Q(interface_a__device_id__in=device_ids) | + Q(interface_b__device_id__in=device_ids) + ).select_related( + "interface_a__device__rack", "interface_a__device__role", "interface_a__device__device_type", + "interface_b__device__rack", "interface_b__device__role", "interface_b__device__device_type", + ) + for wireless_link in wireless_links: + a = _rack_endpoint(wireless_link.interface_a) + b = _rack_endpoint(wireless_link.interface_b) + topology_nodes[a["node_id"]] = a + topology_nodes[b["node_id"]] = b + topology_edges.append({ + "from": a["node_id"], "to": b["node_id"], + "from_device": a["device_name"], "to_device": b["device_name"], + "from_port": a["name"], "to_port": b["name"], + "label": wireless_link.ssid or str(wireless_link), + "color": "#8b5cf6", "url": wireless_link.get_absolute_url(), + "kind": "wireless", + }) - wireless_lans = WirelessLAN.objects.restrict(request.user, "view").filter( - interfaces__device_id__in=device_ids - ).distinct() - for wireless_lan in wireless_lans: - wlan_node_id = f"wlan-{wireless_lan.pk}" - topology_nodes[wlan_node_id] = { - "node_id": wlan_node_id, "device_id": None, - "device_name": f"WLAN · {wireless_lan.ssid}", - "name": wireless_lan.ssid, "subtitle": "Multi-Access WLAN", - "rack_id": None, "rack_name": "Funknetze", - "url": wireless_lan.get_absolute_url(), "color": "8b5cf6", - "position": None, "height": 1, "face": "front", "rack_u_height": None, - } - wlan_interfaces = Interface.objects.restrict(request.user, "view").filter( - wireless_lans=wireless_lan - ).select_related("device__rack", "device__role", "device__device_type") - for interface in wlan_interfaces: - endpoint = _rack_endpoint(interface) - topology_nodes[endpoint["node_id"]] = endpoint - topology_edges.append({ - "from": endpoint["node_id"], "to": wlan_node_id, - "from_device": endpoint["device_name"], - "to_device": f"WLAN · {wireless_lan.ssid}", - "from_port": endpoint["name"], "to_port": wireless_lan.ssid, - "label": wireless_lan.ssid, "color": "#8b5cf6", - "url": wireless_lan.get_absolute_url(), "kind": "wireless-lan", - }) + wireless_lans = WirelessLAN.objects.restrict(request.user, "view").filter( + interfaces__device_id__in=device_ids + ).distinct() + for wireless_lan in wireless_lans: + wlan_node_id = f"wlan-{wireless_lan.pk}" + topology_nodes[wlan_node_id] = { + "node_id": wlan_node_id, "device_id": None, + "device_name": f"WLAN · {wireless_lan.ssid}", + "name": wireless_lan.ssid, "subtitle": "Multi-Access WLAN", + "rack_id": None, "rack_name": "Funknetze", + "url": wireless_lan.get_absolute_url(), "color": "8b5cf6", + "position": None, "height": 1, "face": "front", "rack_u_height": None, + "kind": "wireless-lan", + } + wlan_interfaces = Interface.objects.restrict(request.user, "view").filter( + wireless_lans=wireless_lan + ).select_related("device__rack", "device__role", "device__device_type") + for interface in wlan_interfaces: + endpoint = _rack_endpoint(interface) + topology_nodes[endpoint["node_id"]] = endpoint + topology_edges.append({ + "from": endpoint["node_id"], "to": wlan_node_id, + "from_device": endpoint["device_name"], + "to_device": f"WLAN · {wireless_lan.ssid}", + "from_port": endpoint["name"], "to_port": wireless_lan.ssid, + "label": wireless_lan.ssid, "color": "#8b5cf6", + "url": wireless_lan.get_absolute_url(), "kind": "wireless-lan", + }) return render(request, "netbox_topology_views/rack_elevation.html", { "filter_form": RackElevationFilterForm(request.GET or None), diff --git a/setup.py b/setup.py index ebe5175..4b9f7d5 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ long_description = readme.read_text() setup( name="netbox-topology-views", - version="4.5.1", + version="4.5.2", description="A NetBox plugin to create Topology maps", long_description=long_description, long_description_content_type="text/markdown",