From 9b8dc17d208f157301f529cffea88877d7d05662 Mon Sep 17 00:00:00 2001 From: fixclean Date: Wed, 18 Feb 2026 11:48:30 +0100 Subject: [PATCH] Dashboard-SVG um klickbare Ports/Verbindungen und Aktions-Overlay erweitert; closes #5 --- app/modules/dashboard/list.php | 221 ++++++++++++++++++++++++++++++--- 1 file changed, 204 insertions(+), 17 deletions(-) diff --git a/app/modules/dashboard/list.php b/app/modules/dashboard/list.php index 6642d0d..1ae9296 100644 --- a/app/modules/dashboard/list.php +++ b/app/modules/dashboard/list.php @@ -63,6 +63,51 @@ $topologyPayload = array_map(static function (array $row): array { ]; }, $topologyDevices); +$devicePortCountByDevice = []; +foreach ($sql->get( + "SELECT device_id, COUNT(*) AS cnt + FROM device_ports + GROUP BY device_id", + "", + [] +) as $row) { + $deviceId = (int)($row['device_id'] ?? 0); + if ($deviceId <= 0) { + continue; + } + $devicePortCountByDevice[$deviceId] = (int)($row['cnt'] ?? 0); +} + +$devicePortPreviewByDevice = []; +foreach ($sql->get( + "SELECT id, device_id, name + FROM device_ports + ORDER BY device_id, id", + "", + [] +) as $row) { + $deviceId = (int)($row['device_id'] ?? 0); + if ($deviceId <= 0) { + continue; + } + if (!isset($devicePortPreviewByDevice[$deviceId])) { + $devicePortPreviewByDevice[$deviceId] = []; + } + if (count($devicePortPreviewByDevice[$deviceId]) >= 4) { + continue; + } + $devicePortPreviewByDevice[$deviceId][] = [ + 'id' => (int)($row['id'] ?? 0), + 'name' => (string)($row['name'] ?? '') + ]; +} + +foreach ($topologyPayload as $idx => $entry) { + $deviceId = (int)($entry['device_id'] ?? 0); + $topologyPayload[$idx]['port_count'] = (int)($devicePortCountByDevice[$deviceId] ?? 0); + $topologyPayload[$idx]['port_preview'] = $devicePortPreviewByDevice[$deviceId] ?? []; +} + $rackInfoRows = $sql->get( "SELECT r.id AS rack_id, @@ -156,7 +201,8 @@ foreach ($sql->get( $rackLinksByKey[$key] = [ 'from_rack_id' => $from, 'to_rack_id' => $to, - 'count' => 0 + 'count' => 0, + 'sample_connection_id' => (int)($row['id'] ?? 0) ]; } $rackLinksByKey[$key]['count']++; @@ -175,6 +221,7 @@ foreach ($rackLinksByKey as $entry) { 'count' => (int)$entry['count'], 'from_rack_name' => (string)($fromMeta['rack_name'] ?? ('Rack #' . $fromId)), 'to_rack_name' => (string)($toMeta['rack_name'] ?? ('Rack #' . $toId)), + 'sample_connection_id' => (int)($entry['sample_connection_id'] ?? 0), ]; } ?> @@ -214,6 +261,7 @@ foreach ($rackLinksByKey as $entry) {

