feat: add transparent rack and topology exports

This commit is contained in:
2026-07-22 13:24:45 +02:00
parent 77b1f46ea3
commit 36343c9e23
3 changed files with 164 additions and 8 deletions
+9 -1
View File
@@ -20,6 +20,8 @@ Rückseite und Verkabelung.
- Alle verbundenen Ports und Gegenstellen eines Geräts anzeigen - Alle verbundenen Ports und Gegenstellen eines Geräts anzeigen
- Schnittstellen eines Kabels beim Hover anzeigen - Schnittstellen eines Kabels beim Hover anzeigen
- Geräte und Kabel direkt aus der Topologie in NetBox öffnen - 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 - Topologie als PNG oder diagrams.net/draw.io-XML exportieren
- Zusätzliche Icons für Network-, Leaf-, Spine- und Aggregation-Switche - Zusätzliche Icons für Network-, Leaf-, Spine- und Aggregation-Switche
- Unterstützung für helle und dunkle NetBox-Themes - 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. 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. 7. Lokal gespeicherte Gerätepositionen und eine Funktion zum Zurücksetzen.
8. Ein erweitertes, skalierbares SVG-Iconset für Switch-Rollen. 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. `Rack.units`-Property.
## Vorschau ## 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. Ports aufgelistet. Beim Hover über ein Kabel erscheinen beide Terminierungen.
Ein Klick öffnet das jeweilige NetBox-Objekt. 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 Die Positionen werden im `localStorage` des Browsers gespeichert. Sie gelten
damit nur für den jeweiligen Browser und die konkrete Rack-/Filterauswahl. damit nur für den jeweiligen Browser und die konkrete Rack-/Filterauswahl.
@@ -33,12 +33,23 @@
</div> </div>
{% if racks %} {% if racks %}
<div class="d-flex justify-content-end align-items-center gap-2 mb-3 flex-wrap">
<strong class="me-2">Alle Racks exportieren:</strong>
<button class="btn btn-sm btn-outline-primary rack-export" data-format="svg" data-scope="all">SVG</button>
<button class="btn btn-sm btn-outline-primary rack-export" data-format="png" data-scope="all">PNG</button>
<button class="btn btn-sm btn-outline-primary rack-export" data-format="drawio" data-scope="all">draw.io</button>
</div>
<div class="rack-elevation-grid"> <div class="rack-elevation-grid">
{% for rack in racks %} {% for rack in racks %}
<section class="rack-card"> <section class="rack-card">
<header> <header>
<a href="{{ rack.get_absolute_url }}"><strong>{{ rack.name }}</strong></a> <div><a href="{{ rack.get_absolute_url }}"><strong>{{ rack.name }}</strong></a><br>
<small>{{ rack.site }}{% if rack.location %} · {{ rack.location }}{% endif %} · {{ rack.u_height }}U</small> <small>{{ rack.site }}{% if rack.location %} · {{ rack.location }}{% endif %} · {{ rack.u_height }}U</small></div>
<div class="btn-group" aria-label="{{ rack.name }} exportieren">
<button class="btn btn-sm btn-outline-secondary rack-export" data-format="svg" data-scope="{{ rack.pk }}">SVG</button>
<button class="btn btn-sm btn-outline-secondary rack-export" data-format="png" data-scope="{{ rack.pk }}">PNG</button>
<button class="btn btn-sm btn-outline-secondary rack-export" data-format="drawio" data-scope="{{ rack.pk }}">draw.io</button>
</div>
</header> </header>
<div class="rack-faces"> <div class="rack-faces">
{% for face, face_label in faces %} {% for face, face_label in faces %}
@@ -71,9 +82,16 @@
<div class="card mt-4"> <div class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<strong>Kabeltopologie</strong> <strong>Kabeltopologie</strong>
<button id="rack-topology-reset" class="btn btn-sm btn-outline-secondary" type="button"> <div class="d-flex gap-2">
<i class="mdi mdi-restore"></i> Positionen zurücksetzen <div class="btn-group">
</button> <button class="btn btn-sm btn-outline-primary topology-export" data-format="svg">SVG</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>
</div>
<button id="rack-topology-reset" class="btn btn-sm btn-outline-secondary" type="button">
<i class="mdi mdi-restore"></i> Positionen zurücksetzen
</button>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="rack-topology" class="rack-topology" aria-label="Topologie der ausgewählten Racks"> <div id="rack-topology" class="rack-topology" aria-label="Topologie der ausgewählten Racks">
@@ -84,6 +102,7 @@
</div> </div>
</div> </div>
{{ rack_topology|json_script:"rack-topology-data" }} {{ rack_topology|json_script:"rack-topology-data" }}
{{ rack_export|json_script:"rack-export-data" }}
<div class="card mt-4"> <div class="card mt-4">
<div class="card-header"><strong>Verkabelung der angezeigten Racks</strong> <span class="badge bg-secondary">{{ cables|length }}</span></div> <div class="card-header"><strong>Verkabelung der angezeigten Racks</strong> <span class="badge bg-secondary">{{ cables|length }}</span></div>
@@ -117,6 +136,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 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');
const tooltip = root.querySelector('.rack-topology-tooltip'); const tooltip = root.querySelector('.rack-topology-tooltip');
@@ -174,8 +194,8 @@
const drawEdges = () => { const drawEdges = () => {
const bounds = root.getBoundingClientRect(); const bounds = root.getBoundingClientRect();
const width = Math.max(root.clientWidth, columns.scrollWidth + 32); let width = Math.max(root.clientWidth, columns.scrollWidth + 32);
const height = Math.max(root.clientHeight, columns.scrollHeight + 32); let height = Math.max(root.clientHeight, columns.scrollHeight + 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}`);
@@ -281,6 +301,118 @@
root.querySelectorAll('.rack-topology-node').forEach(node => node.style.transform = ''); root.querySelectorAll('.rack-topology-node').forEach(node => node.style.transform = '');
drawEdges(); drawEdges();
}); });
const xmlEscape = value => String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'
})[char]);
const safeName = value => String(value || 'export').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '');
const svgDocument = (width, height, content) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${content}</svg>`;
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 += `<text x="${baseX}" y="20" fill="#1685fc" font-family="sans-serif" font-size="16" font-weight="700">${xmlEscape(rack.name)}</text>`;
content += `<text x="${baseX}" y="39" fill="#778899" font-family="sans-serif" font-size="11">${xmlEscape(rack.site)}${rack.location ? ` · ${xmlEscape(rack.location)}` : ''} · ${rack.u_height}U</text>`;
['front', 'rear'].forEach((face, faceIndex) => {
const x = baseX + labelWidth + faceIndex * (faceWidth + faceGap);
content += `<text x="${x + faceWidth / 2}" y="51" text-anchor="middle" fill="#778899" font-family="sans-serif" font-size="10">${face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE'}</text>`;
content += `<rect x="${x}" y="${header}" width="${faceWidth}" height="${rackHeight}" fill="none" stroke="#52606d" stroke-width="4"/>`;
for (let u = 1; u <= rack.u_height; u++) {
const y = header + rackHeight - u * unit;
content += `<line x1="${x}" y1="${y}" x2="${x + faceWidth}" y2="${y}" stroke="#667382" stroke-opacity=".35"/>`;
if (faceIndex === 0) content += `<text x="${x - 6}" y="${y + 15}" text-anchor="end" fill="#778899" font-family="sans-serif" font-size="9">${u}</text>`;
}
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 += `<rect x="${x + 3}" y="${y + 1}" width="${faceWidth - 6}" height="${h - 2}" rx="3" fill="#162e45" stroke="${xmlEscape(device.color)}" stroke-width="2"/>`;
if (h >= 13) content += `<text x="${x + 9}" y="${y + Math.min(15, h - 4)}" fill="#fff" font-family="sans-serif" font-size="11" font-weight="600">${xmlEscape(device.name.slice(0, 34))}</text>`;
if (h >= 31) content += `<text x="${x + 9}" y="${y + 28}" fill="#cad5df" font-family="sans-serif" font-size="9">U${device.position} · ${xmlEscape(device.type.slice(0, 34))}</text>`;
});
});
});
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 += `<rect x="${x}" y="${y}" width="${bounds.width}" height="${bounds.height}" rx="6" fill="#162e45" stroke="${xmlEscape(color)}" stroke-width="2"/>`;
content += `<text x="${x + 10}" y="${y + 20}" fill="#fff" font-family="sans-serif" font-size="12" font-weight="600">${xmlEscape(node.querySelector('strong').textContent)}</text>`;
content += `<text x="${x + 10}" y="${y + 37}" fill="#cad5df" font-family="sans-serif" font-size="9">${xmlEscape(node.querySelector('small').textContent)}</text>`;
});
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 += `<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"/>`;
});
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 `<mxfile host="app.diagrams.net" type="device"><diagram name="Rack export"><mxGraphModel page="0" background="none"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="" style="shape=image;imageAspect=0;aspect=fixed;image=${xmlEscape(image)};" vertex="1" parent="1"><mxGeometry x="0" y="0" width="${width}" height="${height}" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`;
};
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); requestAnimationFrame(drawEdges);
window.addEventListener('resize', drawEdges); window.addEventListener('resize', drawEdges);
})(); })();
+16
View File
@@ -971,6 +971,21 @@ class RackElevationView(PermissionRequiredMixin, View):
rack.elevation_devices = rack_devices[rack.pk] rack.elevation_devices = rack_devices[rack.pk]
rack.elevation_unit_labels = range(rack.u_height or 0, 0, -1) 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 = [] cable_rows = []
topology_nodes = {} topology_nodes = {}
topology_edges = [] topology_edges = []
@@ -1003,6 +1018,7 @@ class RackElevationView(PermissionRequiredMixin, View):
"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,
"rack_topology": {"nodes": list(topology_nodes.values()), "edges": topology_edges}, "rack_topology": {"nodes": list(topology_nodes.values()), "edges": topology_edges},
"rack_export": rack_export,
"faces": (("front", "Vorderseite"), ("rear", "Rückseite")), "faces": (("front", "Vorderseite"), ("rear", "Rückseite")),
"has_selection": bool(request.GET), "has_selection": bool(request.GET),
}) })