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 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 10:36:47 +02:00
co-authored by Claude Sonnet 5
parent 86a8daf45e
commit 2e43166949
6 changed files with 89 additions and 57 deletions
+3 -1
View File
@@ -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",
+6
View File
@@ -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):
@@ -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;
@@ -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(
`<strong>${isWirelessEdge(edge) ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</strong>` +
`<strong>${isWirelessEdge(edge) ? 'WLAN · ' : isPowerEdge(edge) ? 'Strom · ' : ''}${escapeHtml(edge.label)}</strong>` +
`<span>${escapeHtml(edge.from_device)} · ${escapeHtml(edge.from_port)}</span>` +
`<span>↔ ${escapeHtml(edge.to_device)} · ${escapeHtml(edge.to_port)}</span>`,
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 `<li><strong>${escapeHtml(localPort)}</strong><span>→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}</span><small>${isWirelessEdge(edge) ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</small></li>`;
return `<li><strong>${escapeHtml(localPort)}</strong><span>→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}</span><small>${isWirelessEdge(edge) ? 'WLAN · ' : isPowerEdge(edge) ? 'Strom · ' : ''}${escapeHtml(edge.label)}</small></li>`;
}).join('');
showTooltip(`<strong>Verbindungen (${connections.length})</strong><ul>${rows || '<li>Keine Verbindungen</li>'}</ul>`, 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 += `<path d="M ${a.x} ${a.y} C ${a.x + bend} ${a.y}, ${b.x - bend} ${b.y}, ${b.x} ${b.y}" fill="none" stroke="${xmlEscape(edge.color)}" stroke-width="3"${isWirelessEdge(edge) ? ' stroke-dasharray="7 7"' : ''}/>`;
const dashPattern = isWirelessEdge(edge) ? '7 7' : isPowerEdge(edge) ? '2 4' : null;
const edgeLabelPrefix = isWirelessEdge(edge) ? 'WLAN · ' : isPowerEdge(edge) ? 'Strom · ' : '';
edgeContent += `<path d="M ${a.x} ${a.y} C ${a.x + bend} ${a.y}, ${b.x - bend} ${b.y}, ${b.x} ${b.y}" fill="none" stroke="${xmlEscape(edge.color)}" stroke-width="3"${dashPattern ? ` stroke-dasharray="${dashPattern}"` : ''}/>`;
const sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to);
if (sourceId && targetId) {
drawioCells += `<mxCell id="edge-${drawioId++}" value="${xmlEscape(isWirelessEdge(edge) ? `WLAN · ${edge.label}` : edge.label)}" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=${xmlEscape(edge.color)};strokeWidth=2;fontSize=9;${isWirelessEdge(edge) ? 'dashed=1;dashPattern=7 7;' : ''}" edge="1" parent="1" source="${sourceId}" target="${targetId}"><mxGeometry relative="1" as="geometry"/></mxCell>`;
drawioCells += `<mxCell id="edge-${drawioId++}" value="${xmlEscape(`${edgeLabelPrefix}${edge.label}`)}" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=${xmlEscape(edge.color)};strokeWidth=2;fontSize=9;${dashPattern ? `dashed=1;dashPattern=${dashPattern};` : ''}" edge="1" parent="1" source="${sourceId}" target="${targetId}"><mxGeometry relative="1" as="geometry"/></mxCell>`;
}
});
return {svg: svgDocument(width, height, edgeContent + content), drawio: drawioDocument(drawioCells), width, height};
+62 -48
View File
@@ -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,