From 2e431669497cbfca729126afe1d6e308f2fffcf9 Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 17 Sep 2026 10:36:47 +0200 Subject: [PATCH] feat: declare NetBox 4.7.x support and add wireless/power toggle to Kabeltopologie Adds min_version/max_version to the plugin config for NetBox 4.7.x and extends the rack view's cable topology with optional wireless and power cable display, rendered with distinct colors and dash patterns from regular network cables. Co-Authored-By: Claude Sonnet 5 --- README.md | 10 +- netbox_topology_views/__init__.py | 4 +- netbox_topology_views/forms.py | 6 + .../css/rack_elevation.css | 4 + .../netbox_topology_views/rack_elevation.html | 12 +- netbox_topology_views/views.py | 110 ++++++++++-------- 6 files changed, 89 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 63dc26c..e323827 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Rückseite und Verkabelung. - Kabeltopologie zwischen 50 und 200 Prozent zoomen und im Vollbild scrollen - Geräte in der Kabeltopologie mit rund 5 mm Abstand darstellen - P2P-WirelessLinks und Multi-Access-WLANs als violette, gestrichelte Funkstrecken darstellen +- Funk-/WLAN-Verbindungen und Stromkabel in der Kabeltopologie der Rack-Ansicht optional ein-/ausblenden, farblich abgesetzt von Netzwerkkabeln (violett gestrichelt bzw. orange gepunktet) - Manuelle Positionen pro Rack-Auswahl im Browser speichern - Verbindungen eines Geräts beim Hover weiß hervorheben - Alle verbundenen Ports und Gegenstellen eines Geräts anzeigen @@ -209,10 +210,11 @@ entsprechenden Rechte auf `netbox_topology_views.coordinate` benötigt. ## Kompatibilität Die Basis dieses Forks ist NetBox Topology Views `4.5.1`. Die neuen -Rack-Funktionen wurden für den Einsatz mit NetBox `4.6.5` angepasst. Da das -ursprüngliche Release offiziell für NetBox 4.5.x ausgewiesen ist, sollten -Updates auf neuere NetBox-Versionen zunächst in einer Testumgebung geprüft -werden. +Rack-Funktionen wurden für den Einsatz mit NetBox `4.6.5` angepasst und das +Plugin ist für NetBox `4.7.x` (`min_version`/`max_version` in +`netbox_topology_views/__init__.py`) freigegeben. Da das ursprüngliche +Release offiziell für NetBox 4.5.x ausgewiesen ist, sollten Updates auf +neuere NetBox-Versionen zunächst in einer Testumgebung geprüft werden. ## Ursprung und Danksagung diff --git a/netbox_topology_views/__init__.py b/netbox_topology_views/__init__.py index 7d65aa5..cd250a6 100644 --- a/netbox_topology_views/__init__.py +++ b/netbox_topology_views/__init__.py @@ -6,9 +6,11 @@ class TopologyViewsConfig(PluginConfig): verbose_name = "Topology views" description = "A plugin to render topology maps" version = "4.5.1" - author = "Mattijs Vanhaverbeke & LKE" + author = "Mattijs Vanhaverbeke, Codex & Claude" author_email = "author@example.com" base_url = "netbox_topology_views" + min_version = "4.7.0" + max_version = "4.7.99" required_settings = [] default_settings = { "static_image_directory": "netbox_topology_views/img", diff --git a/netbox_topology_views/forms.py b/netbox_topology_views/forms.py index cca9f30..7b694d1 100644 --- a/netbox_topology_views/forms.py +++ b/netbox_topology_views/forms.py @@ -379,6 +379,12 @@ 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/static/netbox_topology_views/css/rack_elevation.css b/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css index 59c0715..8d4af75 100644 --- a/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css +++ b/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css @@ -110,6 +110,10 @@ html[data-netbox-color-mode="dark"] .rack-device { stroke-dasharray: 7 7; stroke-linecap: round; } +.rack-topology-edge.is-power { + stroke-dasharray: 2 4; + stroke-linecap: round; +} #rack-topology-card:fullscreen { margin: 0 !important; border: 0; 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 72d78a3..226feac 100644 --- a/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html +++ b/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html @@ -148,6 +148,7 @@ if (!root || !source) return; const data = JSON.parse(source.textContent); const isWirelessEdge = edge => edge.kind === 'wireless' || edge.kind === 'wireless-lan'; + const isPowerEdge = edge => edge.kind === 'power'; const rackData = JSON.parse(document.getElementById('rack-export-data').textContent); const columns = root.querySelector('.rack-topology-columns'); const svg = root.querySelector('svg'); @@ -284,6 +285,7 @@ path.setAttribute('stroke', edge.color); path.classList.add('rack-topology-edge'); if (isWirelessEdge(edge)) path.classList.add('is-wireless'); + if (isPowerEdge(edge)) path.classList.add('is-power'); path.dataset.from = edge.from; path.dataset.to = edge.to; const title = document.createElementNS('http://www.w3.org/2000/svg', 'title'); @@ -293,7 +295,7 @@ path.addEventListener('mouseenter', event => { setEdgeHighlighted(path, true); showTooltip( - `${isWirelessEdge(edge) ? 'WLAN · ' : ''}${escapeHtml(edge.label)}` + + `${isWirelessEdge(edge) ? 'WLAN · ' : isPowerEdge(edge) ? 'Strom · ' : ''}${escapeHtml(edge.label)}` + `${escapeHtml(edge.from_device)} · ${escapeHtml(edge.from_port)}` + `↔ ${escapeHtml(edge.to_device)} · ${escapeHtml(edge.to_port)}`, event.target @@ -322,7 +324,7 @@ const localPort = outgoing ? edge.from_port : edge.to_port; const remoteDevice = outgoing ? edge.to_device : edge.from_device; const remotePort = outgoing ? edge.to_port : edge.from_port; - return `
  • ${escapeHtml(localPort)}→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}${isWirelessEdge(edge) ? 'WLAN · ' : ''}${escapeHtml(edge.label)}
  • `; + return `
  • ${escapeHtml(localPort)}→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}${isWirelessEdge(edge) ? 'WLAN · ' : isPowerEdge(edge) ? 'Strom · ' : ''}${escapeHtml(edge.label)}
  • `; }).join(''); showTooltip(`Verbindungen (${connections.length})`, node); }); @@ -555,10 +557,12 @@ 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); - edgeContent += ``; + const dashPattern = isWirelessEdge(edge) ? '7 7' : isPowerEdge(edge) ? '2 4' : null; + const edgeLabelPrefix = isWirelessEdge(edge) ? 'WLAN · ' : isPowerEdge(edge) ? 'Strom · ' : ''; + edgeContent += ``; const sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to); if (sourceId && targetId) { - drawioCells += ``; + drawioCells += ``; } }); return {svg: svgDocument(width, height, edgeContent + content), drawio: drawioDocument(drawioCells), width, height}; diff --git a/netbox_topology_views/views.py b/netbox_topology_views/views.py index 1938208..0d89f23 100644 --- a/netbox_topology_views/views.py +++ b/netbox_topology_views/views.py @@ -4,6 +4,7 @@ from typing import DefaultDict, Dict, Optional, Union from itertools import chain from circuits.models import Circuit, CircuitTermination, ProviderNetwork +from dcim.choices import CableTypeChoices from dcim.models import ( Cable, CableTermination, @@ -1008,6 +1009,14 @@ 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") + cable_rows = [] topology_nodes = {} topology_edges = [] @@ -1017,6 +1026,9 @@ 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) 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 @@ -1027,65 +1039,67 @@ class RackElevationView(PermissionRequiredMixin, View): if endpoint: topology_nodes[endpoint["node_id"]] = endpoint if a and b: + default_color = "#f59e0b" if is_power_cable else "#1685fc" 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": cable.label or str(cable), - "color": f"#{cable.color}" if cable.color else "#1685fc", + "color": f"#{cable.color}" if cable.color else default_color, "url": cable.get_absolute_url(), - "kind": "cable", + "kind": "power" if is_power_cable else "cable", }) - 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 + 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": 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", + "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", + }) + return render(request, "netbox_topology_views/rack_elevation.html", { "filter_form": RackElevationFilterForm(request.GET or None), "racks": rack_list, "cables": cable_rows,