Compare commits

..
9 Commits
5 changed files with 191 additions and 52 deletions
+4 -1
View File
@@ -13,11 +13,14 @@ Rückseite und Verkabelung.
- Ein oder mehrere Racks gemeinsam anzeigen - Ein oder mehrere Racks gemeinsam anzeigen
- Geräte positionsgenau anhand ihrer Höheneinheiten darstellen - Geräte positionsgenau anhand ihrer Höheneinheiten darstellen
- Vorder- und Rückseite eines Racks getrennt betrachten - Vorder- und Rückseite eines Racks getrennt betrachten
- Auf- und absteigende HE-Nummerierung sowie individuelle Start-HE automatisch berücksichtigen
- Rackübergreifende und externe Kabelverbindungen erkennen - Rackübergreifende und externe Kabelverbindungen erkennen
- Geräte in der Kabeltopologie frei verschieben - Geräte in der Kabeltopologie frei verschieben
- Geräte in der Kabeltopologie innerhalb jedes Racks nach ihrer Rackposition sortieren - Geräte in der Kabeltopologie innerhalb jedes Racks nach ihrer Rackposition sortieren
- Kabeltopologie in den Browser-Vollbildmodus schalten - Kabeltopologie in den Browser-Vollbildmodus schalten
- WLAN-Verbindungen als violette, gestrichelte Funkstrecken darstellen - Kabeltopologie zwischen 50 und 200 Prozent zoomen und im Vollbild scrollen
- Geräte in der Kabeltopologie mit rund 5 mm Abstand darstellen
- P2P-WirelessLinks und Multi-Access-WLANs als violette, gestrichelte Funkstrecken darstellen
- Manuelle Positionen pro Rack-Auswahl im Browser speichern - Manuelle Positionen pro Rack-Auswahl im Browser speichern
- Verbindungen eines Geräts beim Hover weiß hervorheben - Verbindungen eines Geräts beim Hover weiß hervorheben
- Alle verbundenen Ports und Gegenstellen eines Geräts anzeigen - Alle verbundenen Ports und Gegenstellen eines Geräts anzeigen
@@ -37,7 +37,7 @@ html[data-netbox-color-mode="dark"] .rack-device {
.rack-topology { .rack-topology {
position: relative; position: relative;
min-height: 18rem; min-height: 18rem;
overflow-x: auto; overflow: auto;
padding: 1rem; padding: 1rem;
background: var(--tblr-bg-surface-secondary, rgba(127, 127, 127, .05)); background: var(--tblr-bg-surface-secondary, rgba(127, 127, 127, .05));
} }
@@ -66,7 +66,7 @@ html[data-netbox-color-mode="dark"] .rack-device {
width: 15rem; width: 15rem;
} }
.rack-topology-column h4 { text-align: center; font-size: .85rem; } .rack-topology-column h4 { text-align: center; font-size: .85rem; }
.rack-topology-device-list { display: flex; flex-direction: column; gap: 14px; } .rack-topology-device-list { display: flex; flex-direction: column; gap: 40px; }
.rack-topology-node { .rack-topology-node {
display: block; display: block;
padding: .55rem .75rem; padding: .55rem .75rem;
@@ -100,10 +100,10 @@ html[data-netbox-color-mode="dark"] .rack-device {
transition: opacity .12s, stroke .12s, filter .12s, stroke-width .12s; transition: opacity .12s, stroke .12s, filter .12s, stroke-width .12s;
} }
.rack-topology-edge.is-highlighted { .rack-topology-edge.is-highlighted {
stroke: #fff !important; stroke: var(--topology-highlight-color, #16a34a) !important;
stroke-width: 5; stroke-width: 5;
opacity: 1; opacity: 1;
filter: drop-shadow(0 0 5px #fff); filter: drop-shadow(0 0 5px var(--topology-highlight-color, #16a34a));
} }
.rack-topology-edge.is-dimmed { opacity: .12; } .rack-topology-edge.is-dimmed { opacity: .12; }
.rack-topology-edge.is-wireless { .rack-topology-edge.is-wireless {
@@ -115,8 +115,11 @@ html[data-netbox-color-mode="dark"] .rack-device {
border: 0; border: 0;
border-radius: 0; border-radius: 0;
background: var(--tblr-bg-surface, #fff); background: var(--tblr-bg-surface, #fff);
display: flex;
flex-direction: column;
overflow: hidden;
} }
#rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; } #rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; overflow: hidden; }
#rack-topology-card:fullscreen .rack-topology { height: calc(100vh - 5.5rem); } #rack-topology-card:fullscreen .rack-topology { height: calc(100vh - 5.5rem); }
.rack-topology-tooltip { .rack-topology-tooltip {
display: none; display: none;
@@ -143,3 +146,10 @@ html[data-netbox-color-mode="dark"] .rack-device {
border-top: 1px solid var(--tblr-border-color, #ced4da); border-top: 1px solid var(--tblr-border-color, #ced4da);
} }
.rack-topology-tooltip small { opacity: .65; } .rack-topology-tooltip small { opacity: .65; }
.rack-cable-count {
min-width: 2rem;
margin-left: .35rem;
background-color: #52606d !important;
color: #fff !important;
text-align: center;
}
@@ -51,7 +51,7 @@ html[data-netbox-color-mode=dark] #visgraph {
.rack-device:hover span { white-space: normal; } .rack-device:hover span { white-space: normal; }
.rack-device span, .rack-device small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rack-device span, .rack-device small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rack-device small { font-size: .6rem; opacity: .8; } .rack-device small { font-size: .6rem; opacity: .8; }
.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 { position: relative; min-height: 18rem; overflow: 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-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-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: 15rem; }
@@ -62,7 +62,7 @@ html[data-netbox-color-mode=dark] #visgraph {
.rack-topology-node strong, .rack-topology-node small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rack-topology-node strong, .rack-topology-node small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rack-topology-node small { opacity: .7; } .rack-topology-node small { opacity: .7; }
.rack-topology-edge { fill: none; stroke-width: 3; opacity: .8; pointer-events: stroke; cursor: pointer; transition: opacity .12s, stroke .12s, filter .12s, stroke-width .12s; } .rack-topology-edge { fill: none; stroke-width: 3; opacity: .8; pointer-events: stroke; cursor: pointer; transition: opacity .12s, stroke .12s, filter .12s, stroke-width .12s; }
.rack-topology-edge.is-highlighted { stroke: #fff !important; stroke-width: 5; opacity: 1; filter: drop-shadow(0 0 5px #fff); } .rack-topology-edge.is-highlighted { stroke: var(--topology-highlight-color, #16a34a) !important; stroke-width: 5; opacity: 1; filter: drop-shadow(0 0 5px var(--topology-highlight-color, #16a34a)); }
.rack-topology-edge.is-dimmed { opacity: .12; } .rack-topology-edge.is-dimmed { opacity: .12; }
.rack-topology-tooltip { display: none; position: absolute; z-index: 10; width: max-content; max-width: 28rem; padding: .65rem .8rem; border: 1px solid var(--tblr-border-color, #ced4da); border-radius: .35rem; background: var(--tblr-bg-surface, #fff); box-shadow: 0 4px 18px rgba(0,0,0,.25); pointer-events: none; } .rack-topology-tooltip { display: none; position: absolute; z-index: 10; width: max-content; max-width: 28rem; padding: .65rem .8rem; border: 1px solid var(--tblr-border-color, #ced4da); border-radius: .35rem; background: var(--tblr-bg-surface, #fff); box-shadow: 0 4px 18px rgba(0,0,0,.25); pointer-events: none; }
.rack-topology-tooltip.is-visible { display: block; } .rack-topology-tooltip.is-visible { display: block; }
@@ -70,16 +70,17 @@ html[data-netbox-color-mode=dark] #visgraph {
.rack-topology-tooltip ul { margin: .4rem 0 0; padding: 0; list-style: none; } .rack-topology-tooltip ul { margin: .4rem 0 0; padding: 0; list-style: none; }
.rack-topology-tooltip li + li { margin-top: .45rem; padding-top: .45rem; border-top: 1px solid var(--tblr-border-color, #ced4da); } .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-topology-tooltip small { opacity: .65; }
.rack-cable-count { min-width: 2rem; margin-left: .35rem; background-color: #52606d !important; color: #fff !important; text-align: center; }
.rack-frame { .rack-frame {
border-color: #718096; border-color: #718096;
background-color: #fff; background-color: #fff;
} }
.rack-topology-device-list { display: flex; flex-direction: column; gap: 14px; } .rack-topology-device-list { display: flex; flex-direction: column; gap: 40px; }
.rack-topology-edge.is-wireless { stroke-dasharray: 7 7; stroke-linecap: round; } .rack-topology-edge.is-wireless { stroke-dasharray: 7 7; stroke-linecap: round; }
#rack-topology-card:fullscreen { margin: 0 !important; border: 0; border-radius: 0; background: var(--tblr-bg-surface, #fff); } #rack-topology-card:fullscreen { margin: 0 !important; border: 0; border-radius: 0; background: var(--tblr-bg-surface, #fff); display: flex; flex-direction: column; overflow: hidden; }
#rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; } #rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; overflow: hidden; }
#rack-topology-card:fullscreen .rack-topology { height: calc(100vh - 5.5rem); } #rack-topology-card:fullscreen .rack-topology { height: calc(100vh - 5.5rem); }
html[data-bs-theme="dark"], body[data-bs-theme="dark"], html[data-netbox-color-mode="dark"] { html[data-bs-theme="dark"], body[data-bs-theme="dark"], html[data-netbox-color-mode="dark"] {
@@ -91,6 +91,11 @@
<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="png">PNG</button>
<button class="btn btn-sm btn-outline-primary topology-export" data-format="drawio">draw.io</button> <button class="btn btn-sm btn-outline-primary topology-export" data-format="drawio">draw.io</button>
</div> </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"> <button id="rack-topology-reset" class="btn btn-sm btn-outline-secondary" type="button">
<i class="mdi mdi-restore"></i> Positionen zurücksetzen <i class="mdi mdi-restore"></i> Positionen zurücksetzen
</button> </button>
@@ -111,7 +116,7 @@
{{ rack_export|json_script:"rack-export-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 rack-cable-count">{{ cables|length }}</span></div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <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> <thead><tr><th>Kabel</th><th>A-Seite</th><th>B-Seite</th><th>Typ</th><th>Status</th></tr></thead>
@@ -142,6 +147,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 isWirelessEdge = edge => edge.kind === 'wireless' || edge.kind === 'wireless-lan';
const rackData = JSON.parse(document.getElementById('rack-export-data').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');
@@ -149,16 +155,47 @@
const resetButton = document.getElementById('rack-topology-reset'); const resetButton = document.getElementById('rack-topology-reset');
const fullscreenButton = document.getElementById('rack-topology-fullscreen'); const fullscreenButton = document.getElementById('rack-topology-fullscreen');
const topologyCard = document.getElementById('rack-topology-card'); const topologyCard = document.getElementById('rack-topology-card');
const storageKey = `netbox-rack-topology-v2:${window.location.pathname}${window.location.search}`; 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 = {}; let storedPositions = {};
try { storedPositions = JSON.parse(localStorage.getItem(storageKey) || '{}'); } catch (_) {} try { storedPositions = JSON.parse(localStorage.getItem(storageKey) || '{}'); } catch (_) {}
const groups = new Map(); const groups = new Map();
data.nodes.forEach(node => { data.nodes.forEach(node => {
const groupKey = node.rack_id ? `rack-${node.rack_id}` : 'external'; const groupKey = node.rack_id ? `rack-${node.rack_id}` : `external-${node.rack_name || 'Extern'}`;
if (!groups.has(groupKey)) groups.set(groupKey, { if (!groups.has(groupKey)) groups.set(groupKey, {
name: node.rack_name || 'Extern', name: node.rack_name || 'Extern',
rackUnits: node.rack_u_height || null, rackUnits: node.rack_u_height || null,
descUnits: Boolean(node.rack_desc_units),
nodes: [], nodes: [],
}); });
groups.get(groupKey).nodes.push(node); groups.get(groupKey).nodes.push(node);
@@ -172,8 +209,15 @@
column.appendChild(heading); column.appendChild(heading);
const nodeContainer = document.createElement('div'); const nodeContainer = document.createElement('div');
nodeContainer.className = 'rack-topology-device-list'; 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); column.appendChild(nodeContainer);
group.nodes.sort((a, b) => (b.position || 0) - (a.position || 0)).forEach(node => { 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'); const item = document.createElement(node.url ? 'a' : 'div');
item.className = 'rack-topology-node'; item.className = 'rack-topology-node';
item.id = `topology-${node.node_id}`; item.id = `topology-${node.node_id}`;
@@ -206,11 +250,21 @@
element.textContent = value == null ? '—' : String(value); element.textContent = value == null ? '—' : String(value);
return element.innerHTML; 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 drawEdges = () => {
const bounds = root.getBoundingClientRect(); const bounds = root.getBoundingClientRect();
let width = Math.max(root.clientWidth, columns.scrollWidth + 32); let width = Math.max(root.clientWidth, columns.scrollWidth * topologyZoom + 32);
let height = Math.max(root.clientHeight, columns.scrollHeight + 32); let height = Math.max(root.clientHeight, columns.scrollHeight * topologyZoom + 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}`);
@@ -229,7 +283,7 @@
path.setAttribute('d', `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`); path.setAttribute('d', `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`);
path.setAttribute('stroke', edge.color); path.setAttribute('stroke', edge.color);
path.classList.add('rack-topology-edge'); path.classList.add('rack-topology-edge');
if (edge.kind === 'wireless') path.classList.add('is-wireless'); if (isWirelessEdge(edge)) path.classList.add('is-wireless');
path.dataset.from = edge.from; path.dataset.from = edge.from;
path.dataset.to = edge.to; path.dataset.to = edge.to;
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title'); const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
@@ -237,16 +291,16 @@
path.appendChild(title); path.appendChild(title);
if (edge.url) path.addEventListener('click', () => window.location.href = edge.url); if (edge.url) path.addEventListener('click', () => window.location.href = edge.url);
path.addEventListener('mouseenter', event => { path.addEventListener('mouseenter', event => {
path.classList.add('is-highlighted'); setEdgeHighlighted(path, true);
showTooltip( showTooltip(
`<strong>${edge.kind === 'wireless' ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</strong>` + `<strong>${isWirelessEdge(edge) ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</strong>` +
`<span>${escapeHtml(edge.from_device)} · ${escapeHtml(edge.from_port)}</span>` + `<span>${escapeHtml(edge.from_device)} · ${escapeHtml(edge.from_port)}</span>` +
`<span>↔ ${escapeHtml(edge.to_device)} · ${escapeHtml(edge.to_port)}</span>`, `<span>↔ ${escapeHtml(edge.to_device)} · ${escapeHtml(edge.to_port)}</span>`,
event.target event.target
); );
}); });
path.addEventListener('mouseleave', () => { path.addEventListener('mouseleave', () => {
path.classList.remove('is-highlighted'); setEdgeHighlighted(path, false);
hideTooltip(); hideTooltip();
}); });
svg.appendChild(path); svg.appendChild(path);
@@ -259,20 +313,24 @@
node.addEventListener('mouseenter', () => { node.addEventListener('mouseenter', () => {
const connections = connectedEdges(nodeId); const connections = connectedEdges(nodeId);
svg.querySelectorAll('.rack-topology-edge').forEach(path => { svg.querySelectorAll('.rack-topology-edge').forEach(path => {
path.classList.toggle('is-highlighted', path.dataset.from === nodeId || path.dataset.to === nodeId); const connected = path.dataset.from === nodeId || path.dataset.to === nodeId;
path.classList.toggle('is-dimmed', path.dataset.from !== nodeId && path.dataset.to !== nodeId); setEdgeHighlighted(path, connected);
path.classList.toggle('is-dimmed', !connected);
}); });
const rows = connections.map(edge => { const rows = connections.map(edge => {
const outgoing = edge.from === nodeId; const outgoing = edge.from === nodeId;
const localPort = outgoing ? edge.from_port : edge.to_port; const localPort = outgoing ? edge.from_port : edge.to_port;
const remoteDevice = outgoing ? edge.to_device : edge.from_device; const remoteDevice = outgoing ? edge.to_device : edge.from_device;
const remotePort = outgoing ? edge.to_port : edge.from_port; const remotePort = outgoing ? edge.to_port : edge.from_port;
return `<li><strong>${escapeHtml(localPort)}</strong><span>→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}</span><small>${edge.kind === 'wireless' ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</small></li>`; return `<li><strong>${escapeHtml(localPort)}</strong><span>→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}</span><small>${isWirelessEdge(edge) ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</small></li>`;
}).join(''); }).join('');
showTooltip(`<strong>Verbindungen (${connections.length})</strong><ul>${rows || '<li>Keine Verbindungen</li>'}</ul>`, node); showTooltip(`<strong>Verbindungen (${connections.length})</strong><ul>${rows || '<li>Keine Verbindungen</li>'}</ul>`, node);
}); });
node.addEventListener('mouseleave', () => { node.addEventListener('mouseleave', () => {
svg.querySelectorAll('.rack-topology-edge').forEach(path => path.classList.remove('is-highlighted', 'is-dimmed')); svg.querySelectorAll('.rack-topology-edge').forEach(path => {
setEdgeHighlighted(path, false);
path.classList.remove('is-dimmed');
});
hideTooltip(); hideTooltip();
}); });
@@ -287,8 +345,8 @@
}); });
node.addEventListener('pointermove', event => { node.addEventListener('pointermove', event => {
if (!drag) return; if (!drag) return;
const x = drag.x + event.clientX - drag.startX; const x = drag.x + (event.clientX - drag.startX) / topologyZoom;
const y = drag.y + event.clientY - drag.startY; const y = drag.y + (event.clientY - drag.startY) / topologyZoom;
drag.moved ||= Math.abs(x - drag.x) > 3 || Math.abs(y - drag.y) > 3; drag.moved ||= Math.abs(x - drag.x) > 3 || Math.abs(y - drag.y) > 3;
node.style.transform = `translate(${x}px, ${y}px)`; node.style.transform = `translate(${x}px, ${y}px)`;
storedPositions[nodeId] = {x, y}; storedPositions[nodeId] = {x, y};
@@ -328,15 +386,22 @@
: '<i class="mdi mdi-fullscreen"></i> Vollbild'; : '<i class="mdi mdi-fullscreen"></i> Vollbild';
requestAnimationFrame(drawEdges); 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 => ({ const xmlEscape = value => String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'
})[char]); })[char]);
const safeName = value => String(value || 'export').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, ''); const safeName = value => String(value || 'export').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '');
const exportPalette = () => { const exportPalette = () => {
const declaredTheme = document.body.dataset.bsTheme || const declaredTheme = activeNetBoxTheme();
document.documentElement.dataset.bsTheme ||
document.documentElement.dataset.netboxColorMode || '';
const dark = declaredTheme === 'dark' || (!declaredTheme && window.matchMedia('(prefers-color-scheme: dark)').matches); const dark = declaredTheme === 'dark' || (!declaredTheme && window.matchMedia('(prefers-color-scheme: dark)').matches);
return dark ? { return dark ? {
frame: '#52606d', grid: '#667382', heading: '#8fc8ff', muted: '#a9b7c6', frame: '#52606d', grid: '#667382', heading: '#8fc8ff', muted: '#a9b7c6',
@@ -358,11 +423,16 @@
const devices = Array.from(card.querySelectorAll('.rack-device')).map(device => { const devices = Array.from(card.querySelectorAll('.rack-device')).map(device => {
const bottom = Number.parseFloat(device.style.bottom) || 0; const bottom = Number.parseFloat(device.style.bottom) || 0;
const height = Number.parseFloat(device.style.height) || (100 / rackUnits); 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 { return {
name: device.dataset.deviceName || device.querySelector('span')?.textContent || 'Gerät', name: device.dataset.deviceName || device.querySelector('span')?.textContent || 'Gerät',
type: device.dataset.deviceType || '', type: device.dataset.deviceType || '',
position: bottom / 100 * rackUnits + 1, position: rack.desc_units
height: Math.max(height / 100 * rackUnits, 1), ? startingUnit + rackUnits - deviceHeight - bottomUnits
: startingUnit + bottomUnits,
height: deviceHeight,
face: device.dataset.face === 'rear' ? 'rear' : 'front', face: device.dataset.face === 'rear' ? 'rear' : 'front',
color: device.dataset.deviceColor || '#1685fc', color: device.dataset.deviceColor || '#1685fc',
}; };
@@ -383,18 +453,23 @@
racks.forEach((rack, rackIndex) => { racks.forEach((rack, rackIndex) => {
const baseX = 10 + rackIndex * (rackWidth + rackGap); const baseX = 10 + rackIndex * (rackWidth + rackGap);
const rackHeight = rack.u_height * unit; 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.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); 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) => { ['front', 'rear'].forEach((face, faceIndex) => {
const x = baseX + labelWidth + faceIndex * (faceWidth + faceGap); 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(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); 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++) { for (let row = 0; row < rack.u_height; row++) {
const y = header + rackHeight - u * unit; const y = header + row * 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); 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 => { rack.devices.filter(device => device.face === face).forEach(device => {
const y = header + rackHeight - (device.position - 1 + device.height) * unit; 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 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; 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); 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);
@@ -415,19 +490,24 @@
racks.forEach((rack, rackIndex) => { racks.forEach((rack, rackIndex) => {
const baseX = 10 + rackIndex * (rackWidth + rackGap); const baseX = 10 + rackIndex * (rackWidth + rackGap);
const rackHeight = rack.u_height * unit; 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="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>`; 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) => { ['front', 'rear'].forEach((face, faceIndex) => {
const x = baseX + labelWidth + faceIndex * (faceWidth + faceGap); 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 += `<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"/>`; content += `<rect x="${x}" y="${header}" width="${faceWidth}" height="${rackHeight}" fill="none" stroke="${palette.frame}" stroke-width="4"/>`;
for (let u = 1; u <= rack.u_height; u++) { for (let row = 0; row < rack.u_height; row++) {
const y = header + rackHeight - u * unit; 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"/>`; 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">${u}</text>`; 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 => { rack.devices.filter(device => device.face === face).forEach(device => {
const y = header + rackHeight - (device.position - 1 + device.height) * unit; 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 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"/>`; 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 >= 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>`;
@@ -441,8 +521,8 @@
const buildTopologySvg = () => { const buildTopologySvg = () => {
const palette = exportPalette(); const palette = exportPalette();
const rootBounds = root.getBoundingClientRect(); const rootBounds = root.getBoundingClientRect();
let width = Math.max(root.clientWidth, columns.scrollWidth + 32); let width = Math.max(root.clientWidth, columns.scrollWidth * topologyZoom + 32);
let height = Math.max(root.clientHeight, columns.scrollHeight + 32); let height = Math.max(root.clientHeight, columns.scrollHeight * topologyZoom + 32);
const positions = new Map(); const positions = new Map();
const nodeCells = new Map(); const nodeCells = new Map();
let content = ''; let content = '';
@@ -475,10 +555,10 @@
const a = positions.get(edge.from), b = positions.get(edge.to); const a = positions.get(edge.from), b = positions.get(edge.to);
if (!a || !b) return; if (!a || !b) return;
const bend = Math.max(30, Math.abs(b.x - a.x) * .35); 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"${edge.kind === 'wireless' ? ' stroke-dasharray="7 7"' : ''}/>`; 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"${isWirelessEdge(edge) ? ' stroke-dasharray="7 7"' : ''}/>`;
const sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to); const sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to);
if (sourceId && targetId) { if (sourceId && targetId) {
drawioCells += `<mxCell id="edge-${drawioId++}" value="${xmlEscape(edge.kind === 'wireless' ? `WLAN · ${edge.label}` : 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;${edge.kind === 'wireless' ? 'dashed=1;dashPattern=7 7;' : ''}" edge="1" parent="1" source="${sourceId}" target="${targetId}"><mxGeometry relative="1" as="geometry"/></mxCell>`; drawioCells += `<mxCell id="edge-${drawioId++}" value="${xmlEscape(isWirelessEdge(edge) ? `WLAN · ${edge.label}` : 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;${isWirelessEdge(edge) ? 'dashed=1;dashPattern=7 7;' : ''}" 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}; return {svg: svgDocument(width, height, edgeContent + content), drawio: drawioDocument(drawioCells), width, height};
+53 -8
View File
@@ -27,7 +27,7 @@ from django.http import HttpRequest, HttpResponseRedirect, QueryDict
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.views.generic import View from django.views.generic import View
from extras.models import Tag, SavedFilter from extras.models import Tag, SavedFilter
from wireless.models import WirelessLink from wireless.models import WirelessLAN, WirelessLink
from netbox.views.generic import ( from netbox.views.generic import (
ObjectView, ObjectView,
ObjectListView, ObjectListView,
@@ -928,9 +928,11 @@ def _rack_endpoint(termination):
"url": device.get_absolute_url() if device else None, "url": device.get_absolute_url() if device else None,
"color": getattr(getattr(device, "role", None), "color", None) or "1685fc", "color": getattr(getattr(device, "role", None), "color", None) or "1685fc",
"position": float(device.position or 1) if device else None, "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, "height": float(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", "face": str(device.face or "front").lower() if device else "front",
"rack_u_height": getattr(getattr(device, "rack", None), "u_height", None), "rack_u_height": getattr(getattr(device, "rack", None), "u_height", None),
"rack_desc_units": getattr(getattr(device, "rack", None), "desc_units", False),
"rack_starting_unit": getattr(getattr(device, "rack", None), "starting_unit", 1),
"subtitle": str(device.device_type) if device else getattr(termination, "name", str(termination)), "subtitle": str(device.device_type) if device else getattr(termination, "name", str(termination)),
} }
@@ -965,27 +967,42 @@ class RackElevationView(PermissionRequiredMixin, View):
device_ids = [] device_ids = []
for device in devices.filter(rack_id__in=rack_ids).order_by("rack_id", "position", "name"): for device in devices.filter(rack_id__in=rack_ids).order_by("rack_id", "position", "name"):
device_ids.append(device.pk) device_ids.append(device.pk)
height = max(getattr(device.device_type, "u_height", 1) or 1, 1) height = float(max(getattr(device.device_type, "u_height", 1) or 1, 1))
rack_height = max(device.rack.u_height or 1, 1) rack_height = max(device.rack.u_height or 1, 1)
device.elevation_bottom = f"{(float(device.position or 1) - 1) / rack_height * 100:.4f}" rack_start = float(device.rack.starting_unit or 1)
position_offset = float(device.position or rack_start) - rack_start
if device.rack.desc_units:
bottom_units = rack_height - position_offset - height
else:
bottom_units = position_offset
device.elevation_bottom = f"{max(bottom_units, 0) / rack_height * 100:.4f}"
device.elevation_height = f"{min(height / rack_height * 100, 100):.4f}" device.elevation_height = f"{min(height / rack_height * 100, 100):.4f}"
device.elevation_face = str(device.face or "front").lower() device.elevation_face = str(device.face or "front").lower()
rack_devices[device.rack_id].append(device) rack_devices[device.rack_id].append(device)
for rack in rack_list: for rack in rack_list:
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_start = rack.starting_unit or 1
if rack.desc_units:
rack.elevation_unit_labels = range(rack_start, rack_start + (rack.u_height or 0))
else:
rack.elevation_unit_labels = range(
rack_start + (rack.u_height or 0) - 1,
rack_start - 1,
-1,
)
rack_export = [] rack_export = []
for rack in rack_list: for rack in rack_list:
rack_export.append({ rack_export.append({
"id": rack.pk, "name": rack.name, "u_height": rack.u_height, "id": rack.pk, "name": rack.name, "u_height": rack.u_height,
"starting_unit": rack.starting_unit, "desc_units": rack.desc_units,
"site": str(rack.site), "location": str(rack.location or ""), "site": str(rack.site), "location": str(rack.location or ""),
"devices": [{ "devices": [{
"name": device.name or str(device.device_type), "name": device.name or str(device.device_type),
"type": str(device.device_type), "type": str(device.device_type),
"position": float(device.position or 1), "position": float(device.position or 1),
"height": max(device.device_type.u_height or 1, 1), "height": float(max(device.device_type.u_height or 1, 1)),
"face": device.elevation_face, "face": device.elevation_face,
"color": f"#{device.role.color}" if device.role.color else "#1685fc", "color": f"#{device.role.color}" if device.role.color else "#1685fc",
} for device in rack.elevation_devices], } for device in rack.elevation_devices],
@@ -1021,8 +1038,8 @@ class RackElevationView(PermissionRequiredMixin, View):
}) })
wireless_links = WirelessLink.objects.restrict(request.user, "view").filter( wireless_links = WirelessLink.objects.restrict(request.user, "view").filter(
Q(_interface_a_device_id__in=device_ids) | Q(interface_a__device_id__in=device_ids) |
Q(_interface_b_device_id__in=device_ids) Q(interface_b__device_id__in=device_ids)
).select_related( ).select_related(
"interface_a__device__rack", "interface_a__device__role", "interface_a__device__device_type", "interface_a__device__rack", "interface_a__device__role", "interface_a__device__device_type",
"interface_b__device__rack", "interface_b__device__role", "interface_b__device__device_type", "interface_b__device__rack", "interface_b__device__role", "interface_b__device__device_type",
@@ -1041,6 +1058,34 @@ class RackElevationView(PermissionRequiredMixin, View):
"kind": "wireless", "kind": "wireless",
}) })
wireless_lans = WirelessLAN.objects.restrict(request.user, "view").filter(
interfaces__device_id__in=device_ids
).distinct()
for wireless_lan in wireless_lans:
wlan_node_id = f"wlan-{wireless_lan.pk}"
topology_nodes[wlan_node_id] = {
"node_id": wlan_node_id, "device_id": None,
"device_name": f"WLAN · {wireless_lan.ssid}",
"name": wireless_lan.ssid, "subtitle": "Multi-Access WLAN",
"rack_id": None, "rack_name": "Funknetze",
"url": wireless_lan.get_absolute_url(), "color": "8b5cf6",
"position": None, "height": 1, "face": "front", "rack_u_height": None,
}
wlan_interfaces = Interface.objects.restrict(request.user, "view").filter(
wireless_lans=wireless_lan
).select_related("device__rack", "device__role", "device__device_type")
for interface in wlan_interfaces:
endpoint = _rack_endpoint(interface)
topology_nodes[endpoint["node_id"]] = endpoint
topology_edges.append({
"from": endpoint["node_id"], "to": wlan_node_id,
"from_device": endpoint["device_name"],
"to_device": f"WLAN · {wireless_lan.ssid}",
"from_port": endpoint["name"], "to_port": wireless_lan.ssid,
"label": wireless_lan.ssid, "color": "#8b5cf6",
"url": wireless_lan.get_absolute_url(), "kind": "wireless-lan",
})
return render(request, "netbox_topology_views/rack_elevation.html", { return render(request, "netbox_topology_views/rack_elevation.html", {
"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,