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 @@
-
Kabeltopologie
+
+ Kabeltopologie + +
+
@@ -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})`, 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(),