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})${rows || '- Keine Verbindungen
'}
`, 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,