diff --git a/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css b/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css
index 84d9353..19536c6 100644
--- a/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css
+++ b/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css
@@ -36,6 +36,7 @@
justify-content: space-around;
gap: 5rem;
min-width: max-content;
+ pointer-events: none;
}
.rack-topology-column {
display: flex;
@@ -53,7 +54,12 @@
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 strong,
.rack-topology-node small {
@@ -69,4 +75,37 @@
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; }
diff --git a/netbox_topology_views/static_dev/css/app.scss b/netbox_topology_views/static_dev/css/app.scss
index e01b6ce..bcbd636 100644
--- a/netbox_topology_views/static_dev/css/app.scss
+++ b/netbox_topology_views/static_dev/css/app.scss
@@ -53,14 +53,23 @@ html[data-netbox-color-mode=dark] #visgraph {
.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-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 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 strong, .rack-topology-node small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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) {
.rack-elevation-grid { grid-template-columns: 1fr; }
diff --git a/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html b/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html
index 32b1ad0..30a5589 100644
--- a/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html
+++ b/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html
@@ -69,11 +69,17 @@
@@ -113,6 +119,11 @@
const data = JSON.parse(source.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 storageKey = `netbox-rack-topology:${window.location.pathname}${window.location.search}`;
+ let storedPositions = {};
+ try { storedPositions = JSON.parse(localStorage.getItem(storageKey) || '{}'); } catch (_) {}
const groups = new Map();
data.nodes.forEach(node => {
@@ -132,17 +143,35 @@
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.url) item.href = node.url;
const nameElement = document.createElement('strong');
nameElement.textContent = node.device_name;
const portElement = document.createElement('small');
portElement.textContent = node.name;
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);
});
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 bounds = root.getBoundingClientRect();
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('stroke', edge.color);
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');
title.textContent = edge.label;
path.appendChild(title);
if (edge.url) path.addEventListener('click', () => window.location.href = edge.url);
+ path.addEventListener('mouseenter', event => {
+ path.classList.add('is-highlighted');
+ showTooltip(
+ `${escapeHtml(edge.label)}` +
+ `${escapeHtml(edge.from_device)} · ${escapeHtml(edge.from_port)}` +
+ `↔ ${escapeHtml(edge.to_device)} · ${escapeHtml(edge.to_port)}`,
+ event.target
+ );
+ });
+ path.addEventListener('mouseleave', () => {
+ path.classList.remove('is-highlighted');
+ hideTooltip();
+ });
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 `${escapeHtml(localPort)}→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}${escapeHtml(edge.label)}`;
+ }).join('');
+ showTooltip(`Verbindungen (${connections.length})${rows || '- Keine Verbindungen
'}
`, 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);
window.addEventListener('resize', drawEdges);
})();
diff --git a/netbox_topology_views/views.py b/netbox_topology_views/views.py
index 8b9aa6e..e842d9c 100644
--- a/netbox_topology_views/views.py
+++ b/netbox_topology_views/views.py
@@ -992,6 +992,8 @@ class RackElevationView(PermissionRequiredMixin, View):
if a and b:
topology_edges.append({
"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),
"color": f"#{cable.color}" if cable.color else "#1685fc",
"url": cable.get_absolute_url(),