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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user