From 36343c9e238be26430cbbba22884b6c6cfde6a5e Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 22 Jul 2026 13:24:45 +0200 Subject: [PATCH] feat: add transparent rack and topology exports --- README.md | 10 +- .../netbox_topology_views/rack_elevation.html | 146 +++++++++++++++++- netbox_topology_views/views.py | 16 ++ 3 files changed, 164 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 175c910..d0502a7 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Rückseite und Verkabelung. - Alle verbundenen Ports und Gegenstellen eines Geräts anzeigen - Schnittstellen eines Kabels beim Hover anzeigen - 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 - 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 @@ -36,7 +38,8 @@ Dieser Fork ergänzt insbesondere: 6. Hover-Hervorhebung und Port-zu-Port-Verfolgung für Geräte und Kabel. 7. Lokal gespeicherte Gerätepositionen und eine Funktion zum Zurücksetzen. 8. Ein erweitertes, skalierbares SVG-Iconset für Switch-Rollen. -9. Anpassungen für die Verwendung mit NetBox 4.6, darunter die geänderte +9. Transparente SVG-, PNG- und draw.io-Exporte für Rack- und Topologieansichten. +10. Anpassungen für die Verwendung mit NetBox 4.6, darunter die geänderte `Rack.units`-Property. ## Vorschau @@ -154,6 +157,11 @@ Hover über ein Gerät werden dessen Kabel hervorgehoben und alle verbundenen Ports aufgelistet. Beim Hover über ein Kabel erscheinen beide Terminierungen. Ein Klick öffnet das jeweilige NetBox-Objekt. +Über die Export-Schaltflächen können ein einzelnes Rack, alle aktuell +angezeigten Racks oder die interaktive Kabeltopologie als SVG, PNG oder +draw.io-Datei gespeichert werden. Sämtliche Exportformate werden ohne +Hintergrundfläche erzeugt. + Die Positionen werden im `localStorage` des Browsers gespeichert. Sie gelten damit nur für den jeweiligen Browser und die konkrete Rack-/Filterauswahl. 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 30a5589..3be3f00 100644 --- a/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html +++ b/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html @@ -33,12 +33,23 @@ {% if racks %} +
+ Alle Racks exportieren: + + + +
{% for rack in racks %}
- {{ rack.name }} - {{ rack.site }}{% if rack.location %} · {{ rack.location }}{% endif %} · {{ rack.u_height }}U +
{{ rack.name }}
+ {{ rack.site }}{% if rack.location %} · {{ rack.location }}{% endif %} · {{ rack.u_height }}U
+
+ + + +
{% for face, face_label in faces %} @@ -71,9 +82,16 @@
Kabeltopologie - +
+
+ + + +
+ +
@@ -84,6 +102,7 @@
{{ rack_topology|json_script:"rack-topology-data" }} +{{ rack_export|json_script:"rack-export-data" }}
Verkabelung der angezeigten Racks {{ cables|length }}
@@ -117,6 +136,7 @@ const source = document.getElementById('rack-topology-data'); if (!root || !source) return; const data = JSON.parse(source.textContent); + const rackData = JSON.parse(document.getElementById('rack-export-data').textContent); const columns = root.querySelector('.rack-topology-columns'); const svg = root.querySelector('svg'); const tooltip = root.querySelector('.rack-topology-tooltip'); @@ -174,8 +194,8 @@ const drawEdges = () => { const bounds = root.getBoundingClientRect(); - const width = Math.max(root.clientWidth, columns.scrollWidth + 32); - const height = Math.max(root.clientHeight, columns.scrollHeight + 32); + let width = Math.max(root.clientWidth, columns.scrollWidth + 32); + let height = Math.max(root.clientHeight, columns.scrollHeight + 32); svg.style.width = `${width}px`; svg.style.height = `${height}px`; svg.setAttribute('viewBox', `0 0 ${width} ${height}`); @@ -281,6 +301,118 @@ root.querySelectorAll('.rack-topology-node').forEach(node => node.style.transform = ''); drawEdges(); }); + + const xmlEscape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + })[char]); + const safeName = value => String(value || 'export').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, ''); + const svgDocument = (width, height, content) => + `${content}`; + + const buildRackSvg = racks => { + 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); + const width = racks.length * rackWidth + Math.max(0, racks.length - 1) * rackGap + 20; + const height = header + maxUnits * unit + 25; + let content = ''; + 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`; + ['front', 'rear'].forEach((face, faceIndex) => { + const x = baseX + labelWidth + faceIndex * (faceWidth + faceGap); + 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}`; + } + 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))}`; + }); + }); + }); + return {svg: svgDocument(width, height, content), width, height}; + }; + + const buildTopologySvg = () => { + const rootBounds = root.getBoundingClientRect(); + let width = Math.max(root.clientWidth, columns.scrollWidth + 32); + let height = Math.max(root.clientHeight, columns.scrollHeight + 32); + const positions = new Map(); + let content = ''; + root.querySelectorAll('.rack-topology-node').forEach(node => { + const bounds = node.getBoundingClientRect(); + const x = bounds.left - rootBounds.left + root.scrollLeft; + const y = bounds.top - rootBounds.top + root.scrollTop; + width = Math.max(width, x + bounds.width + 20); + height = Math.max(height, y + bounds.height + 20); + positions.set(node.dataset.nodeId, {x: x + bounds.width / 2, y: y + bounds.height / 2}); + const color = getComputedStyle(node).getPropertyValue('--node-color').trim() || '#1685fc'; + content += ``; + content += `${xmlEscape(node.querySelector('strong').textContent)}`; + content += `${xmlEscape(node.querySelector('small').textContent)}`; + }); + let edgeContent = ''; + data.edges.forEach(edge => { + 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 += ``; + }); + return {svg: svgDocument(width, height, edgeContent + content), width, height}; + }; + + const downloadBlob = (blob, filename) => { + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + setTimeout(() => URL.revokeObjectURL(link.href), 1000); + }; + const asDrawio = ({svg, width, height}) => { + const image = `data:image/svg+xml,${encodeURIComponent(svg)}`; + return ``; + }; + const exportGraphic = (graphic, format, filename) => { + if (format === 'svg') { + downloadBlob(new Blob([graphic.svg], {type: 'image/svg+xml'}), `${filename}.svg`); + } else if (format === 'drawio') { + downloadBlob(new Blob([asDrawio(graphic)], {type: 'application/xml'}), `${filename}.drawio`); + } else { + const image = new Image(); + const url = URL.createObjectURL(new Blob([graphic.svg], {type: 'image/svg+xml'})); + image.onload = () => { + const scale = 2; + const canvas = document.createElement('canvas'); + canvas.width = graphic.width * scale; + canvas.height = graphic.height * scale; + const context = canvas.getContext('2d'); + context.clearRect(0, 0, canvas.width, canvas.height); + context.scale(scale, scale); + context.drawImage(image, 0, 0); + canvas.toBlob(blob => downloadBlob(blob, `${filename}.png`), 'image/png'); + URL.revokeObjectURL(url); + }; + image.src = url; + } + }; + + document.querySelectorAll('.rack-export').forEach(button => button.addEventListener('click', () => { + const racks = button.dataset.scope === 'all' ? rackData : rackData.filter(rack => String(rack.id) === button.dataset.scope); + const name = button.dataset.scope === 'all' ? 'racks-gesamt' : `rack-${safeName(racks[0]?.name)}`; + exportGraphic(buildRackSvg(racks), button.dataset.format, name); + })); + document.querySelectorAll('.topology-export').forEach(button => button.addEventListener('click', () => { + exportGraphic(buildTopologySvg(), button.dataset.format, 'rack-topologie'); + })); requestAnimationFrame(drawEdges); window.addEventListener('resize', drawEdges); })(); diff --git a/netbox_topology_views/views.py b/netbox_topology_views/views.py index e842d9c..3b6f46f 100644 --- a/netbox_topology_views/views.py +++ b/netbox_topology_views/views.py @@ -971,6 +971,21 @@ class RackElevationView(PermissionRequiredMixin, View): rack.elevation_devices = rack_devices[rack.pk] rack.elevation_unit_labels = range(rack.u_height or 0, 0, -1) + rack_export = [] + for rack in rack_list: + rack_export.append({ + "id": rack.pk, "name": rack.name, "u_height": rack.u_height, + "site": str(rack.site), "location": str(rack.location or ""), + "devices": [{ + "name": device.name or str(device.device_type), + "type": str(device.device_type), + "position": float(device.position or 1), + "height": max(device.device_type.u_height or 1, 1), + "face": device.elevation_face, + "color": f"#{device.role.color}" if device.role.color else "#1685fc", + } for device in rack.elevation_devices], + }) + cable_rows = [] topology_nodes = {} topology_edges = [] @@ -1003,6 +1018,7 @@ class RackElevationView(PermissionRequiredMixin, View): "filter_form": RackElevationFilterForm(request.GET or None), "racks": rack_list, "cables": cable_rows, "rack_topology": {"nodes": list(topology_nodes.values()), "edges": topology_edges}, + "rack_export": rack_export, "faces": (("front", "Vorderseite"), ("rear", "Rückseite")), "has_selection": bool(request.GET), })