diff --git a/README.md b/README.md index 89d8530..c58429e 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ Rückseite und Verkabelung. - Geräte in der Kabeltopologie frei verschieben - Geräte in der Kabeltopologie innerhalb jedes Racks nach ihrer Rackposition sortieren - 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 - Verbindungen eines Geräts beim Hover weiß hervorheben - Alle verbundenen Ports und Gegenstellen eines Geräts anzeigen diff --git a/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css b/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css index 6d88487..8f09a84 100644 --- a/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css +++ b/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css @@ -37,7 +37,7 @@ html[data-netbox-color-mode="dark"] .rack-device { .rack-topology { position: relative; min-height: 18rem; - overflow-x: auto; + overflow: auto; padding: 1rem; 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; } .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 { display: block; padding: .55rem .75rem; @@ -115,8 +115,11 @@ html[data-netbox-color-mode="dark"] .rack-device { 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-tooltip { display: none; diff --git a/netbox_topology_views/static_dev/css/app.scss b/netbox_topology_views/static_dev/css/app.scss index cc7d3c2..f7edb02 100644 --- a/netbox_topology_views/static_dev/css/app.scss +++ b/netbox_topology_views/static_dev/css/app.scss @@ -51,7 +51,7 @@ html[data-netbox-color-mode=dark] #visgraph { .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 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-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; } @@ -76,10 +76,10 @@ html[data-netbox-color-mode=dark] #visgraph { 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-card:fullscreen { margin: 0 !important; border: 0; border-radius: 0; background: var(--tblr-bg-surface, #fff); } -#rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; } +#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; overflow: hidden; } #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"] { diff --git a/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html b/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html index 3cb9e98..6f460b3 100644 --- a/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html +++ b/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html @@ -91,6 +91,11 @@ +
+ + + +
@@ -142,6 +147,7 @@ const source = document.getElementById('rack-topology-data'); if (!root || !source) return; 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 columns = root.querySelector('.rack-topology-columns'); const svg = root.querySelector('svg'); @@ -149,13 +155,18 @@ const resetButton = document.getElementById('rack-topology-reset'); const fullscreenButton = document.getElementById('rack-topology-fullscreen'); 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}`; let storedPositions = {}; try { storedPositions = JSON.parse(localStorage.getItem(storageKey) || '{}'); } catch (_) {} const groups = new Map(); 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, { name: node.rack_name || 'Extern', rackUnits: node.rack_u_height || null, @@ -209,8 +220,8 @@ const drawEdges = () => { const bounds = root.getBoundingClientRect(); - let width = Math.max(root.clientWidth, columns.scrollWidth + 32); - let height = Math.max(root.clientHeight, columns.scrollHeight + 32); + let width = Math.max(root.clientWidth, columns.scrollWidth * topologyZoom + 32); + let height = Math.max(root.clientHeight, columns.scrollHeight * topologyZoom + 32); svg.style.width = `${width}px`; svg.style.height = `${height}px`; 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('stroke', edge.color); 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.to = edge.to; const title = document.createElementNS('http://www.w3.org/2000/svg', 'title'); @@ -239,7 +250,7 @@ path.addEventListener('mouseenter', event => { path.classList.add('is-highlighted'); showTooltip( - `${edge.kind === 'wireless' ? 'WLAN · ' : ''}${escapeHtml(edge.label)}` + + `${isWirelessEdge(edge) ? 'WLAN · ' : ''}${escapeHtml(edge.label)}` + `${escapeHtml(edge.from_device)} · ${escapeHtml(edge.from_port)}` + `↔ ${escapeHtml(edge.to_device)} · ${escapeHtml(edge.to_port)}`, event.target @@ -267,7 +278,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 `
  • ${escapeHtml(localPort)}→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}${edge.kind === 'wireless' ? 'WLAN · ' : ''}${escapeHtml(edge.label)}
  • `; + return `
  • ${escapeHtml(localPort)}→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}${isWirelessEdge(edge) ? 'WLAN · ' : ''}${escapeHtml(edge.label)}
  • `; }).join(''); showTooltip(`Verbindungen (${connections.length})`, node); }); @@ -287,8 +298,8 @@ }); node.addEventListener('pointermove', event => { if (!drag) return; - const x = drag.x + event.clientX - drag.startX; - const y = drag.y + event.clientY - drag.startY; + const x = drag.x + (event.clientX - drag.startX) / topologyZoom; + const y = drag.y + (event.clientY - drag.startY) / topologyZoom; drag.moved ||= Math.abs(x - drag.x) > 3 || Math.abs(y - drag.y) > 3; node.style.transform = `translate(${x}px, ${y}px)`; storedPositions[nodeId] = {x, y}; @@ -328,6 +339,15 @@ : ' Vollbild'; 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' @@ -441,8 +461,8 @@ const buildTopologySvg = () => { const palette = exportPalette(); const rootBounds = root.getBoundingClientRect(); - let width = Math.max(root.clientWidth, columns.scrollWidth + 32); - let height = Math.max(root.clientHeight, columns.scrollHeight + 32); + let width = Math.max(root.clientWidth, columns.scrollWidth * topologyZoom + 32); + let height = Math.max(root.clientHeight, columns.scrollHeight * topologyZoom + 32); const positions = new Map(); const nodeCells = new Map(); let content = ''; @@ -475,10 +495,10 @@ 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 += ``; + edgeContent += ``; const sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to); if (sourceId && targetId) { - drawioCells += ``; + drawioCells += ``; } }); return {svg: svgDocument(width, height, edgeContent + content), drawio: drawioDocument(drawioCells), width, height}; diff --git a/netbox_topology_views/views.py b/netbox_topology_views/views.py index ae93487..61a89e9 100644 --- a/netbox_topology_views/views.py +++ b/netbox_topology_views/views.py @@ -27,7 +27,7 @@ from django.http import HttpRequest, HttpResponseRedirect, QueryDict from django.shortcuts import render, get_object_or_404 from django.views.generic import View from extras.models import Tag, SavedFilter -from wireless.models import WirelessLink +from wireless.models import WirelessLAN, WirelessLink from netbox.views.generic import ( ObjectView, ObjectListView, @@ -1021,8 +1021,8 @@ class RackElevationView(PermissionRequiredMixin, View): }) 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) + 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", @@ -1041,6 +1041,34 @@ class RackElevationView(PermissionRequiredMixin, View): "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,