feat: add draggable topology with interactive cable tracing
This commit is contained in:
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user