Dashboard-SVG um klickbare Ports/Verbindungen und Aktions-Overlay erweitert; closes #5
This commit is contained in:
@@ -63,6 +63,51 @@ $topologyPayload = array_map(static function (array $row): array {
|
|||||||
];
|
];
|
||||||
}, $topologyDevices);
|
}, $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(
|
$rackInfoRows = $sql->get(
|
||||||
"SELECT
|
"SELECT
|
||||||
r.id AS rack_id,
|
r.id AS rack_id,
|
||||||
@@ -156,7 +201,8 @@ foreach ($sql->get(
|
|||||||
$rackLinksByKey[$key] = [
|
$rackLinksByKey[$key] = [
|
||||||
'from_rack_id' => $from,
|
'from_rack_id' => $from,
|
||||||
'to_rack_id' => $to,
|
'to_rack_id' => $to,
|
||||||
'count' => 0
|
'count' => 0,
|
||||||
|
'sample_connection_id' => (int)($row['id'] ?? 0)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
$rackLinksByKey[$key]['count']++;
|
$rackLinksByKey[$key]['count']++;
|
||||||
@@ -175,6 +221,7 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
'count' => (int)$entry['count'],
|
'count' => (int)$entry['count'],
|
||||||
'from_rack_name' => (string)($fromMeta['rack_name'] ?? ('Rack #' . $fromId)),
|
'from_rack_name' => (string)($fromMeta['rack_name'] ?? ('Rack #' . $fromId)),
|
||||||
'to_rack_name' => (string)($toMeta['rack_name'] ?? ('Rack #' . $toId)),
|
'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) {
|
|||||||
<p data-topology-meta></p>
|
<p data-topology-meta></p>
|
||||||
<p data-topology-rack-link></p>
|
<p data-topology-rack-link></p>
|
||||||
<p data-topology-device-link></p>
|
<p data-topology-device-link></p>
|
||||||
|
<div class="topology-overlay__actions" data-topology-actions></div>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -292,6 +340,7 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
const overlayMeta = overlay ? overlay.querySelector('[data-topology-meta]') : null;
|
const overlayMeta = overlay ? overlay.querySelector('[data-topology-meta]') : null;
|
||||||
const overlayRackLink = overlay ? overlay.querySelector('[data-topology-rack-link]') : null;
|
const overlayRackLink = overlay ? overlay.querySelector('[data-topology-rack-link]') : null;
|
||||||
const overlayDeviceLink = overlay ? overlay.querySelector('[data-topology-device-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 closeButton = overlay ? overlay.querySelector('[data-topology-close]') : null;
|
||||||
|
|
||||||
const dataTag = document.getElementById('dashboard-topology-data');
|
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 view = { x: 0, y: 0, width: scene.width, height: scene.height };
|
||||||
let drag = null;
|
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() {
|
function clampView() {
|
||||||
view.width = Math.max(220, Math.min(scene.width, view.width));
|
view.width = Math.max(220, Math.min(scene.width, view.width));
|
||||||
view.height = Math.max(180, Math.min(scene.height, view.height));
|
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',
|
location_name: device.location_name || 'Ohne Standort',
|
||||||
device_id: Number(device.device_id || 0),
|
device_id: Number(device.device_id || 0),
|
||||||
device_name: device.device_name || 'Unbenannt',
|
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 };
|
return { entries: positioned, rackCenters, width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
function showOverlay(node) {
|
function showOverlay(item) {
|
||||||
if (!overlay || !overlayTitle || !overlayMeta || !overlayRackLink || !overlayDeviceLink) {
|
if (!overlay || !overlayTitle || !overlayMeta || !overlayRackLink || !overlayDeviceLink || !overlayActions) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
overlayTitle.textContent = node.rack_name || 'Ohne Rack';
|
overlayRackLink.textContent = '';
|
||||||
const typeLabel = node.device_type_name ? ` (${node.device_type_name})` : '';
|
overlayDeviceLink.textContent = '';
|
||||||
overlayMeta.textContent = `Standort: ${node.location_name} | Gebaeude: ${node.building_name} | Stockwerk: ${node.floor_name} | Geraet: ${node.device_name}${typeLabel}`;
|
overlayActions.innerHTML = '';
|
||||||
|
|
||||||
if (node.rack_id > 0) {
|
if (item.kind === 'connection') {
|
||||||
overlayRackLink.innerHTML = `<a href="?module=racks&action=edit&id=${node.rack_id}">Rack bearbeiten</a>`;
|
overlayTitle.textContent = 'Verbindung';
|
||||||
|
overlayMeta.textContent = `${item.from_name} <-> ${item.to_name} | Anzahl: ${item.count}`;
|
||||||
|
if (item.sample_connection_id > 0) {
|
||||||
|
overlayRackLink.innerHTML = `<a href="?module=connections&action=edit&id=${item.sample_connection_id}">Verbindung bearbeiten</a>`;
|
||||||
|
overlayDeviceLink.innerHTML = `<a href="?module=connections&action=delete&id=${item.sample_connection_id}" onclick="return confirm('Diese Verbindung wirklich loeschen?');">Verbindung entfernen</a>`;
|
||||||
|
}
|
||||||
|
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 = `<a href="?module=devices&action=edit&id=${item.device_id}">Geraet bearbeiten</a>`;
|
||||||
|
overlayDeviceLink.innerHTML = `<a href="?module=connections&action=edit">Verbindung fuer Port erstellen</a>`;
|
||||||
|
}
|
||||||
|
if (item.port_id > 0) {
|
||||||
|
overlayActions.innerHTML = `<a class="button button-small" href="?module=devices&action=edit&id=${item.device_id}">Port im Geraet aendern</a>`;
|
||||||
|
}
|
||||||
|
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 = `<a href="?module=racks&action=edit&id=${item.rack_id}">Rack bearbeiten</a>`;
|
||||||
} else {
|
} else {
|
||||||
overlayRackLink.textContent = 'Kein Rack verknuepft';
|
overlayRackLink.textContent = 'Kein Rack verknuepft';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.device_id > 0) {
|
if (item.device_id > 0) {
|
||||||
overlayDeviceLink.innerHTML = `<a href="?module=devices&action=edit&id=${node.device_id}">Geraet bearbeiten</a>`;
|
overlayDeviceLink.innerHTML = `<a href="?module=devices&action=edit&id=${item.device_id}">Geraet bearbeiten</a>`;
|
||||||
} else {
|
overlayActions.innerHTML = `
|
||||||
overlayDeviceLink.textContent = '';
|
<a class="button button-small" href="?module=devices&action=edit&id=${item.device_id}">Editieren</a>
|
||||||
|
<a class="button button-small button-danger" href="?module=devices&action=delete&id=${item.device_id}" onclick="return confirm('Dieses Geraet wirklich loeschen?');">Entfernen</a>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
overlay.hidden = false;
|
overlay.hidden = false;
|
||||||
@@ -724,12 +815,10 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
circle.appendChild(title);
|
circle.appendChild(title);
|
||||||
|
|
||||||
const activate = () => {
|
const activate = () => {
|
||||||
nodeLayer.querySelectorAll('.topology-node.active').forEach((node) => {
|
clearActiveSelections();
|
||||||
node.classList.remove('active');
|
|
||||||
});
|
|
||||||
circle.classList.add('active');
|
circle.classList.add('active');
|
||||||
zoomToNode(entry.x, entry.y);
|
zoomToNode(entry.x, entry.y);
|
||||||
showOverlay(entry);
|
showOverlay({ ...entry, kind: 'device' });
|
||||||
};
|
};
|
||||||
|
|
||||||
circle.addEventListener('click', activate);
|
circle.addEventListener('click', activate);
|
||||||
@@ -741,6 +830,46 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
nodeLayer.appendChild(circle);
|
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) => {
|
rackLinks.forEach((link) => {
|
||||||
@@ -760,10 +889,31 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
line.setAttribute('y2', String(toPoint.y));
|
line.setAttribute('y2', String(toPoint.y));
|
||||||
line.setAttribute('class', 'topology-connection-line');
|
line.setAttribute('class', 'topology-connection-line');
|
||||||
line.setAttribute('stroke-width', String(Math.min(8, 1 + count)));
|
line.setAttribute('stroke-width', String(Math.min(8, 1 + count)));
|
||||||
|
line.setAttribute('tabindex', '0');
|
||||||
|
|
||||||
const title = svgElement('title');
|
const title = svgElement('title');
|
||||||
title.textContent = `${link.from_rack_name} <-> ${link.to_rack_name}: ${count} Verbindungen`;
|
title.textContent = `${link.from_rack_name} <-> ${link.to_rack_name}: ${count} Verbindungen`;
|
||||||
line.appendChild(title);
|
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);
|
connectionLayer.appendChild(line);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -776,6 +926,11 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
svg.addEventListener('pointerdown', (event) => {
|
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 = {
|
drag = {
|
||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
@@ -827,6 +982,7 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
if (closeButton && overlay) {
|
if (closeButton && overlay) {
|
||||||
closeButton.addEventListener('click', () => {
|
closeButton.addEventListener('click', () => {
|
||||||
overlay.hidden = true;
|
overlay.hidden = true;
|
||||||
|
clearActiveSelections();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1041,12 +1197,22 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
stroke: #426da4;
|
stroke: #426da4;
|
||||||
stroke-opacity: 0.55;
|
stroke-opacity: 0.55;
|
||||||
stroke-linecap: round;
|
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 {
|
.topology-node {
|
||||||
fill: #1f73c9;
|
fill: #1f73c9;
|
||||||
stroke: #ffffff;
|
stroke: #ffffff;
|
||||||
stroke-width: 3;
|
stroke-width: 3;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topology-node:hover,
|
.topology-node:hover,
|
||||||
@@ -1056,6 +1222,20 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
outline: none;
|
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 {
|
.topology-empty {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -1091,6 +1271,13 @@ foreach ($rackLinksByKey as $entry) {
|
|||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topology-overlay__actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
#dashboard-topology-svg {
|
#dashboard-topology-svg {
|
||||||
height: 360px;
|
height: 360px;
|
||||||
|
|||||||
Reference in New Issue
Block a user