From 1de5ed056536acb95651423c2d678c4b551d341e Mon Sep 17 00:00:00 2001 From: Louis Date: Tue, 28 Jul 2026 14:26:28 +0200 Subject: [PATCH] feat: align topology nodes with racks and add theme-aware exports --- README.md | 2 + .../css/rack_elevation.css | 63 ++++++++- netbox_topology_views/static_dev/css/app.scss | 27 +++- .../netbox_topology_views/rack_elevation.html | 125 +++++++++++++----- netbox_topology_views/views.py | 23 +++- 5 files changed, 202 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 21cbacd..01855a4 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Rückseite und Verkabelung. - Vorder- und Rückseite eines Racks getrennt betrachten - Rackübergreifende und externe Kabelverbindungen erkennen - Geräte in der Kabeltopologie frei verschieben +- Geräte in der Kabeltopologie zunächst entsprechend ihrer Rackposition und Rackseite anordnen - 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 @@ -22,6 +23,7 @@ Rückseite und Verkabelung. - Geräte und Kabel direkt aus der Topologie in NetBox öffnen - Einzelne Racks, alle Racks oder die Kabeltopologie als SVG, PNG und draw.io exportieren - Exporte grundsätzlich mit transparentem Hintergrund erzeugen +- Farben von Rackansicht und Export an den aktiven Light- oder Dark-Mode anpassen - Topologie als PNG oder diagrams.net/draw.io-XML exportieren - Zusätzliche Icons für Network-, Leaf-, Spine- und Aggregation-Switche - Unterstützung für helle und dunkle NetBox-Themes 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 19536c6..75768ab 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 @@ -6,6 +6,8 @@ padding: 2px 6px; font-size: .78rem; line-height: 1.15; + background: #eef6ff; + color: #132238; } .rack-device:hover { min-height: 2.8rem; @@ -13,6 +15,25 @@ z-index: 3; } .rack-device:hover span { white-space: normal; } +.rack-frame { + border-color: #718096; + background-color: #fff; + background-image: repeating-linear-gradient(to top, rgba(56, 73, 89, .16) 0, rgba(56, 73, 89, .16) 1px, transparent 1px, transparent calc(100% / var(--rack-units))); +} +.rack-device:hover { color: #132238; } +html[data-bs-theme="dark"] .rack-frame, +body[data-bs-theme="dark"] .rack-frame, +html[data-netbox-color-mode="dark"] .rack-frame { + border-color: #3b4654; + background-color: #212b36; + background-image: repeating-linear-gradient(to top, rgba(200, 215, 230, .14) 0, rgba(200, 215, 230, .14) 1px, transparent 1px, transparent calc(100% / var(--rack-units))); +} +html[data-bs-theme="dark"] .rack-device, +body[data-bs-theme="dark"] .rack-device, +html[data-netbox-color-mode="dark"] .rack-device { + background: #162e45; + color: #fff; +} .rack-topology { position: relative; min-height: 18rem; @@ -42,9 +63,42 @@ display: flex; flex-direction: column; gap: .75rem; - width: 15rem; + width: 30rem; } +.rack-topology-column.is-external { width: 15rem; } .rack-topology-column h4 { text-align: center; font-size: .85rem; } +.rack-topology-rack-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: .5rem; + height: calc(var(--rack-units) * 22px); + min-height: 280px; + margin-top: 1rem; +} +.rack-topology-lane { + position: relative; + border: 3px solid #718096; + background: repeating-linear-gradient(to top, rgba(56, 73, 89, .14) 0, rgba(56, 73, 89, .14) 1px, transparent 1px, transparent calc(100% / var(--rack-units))); +} +.rack-topology-lane-label { + position: absolute; + top: -1.4rem; + left: 0; + right: 0; + text-align: center; + font-size: .65rem; + text-transform: uppercase; +} +.rack-topology-external-body { display: flex; flex-direction: column; gap: .75rem; } +.rack-topology-lane .rack-topology-node { + position: absolute; + left: 3px; + right: 3px; + min-height: 18px; + padding: 2px 6px; + overflow: hidden; + box-sizing: border-box; +} .rack-topology-node { display: block; padding: .55rem .75rem; @@ -109,3 +163,10 @@ border-top: 1px solid var(--tblr-border-color, #ced4da); } .rack-topology-tooltip small { opacity: .65; } +html[data-bs-theme="dark"] .rack-topology-lane, +body[data-bs-theme="dark"] .rack-topology-lane, +html[data-netbox-color-mode="dark"] .rack-topology-lane { + border-color: #3b4654; + background-color: #212b36; + background-image: repeating-linear-gradient(to top, rgba(200, 215, 230, .12) 0, rgba(200, 215, 230, .12) 1px, transparent 1px, transparent calc(100% / var(--rack-units))); +} diff --git a/netbox_topology_views/static_dev/css/app.scss b/netbox_topology_views/static_dev/css/app.scss index bcbd636..ccee7d2 100644 --- a/netbox_topology_views/static_dev/css/app.scss +++ b/netbox_topology_views/static_dev/css/app.scss @@ -46,7 +46,7 @@ html[data-netbox-color-mode=dark] #visgraph { .rack-frame { display: flex; height: calc(var(--rack-units) * 22px); min-height: 280px; border: 4px solid #3b4654; background: repeating-linear-gradient(to top, rgba(127,127,127,.14) 0, rgba(127,127,127,.14) 1px, transparent 1px, transparent calc(100% / var(--rack-units))); } .rack-unit-labels { width: 2rem; display: flex; flex-direction: column; justify-content: space-around; font-size: .55rem; text-align: center; color: var(--tblr-secondary, #667382); } .rack-device-area { position: relative; flex: 1; border-left: 1px solid #3b4654; } -.rack-device { position: absolute; left: 3px; right: 3px; min-height: 2px; overflow: hidden; padding: 2px 6px; border: 2px solid #1685fc; border-radius: 3px; background: #162e45; color: #fff; font-size: .78rem; line-height: 1.15; text-decoration: none; } +.rack-device { position: absolute; left: 3px; right: 3px; min-height: 2px; overflow: hidden; padding: 2px 6px; border: 2px solid #1685fc; border-radius: 3px; background: #eef6ff; color: #132238; font-size: .78rem; line-height: 1.15; text-decoration: none; } .rack-device:hover { min-height: 2.8rem; overflow: visible; filter: brightness(1.25); color: #fff; z-index: 3; } .rack-device:hover span { white-space: normal; } .rack-device span, .rack-device small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -54,7 +54,7 @@ html[data-netbox-color-mode=dark] #visgraph { .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-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; } +.rack-topology-column { display: flex; flex-direction: column; gap: .75rem; width: 30rem; } .rack-topology-column h4 { text-align: center; font-size: .85rem; } .rack-topology-node { display: block; padding: .55rem .75rem; border: 2px solid var(--node-color); border-radius: .35rem; background: var(--tblr-bg-surface, #fff); color: inherit; text-decoration: none; box-shadow: 0 2px 6px rgba(0,0,0,.12); cursor: grab; touch-action: none; user-select: none; pointer-events: auto; } .rack-topology-node.is-dragging { cursor: grabbing; z-index: 5; } @@ -71,6 +71,29 @@ html[data-netbox-color-mode=dark] #visgraph { .rack-topology-tooltip li + li { margin-top: .45rem; padding-top: .45rem; border-top: 1px solid var(--tblr-border-color, #ced4da); } .rack-topology-tooltip small { opacity: .65; } +.rack-frame { + border-color: #718096; + background-color: #fff; +} + +.rack-topology-rack-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: .5rem; + height: calc(var(--rack-units) * 22px); + min-height: 280px; + margin-top: 1rem; +} + +.rack-topology-lane { position: relative; border: 3px solid #718096; } +.rack-topology-lane .rack-topology-node { position: absolute; left: 3px; right: 3px; min-height: 18px; box-sizing: border-box; } +.rack-topology-external-body { display: flex; flex-direction: column; gap: .75rem; } + +html[data-bs-theme="dark"], body[data-bs-theme="dark"], html[data-netbox-color-mode="dark"] { + .rack-frame, .rack-topology-lane { border-color: #3b4654; background-color: #212b36; } + .rack-device { background: #162e45; color: #fff; } +} + @media (max-width: 700px) { .rack-elevation-grid { grid-template-columns: 1fr; } .rack-faces { grid-template-columns: 1fr; } 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 d3c2ea1..eb11fdf 100644 --- a/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html +++ b/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html @@ -144,24 +144,44 @@ const svg = root.querySelector('svg'); const tooltip = root.querySelector('.rack-topology-tooltip'); const resetButton = document.getElementById('rack-topology-reset'); - const storageKey = `netbox-rack-topology:${window.location.pathname}${window.location.search}`; + 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 groupName = node.rack_name || 'Extern'; - if (!groups.has(groupName)) groups.set(groupName, []); - groups.get(groupName).push(node); + const groupKey = node.rack_id ? `rack-${node.rack_id}` : 'external'; + if (!groups.has(groupKey)) groups.set(groupKey, { + name: node.rack_name || 'Extern', + rackUnits: node.rack_u_height || null, + nodes: [], + }); + groups.get(groupKey).nodes.push(node); }); - groups.forEach((nodes, name) => { + groups.forEach(group => { const column = document.createElement('section'); - column.className = 'rack-topology-column'; + column.className = `rack-topology-column ${group.rackUnits ? 'is-rack' : 'is-external'}`; const heading = document.createElement('h4'); - heading.textContent = name; + heading.textContent = group.name; column.appendChild(heading); - nodes.forEach(node => { + const nodeContainer = document.createElement('div'); + nodeContainer.className = group.rackUnits ? 'rack-topology-rack-body' : 'rack-topology-external-body'; + if (group.rackUnits) { + nodeContainer.style.setProperty('--rack-units', group.rackUnits); + ['front', 'rear'].forEach(face => { + const lane = document.createElement('div'); + lane.className = `rack-topology-lane is-${face}`; + lane.dataset.face = face; + const label = document.createElement('span'); + label.className = 'rack-topology-lane-label'; + label.textContent = face === 'front' ? 'Vorderseite' : 'Rückseite'; + lane.appendChild(label); + nodeContainer.appendChild(lane); + }); + } + column.appendChild(nodeContainer); + group.nodes.sort((a, b) => (b.position || 0) - (a.position || 0)).forEach(node => { const item = document.createElement(node.url ? 'a' : 'div'); item.className = 'rack-topology-node'; item.id = `topology-${node.node_id}`; @@ -171,11 +191,18 @@ const nameElement = document.createElement('strong'); nameElement.textContent = node.device_name; const portElement = document.createElement('small'); - portElement.textContent = node.name; + portElement.textContent = node.subtitle || node.name; item.append(nameElement, portElement); + if (group.rackUnits) { + item.style.bottom = `${((node.position || 1) - 1) / group.rackUnits * 100}%`; + item.style.height = `${Math.max(node.height || 1, 1) / group.rackUnits * 100}%`; + } const saved = storedPositions[node.node_id]; if (saved) item.style.transform = `translate(${saved.x}px, ${saved.y}px)`; - column.appendChild(item); + const target = group.rackUnits + ? nodeContainer.querySelector(`.rack-topology-lane.is-${node.face === 'rear' ? 'rear' : 'front'}`) + : nodeContainer; + target.appendChild(item); }); columns.appendChild(column); }); @@ -208,10 +235,10 @@ const to = document.getElementById(`topology-${edge.to}`); if (!from || !to) return; const a = from.getBoundingClientRect(), b = to.getBoundingClientRect(); - const x1 = a.left + a.width / 2 - bounds.left; - const y1 = a.top + a.height / 2 - bounds.top; - const x2 = b.left + b.width / 2 - bounds.left; - const y2 = b.top + b.height / 2 - bounds.top; + const x1 = a.left + a.width / 2 - bounds.left + root.scrollLeft; + const y1 = a.top + a.height / 2 - bounds.top + root.scrollTop; + const x2 = b.left + b.width / 2 - bounds.left + root.scrollLeft; + const y2 = b.top + b.height / 2 - bounds.top + root.scrollTop; const bend = Math.max(30, Math.abs(x2 - x1) * .35); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`); @@ -309,6 +336,19 @@ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]); const safeName = value => String(value || 'export').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, ''); + const exportPalette = () => { + const declaredTheme = document.body.dataset.bsTheme || + document.documentElement.dataset.bsTheme || + document.documentElement.dataset.netboxColorMode || ''; + const dark = declaredTheme === 'dark' || (!declaredTheme && window.matchMedia('(prefers-color-scheme: dark)').matches); + return dark ? { + frame: '#52606d', grid: '#667382', heading: '#8fc8ff', muted: '#a9b7c6', + deviceFill: '#162e45', deviceText: '#ffffff', deviceSubtext: '#cad5df', + } : { + frame: '#718096', grid: '#8b99a8', heading: '#0b69b7', muted: '#52606d', + deviceFill: '#eef6ff', deviceText: '#132238', deviceSubtext: '#52606d', + }; + }; const svgDocument = (width, height, content) => `${content}`; const drawioDocument = cells => @@ -334,6 +374,7 @@ }); const buildRackDrawio = racks => { + const palette = exportPalette(); const unit = 22, faceWidth = 250, labelWidth = 28, faceGap = 28, rackGap = 44, header = 55; const rackWidth = labelWidth + faceWidth * 2 + faceGap; let cells = '', cellId = 2; @@ -345,21 +386,21 @@ racks.forEach((rack, rackIndex) => { const baseX = 10 + rackIndex * (rackWidth + rackGap); const rackHeight = rack.u_height * unit; - vertex(rack.name, 'text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=16;fontStyle=1;fontColor=#1685fc;', baseX, 0, rackWidth, 25); - vertex(`${rack.site}${rack.location ? ` · ${rack.location}` : ''} · ${rack.u_height}U`, 'text;html=1;strokeColor=none;fillColor=none;align=left;fontSize=10;fontColor=#778899;', baseX, 24, rackWidth, 20); + vertex(rack.name, `text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=16;fontStyle=1;fontColor=${palette.heading};`, baseX, 0, rackWidth, 25); + vertex(`${rack.site}${rack.location ? ` · ${rack.location}` : ''} · ${rack.u_height}U`, `text;html=1;strokeColor=none;fillColor=none;align=left;fontSize=10;fontColor=${palette.muted};`, baseX, 24, rackWidth, 20); ['front', 'rear'].forEach((face, faceIndex) => { const x = baseX + labelWidth + faceIndex * (faceWidth + faceGap); - vertex(face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE', 'text;html=1;strokeColor=none;fillColor=none;align=center;fontSize=10;fontColor=#778899;', x, 38, faceWidth, 17); - vertex('', 'rounded=0;html=1;fillColor=none;strokeColor=#52606d;strokeWidth=4;', x, header, faceWidth, rackHeight); + vertex(face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE', `text;html=1;strokeColor=none;fillColor=none;align=center;fontSize=10;fontColor=${palette.muted};`, x, 38, faceWidth, 17); + vertex('', `rounded=0;html=1;fillColor=none;strokeColor=${palette.frame};strokeWidth=4;`, x, header, faceWidth, rackHeight); for (let u = 1; u <= rack.u_height; u++) { const y = header + rackHeight - u * unit; - if (faceIndex === 0) vertex(String(u), 'text;html=1;strokeColor=none;fillColor=none;align=right;fontSize=8;fontColor=#778899;', x - labelWidth, y, labelWidth - 4, unit); + if (faceIndex === 0) vertex(String(u), `text;html=1;strokeColor=none;fillColor=none;align=right;fontSize=8;fontColor=${palette.muted};`, x - labelWidth, y, labelWidth - 4, unit); } rack.devices.filter(device => device.face === face).forEach(device => { const y = header + rackHeight - (device.position - 1 + device.height) * unit; const h = Math.max(device.height * unit, 4); const value = h >= 31 ? `${device.name}
U${device.position} · ${device.type}` : device.name; - vertex(value, `rounded=1;html=1;whiteSpace=wrap;overflow=hidden;align=left;verticalAlign=top;spacingLeft=5;spacingTop=2;fillColor=#162e45;strokeColor=${device.color};strokeWidth=2;fontColor=#ffffff;fontSize=11;`, x + 3, y + 1, faceWidth - 6, h - 2); + vertex(value, `rounded=1;html=1;whiteSpace=wrap;overflow=hidden;align=left;verticalAlign=top;spacingLeft=5;spacingTop=2;fillColor=${palette.deviceFill};strokeColor=${device.color};strokeWidth=2;fontColor=${palette.deviceText};fontSize=11;`, x + 3, y + 1, faceWidth - 6, h - 2); }); }); }); @@ -367,6 +408,7 @@ }; const buildRackSvg = racks => { + const palette = exportPalette(); const unit = 22, faceWidth = 250, labelWidth = 28, faceGap = 28, rackGap = 44, header = 55; const rackWidth = labelWidth + faceWidth * 2 + faceGap; const maxUnits = Math.max(...racks.map(rack => rack.u_height || 1), 1); @@ -376,23 +418,23 @@ racks.forEach((rack, rackIndex) => { const baseX = 10 + rackIndex * (rackWidth + rackGap); const rackHeight = rack.u_height * unit; - content += `${xmlEscape(rack.name)}`; - content += `${xmlEscape(rack.site)}${rack.location ? ` · ${xmlEscape(rack.location)}` : ''} · ${rack.u_height}U`; + content += `${xmlEscape(rack.name)}`; + content += `${xmlEscape(rack.site)}${rack.location ? ` · ${xmlEscape(rack.location)}` : ''} · ${rack.u_height}U`; ['front', 'rear'].forEach((face, faceIndex) => { const x = baseX + labelWidth + faceIndex * (faceWidth + faceGap); - content += `${face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE'}`; - content += ``; + content += `${face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE'}`; + content += ``; for (let u = 1; u <= rack.u_height; u++) { const y = header + rackHeight - u * unit; - content += ``; - if (faceIndex === 0) content += `${u}`; + content += ``; + if (faceIndex === 0) content += `${u}`; } rack.devices.filter(device => device.face === face).forEach(device => { const y = header + rackHeight - (device.position - 1 + device.height) * unit; const h = Math.max(device.height * unit, 4); - content += ``; - if (h >= 13) content += `${xmlEscape(device.name.slice(0, 34))}`; - if (h >= 31) content += `U${device.position} · ${xmlEscape(device.type.slice(0, 34))}`; + content += ``; + if (h >= 13) content += `${xmlEscape(device.name.slice(0, 34))}`; + if (h >= 31) content += `U${device.position} · ${xmlEscape(device.type.slice(0, 34))}`; }); }); }); @@ -400,6 +442,7 @@ }; 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); @@ -407,6 +450,21 @@ const nodeCells = new Map(); let content = ''; let drawioCells = '', drawioId = 2; + root.querySelectorAll('.rack-topology-column').forEach(column => { + const heading = column.querySelector('h4'); + const headingBounds = heading.getBoundingClientRect(); + const headingX = headingBounds.left - rootBounds.left + root.scrollLeft; + const headingY = headingBounds.top - rootBounds.top + root.scrollTop; + content += `${xmlEscape(heading.textContent)}`; + drawioCells += ``; + column.querySelectorAll('.rack-topology-lane').forEach(lane => { + const bounds = lane.getBoundingClientRect(); + const x = bounds.left - rootBounds.left + root.scrollLeft; + const y = bounds.top - rootBounds.top + root.scrollTop; + content += ``; + drawioCells += ``; + }); + }); root.querySelectorAll('.rack-topology-node').forEach(node => { const bounds = node.getBoundingClientRect(); const x = bounds.left - rootBounds.left + root.scrollLeft; @@ -417,10 +475,10 @@ const color = getComputedStyle(node).getPropertyValue('--node-color').trim() || '#1685fc'; const cellId = `node-${drawioId++}`; nodeCells.set(node.dataset.nodeId, cellId); - drawioCells += ``; - content += ``; - content += `${xmlEscape(node.querySelector('strong').textContent)}`; - content += `${xmlEscape(node.querySelector('small').textContent)}`; + drawioCells += ``; + content += ``; + content += `${xmlEscape(node.querySelector('strong').textContent)}`; + content += `${xmlEscape(node.querySelector('small').textContent)}`; }); let edgeContent = ''; data.edges.forEach(edge => { @@ -483,6 +541,7 @@ })); requestAnimationFrame(drawEdges); window.addEventListener('resize', drawEdges); + root.addEventListener('scroll', drawEdges, {passive: true}); })(); {% endblock %} diff --git a/netbox_topology_views/views.py b/netbox_topology_views/views.py index 3b6f46f..383446d 100644 --- a/netbox_topology_views/views.py +++ b/netbox_topology_views/views.py @@ -927,6 +927,11 @@ def _rack_endpoint(termination): "rack_name": getattr(getattr(device, "rack", None), "name", None) or "Extern", "url": device.get_absolute_url() if device else None, "color": getattr(getattr(device, "role", None), "color", None) or "1685fc", + "position": float(device.position or 1) if device else None, + "height": max(getattr(getattr(device, "device_type", None), "u_height", 1) or 1, 1) if device else 1, + "face": str(device.face or "front").lower() if device else "front", + "rack_u_height": getattr(getattr(device, "rack", None), "u_height", None), + "subtitle": str(device.device_type) if device else getattr(termination, "name", str(termination)), } @@ -987,7 +992,21 @@ class RackElevationView(PermissionRequiredMixin, View): }) cable_rows = [] - topology_nodes = {} + topology_nodes = { + f"device-{device.pk}": { + "node_id": f"device-{device.pk}", "device_id": device.pk, + "device_name": device.name or str(device.device_type), + "name": "", "subtitle": str(device.device_type), + "rack_id": device.rack_id, "rack_name": device.rack.name, + "url": device.get_absolute_url(), + "color": str(device.role.color or "1685fc"), + "position": float(device.position or 1), + "height": max(device.device_type.u_height or 1, 1), + "face": device.elevation_face, + "rack_u_height": device.rack.u_height, + } + for rack in rack_list for device in rack.elevation_devices + } topology_edges = [] if device_ids: cable_ids = CableTermination.objects.filter( @@ -1003,7 +1022,7 @@ class RackElevationView(PermissionRequiredMixin, View): cable_rows.append({"cable": cable, "a": a, "b": b}) for endpoint in (a, b): if endpoint: - topology_nodes[endpoint["node_id"]] = endpoint + topology_nodes.setdefault(endpoint["node_id"], endpoint) if a and b: topology_edges.append({ "from": a["node_id"], "to": b["node_id"],