fix: detect power cables by termination type and move toggle into topology card

Power-cable detection previously relied on cable.type, which is usually
unset for power patch cables (e.g. PDU-to-PDU), so they never got the
distinct color. Power cables are now also detected by termination
component type (PowerPort/PowerOutlet/PowerFeed).

Wireless/power display is now toggled client-side via checkboxes in the
Kabeltopologie card header (before the SVG/PNG/draw.io export buttons)
instead of a server round-trip through the top filter form, so toggling
is instant and affects exports too.

Bump plugin version to 4.5.2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 10:45:27 +02:00
co-authored by Claude Sonnet 5
parent 2e43166949
commit f08df4fd52
6 changed files with 90 additions and 70 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ sudo systemctl restart netbox netbox-rq
``` ```
`--force-reinstall` ist derzeit sinnvoll, da der Fork intern noch die `--force-reinstall` ist derzeit sinnvoll, da der Fork intern noch die
Paketversion `4.5.1` verwendet. Paketversion `4.5.2` verwendet.
## Verwendung ## Verwendung
+1 -1
View File
@@ -5,7 +5,7 @@ class TopologyViewsConfig(PluginConfig):
name = "netbox_topology_views" name = "netbox_topology_views"
verbose_name = "Topology views" verbose_name = "Topology views"
description = "A plugin to render topology maps" description = "A plugin to render topology maps"
version = "4.5.1" version = "4.5.2"
author = "Mattijs Vanhaverbeke, Codex & Claude" author = "Mattijs Vanhaverbeke, Codex & Claude"
author_email = "author@example.com" author_email = "author@example.com"
base_url = "netbox_topology_views" base_url = "netbox_topology_views"
-6
View File
@@ -379,12 +379,6 @@ class RackElevationFilterForm(forms.Form):
include_empty = forms.BooleanField( include_empty = forms.BooleanField(
required=False, initial=False, label=_("Include empty racks") 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): class CoordinateGroupsForm(NetBoxModelForm):
@@ -85,7 +85,17 @@
<div id="rack-topology-card" class="card mt-4"> <div id="rack-topology-card" class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<strong>Kabeltopologie</strong> <strong>Kabeltopologie</strong>
<div class="d-flex gap-2"> <div class="d-flex gap-2 align-items-center">
<div class="d-flex gap-3 align-items-center rack-topology-kind-toggles">
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" id="rack-topology-show-wireless" checked>
<label class="form-check-label" for="rack-topology-show-wireless">Funk/WLAN</label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" id="rack-topology-show-power" checked>
<label class="form-check-label" for="rack-topology-show-power">Strom</label>
</div>
</div>
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm btn-outline-primary topology-export" data-format="svg">SVG</button> <button class="btn btn-sm btn-outline-primary topology-export" data-format="svg">SVG</button>
<button class="btn btn-sm btn-outline-primary topology-export" data-format="png">PNG</button> <button class="btn btn-sm btn-outline-primary topology-export" data-format="png">PNG</button>
@@ -149,6 +159,22 @@
const data = JSON.parse(source.textContent); const data = JSON.parse(source.textContent);
const isWirelessEdge = edge => edge.kind === 'wireless' || edge.kind === 'wireless-lan'; const isWirelessEdge = edge => edge.kind === 'wireless' || edge.kind === 'wireless-lan';
const isPowerEdge = edge => edge.kind === 'power'; const isPowerEdge = edge => edge.kind === 'power';
const showWirelessCheckbox = document.getElementById('rack-topology-show-wireless');
const showPowerCheckbox = document.getElementById('rack-topology-show-power');
const isEdgeVisible = edge => {
if (isWirelessEdge(edge)) return showWirelessCheckbox ? showWirelessCheckbox.checked : true;
if (isPowerEdge(edge)) return showPowerCheckbox ? showPowerCheckbox.checked : true;
return true;
};
const applyKindVisibility = () => {
const showWireless = showWirelessCheckbox ? showWirelessCheckbox.checked : true;
root.querySelectorAll('.rack-topology-node').forEach(node => {
if (node.dataset.kind === 'wireless-lan') {
node.style.display = showWireless ? '' : 'none';
}
});
drawEdges();
};
const rackData = JSON.parse(document.getElementById('rack-export-data').textContent); const rackData = JSON.parse(document.getElementById('rack-export-data').textContent);
const columns = root.querySelector('.rack-topology-columns'); const columns = root.querySelector('.rack-topology-columns');
const svg = root.querySelector('svg'); const svg = root.querySelector('svg');
@@ -224,6 +250,7 @@
item.id = `topology-${node.node_id}`; item.id = `topology-${node.node_id}`;
item.style.setProperty('--node-color', `#${node.color}`); item.style.setProperty('--node-color', `#${node.color}`);
item.dataset.nodeId = node.node_id; item.dataset.nodeId = node.node_id;
if (node.kind) item.dataset.kind = node.kind;
if (node.url) item.href = node.url; if (node.url) item.href = node.url;
const nameElement = document.createElement('strong'); const nameElement = document.createElement('strong');
nameElement.textContent = node.device_name; nameElement.textContent = node.device_name;
@@ -271,6 +298,7 @@
svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.replaceChildren(); svg.replaceChildren();
data.edges.forEach(edge => { data.edges.forEach(edge => {
if (!isEdgeVisible(edge)) return;
const from = document.getElementById(`topology-${edge.from}`); const from = document.getElementById(`topology-${edge.from}`);
const to = document.getElementById(`topology-${edge.to}`); const to = document.getElementById(`topology-${edge.to}`);
if (!from || !to) return; if (!from || !to) return;
@@ -309,7 +337,7 @@
}); });
}; };
const connectedEdges = nodeId => data.edges.filter(edge => edge.from === nodeId || edge.to === nodeId); const connectedEdges = nodeId => data.edges.filter(edge => isEdgeVisible(edge) && (edge.from === nodeId || edge.to === nodeId));
root.querySelectorAll('.rack-topology-node').forEach(node => { root.querySelectorAll('.rack-topology-node').forEach(node => {
const nodeId = node.dataset.nodeId; const nodeId = node.dataset.nodeId;
node.addEventListener('mouseenter', () => { node.addEventListener('mouseenter', () => {
@@ -538,6 +566,7 @@
drawioCells += `<mxCell id="group-${drawioId++}" value="${xmlEscape(heading.textContent)}" style="text;html=1;strokeColor=none;fillColor=none;align=center;fontStyle=1;fontColor=${palette.heading};" vertex="1" parent="1"><mxGeometry x="${headingX}" y="${headingY}" width="${headingBounds.width}" height="22" as="geometry"/></mxCell>`; drawioCells += `<mxCell id="group-${drawioId++}" value="${xmlEscape(heading.textContent)}" style="text;html=1;strokeColor=none;fillColor=none;align=center;fontStyle=1;fontColor=${palette.heading};" vertex="1" parent="1"><mxGeometry x="${headingX}" y="${headingY}" width="${headingBounds.width}" height="22" as="geometry"/></mxCell>`;
}); });
root.querySelectorAll('.rack-topology-node').forEach(node => { root.querySelectorAll('.rack-topology-node').forEach(node => {
if (node.style.display === 'none') return;
const bounds = node.getBoundingClientRect(); const bounds = node.getBoundingClientRect();
const x = bounds.left - rootBounds.left + root.scrollLeft; const x = bounds.left - rootBounds.left + root.scrollLeft;
const y = bounds.top - rootBounds.top + root.scrollTop; const y = bounds.top - rootBounds.top + root.scrollTop;
@@ -554,6 +583,7 @@
}); });
let edgeContent = ''; let edgeContent = '';
data.edges.forEach(edge => { data.edges.forEach(edge => {
if (!isEdgeVisible(edge)) return;
const a = positions.get(edge.from), b = positions.get(edge.to); const a = positions.get(edge.from), b = positions.get(edge.to);
if (!a || !b) return; if (!a || !b) return;
const bend = Math.max(30, Math.abs(b.x - a.x) * .35); const bend = Math.max(30, Math.abs(b.x - a.x) * .35);
@@ -613,7 +643,9 @@
document.querySelectorAll('.topology-export').forEach(button => button.addEventListener('click', () => { document.querySelectorAll('.topology-export').forEach(button => button.addEventListener('click', () => {
exportGraphic(buildTopologySvg(), button.dataset.format, 'rack-topologie'); exportGraphic(buildTopologySvg(), button.dataset.format, 'rack-topologie');
})); }));
requestAnimationFrame(drawEdges); showWirelessCheckbox?.addEventListener('change', applyKindVisibility);
showPowerCheckbox?.addEventListener('change', applyKindVisibility);
requestAnimationFrame(applyKindVisibility);
window.addEventListener('resize', drawEdges); window.addEventListener('resize', drawEdges);
root.addEventListener('scroll', drawEdges, {passive: true}); root.addEventListener('scroll', drawEdges, {passive: true});
})(); })();
+52 -58
View File
@@ -1009,13 +1009,7 @@ class RackElevationView(PermissionRequiredMixin, View):
} for device in rack.elevation_devices], } for device in rack.elevation_devices],
}) })
def _flag_enabled(name): power_component_models = {"powerport", "poweroutlet", "powerfeed"}
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 = [] cable_rows = []
topology_nodes = {} topology_nodes = {}
@@ -1026,10 +1020,10 @@ class RackElevationView(PermissionRequiredMixin, View):
).values_list("cable_id", flat=True).distinct() ).values_list("cable_id", flat=True).distinct()
cables = Cable.objects.restrict(request.user, "view").filter(pk__in=cable_ids) cables = Cable.objects.restrict(request.user, "view").filter(pk__in=cable_ids)
for cable in cables.order_by("label", "pk"): 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) a_terms, b_terms = list(cable.a_terminations), list(cable.b_terminations)
is_power_cable = cable.type == CableTypeChoices.TYPE_POWER or any(
term._meta.model_name in power_component_models for term in a_terms + b_terms
)
for index in range(max(len(a_terms), len(b_terms), 1)): 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 a = _rack_endpoint(a_terms[index]) if index < len(a_terms) else None
b = _rack_endpoint(b_terms[index]) if index < len(b_terms) else None b = _rack_endpoint(b_terms[index]) if index < len(b_terms) else None
@@ -1050,55 +1044,55 @@ class RackElevationView(PermissionRequiredMixin, View):
"kind": "power" if is_power_cable else "cable", "kind": "power" if is_power_cable else "cable",
}) })
if show_wireless: wireless_links = WirelessLink.objects.restrict(request.user, "view").filter(
wireless_links = WirelessLink.objects.restrict(request.user, "view").filter( Q(interface_a__device_id__in=device_ids) |
Q(interface_a__device_id__in=device_ids) | Q(interface_b__device_id__in=device_ids)
Q(interface_b__device_id__in=device_ids) ).select_related(
).select_related( "interface_a__device__rack", "interface_a__device__role", "interface_a__device__device_type",
"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",
"interface_b__device__rack", "interface_b__device__role", "interface_b__device__device_type", )
) for wireless_link in wireless_links:
for wireless_link in wireless_links: a = _rack_endpoint(wireless_link.interface_a)
a = _rack_endpoint(wireless_link.interface_a) b = _rack_endpoint(wireless_link.interface_b)
b = _rack_endpoint(wireless_link.interface_b) topology_nodes[a["node_id"]] = a
topology_nodes[a["node_id"]] = a topology_nodes[b["node_id"]] = b
topology_nodes[b["node_id"]] = b topology_edges.append({
topology_edges.append({ "from": a["node_id"], "to": b["node_id"],
"from": a["node_id"], "to": b["node_id"], "from_device": a["device_name"], "to_device": b["device_name"],
"from_device": a["device_name"], "to_device": b["device_name"], "from_port": a["name"], "to_port": b["name"],
"from_port": a["name"], "to_port": b["name"], "label": wireless_link.ssid or str(wireless_link),
"label": wireless_link.ssid or str(wireless_link), "color": "#8b5cf6", "url": wireless_link.get_absolute_url(),
"color": "#8b5cf6", "url": wireless_link.get_absolute_url(), "kind": "wireless",
"kind": "wireless", })
})
wireless_lans = WirelessLAN.objects.restrict(request.user, "view").filter( wireless_lans = WirelessLAN.objects.restrict(request.user, "view").filter(
interfaces__device_id__in=device_ids interfaces__device_id__in=device_ids
).distinct() ).distinct()
for wireless_lan in wireless_lans: for wireless_lan in wireless_lans:
wlan_node_id = f"wlan-{wireless_lan.pk}" wlan_node_id = f"wlan-{wireless_lan.pk}"
topology_nodes[wlan_node_id] = { topology_nodes[wlan_node_id] = {
"node_id": wlan_node_id, "device_id": None, "node_id": wlan_node_id, "device_id": None,
"device_name": f"WLAN · {wireless_lan.ssid}", "device_name": f"WLAN · {wireless_lan.ssid}",
"name": wireless_lan.ssid, "subtitle": "Multi-Access WLAN", "name": wireless_lan.ssid, "subtitle": "Multi-Access WLAN",
"rack_id": None, "rack_name": "Funknetze", "rack_id": None, "rack_name": "Funknetze",
"url": wireless_lan.get_absolute_url(), "color": "8b5cf6", "url": wireless_lan.get_absolute_url(), "color": "8b5cf6",
"position": None, "height": 1, "face": "front", "rack_u_height": None, "position": None, "height": 1, "face": "front", "rack_u_height": None,
} "kind": "wireless-lan",
wlan_interfaces = Interface.objects.restrict(request.user, "view").filter( }
wireless_lans=wireless_lan wlan_interfaces = Interface.objects.restrict(request.user, "view").filter(
).select_related("device__rack", "device__role", "device__device_type") wireless_lans=wireless_lan
for interface in wlan_interfaces: ).select_related("device__rack", "device__role", "device__device_type")
endpoint = _rack_endpoint(interface) for interface in wlan_interfaces:
topology_nodes[endpoint["node_id"]] = endpoint endpoint = _rack_endpoint(interface)
topology_edges.append({ topology_nodes[endpoint["node_id"]] = endpoint
"from": endpoint["node_id"], "to": wlan_node_id, topology_edges.append({
"from_device": endpoint["device_name"], "from": endpoint["node_id"], "to": wlan_node_id,
"to_device": f"WLAN · {wireless_lan.ssid}", "from_device": endpoint["device_name"],
"from_port": endpoint["name"], "to_port": wireless_lan.ssid, "to_device": f"WLAN · {wireless_lan.ssid}",
"label": wireless_lan.ssid, "color": "#8b5cf6", "from_port": endpoint["name"], "to_port": wireless_lan.ssid,
"url": wireless_lan.get_absolute_url(), "kind": "wireless-lan", "label": wireless_lan.ssid, "color": "#8b5cf6",
}) "url": wireless_lan.get_absolute_url(), "kind": "wireless-lan",
})
return render(request, "netbox_topology_views/rack_elevation.html", { return render(request, "netbox_topology_views/rack_elevation.html", {
"filter_form": RackElevationFilterForm(request.GET or None), "filter_form": RackElevationFilterForm(request.GET or None),
+1 -1
View File
@@ -7,7 +7,7 @@ long_description = readme.read_text()
setup( setup(
name="netbox-topology-views", name="netbox-topology-views",
version="4.5.1", version="4.5.2",
description="A NetBox plugin to create Topology maps", description="A NetBox plugin to create Topology maps",
long_description=long_description, long_description=long_description,
long_description_content_type="text/markdown", long_description_content_type="text/markdown",