+
@@ -292,6 +340,7 @@ foreach ($rackLinksByKey as $entry) { const overlayMeta = overlay ? overlay.querySelector('[data-topology-meta]') : null; const overlayRackLink = overlay ? overlay.querySelector('[data-topology-rack-link]') : null; const overlayDeviceLink = overlay ? overlay.querySelector('[data-topology-device-link]') : null; + const overlayActions = overlay ? overlay.querySelector('[data-topology-actions]') : null; const closeButton = overlay ? overlay.querySelector('[data-topology-close]') : null; const dataTag = document.getElementById('dashboard-topology-data'); @@ -313,6 +362,15 @@ foreach ($rackLinksByKey as $entry) { let view = { x: 0, y: 0, width: scene.width, height: scene.height }; let drag = null; + function clearActiveSelections() { + nodeLayer.querySelectorAll('.topology-node.active, .topology-port-node.active').forEach((node) => { + node.classList.remove('active'); + }); + connectionLayer.querySelectorAll('.topology-connection-line.active').forEach((line) => { + line.classList.remove('active'); + }); + } + function clampView() { view.width = Math.max(220, Math.min(scene.width, view.width)); view.height = Math.max(180, Math.min(scene.height, view.height)); @@ -524,7 +582,9 @@ foreach ($rackLinksByKey as $entry) { location_name: device.location_name || 'Ohne Standort', device_id: Number(device.device_id || 0), device_name: device.device_name || 'Unbenannt', - device_type_name: device.device_type_name || '' + device_type_name: device.device_type_name || '', + port_count: Number(device.port_count || 0), + port_preview: Array.isArray(device.port_preview) ? device.port_preview : [] }); }); @@ -573,25 +633,56 @@ foreach ($rackLinksByKey as $entry) { return { entries: positioned, rackCenters, width, height }; } - function showOverlay(node) { - if (!overlay || !overlayTitle || !overlayMeta || !overlayRackLink || !overlayDeviceLink) { + function showOverlay(item) { + if (!overlay || !overlayTitle || !overlayMeta || !overlayRackLink || !overlayDeviceLink || !overlayActions) { return; } - overlayTitle.textContent = node.rack_name || 'Ohne Rack'; - const typeLabel = node.device_type_name ? ` (${node.device_type_name})` : ''; - overlayMeta.textContent = `Standort: ${node.location_name} | Gebaeude: ${node.building_name} | Stockwerk: ${node.floor_name} | Geraet: ${node.device_name}${typeLabel}`; + overlayRackLink.textContent = ''; + overlayDeviceLink.textContent = ''; + overlayActions.innerHTML = ''; - if (node.rack_id > 0) { - overlayRackLink.innerHTML = `Rack bearbeiten`; + if (item.kind === 'connection') { + overlayTitle.textContent = 'Verbindung'; + overlayMeta.textContent = `${item.from_name} <-> ${item.to_name} | Anzahl: ${item.count}`; + if (item.sample_connection_id > 0) { + overlayRackLink.innerHTML = `Verbindung bearbeiten`; + overlayDeviceLink.innerHTML = `Verbindung entfernen`; + } + overlay.hidden = false; + return; + } + + if (item.kind === 'port') { + overlayTitle.textContent = `Port: ${item.port_name}`; + overlayMeta.textContent = `Geraet: ${item.device_name} | Rack: ${item.rack_name} | Stockwerk: ${item.floor_name}`; + if (item.device_id > 0) { + overlayRackLink.innerHTML = `Geraet bearbeiten`; + overlayDeviceLink.innerHTML = `Verbindung fuer Port erstellen`; + } + if (item.port_id > 0) { + overlayActions.innerHTML = `Port im Geraet aendern`; + } + overlay.hidden = false; + return; + } + + overlayTitle.textContent = item.rack_name || 'Ohne Rack'; + const typeLabel = item.device_type_name ? ` (${item.device_type_name})` : ''; + overlayMeta.textContent = `Standort: ${item.location_name} | Gebaeude: ${item.building_name} | Stockwerk: ${item.floor_name} | Geraet: ${item.device_name}${typeLabel}`; + + if (item.rack_id > 0) { + overlayRackLink.innerHTML = `Rack bearbeiten`; } else { overlayRackLink.textContent = 'Kein Rack verknuepft'; } - if (node.device_id > 0) { - overlayDeviceLink.innerHTML = `Geraet bearbeiten`; - } else { - overlayDeviceLink.textContent = ''; + if (item.device_id > 0) { + overlayDeviceLink.innerHTML = `Geraet bearbeiten`; + overlayActions.innerHTML = ` + Editieren + Entfernen + `; } overlay.hidden = false; @@ -724,12 +815,10 @@ foreach ($rackLinksByKey as $entry) { circle.appendChild(title); const activate = () => { - nodeLayer.querySelectorAll('.topology-node.active').forEach((node) => { - node.classList.remove('active'); - }); + clearActiveSelections(); circle.classList.add('active'); zoomToNode(entry.x, entry.y); - showOverlay(entry); + showOverlay({ ...entry, kind: 'device' }); }; circle.addEventListener('click', activate); @@ -741,6 +830,46 @@ foreach ($rackLinksByKey as $entry) { }); nodeLayer.appendChild(circle); + + const portPreview = Array.isArray(entry.port_preview) ? entry.port_preview : []; + portPreview.forEach((port, portIndex) => { + const portNode = svgElement('circle'); + portNode.setAttribute('cx', String(entry.x + 16 + (portIndex * 10))); + portNode.setAttribute('cy', String(entry.y - 16)); + portNode.setAttribute('r', '5'); + portNode.setAttribute('tabindex', '0'); + portNode.setAttribute('class', 'topology-port-node'); + portNode.setAttribute('data-port-id', String(Number(port.id || 0))); + portNode.setAttribute('data-device-id', String(entry.device_id)); + + const portTitle = svgElement('title'); + portTitle.textContent = `${port.name || 'Port'} (${entry.device_name})`; + portNode.appendChild(portTitle); + + const activatePort = () => { + clearActiveSelections(); + portNode.classList.add('active'); + showOverlay({ + kind: 'port', + port_id: Number(port.id || 0), + port_name: port.name || `Port ${portIndex + 1}`, + device_id: entry.device_id, + device_name: entry.device_name, + rack_name: entry.rack_name, + floor_name: entry.floor_name + }); + }; + + portNode.addEventListener('click', activatePort); + portNode.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + activatePort(); + } + }); + + nodeLayer.appendChild(portNode); + }); }); rackLinks.forEach((link) => { @@ -760,10 +889,31 @@ foreach ($rackLinksByKey as $entry) { line.setAttribute('y2', String(toPoint.y)); line.setAttribute('class', 'topology-connection-line'); line.setAttribute('stroke-width', String(Math.min(8, 1 + count))); + line.setAttribute('tabindex', '0'); const title = svgElement('title'); title.textContent = `${link.from_rack_name} <-> ${link.to_rack_name}: ${count} Verbindungen`; line.appendChild(title); + + const activateConnection = () => { + clearActiveSelections(); + line.classList.add('active'); + showOverlay({ + kind: 'connection', + from_name: link.from_rack_name || `Rack ${fromRackId}`, + to_name: link.to_rack_name || `Rack ${toRackId}`, + count, + sample_connection_id: Number(link.sample_connection_id || 0) + }); + }; + + line.addEventListener('click', activateConnection); + line.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + activateConnection(); + } + }); connectionLayer.appendChild(line); }); } @@ -776,6 +926,11 @@ foreach ($rackLinksByKey as $entry) { }, { passive: false }); svg.addEventListener('pointerdown', (event) => { + const target = event.target; + if (target instanceof Element && (target.closest('.topology-node') || target.closest('.topology-port-node') || target.closest('.topology-connection-line'))) { + drag = null; + return; + } drag = { startX: event.clientX, startY: event.clientY, @@ -827,6 +982,7 @@ foreach ($rackLinksByKey as $entry) { if (closeButton && overlay) { closeButton.addEventListener('click', () => { overlay.hidden = true; + clearActiveSelections(); }); } @@ -1041,12 +1197,22 @@ foreach ($rackLinksByKey as $entry) { stroke: #426da4; stroke-opacity: 0.55; stroke-linecap: round; + cursor: pointer; +} + +.topology-connection-line:hover, +.topology-connection-line:focus, +.topology-connection-line.active { + stroke: #d2612a; + stroke-opacity: 0.85; + outline: none; } .topology-node { fill: #1f73c9; stroke: #ffffff; stroke-width: 3; + cursor: pointer; } .topology-node:hover, @@ -1056,6 +1222,20 @@ foreach ($rackLinksByKey as $entry) { outline: none; } +.topology-port-node { + fill: #3d9640; + stroke: #ffffff; + stroke-width: 2; + cursor: pointer; +} + +.topology-port-node:hover, +.topology-port-node:focus, +.topology-port-node.active { + fill: #f18d32; + outline: none; +} + .topology-empty { margin-top: 12px; padding: 10px; @@ -1091,6 +1271,13 @@ foreach ($rackLinksByKey as $entry) { margin: 8px 0; } +.topology-overlay__actions { + margin-top: 10px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + @media (max-width: 900px) { #dashboard-topology-svg { height: 360px;