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