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
+6 -4
View File
@@ -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
+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};
+16 -2
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,16 +1039,18 @@ 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",
})
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)