fix: improve topology spacing zoom scrolling and wireless support

This commit is contained in:
2026-07-28 14:42:41 +02:00
parent 4680209d83
commit 1f80ae5099
5 changed files with 76 additions and 23 deletions
+3 -1
View File
@@ -17,7 +17,9 @@ Rückseite und Verkabelung.
- Geräte in der Kabeltopologie frei verschieben - Geräte in der Kabeltopologie frei verschieben
- Geräte in der Kabeltopologie innerhalb jedes Racks nach ihrer Rackposition sortieren - Geräte in der Kabeltopologie innerhalb jedes Racks nach ihrer Rackposition sortieren
- Kabeltopologie in den Browser-Vollbildmodus schalten - Kabeltopologie in den Browser-Vollbildmodus schalten
- WLAN-Verbindungen als violette, gestrichelte Funkstrecken darstellen - 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
- Manuelle Positionen pro Rack-Auswahl im Browser speichern - Manuelle Positionen pro Rack-Auswahl im Browser speichern
- Verbindungen eines Geräts beim Hover weiß hervorheben - Verbindungen eines Geräts beim Hover weiß hervorheben
- Alle verbundenen Ports und Gegenstellen eines Geräts anzeigen - Alle verbundenen Ports und Gegenstellen eines Geräts anzeigen
@@ -37,7 +37,7 @@ html[data-netbox-color-mode="dark"] .rack-device {
.rack-topology { .rack-topology {
position: relative; position: relative;
min-height: 18rem; min-height: 18rem;
overflow-x: auto; overflow: auto;
padding: 1rem; padding: 1rem;
background: var(--tblr-bg-surface-secondary, rgba(127, 127, 127, .05)); background: var(--tblr-bg-surface-secondary, rgba(127, 127, 127, .05));
} }
@@ -66,7 +66,7 @@ html[data-netbox-color-mode="dark"] .rack-device {
width: 15rem; width: 15rem;
} }
.rack-topology-column h4 { text-align: center; font-size: .85rem; } .rack-topology-column h4 { text-align: center; font-size: .85rem; }
.rack-topology-device-list { display: flex; flex-direction: column; gap: 14px; } .rack-topology-device-list { display: flex; flex-direction: column; gap: 20px; }
.rack-topology-node { .rack-topology-node {
display: block; display: block;
padding: .55rem .75rem; padding: .55rem .75rem;
@@ -115,8 +115,11 @@ html[data-netbox-color-mode="dark"] .rack-device {
border: 0; border: 0;
border-radius: 0; border-radius: 0;
background: var(--tblr-bg-surface, #fff); background: var(--tblr-bg-surface, #fff);
display: flex;
flex-direction: column;
overflow: hidden;
} }
#rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; } #rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; overflow: hidden; }
#rack-topology-card:fullscreen .rack-topology { height: calc(100vh - 5.5rem); } #rack-topology-card:fullscreen .rack-topology { height: calc(100vh - 5.5rem); }
.rack-topology-tooltip { .rack-topology-tooltip {
display: none; display: none;
@@ -51,7 +51,7 @@ html[data-netbox-color-mode=dark] #visgraph {
.rack-device:hover span { white-space: normal; } .rack-device:hover span { white-space: normal; }
.rack-device span, .rack-device small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rack-device span, .rack-device small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rack-device small { font-size: .6rem; opacity: .8; } .rack-device small { font-size: .6rem; opacity: .8; }
.rack-topology { position: relative; min-height: 18rem; overflow-x: auto; padding: 1rem; background: var(--tblr-bg-surface-secondary, rgba(127,127,127,.05)); } .rack-topology { position: relative; min-height: 18rem; overflow: auto; padding: 1rem; background: var(--tblr-bg-surface-secondary, rgba(127,127,127,.05)); }
.rack-topology-lines { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1; } .rack-topology-lines { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1; }
.rack-topology-columns { position: relative; z-index: 2; display: flex; align-items: flex-start; justify-content: space-around; gap: 5rem; min-width: max-content; pointer-events: none; } .rack-topology-columns { position: relative; z-index: 2; display: flex; align-items: flex-start; justify-content: space-around; gap: 5rem; min-width: max-content; pointer-events: none; }
.rack-topology-column { display: flex; flex-direction: column; gap: .75rem; width: 15rem; } .rack-topology-column { display: flex; flex-direction: column; gap: .75rem; width: 15rem; }
@@ -76,10 +76,10 @@ html[data-netbox-color-mode=dark] #visgraph {
background-color: #fff; background-color: #fff;
} }
.rack-topology-device-list { display: flex; flex-direction: column; gap: 14px; } .rack-topology-device-list { display: flex; flex-direction: column; gap: 20px; }
.rack-topology-edge.is-wireless { stroke-dasharray: 7 7; stroke-linecap: round; } .rack-topology-edge.is-wireless { stroke-dasharray: 7 7; stroke-linecap: round; }
#rack-topology-card:fullscreen { margin: 0 !important; border: 0; border-radius: 0; background: var(--tblr-bg-surface, #fff); } #rack-topology-card:fullscreen { margin: 0 !important; border: 0; border-radius: 0; background: var(--tblr-bg-surface, #fff); display: flex; flex-direction: column; overflow: hidden; }
#rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; } #rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; overflow: hidden; }
#rack-topology-card:fullscreen .rack-topology { height: calc(100vh - 5.5rem); } #rack-topology-card:fullscreen .rack-topology { height: calc(100vh - 5.5rem); }
html[data-bs-theme="dark"], body[data-bs-theme="dark"], html[data-netbox-color-mode="dark"] { html[data-bs-theme="dark"], body[data-bs-theme="dark"], html[data-netbox-color-mode="dark"] {
@@ -91,6 +91,11 @@
<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>
<button class="btn btn-sm btn-outline-primary topology-export" data-format="drawio">draw.io</button> <button class="btn btn-sm btn-outline-primary topology-export" data-format="drawio">draw.io</button>
</div> </div>
<div class="btn-group" aria-label="Topologie zoomen">
<button id="rack-topology-zoom-out" class="btn btn-sm btn-outline-secondary" type="button" title="Verkleinern"><i class="mdi mdi-minus"></i></button>
<button id="rack-topology-zoom-reset" class="btn btn-sm btn-outline-secondary" type="button" title="Zoom zurücksetzen"><span id="rack-topology-zoom-label">100%</span></button>
<button id="rack-topology-zoom-in" class="btn btn-sm btn-outline-secondary" type="button" title="Vergrößern"><i class="mdi mdi-plus"></i></button>
</div>
<button id="rack-topology-reset" class="btn btn-sm btn-outline-secondary" type="button"> <button id="rack-topology-reset" class="btn btn-sm btn-outline-secondary" type="button">
<i class="mdi mdi-restore"></i> Positionen zurücksetzen <i class="mdi mdi-restore"></i> Positionen zurücksetzen
</button> </button>
@@ -142,6 +147,7 @@
const source = document.getElementById('rack-topology-data'); const source = document.getElementById('rack-topology-data');
if (!root || !source) return; if (!root || !source) return;
const data = JSON.parse(source.textContent); const data = JSON.parse(source.textContent);
const isWirelessEdge = edge => edge.kind === 'wireless' || edge.kind === 'wireless-lan';
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');
@@ -149,13 +155,18 @@
const resetButton = document.getElementById('rack-topology-reset'); const resetButton = document.getElementById('rack-topology-reset');
const fullscreenButton = document.getElementById('rack-topology-fullscreen'); const fullscreenButton = document.getElementById('rack-topology-fullscreen');
const topologyCard = document.getElementById('rack-topology-card'); const topologyCard = document.getElementById('rack-topology-card');
const zoomInButton = document.getElementById('rack-topology-zoom-in');
const zoomOutButton = document.getElementById('rack-topology-zoom-out');
const zoomResetButton = document.getElementById('rack-topology-zoom-reset');
const zoomLabel = document.getElementById('rack-topology-zoom-label');
let topologyZoom = 1;
const storageKey = `netbox-rack-topology-v2:${window.location.pathname}${window.location.search}`; const storageKey = `netbox-rack-topology-v2:${window.location.pathname}${window.location.search}`;
let storedPositions = {}; let storedPositions = {};
try { storedPositions = JSON.parse(localStorage.getItem(storageKey) || '{}'); } catch (_) {} try { storedPositions = JSON.parse(localStorage.getItem(storageKey) || '{}'); } catch (_) {}
const groups = new Map(); const groups = new Map();
data.nodes.forEach(node => { data.nodes.forEach(node => {
const groupKey = node.rack_id ? `rack-${node.rack_id}` : 'external'; const groupKey = node.rack_id ? `rack-${node.rack_id}` : `external-${node.rack_name || 'Extern'}`;
if (!groups.has(groupKey)) groups.set(groupKey, { if (!groups.has(groupKey)) groups.set(groupKey, {
name: node.rack_name || 'Extern', name: node.rack_name || 'Extern',
rackUnits: node.rack_u_height || null, rackUnits: node.rack_u_height || null,
@@ -209,8 +220,8 @@
const drawEdges = () => { const drawEdges = () => {
const bounds = root.getBoundingClientRect(); const bounds = root.getBoundingClientRect();
let width = Math.max(root.clientWidth, columns.scrollWidth + 32); let width = Math.max(root.clientWidth, columns.scrollWidth * topologyZoom + 32);
let height = Math.max(root.clientHeight, columns.scrollHeight + 32); let height = Math.max(root.clientHeight, columns.scrollHeight * topologyZoom + 32);
svg.style.width = `${width}px`; svg.style.width = `${width}px`;
svg.style.height = `${height}px`; svg.style.height = `${height}px`;
svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
@@ -229,7 +240,7 @@
path.setAttribute('d', `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`); path.setAttribute('d', `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`);
path.setAttribute('stroke', edge.color); path.setAttribute('stroke', edge.color);
path.classList.add('rack-topology-edge'); path.classList.add('rack-topology-edge');
if (edge.kind === 'wireless') path.classList.add('is-wireless'); if (isWirelessEdge(edge)) path.classList.add('is-wireless');
path.dataset.from = edge.from; path.dataset.from = edge.from;
path.dataset.to = edge.to; path.dataset.to = edge.to;
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title'); const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
@@ -239,7 +250,7 @@
path.addEventListener('mouseenter', event => { path.addEventListener('mouseenter', event => {
path.classList.add('is-highlighted'); path.classList.add('is-highlighted');
showTooltip( showTooltip(
`<strong>${edge.kind === 'wireless' ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</strong>` + `<strong>${isWirelessEdge(edge) ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</strong>` +
`<span>${escapeHtml(edge.from_device)} · ${escapeHtml(edge.from_port)}</span>` + `<span>${escapeHtml(edge.from_device)} · ${escapeHtml(edge.from_port)}</span>` +
`<span>↔ ${escapeHtml(edge.to_device)} · ${escapeHtml(edge.to_port)}</span>`, `<span>↔ ${escapeHtml(edge.to_device)} · ${escapeHtml(edge.to_port)}</span>`,
event.target event.target
@@ -267,7 +278,7 @@
const localPort = outgoing ? edge.from_port : edge.to_port; const localPort = outgoing ? edge.from_port : edge.to_port;
const remoteDevice = outgoing ? edge.to_device : edge.from_device; const remoteDevice = outgoing ? edge.to_device : edge.from_device;
const remotePort = outgoing ? edge.to_port : edge.from_port; const remotePort = outgoing ? edge.to_port : edge.from_port;
return `<li><strong>${escapeHtml(localPort)}</strong><span>→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}</span><small>${edge.kind === 'wireless' ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</small></li>`; return `<li><strong>${escapeHtml(localPort)}</strong><span>→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}</span><small>${isWirelessEdge(edge) ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</small></li>`;
}).join(''); }).join('');
showTooltip(`<strong>Verbindungen (${connections.length})</strong><ul>${rows || '<li>Keine Verbindungen</li>'}</ul>`, node); showTooltip(`<strong>Verbindungen (${connections.length})</strong><ul>${rows || '<li>Keine Verbindungen</li>'}</ul>`, node);
}); });
@@ -287,8 +298,8 @@
}); });
node.addEventListener('pointermove', event => { node.addEventListener('pointermove', event => {
if (!drag) return; if (!drag) return;
const x = drag.x + event.clientX - drag.startX; const x = drag.x + (event.clientX - drag.startX) / topologyZoom;
const y = drag.y + event.clientY - drag.startY; const y = drag.y + (event.clientY - drag.startY) / topologyZoom;
drag.moved ||= Math.abs(x - drag.x) > 3 || Math.abs(y - drag.y) > 3; drag.moved ||= Math.abs(x - drag.x) > 3 || Math.abs(y - drag.y) > 3;
node.style.transform = `translate(${x}px, ${y}px)`; node.style.transform = `translate(${x}px, ${y}px)`;
storedPositions[nodeId] = {x, y}; storedPositions[nodeId] = {x, y};
@@ -328,6 +339,15 @@
: '<i class="mdi mdi-fullscreen"></i> Vollbild'; : '<i class="mdi mdi-fullscreen"></i> Vollbild';
requestAnimationFrame(drawEdges); requestAnimationFrame(drawEdges);
}); });
const setTopologyZoom = value => {
topologyZoom = Math.min(2, Math.max(.5, Math.round(value * 10) / 10));
columns.style.zoom = topologyZoom;
zoomLabel.textContent = `${Math.round(topologyZoom * 100)}%`;
requestAnimationFrame(drawEdges);
};
zoomInButton?.addEventListener('click', () => setTopologyZoom(topologyZoom + .1));
zoomOutButton?.addEventListener('click', () => setTopologyZoom(topologyZoom - .1));
zoomResetButton?.addEventListener('click', () => setTopologyZoom(1));
const xmlEscape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ const xmlEscape = value => String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'
@@ -441,8 +461,8 @@
const buildTopologySvg = () => { const buildTopologySvg = () => {
const palette = exportPalette(); const palette = exportPalette();
const rootBounds = root.getBoundingClientRect(); const rootBounds = root.getBoundingClientRect();
let width = Math.max(root.clientWidth, columns.scrollWidth + 32); let width = Math.max(root.clientWidth, columns.scrollWidth * topologyZoom + 32);
let height = Math.max(root.clientHeight, columns.scrollHeight + 32); let height = Math.max(root.clientHeight, columns.scrollHeight * topologyZoom + 32);
const positions = new Map(); const positions = new Map();
const nodeCells = new Map(); const nodeCells = new Map();
let content = ''; let content = '';
@@ -475,10 +495,10 @@
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);
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"${edge.kind === 'wireless' ? ' stroke-dasharray="7 7"' : ''}/>`; 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 sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to); const sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to);
if (sourceId && targetId) { if (sourceId && targetId) {
drawioCells += `<mxCell id="edge-${drawioId++}" value="${xmlEscape(edge.kind === 'wireless' ? `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;${edge.kind === 'wireless' ? '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(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>`;
} }
}); });
return {svg: svgDocument(width, height, edgeContent + content), drawio: drawioDocument(drawioCells), width, height}; return {svg: svgDocument(width, height, edgeContent + content), drawio: drawioDocument(drawioCells), width, height};
+31 -3
View File
@@ -27,7 +27,7 @@ from django.http import HttpRequest, HttpResponseRedirect, QueryDict
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.views.generic import View from django.views.generic import View
from extras.models import Tag, SavedFilter from extras.models import Tag, SavedFilter
from wireless.models import WirelessLink from wireless.models import WirelessLAN, WirelessLink
from netbox.views.generic import ( from netbox.views.generic import (
ObjectView, ObjectView,
ObjectListView, ObjectListView,
@@ -1021,8 +1021,8 @@ class RackElevationView(PermissionRequiredMixin, View):
}) })
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",
@@ -1041,6 +1041,34 @@ class RackElevationView(PermissionRequiredMixin, View):
"kind": "wireless", "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", { return render(request, "netbox_topology_views/rack_elevation.html", {
"filter_form": RackElevationFilterForm(request.GET or None), "filter_form": RackElevationFilterForm(request.GET or None),
"racks": rack_list, "cables": cable_rows, "racks": rack_list, "cables": cable_rows,