feat: add draggable topology with interactive cable tracing

This commit is contained in:
2026-07-22 09:45:56 +02:00
parent d8069edd73
commit cbdf217f18
4 changed files with 163 additions and 4 deletions
@@ -36,6 +36,7 @@
justify-content: space-around; justify-content: space-around;
gap: 5rem; gap: 5rem;
min-width: max-content; min-width: max-content;
pointer-events: none;
} }
.rack-topology-column { .rack-topology-column {
display: flex; display: flex;
@@ -53,7 +54,12 @@
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, .12); box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
cursor: grab;
touch-action: none;
user-select: none;
pointer-events: auto;
} }
.rack-topology-node.is-dragging { cursor: grabbing; z-index: 5; }
.rack-topology-node:hover { color: inherit; filter: brightness(.95); } .rack-topology-node:hover { color: inherit; filter: brightness(.95); }
.rack-topology-node strong, .rack-topology-node strong,
.rack-topology-node small { .rack-topology-node small {
@@ -69,4 +75,37 @@
opacity: .8; opacity: .8;
pointer-events: stroke; pointer-events: stroke;
cursor: pointer; 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-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.is-visible { display: block; }
.rack-topology-tooltip > strong,
.rack-topology-tooltip > span,
.rack-topology-tooltip li span,
.rack-topology-tooltip li small { display: block; }
.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 small { opacity: .65; }
+12 -3
View File
@@ -53,14 +53,23 @@ html[data-netbox-color-mode=dark] #visgraph {
.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-x: 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; } .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; }
.rack-topology-column h4 { text-align: center; font-size: .85rem; } .rack-topology-column h4 { text-align: center; font-size: .85rem; }
.rack-topology-node { display: block; padding: .55rem .75rem; border: 2px solid var(--node-color); border-radius: .35rem; background: var(--tblr-bg-surface, #fff); color: inherit; text-decoration: none; box-shadow: 0 2px 6px rgba(0,0,0,.12); } .rack-topology-node { display: block; padding: .55rem .75rem; border: 2px solid var(--node-color); border-radius: .35rem; background: var(--tblr-bg-surface, #fff); color: inherit; text-decoration: none; box-shadow: 0 2px 6px rgba(0,0,0,.12); cursor: grab; touch-action: none; user-select: none; pointer-events: auto; }
.rack-topology-node.is-dragging { cursor: grabbing; z-index: 5; }
.rack-topology-node:hover { color: inherit; filter: brightness(.95); } .rack-topology-node:hover { color: inherit; filter: brightness(.95); }
.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; } .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-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.is-visible { display: block; }
.rack-topology-tooltip > strong, .rack-topology-tooltip > span, .rack-topology-tooltip li span, .rack-topology-tooltip li small { display: block; }
.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 small { opacity: .65; }
@media (max-width: 700px) { @media (max-width: 700px) {
.rack-elevation-grid { grid-template-columns: 1fr; } .rack-elevation-grid { grid-template-columns: 1fr; }
@@ -69,11 +69,17 @@
</div> </div>
<div class="card mt-4"> <div class="card mt-4">
<div class="card-header"><strong>Kabeltopologie</strong></div> <div class="card-header d-flex justify-content-between align-items-center">
<strong>Kabeltopologie</strong>
<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 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">
<svg class="rack-topology-lines" aria-hidden="true"></svg> <svg class="rack-topology-lines" aria-hidden="true"></svg>
<div class="rack-topology-columns"></div> <div class="rack-topology-columns"></div>
<div class="rack-topology-tooltip" role="tooltip"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -113,6 +119,11 @@
const data = JSON.parse(source.textContent); const data = JSON.parse(source.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 resetButton = document.getElementById('rack-topology-reset');
const storageKey = `netbox-rack-topology:${window.location.pathname}${window.location.search}`;
let storedPositions = {};
try { storedPositions = JSON.parse(localStorage.getItem(storageKey) || '{}'); } catch (_) {}
const groups = new Map(); const groups = new Map();
data.nodes.forEach(node => { data.nodes.forEach(node => {
@@ -132,17 +143,35 @@
item.className = 'rack-topology-node'; item.className = 'rack-topology-node';
item.id = `topology-${node.node_id}`; item.id = `topology-${node.node_id}`;
item.style.setProperty('--node-color', `#${node.color}`); item.style.setProperty('--node-color', `#${node.color}`);
item.dataset.nodeId = node.node_id;
if (node.url) item.href = node.url; if (node.url) item.href = node.url;
const nameElement = document.createElement('strong'); const nameElement = document.createElement('strong');
nameElement.textContent = node.device_name; nameElement.textContent = node.device_name;
const portElement = document.createElement('small'); const portElement = document.createElement('small');
portElement.textContent = node.name; portElement.textContent = node.name;
item.append(nameElement, portElement); item.append(nameElement, portElement);
const saved = storedPositions[node.node_id];
if (saved) item.style.transform = `translate(${saved.x}px, ${saved.y}px)`;
column.appendChild(item); column.appendChild(item);
}); });
columns.appendChild(column); 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 drawEdges = () => { const drawEdges = () => {
const bounds = root.getBoundingClientRect(); const bounds = root.getBoundingClientRect();
const width = Math.max(root.clientWidth, columns.scrollWidth + 32); const width = Math.max(root.clientWidth, columns.scrollWidth + 32);
@@ -165,13 +194,93 @@
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');
path.dataset.from = edge.from;
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');
title.textContent = edge.label; title.textContent = edge.label;
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.classList.add('is-highlighted');
showTooltip(
`<strong>${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', () => {
path.classList.remove('is-highlighted');
hideTooltip();
});
svg.appendChild(path); svg.appendChild(path);
}); });
}; };
const connectedEdges = nodeId => data.edges.filter(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 => {
path.classList.toggle('is-highlighted', path.dataset.from === nodeId || path.dataset.to === nodeId);
path.classList.toggle('is-dimmed', path.dataset.from !== nodeId && path.dataset.to !== nodeId);
});
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>${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 => path.classList.remove('is-highlighted', '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;
const y = drag.y + event.clientY - drag.startY;
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();
});
requestAnimationFrame(drawEdges); requestAnimationFrame(drawEdges);
window.addEventListener('resize', drawEdges); window.addEventListener('resize', drawEdges);
})(); })();
+2
View File
@@ -992,6 +992,8 @@ class RackElevationView(PermissionRequiredMixin, View):
if a and b: if a and b:
topology_edges.append({ topology_edges.append({
"from": a["node_id"], "to": b["node_id"], "from": a["node_id"], "to": b["node_id"],
"from_device": a["device_name"], "to_device": b["device_name"],
"from_port": a["name"], "to_port": b["name"],
"label": cable.label or str(cable), "label": cable.label or str(cable),
"color": f"#{cable.color}" if cable.color else "#1685fc", "color": f"#{cable.color}" if cable.color else "#1685fc",
"url": cable.get_absolute_url(), "url": cable.get_absolute_url(),