Files
mrb-netbox-topology-views/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html
T
MrBlakeandClaude Sonnet 5 f08df4fd52 fix: detect power cables by termination type and move toggle into topology card
Power-cable detection previously relied on cable.type, which is usually
unset for power patch cables (e.g. PDU-to-PDU), so they never got the
distinct color. Power cables are now also detected by termination
component type (PowerPort/PowerOutlet/PowerFeed).

Wireless/power display is now toggled client-side via checkboxes in the
Kabeltopologie card header (before the SVG/PNG/draw.io export buttons)
instead of a server round-trip through the top filter form, so toggling
is instant and affects exports too.

Bump plugin version to 4.5.2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 10:45:27 +02:00

654 lines
36 KiB
HTML

{% extends 'generic/_base.html' %}
{% load helpers %}
{% load static %}
{% block title %}Rack-Ansicht{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static 'netbox_topology_views/css/app.css' %}">
<link rel="stylesheet" href="{% static 'netbox_topology_views/css/rack_elevation.css' %}">
{% endblock %}
{% block content %}
<div class="card mb-3">
<div class="card-header"><strong>Racks auswählen</strong></div>
<div class="card-body">
<form method="get">
<div class="row g-3">
{% for field in filter_form %}
<div class="col-md-3">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<div class="form-hint">{{ field.help_text }}</div>{% endif %}
{% for error in field.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
</div>
{% endfor %}
</div>
<div class="mt-3">
<button class="btn btn-primary" type="submit"><i class="mdi mdi-magnify"></i> Anzeigen</button>
<a class="btn btn-outline-secondary" href="{% url 'plugins:netbox_topology_views:rack_elevation' %}">Zurücksetzen</a>
</div>
</form>
</div>
</div>
{% 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">
{% for rack in racks %}
<section class="rack-card" data-rack-id="{{ rack.pk }}" data-rack-units="{{ rack.u_height }}">
<header>
<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></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>
<div class="rack-faces">
{% for face, face_label in faces %}
<div class="rack-face">
<div class="rack-face-title">{{ face_label }}</div>
<div class="rack-frame" style="--rack-units: {{ rack.u_height }}">
<div class="rack-unit-labels">
{% for unit in rack.elevation_unit_labels %}<span>{{ unit }}</span>{% endfor %}
</div>
<div class="rack-device-area">
{% for device in rack.elevation_devices %}
{% if device.elevation_face == face %}
<a class="rack-device" href="{{ device.get_absolute_url }}"
data-face="{{ face }}" data-device-name="{{ device.name|default:device.device_type.model }}"
data-device-type="{{ device.device_type }}" data-position="{{ device.position|default:1 }}"
data-device-height="{{ device.device_type.u_height|default:1 }}" data-device-color="#{{ device.role.color|default:'1685fc' }}"
style="bottom: {{ device.elevation_bottom }}%; height: {{ device.elevation_height }}%; border-color: #{{ device.role.color }}"
title="{{ device.name }} · {{ device.device_type }} · U{{ device.position }}">
<span>{{ device.name|default:device.device_type.model }}</span>
<small>U{{ device.position }} · {{ device.device_type }}</small>
</a>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
<div id="rack-topology-card" class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Kabeltopologie</strong>
<div class="d-flex gap-2 align-items-center">
<div class="d-flex gap-3 align-items-center rack-topology-kind-toggles">
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" id="rack-topology-show-wireless" checked>
<label class="form-check-label" for="rack-topology-show-wireless">Funk/WLAN</label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" id="rack-topology-show-power" checked>
<label class="form-check-label" for="rack-topology-show-power">Strom</label>
</div>
</div>
<div class="btn-group">
<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>
<div class="btn-group" aria-label="Topologie zoomen">
<button id="rack-topology-zoom-out" class="btn btn-sm btn-outline-secondary" type="button" title="Verkleinern"><i class="mdi mdi-minus"></i></button>
<button id="rack-topology-zoom-reset" class="btn btn-sm btn-outline-secondary" type="button" title="Zoom zurücksetzen"><span id="rack-topology-zoom-label">100%</span></button>
<button id="rack-topology-zoom-in" class="btn btn-sm btn-outline-secondary" type="button" title="Vergrößern"><i class="mdi mdi-plus"></i></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>
<button id="rack-topology-fullscreen" class="btn btn-sm btn-outline-secondary" type="button">
<i class="mdi mdi-fullscreen"></i> Vollbild
</button>
</div>
</div>
<div class="card-body">
<div id="rack-topology" class="rack-topology" aria-label="Topologie der ausgewählten Racks">
<svg class="rack-topology-lines" aria-hidden="true"></svg>
<div class="rack-topology-columns"></div>
<div class="rack-topology-tooltip" role="tooltip"></div>
</div>
</div>
</div>
{{ rack_topology|json_script:"rack-topology-data" }}
{{ rack_export|json_script:"rack-export-data" }}
<div class="card mt-4">
<div class="card-header"><strong>Verkabelung der angezeigten Racks</strong> <span class="badge rack-cable-count">{{ cables|length }}</span></div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead><tr><th>Kabel</th><th>A-Seite</th><th>B-Seite</th><th>Typ</th><th>Status</th></tr></thead>
<tbody>
{% for row in cables %}
<tr>
<td><a href="{{ row.cable.get_absolute_url }}">{{ row.cable.label|default:row.cable }}</a></td>
<td>{% if row.a %}<strong>{{ row.a.device_name }}</strong><br><small>{{ row.a.name }}</small>{% else %}—{% endif %}</td>
<td>{% if row.b %}<strong>{{ row.b.device_name }}</strong><br><small>{{ row.b.name }}</small>{% else %}—{% endif %}</td>
<td>{{ row.cable.type|default:'—' }}</td><td>{{ row.cable.get_status_display }}</td>
</tr>
{% empty %}<tr><td colspan="5" class="text-muted text-center py-4">Keine Verkabelung gefunden.</td></tr>{% endfor %}
</tbody>
</table>
</div>
</div>
{% elif has_selection %}
<div class="alert alert-warning">Für diese Auswahl wurden keine belegten Racks gefunden. Aktiviere „Leere Racks einschließen“, um auch leere Racks zu sehen.</div>
{% else %}
<div class="alert alert-info">Wähle ein oder mehrere Racks oder grenze die Ansicht über Standort und Location ein.</div>
{% endif %}
{% endblock %}
{% block javascript %}
<script>
(() => {
const root = document.getElementById('rack-topology');
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 isPowerEdge = edge => edge.kind === 'power';
const showWirelessCheckbox = document.getElementById('rack-topology-show-wireless');
const showPowerCheckbox = document.getElementById('rack-topology-show-power');
const isEdgeVisible = edge => {
if (isWirelessEdge(edge)) return showWirelessCheckbox ? showWirelessCheckbox.checked : true;
if (isPowerEdge(edge)) return showPowerCheckbox ? showPowerCheckbox.checked : true;
return true;
};
const applyKindVisibility = () => {
const showWireless = showWirelessCheckbox ? showWirelessCheckbox.checked : true;
root.querySelectorAll('.rack-topology-node').forEach(node => {
if (node.dataset.kind === 'wireless-lan') {
node.style.display = showWireless ? '' : 'none';
}
});
drawEdges();
};
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');
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 activeNetBoxTheme = () => {
for (const element of [topologyCard, document.body, document.documentElement]) {
const match = getComputedStyle(element).backgroundColor.match(/[\d.]+/g);
if (match && match.length >= 3 && (match.length < 4 || Number(match[3]) > 0)) {
const [red, green, blue] = match.map(Number);
return (.2126 * red + .7152 * green + .0722 * blue) < 140 ? 'dark' : 'light';
}
}
return document.body.dataset.bsTheme || document.documentElement.dataset.bsTheme || 'light';
};
const topologyHighlightColor = () => activeNetBoxTheme() === 'dark' ? '#ffffff' : '#16a34a';
const applyTopologyTheme = () => {
root.style.setProperty('--topology-highlight-color', topologyHighlightColor());
root.querySelectorAll('.rack-topology-edge.is-highlighted').forEach(path => {
path.style.setProperty('stroke', topologyHighlightColor(), 'important');
path.style.setProperty('filter', `drop-shadow(0 0 5px ${topologyHighlightColor()})`, 'important');
});
};
applyTopologyTheme();
const topologyThemeObserver = new MutationObserver(() => requestAnimationFrame(applyTopologyTheme));
topologyThemeObserver.observe(document.body, {attributes: true, attributeFilter: ['data-bs-theme']});
topologyThemeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-bs-theme', 'data-netbox-color-mode'],
});
const storageKey = `netbox-rack-topology-v3:${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-${node.rack_name || 'Extern'}`;
if (!groups.has(groupKey)) groups.set(groupKey, {
name: node.rack_name || 'Extern',
rackUnits: node.rack_u_height || null,
descUnits: Boolean(node.rack_desc_units),
nodes: [],
});
groups.get(groupKey).nodes.push(node);
});
groups.forEach(group => {
const column = document.createElement('section');
column.className = 'rack-topology-column';
const heading = document.createElement('h4');
heading.textContent = group.name;
column.appendChild(heading);
const nodeContainer = document.createElement('div');
nodeContainer.className = 'rack-topology-device-list';
nodeContainer.style.display = 'flex';
nodeContainer.style.flexDirection = 'column';
nodeContainer.style.gap = '20px';
nodeContainer.style.rowGap = '20px';
column.appendChild(nodeContainer);
group.nodes.sort((a, b) => group.descUnits
? (a.position || 0) - (b.position || 0)
: (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}`;
item.style.setProperty('--node-color', `#${node.color}`);
item.dataset.nodeId = node.node_id;
if (node.kind) item.dataset.kind = node.kind;
if (node.url) item.href = node.url;
const nameElement = document.createElement('strong');
nameElement.textContent = node.device_name;
const portElement = document.createElement('small');
portElement.textContent = node.subtitle || node.name;
item.append(nameElement, portElement);
const saved = storedPositions[node.node_id];
if (saved) item.style.transform = `translate(${saved.x}px, ${saved.y}px)`;
nodeContainer.appendChild(item);
});
columns.appendChild(column);
});
const showTooltip = (html, anchor) => {
tooltip.innerHTML = html;
tooltip.classList.add('is-visible');
const rootBounds = root.getBoundingClientRect();
const anchorBounds = anchor.getBoundingClientRect();
tooltip.style.left = `${anchorBounds.right - rootBounds.left + root.scrollLeft + 10}px`;
tooltip.style.top = `${anchorBounds.top - rootBounds.top + root.scrollTop}px`;
};
const hideTooltip = () => tooltip.classList.remove('is-visible');
const escapeHtml = value => {
const element = document.createElement('span');
element.textContent = value == null ? '—' : String(value);
return element.innerHTML;
};
const setEdgeHighlighted = (path, highlighted) => {
path.classList.toggle('is-highlighted', highlighted);
if (highlighted) {
path.style.setProperty('stroke', topologyHighlightColor(), 'important');
path.style.setProperty('filter', `drop-shadow(0 0 5px ${topologyHighlightColor()})`, 'important');
} else {
path.style.removeProperty('stroke');
path.style.removeProperty('filter');
}
};
const drawEdges = () => {
const bounds = root.getBoundingClientRect();
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}`);
svg.replaceChildren();
data.edges.forEach(edge => {
if (!isEdgeVisible(edge)) return;
const from = document.getElementById(`topology-${edge.from}`);
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 + 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}`);
path.setAttribute('stroke', edge.color);
path.classList.add('rack-topology-edge');
if (isWirelessEdge(edge)) path.classList.add('is-wireless');
if (isPowerEdge(edge)) path.classList.add('is-power');
path.dataset.from = edge.from;
path.dataset.to = edge.to;
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
title.textContent = edge.label;
path.appendChild(title);
if (edge.url) path.addEventListener('click', () => window.location.href = edge.url);
path.addEventListener('mouseenter', event => {
setEdgeHighlighted(path, true);
showTooltip(
`<strong>${isWirelessEdge(edge) ? 'WLAN · ' : isPowerEdge(edge) ? 'Strom · ' : ''}${escapeHtml(edge.label)}</strong>` +
`<span>${escapeHtml(edge.from_device)} · ${escapeHtml(edge.from_port)}</span>` +
`<span>↔ ${escapeHtml(edge.to_device)} · ${escapeHtml(edge.to_port)}</span>`,
event.target
);
});
path.addEventListener('mouseleave', () => {
setEdgeHighlighted(path, false);
hideTooltip();
});
svg.appendChild(path);
});
};
const connectedEdges = nodeId => data.edges.filter(edge => isEdgeVisible(edge) && (edge.from === nodeId || edge.to === nodeId));
root.querySelectorAll('.rack-topology-node').forEach(node => {
const nodeId = node.dataset.nodeId;
node.addEventListener('mouseenter', () => {
const connections = connectedEdges(nodeId);
svg.querySelectorAll('.rack-topology-edge').forEach(path => {
const connected = path.dataset.from === nodeId || path.dataset.to === nodeId;
setEdgeHighlighted(path, connected);
path.classList.toggle('is-dimmed', !connected);
});
const rows = connections.map(edge => {
const outgoing = edge.from === nodeId;
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 `<li><strong>${escapeHtml(localPort)}</strong><span>→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}</span><small>${isWirelessEdge(edge) ? 'WLAN · ' : isPowerEdge(edge) ? 'Strom · ' : ''}${escapeHtml(edge.label)}</small></li>`;
}).join('');
showTooltip(`<strong>Verbindungen (${connections.length})</strong><ul>${rows || '<li>Keine Verbindungen</li>'}</ul>`, node);
});
node.addEventListener('mouseleave', () => {
svg.querySelectorAll('.rack-topology-edge').forEach(path => {
setEdgeHighlighted(path, false);
path.classList.remove('is-dimmed');
});
hideTooltip();
});
let drag = null;
node.addEventListener('pointerdown', event => {
if (event.button !== 0) return;
const saved = storedPositions[nodeId] || {x: 0, y: 0};
drag = {startX: event.clientX, startY: event.clientY, x: saved.x, y: saved.y, moved: false};
node.setPointerCapture(event.pointerId);
node.classList.add('is-dragging');
event.preventDefault();
});
node.addEventListener('pointermove', event => {
if (!drag) return;
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};
drawEdges();
});
node.addEventListener('pointerup', event => {
if (!drag) return;
const moved = drag.moved;
drag = null;
node.releasePointerCapture(event.pointerId);
node.classList.remove('is-dragging');
localStorage.setItem(storageKey, JSON.stringify(storedPositions));
if (moved) node.dataset.suppressClick = 'true';
});
node.addEventListener('click', event => {
if (node.dataset.suppressClick === 'true') {
event.preventDefault();
delete node.dataset.suppressClick;
}
});
});
resetButton?.addEventListener('click', () => {
storedPositions = {};
localStorage.removeItem(storageKey);
root.querySelectorAll('.rack-topology-node').forEach(node => node.style.transform = '');
drawEdges();
});
fullscreenButton?.addEventListener('click', async () => {
if (document.fullscreenElement === topologyCard) await document.exitFullscreen();
else await topologyCard.requestFullscreen();
});
document.addEventListener('fullscreenchange', () => {
const active = document.fullscreenElement === topologyCard;
fullscreenButton.innerHTML = active
? '<i class="mdi mdi-fullscreen-exit"></i> Vollbild verlassen'
: '<i class="mdi mdi-fullscreen"></i> 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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'
})[char]);
const safeName = value => String(value || 'export').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '');
const exportPalette = () => {
const declaredTheme = activeNetBoxTheme();
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) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${content}</svg>`;
const drawioDocument = cells =>
`<mxfile host="app.diagrams.net" type="device"><diagram name="Rack export"><mxGraphModel page="0" background="none" grid="1" guides="1" connect="1"><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`;
const renderedRackData = racks => racks.map(rack => {
const card = document.querySelector(`.rack-card[data-rack-id="${rack.id}"]`);
if (!card) return rack;
const rackUnits = Number.parseFloat(card.dataset.rackUnits) || rack.u_height || 1;
const devices = Array.from(card.querySelectorAll('.rack-device')).map(device => {
const bottom = Number.parseFloat(device.style.bottom) || 0;
const height = Number.parseFloat(device.style.height) || (100 / rackUnits);
const deviceHeight = Math.max(height / 100 * rackUnits, 1);
const bottomUnits = bottom / 100 * rackUnits;
const startingUnit = Number(rack.starting_unit) || 1;
return {
name: device.dataset.deviceName || device.querySelector('span')?.textContent || 'Gerät',
type: device.dataset.deviceType || '',
position: rack.desc_units
? startingUnit + rackUnits - deviceHeight - bottomUnits
: startingUnit + bottomUnits,
height: deviceHeight,
face: device.dataset.face === 'rear' ? 'rear' : 'front',
color: device.dataset.deviceColor || '#1685fc',
};
});
return {...rack, devices};
});
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;
const vertex = (value, style, x, y, width, height) => {
const id = cellId++;
cells += `<mxCell id="${id}" value="${xmlEscape(value)}" style="${style}" vertex="1" parent="1"><mxGeometry x="${x}" y="${y}" width="${width}" height="${height}" as="geometry"/></mxCell>`;
return id;
};
racks.forEach((rack, rackIndex) => {
const baseX = 10 + rackIndex * (rackWidth + rackGap);
const rackHeight = rack.u_height * unit;
const startingUnit = Number(rack.starting_unit) || 1;
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=${palette.muted};`, x, 38, faceWidth, 17);
vertex('', `rounded=0;html=1;fillColor=none;strokeColor=${palette.frame};strokeWidth=4;`, x, header, faceWidth, rackHeight);
for (let row = 0; row < rack.u_height; row++) {
const y = header + row * unit;
const unitLabel = rack.desc_units ? startingUnit + row : startingUnit + rack.u_height - 1 - row;
if (faceIndex === 0) vertex(String(unitLabel), `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 positionOffset = device.position - startingUnit;
const y = rack.desc_units
? header + positionOffset * unit
: header + rackHeight - (positionOffset + device.height) * unit;
const h = Math.max(device.height * unit, 4);
const value = h >= 31 ? `${device.name}<br><font style="font-size:9px">U${device.position} · ${device.type}</font>` : device.name;
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);
});
});
});
return drawioDocument(cells);
};
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);
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;
const startingUnit = Number(rack.starting_unit) || 1;
content += `<text x="${baseX}" y="20" fill="${palette.heading}" font-family="sans-serif" font-size="16" font-weight="700">${xmlEscape(rack.name)}</text>`;
content += `<text x="${baseX}" y="39" fill="${palette.muted}" 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="${palette.muted}" 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="${palette.frame}" stroke-width="4"/>`;
for (let row = 0; row < rack.u_height; row++) {
const y = header + row * unit;
const unitLabel = rack.desc_units ? startingUnit + row : startingUnit + rack.u_height - 1 - row;
content += `<line x1="${x}" y1="${y}" x2="${x + faceWidth}" y2="${y}" stroke="${palette.grid}" stroke-opacity=".35"/>`;
if (faceIndex === 0) content += `<text x="${x - 6}" y="${y + 15}" text-anchor="end" fill="${palette.muted}" font-family="sans-serif" font-size="9">${unitLabel}</text>`;
}
rack.devices.filter(device => device.face === face).forEach(device => {
const positionOffset = device.position - startingUnit;
const y = rack.desc_units
? header + positionOffset * unit
: header + rackHeight - (positionOffset + 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="${palette.deviceFill}" stroke="${xmlEscape(device.color)}" stroke-width="2"/>`;
if (h >= 13) content += `<text x="${x + 9}" y="${y + Math.min(15, h - 4)}" fill="${palette.deviceText}" 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="${palette.deviceSubtext}" font-family="sans-serif" font-size="9">U${device.position} · ${xmlEscape(device.type.slice(0, 34))}</text>`;
});
});
});
return {svg: svgDocument(width, height, content), drawio: buildRackDrawio(racks), width, height};
};
const buildTopologySvg = () => {
const palette = exportPalette();
const rootBounds = root.getBoundingClientRect();
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 = '';
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 += `<text x="${headingX + headingBounds.width / 2}" y="${headingY + 15}" text-anchor="middle" fill="${palette.heading}" font-family="sans-serif" font-size="13" font-weight="600">${xmlEscape(heading.textContent)}</text>`;
drawioCells += `<mxCell id="group-${drawioId++}" value="${xmlEscape(heading.textContent)}" style="text;html=1;strokeColor=none;fillColor=none;align=center;fontStyle=1;fontColor=${palette.heading};" vertex="1" parent="1"><mxGeometry x="${headingX}" y="${headingY}" width="${headingBounds.width}" height="22" as="geometry"/></mxCell>`;
});
root.querySelectorAll('.rack-topology-node').forEach(node => {
if (node.style.display === 'none') return;
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';
const cellId = `node-${drawioId++}`;
nodeCells.set(node.dataset.nodeId, cellId);
drawioCells += `<mxCell id="${cellId}" value="${xmlEscape(node.querySelector('strong').textContent)}&lt;br&gt;&lt;font style=&quot;font-size:9px&quot;&gt;${xmlEscape(node.querySelector('small').textContent)}&lt;/font&gt;" style="rounded=1;html=1;whiteSpace=wrap;align=left;verticalAlign=middle;spacingLeft=8;fillColor=${palette.deviceFill};strokeColor=${xmlEscape(color)};strokeWidth=2;fontColor=${palette.deviceText};fontSize=12;" vertex="1" parent="1"><mxGeometry x="${x}" y="${y}" width="${bounds.width}" height="${bounds.height}" as="geometry"/></mxCell>`;
content += `<rect x="${x}" y="${y}" width="${bounds.width}" height="${bounds.height}" rx="6" fill="${palette.deviceFill}" stroke="${xmlEscape(color)}" stroke-width="2"/>`;
content += `<text x="${x + 10}" y="${y + 20}" fill="${palette.deviceText}" 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="${palette.deviceSubtext}" font-family="sans-serif" font-size="9">${xmlEscape(node.querySelector('small').textContent)}</text>`;
});
let edgeContent = '';
data.edges.forEach(edge => {
if (!isEdgeVisible(edge)) return;
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);
const dashPattern = isWirelessEdge(edge) ? '7 7' : isPowerEdge(edge) ? '2 4' : null;
const edgeLabelPrefix = isWirelessEdge(edge) ? 'WLAN · ' : isPowerEdge(edge) ? 'Strom · ' : '';
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"${dashPattern ? ` stroke-dasharray="${dashPattern}"` : ''}/>`;
const sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to);
if (sourceId && targetId) {
drawioCells += `<mxCell id="edge-${drawioId++}" value="${xmlEscape(`${edgeLabelPrefix}${edge.label}`)}" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=${xmlEscape(edge.color)};strokeWidth=2;fontSize=9;${dashPattern ? `dashed=1;dashPattern=${dashPattern};` : ''}" edge="1" parent="1" source="${sourceId}" target="${targetId}"><mxGeometry relative="1" as="geometry"/></mxCell>`;
}
});
return {svg: svgDocument(width, height, edgeContent + content), drawio: drawioDocument(drawioCells), 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, drawio, width, height}) => {
if (drawio) return drawio;
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 selectedRacks = button.dataset.scope === 'all' ? rackData : rackData.filter(rack => String(rack.id) === button.dataset.scope);
const racks = renderedRackData(selectedRacks);
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');
}));
showWirelessCheckbox?.addEventListener('change', applyKindVisibility);
showPowerCheckbox?.addEventListener('change', applyKindVisibility);
requestAnimationFrame(applyKindVisibility);
window.addEventListener('resize', drawEdges);
root.addEventListener('scroll', drawEdges, {passive: true});
})();
</script>
{% endblock %}