diff --git a/README.md b/README.md
index d0502a7..21cbacd 100644
--- a/README.md
+++ b/README.md
@@ -160,7 +160,9 @@ 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.
+Hintergrundfläche erzeugt. Im draw.io-Export bleiben Rack-Rahmen, Geräte und
+Beschriftungen als einzelne Elemente bearbeitbar. Topologiekabel werden als
+echte Kanten exportiert und folgen den Geräten beim Verschieben in draw.io.
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 3be3f00..1592b97 100644
--- a/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html
+++ b/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html
@@ -308,6 +308,41 @@
const safeName = value => String(value || 'export').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '');
const svgDocument = (width, height, content) =>
``;
+ const drawioDocument = cells =>
+ `${cells}`;
+
+ const buildRackDrawio = racks => {
+ const unit = 22, faceWidth = 250, labelWidth = 28, faceGap = 28, rackGap = 44, header = 55;
+ const rackWidth = labelWidth + faceWidth * 2 + faceGap;
+ let cells = '', cellId = 2;
+ const vertex = (value, style, x, y, width, height) => {
+ const id = cellId++;
+ cells += ``;
+ return id;
+ };
+ 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);
+ ['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);
+ 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);
+ }
+ 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);
+ });
+ });
+ });
+ return drawioDocument(cells);
+ };
const buildRackSvg = racks => {
const unit = 22, faceWidth = 250, labelWidth = 28, faceGap = 28, rackGap = 44, header = 55;
@@ -339,7 +374,7 @@
});
});
});
- return {svg: svgDocument(width, height, content), width, height};
+ return {svg: svgDocument(width, height, content), drawio: buildRackDrawio(racks), width, height};
};
const buildTopologySvg = () => {
@@ -347,7 +382,9 @@
let width = Math.max(root.clientWidth, columns.scrollWidth + 32);
let height = Math.max(root.clientHeight, columns.scrollHeight + 32);
const positions = new Map();
+ const nodeCells = new Map();
let content = '';
+ let drawioCells = '', drawioId = 2;
root.querySelectorAll('.rack-topology-node').forEach(node => {
const bounds = node.getBoundingClientRect();
const x = bounds.left - rootBounds.left + root.scrollLeft;
@@ -356,6 +393,9 @@
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';
+ const cellId = `node-${drawioId++}`;
+ nodeCells.set(node.dataset.nodeId, cellId);
+ drawioCells += ``;
content += ``;
content += `${xmlEscape(node.querySelector('strong').textContent)}`;
content += `${xmlEscape(node.querySelector('small').textContent)}`;
@@ -366,8 +406,12 @@
if (!a || !b) return;
const bend = Math.max(30, Math.abs(b.x - a.x) * .35);
edgeContent += ``;
+ const sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to);
+ if (sourceId && targetId) {
+ drawioCells += ``;
+ }
});
- return {svg: svgDocument(width, height, edgeContent + content), width, height};
+ return {svg: svgDocument(width, height, edgeContent + content), drawio: drawioDocument(drawioCells), width, height};
};
const downloadBlob = (blob, filename) => {
@@ -377,7 +421,8 @@
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
};
- const asDrawio = ({svg, width, height}) => {
+ const asDrawio = ({svg, drawio, width, height}) => {
+ if (drawio) return drawio;
const image = `data:image/svg+xml,${encodeURIComponent(svg)}`;
return ``;
};