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,