Dashboard-Topologie um Hierarchie und Rack-Verbindungen erweitert; closes #10

This commit is contained in:
2026-02-18 11:43:45 +01:00
parent 20638cb3a5
commit 4dc1530402

View File

@@ -31,12 +31,18 @@ $topologyDevices = $sql->get(
r.id AS rack_id, r.id AS rack_id,
r.name AS rack_name, r.name AS rack_name,
f.id AS floor_id, f.id AS floor_id,
f.name AS floor_name f.name AS floor_name,
b.id AS building_id,
b.name AS building_name,
l.id AS location_id,
l.name AS location_name
FROM devices d FROM devices d
LEFT JOIN device_types dt ON dt.id = d.device_type_id LEFT JOIN device_types dt ON dt.id = d.device_type_id
LEFT JOIN racks r ON r.id = d.rack_id LEFT JOIN racks r ON r.id = d.rack_id
LEFT JOIN floors f ON f.id = r.floor_id LEFT JOIN floors f ON f.id = r.floor_id
ORDER BY floor_name, rack_name, device_name", LEFT JOIN buildings b ON b.id = f.building_id
LEFT JOIN locations l ON l.id = b.location_id
ORDER BY location_name, building_name, floor_name, rack_name, device_name",
"", "",
[] []
); );
@@ -50,8 +56,127 @@ $topologyPayload = array_map(static function (array $row): array {
'rack_name' => (string)($row['rack_name'] ?? ''), 'rack_name' => (string)($row['rack_name'] ?? ''),
'floor_id' => (int)($row['floor_id'] ?? 0), 'floor_id' => (int)($row['floor_id'] ?? 0),
'floor_name' => (string)($row['floor_name'] ?? ''), 'floor_name' => (string)($row['floor_name'] ?? ''),
'building_id' => (int)($row['building_id'] ?? 0),
'building_name' => (string)($row['building_name'] ?? ''),
'location_id' => (int)($row['location_id'] ?? 0),
'location_name' => (string)($row['location_name'] ?? ''),
]; ];
}, $topologyDevices); }, $topologyDevices);
$rackInfoRows = $sql->get(
"SELECT
r.id AS rack_id,
r.name AS rack_name,
f.id AS floor_id,
f.name AS floor_name,
b.id AS building_id,
b.name AS building_name,
l.id AS location_id,
l.name AS location_name
FROM racks r
LEFT JOIN floors f ON f.id = r.floor_id
LEFT JOIN buildings b ON b.id = f.building_id
LEFT JOIN locations l ON l.id = b.location_id",
"",
[]
);
$rackInfoById = [];
foreach ($rackInfoRows as $row) {
$rackId = (int)($row['rack_id'] ?? 0);
if ($rackId <= 0) {
continue;
}
$rackInfoById[$rackId] = [
'rack_id' => $rackId,
'rack_name' => (string)($row['rack_name'] ?? ''),
'floor_id' => (int)($row['floor_id'] ?? 0),
'floor_name' => (string)($row['floor_name'] ?? ''),
'building_id' => (int)($row['building_id'] ?? 0),
'building_name' => (string)($row['building_name'] ?? ''),
'location_id' => (int)($row['location_id'] ?? 0),
'location_name' => (string)($row['location_name'] ?? ''),
];
}
$devicePortRacks = [];
foreach ($sql->get(
"SELECT dp.id AS port_id, d.rack_id
FROM device_ports dp
JOIN devices d ON d.id = dp.device_id
WHERE d.rack_id IS NOT NULL",
"",
[]
) as $row) {
$devicePortRacks[(int)$row['port_id']] = (int)$row['rack_id'];
}
$modulePortRacks = [];
foreach ($sql->get(
"SELECT mp.id AS port_id, d.rack_id
FROM module_ports mp
JOIN modules m ON m.id = mp.module_id
JOIN device_port_modules dpm ON dpm.module_id = m.id
JOIN device_ports dp ON dp.id = dpm.device_port_id
JOIN devices d ON d.id = dp.device_id
WHERE d.rack_id IS NOT NULL",
"",
[]
) as $row) {
$modulePortRacks[(int)$row['port_id']] = (int)$row['rack_id'];
}
$resolveRackId = static function (string $endpointType, int $endpointId) use ($devicePortRacks, $modulePortRacks): int {
if ($endpointType === 'device') {
return (int)($devicePortRacks[$endpointId] ?? 0);
}
if ($endpointType === 'module') {
return (int)($modulePortRacks[$endpointId] ?? 0);
}
return 0;
};
$rackLinksByKey = [];
foreach ($sql->get(
"SELECT id, port_a_type, port_a_id, port_b_type, port_b_id
FROM connections",
"",
[]
) as $row) {
$rackA = $resolveRackId((string)($row['port_a_type'] ?? ''), (int)($row['port_a_id'] ?? 0));
$rackB = $resolveRackId((string)($row['port_b_type'] ?? ''), (int)($row['port_b_id'] ?? 0));
if ($rackA <= 0 || $rackB <= 0 || $rackA === $rackB) {
continue;
}
$from = min($rackA, $rackB);
$to = max($rackA, $rackB);
$key = $from . ':' . $to;
if (!isset($rackLinksByKey[$key])) {
$rackLinksByKey[$key] = [
'from_rack_id' => $from,
'to_rack_id' => $to,
'count' => 0
];
}
$rackLinksByKey[$key]['count']++;
}
$rackLinkPayload = [];
foreach ($rackLinksByKey as $entry) {
$fromId = (int)$entry['from_rack_id'];
$toId = (int)$entry['to_rack_id'];
$fromMeta = $rackInfoById[$fromId] ?? ['rack_name' => 'Rack #' . $fromId];
$toMeta = $rackInfoById[$toId] ?? ['rack_name' => 'Rack #' . $toId];
$rackLinkPayload[] = [
'from_rack_id' => $fromId,
'to_rack_id' => $toId,
'count' => (int)$entry['count'],
'from_rack_name' => (string)($fromMeta['rack_name'] ?? ('Rack #' . $fromId)),
'to_rack_name' => (string)($toMeta['rack_name'] ?? ('Rack #' . $toId)),
];
}
?> ?>
<div class="dashboard"> <div class="dashboard">
@@ -70,11 +195,12 @@ $topologyPayload = array_map(static function (array $row): array {
<button type="button" class="button button-small" data-topology-zoom="reset">Reset</button> <button type="button" class="button button-small" data-topology-zoom="reset">Reset</button>
</div> </div>
</div> </div>
<p class="topology-wall__hint">Mausrad zoomt, Ziehen verschiebt. Klick auf einen Punkt zoomt auf den Rack-Kontext und oeffnet die Detailkarte.</p> <p class="topology-wall__hint">Hierarchie: Standort → Gebaeude → Stockwerk → Rack → Geraet. Linien zeigen Rack-Verbindungen (dicker = mehr Links).</p>
<svg id="dashboard-topology-svg" viewBox="0 0 2400 1400" role="img" aria-label="Topologie-Wand"> <svg id="dashboard-topology-svg" viewBox="0 0 2400 1400" role="img" aria-label="Topologie-Wand">
<rect x="0" y="0" width="2400" height="1400" class="topology-bg"></rect> <rect id="dashboard-topology-bg" x="0" y="0" width="2400" height="1400" class="topology-bg"></rect>
<g id="dashboard-topology-grid"></g> <g id="dashboard-topology-grid"></g>
<g id="dashboard-topology-connections"></g>
<g id="dashboard-topology-layer"></g> <g id="dashboard-topology-layer"></g>
</svg> </svg>
@@ -147,6 +273,7 @@ $topologyPayload = array_map(static function (array $row): array {
</div> </div>
<script id="dashboard-topology-data" type="application/json"><?php echo json_encode($topologyPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?></script> <script id="dashboard-topology-data" type="application/json"><?php echo json_encode($topologyPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?></script>
<script id="dashboard-topology-links" type="application/json"><?php echo json_encode($rackLinkPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?></script>
<script> <script>
(function () { (function () {
const root = document.getElementById('dashboard-topology-wall'); const root = document.getElementById('dashboard-topology-wall');
@@ -155,7 +282,9 @@ $topologyPayload = array_map(static function (array $row): array {
} }
const svg = document.getElementById('dashboard-topology-svg'); const svg = document.getElementById('dashboard-topology-svg');
const bgNode = document.getElementById('dashboard-topology-bg');
const gridLayer = document.getElementById('dashboard-topology-grid'); const gridLayer = document.getElementById('dashboard-topology-grid');
const connectionLayer = document.getElementById('dashboard-topology-connections');
const nodeLayer = document.getElementById('dashboard-topology-layer'); const nodeLayer = document.getElementById('dashboard-topology-layer');
const emptyNode = document.getElementById('dashboard-topology-empty'); const emptyNode = document.getElementById('dashboard-topology-empty');
const overlay = document.getElementById('dashboard-topology-overlay'); const overlay = document.getElementById('dashboard-topology-overlay');
@@ -166,12 +295,19 @@ $topologyPayload = array_map(static function (array $row): array {
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');
const linkTag = document.getElementById('dashboard-topology-links');
let nodes = []; let nodes = [];
let rackLinks = [];
try { try {
nodes = JSON.parse((dataTag && dataTag.textContent) || '[]'); nodes = JSON.parse((dataTag && dataTag.textContent) || '[]');
} catch (error) { } catch (error) {
nodes = []; nodes = [];
} }
try {
rackLinks = JSON.parse((linkTag && linkTag.textContent) || '[]');
} catch (error) {
rackLinks = [];
}
const scene = { width: 2400, height: 1400 }; const scene = { width: 2400, height: 1400 };
let view = { x: 0, y: 0, width: scene.width, height: scene.height }; let view = { x: 0, y: 0, width: scene.width, height: scene.height };
@@ -261,59 +397,121 @@ $topologyPayload = array_map(static function (array $row): array {
} }
function buildLayout(data) { function buildLayout(data) {
const floors = new Map(); const hierarchy = new Map();
data.forEach((entry) => { data.forEach((entry) => {
const locationId = Number(entry.location_id || 0);
const locationName = (entry.location_name || '').trim() || 'Ohne Standort';
const buildingId = Number(entry.building_id || 0);
const buildingName = (entry.building_name || '').trim() || 'Ohne Gebaeude';
const floorId = Number(entry.floor_id || 0);
const floorName = (entry.floor_name || '').trim() || 'Ohne Stockwerk'; const floorName = (entry.floor_name || '').trim() || 'Ohne Stockwerk';
const rackId = Number(entry.rack_id || 0); const rackId = Number(entry.rack_id || 0);
const rackName = (entry.rack_name || '').trim() || 'Ohne Rack'; const rackName = (entry.rack_name || '').trim() || 'Ohne Rack';
const locationKey = `${locationId}:${locationName}`;
const buildingKey = `${buildingId}:${buildingName}`;
const floorKey = `${floorId}:${floorName}`;
const rackKey = `${rackId}:${rackName}`; const rackKey = `${rackId}:${rackName}`;
if (!floors.has(floorName)) { if (!hierarchy.has(locationKey)) {
floors.set(floorName, new Map()); hierarchy.set(locationKey, new Map());
} }
const racks = floors.get(floorName); const buildings = hierarchy.get(locationKey);
if (!buildings.has(buildingKey)) {
buildings.set(buildingKey, new Map());
}
const floors = buildings.get(buildingKey);
if (!floors.has(floorKey)) {
floors.set(floorKey, new Map());
}
const racks = floors.get(floorKey);
if (!racks.has(rackKey)) { if (!racks.has(rackKey)) {
racks.set(rackKey, []); racks.set(rackKey, []);
} }
racks.get(rackKey).push(entry); racks.get(rackKey).push(entry);
}); });
const floorNames = Array.from(floors.keys()).sort((a, b) => a.localeCompare(b));
const positioned = []; const positioned = [];
const rackCenters = new Map();
let maxY = 1400;
let locationIndex = 0;
floorNames.forEach((floorName, floorIndex) => { const locationKeys = Array.from(hierarchy.keys()).sort((a, b) => a.localeCompare(b));
const rackMap = floors.get(floorName); locationKeys.forEach((locationKey) => {
const rackKeys = Array.from(rackMap.keys()).sort((a, b) => a.localeCompare(b)); const locationName = locationKey.split(':').slice(1).join(':');
const floorX = 140 + floorIndex * 540; const locationX = 120 + locationIndex * 760;
const buildings = hierarchy.get(locationKey);
const buildingKeys = Array.from(buildings.keys()).sort((a, b) => a.localeCompare(b));
let currentY = 110;
const locationTop = currentY - 46;
positioned.push({ type: 'floor-label', x: floorX, y: 70, label: floorName }); positioned.push({
type: 'location-label',
x: locationX,
y: 70,
label: locationName
});
rackKeys.forEach((rackKey, rackIndex) => { buildingKeys.forEach((buildingKey) => {
const devices = rackMap.get(rackKey); const buildingName = buildingKey.split(':').slice(1).join(':');
const [rackIdPart, rackNamePart] = rackKey.split(':'); const floors = buildings.get(buildingKey);
const floorKeys = Array.from(floors.keys()).sort((a, b) => a.localeCompare(b));
const buildingTop = currentY;
positioned.push({
type: 'building-label',
x: locationX + 18,
y: buildingTop + 24,
label: buildingName
});
let floorCursorY = buildingTop + 32;
floorKeys.forEach((floorKey) => {
const floorName = floorKey.split(':').slice(1).join(':');
const racks = floors.get(floorKey);
const rackKeys = Array.from(racks.keys()).sort((a, b) => a.localeCompare(b));
const floorTop = floorCursorY;
let rackCursorY = floorTop + 36;
positioned.push({
type: 'floor-label',
x: locationX + 28,
y: floorTop + 22,
label: floorName
});
rackKeys.forEach((rackKey) => {
const devices = racks.get(rackKey);
const [rackIdPart, ...rackNameParts] = rackKey.split(':');
const rackId = Number(rackIdPart || 0); const rackId = Number(rackIdPart || 0);
const rackName = rackNamePart || 'Ohne Rack'; const rackName = rackNameParts.join(':') || 'Ohne Rack';
const rowCount = Math.ceil(Math.max(1, devices.length) / 6);
const rackTop = 120 + rackIndex * 180; const rackHeight = Math.max(78, 28 + rowCount * 30);
const rowCount = Math.ceil(Math.max(1, devices.length) / 4); const rackX = locationX + 40;
const rackHeight = Math.max(90, 40 + rowCount * 45);
positioned.push({ positioned.push({
type: 'rack-box', type: 'rack-box',
x: floorX - 50, x: rackX,
y: rackTop - 30, y: rackCursorY,
width: 380, width: 640,
height: rackHeight, height: rackHeight,
label: rackName, label: rackName,
rack_id: rackId rack_id: rackId
}); });
if (rackId > 0) {
rackCenters.set(rackId, {
x: rackX + 320,
y: rackCursorY + rackHeight / 2
});
}
devices.forEach((device, deviceIndex) => { devices.forEach((device, deviceIndex) => {
const col = deviceIndex % 4; const col = deviceIndex % 6;
const row = Math.floor(deviceIndex / 4); const row = Math.floor(deviceIndex / 6);
const x = floorX + col * 85; const x = rackX + 24 + col * 96;
const y = rackTop + row * 40; const y = rackCursorY + 28 + row * 30;
positioned.push({ positioned.push({
type: 'node', type: 'node',
@@ -322,15 +520,57 @@ $topologyPayload = array_map(static function (array $row): array {
rack_id: Number(device.rack_id || 0), rack_id: Number(device.rack_id || 0),
rack_name: device.rack_name || 'Ohne Rack', rack_name: device.rack_name || 'Ohne Rack',
floor_name: device.floor_name || 'Ohne Stockwerk', floor_name: device.floor_name || 'Ohne Stockwerk',
building_name: device.building_name || 'Ohne Gebaeude',
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 || ''
}); });
}); });
});
rackCursorY += rackHeight + 16;
}); });
return positioned; const floorHeight = Math.max(84, rackCursorY - floorTop + 8);
positioned.push({
type: 'floor-box',
x: locationX + 20,
y: floorTop,
width: 680,
height: floorHeight
});
floorCursorY = floorTop + floorHeight + 18;
});
const buildingHeight = Math.max(130, floorCursorY - buildingTop + 8);
positioned.push({
type: 'building-box',
x: locationX + 8,
y: buildingTop,
width: 708,
height: buildingHeight
});
currentY = buildingTop + buildingHeight + 26;
});
const locationHeight = Math.max(220, currentY - locationTop + 16);
positioned.push({
type: 'location-box',
x: locationX - 8,
y: locationTop,
width: 736,
height: locationHeight
});
maxY = Math.max(maxY, locationTop + locationHeight + 40);
locationIndex++;
});
const width = Math.max(2400, 220 + locationIndex * 760);
const height = Math.max(1400, Math.ceil(maxY));
return { entries: positioned, rackCenters, width, height };
} }
function showOverlay(node) { function showOverlay(node) {
@@ -340,7 +580,7 @@ $topologyPayload = array_map(static function (array $row): array {
overlayTitle.textContent = node.rack_name || 'Ohne Rack'; overlayTitle.textContent = node.rack_name || 'Ohne Rack';
const typeLabel = node.device_type_name ? ` (${node.device_type_name})` : ''; const typeLabel = node.device_type_name ? ` (${node.device_type_name})` : '';
overlayMeta.textContent = `Stockwerk: ${node.floor_name} | Geraet: ${node.device_name}${typeLabel}`; overlayMeta.textContent = `Standort: ${node.location_name} | Gebaeude: ${node.building_name} | Stockwerk: ${node.floor_name} | Geraet: ${node.device_name}${typeLabel}`;
if (node.rack_id > 0) { if (node.rack_id > 0) {
overlayRackLink.innerHTML = `<a href="?module=racks&action=edit&id=${node.rack_id}">Rack bearbeiten</a>`; overlayRackLink.innerHTML = `<a href="?module=racks&action=edit&id=${node.rack_id}">Rack bearbeiten</a>`;
@@ -358,10 +598,10 @@ $topologyPayload = array_map(static function (array $row): array {
} }
function renderTopology() { function renderTopology() {
drawGrid();
clearLayer(nodeLayer);
if (!nodes.length) { if (!nodes.length) {
drawGrid();
clearLayer(connectionLayer);
clearLayer(nodeLayer);
if (emptyNode) { if (emptyNode) {
emptyNode.hidden = false; emptyNode.hidden = false;
} }
@@ -372,9 +612,75 @@ $topologyPayload = array_map(static function (array $row): array {
emptyNode.hidden = true; emptyNode.hidden = true;
} }
const entries = buildLayout(nodes); const layout = buildLayout(nodes);
const entries = layout.entries;
scene.width = layout.width;
scene.height = layout.height;
drawGrid();
clearLayer(connectionLayer);
clearLayer(nodeLayer);
if (bgNode) {
bgNode.setAttribute('width', String(scene.width));
bgNode.setAttribute('height', String(scene.height));
}
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.type === 'location-box') {
const rect = svgElement('rect');
rect.setAttribute('x', String(entry.x));
rect.setAttribute('y', String(entry.y));
rect.setAttribute('width', String(entry.width));
rect.setAttribute('height', String(entry.height));
rect.setAttribute('rx', '12');
rect.setAttribute('class', 'topology-location-box');
nodeLayer.appendChild(rect);
return;
}
if (entry.type === 'building-box') {
const rect = svgElement('rect');
rect.setAttribute('x', String(entry.x));
rect.setAttribute('y', String(entry.y));
rect.setAttribute('width', String(entry.width));
rect.setAttribute('height', String(entry.height));
rect.setAttribute('rx', '10');
rect.setAttribute('class', 'topology-building-box');
nodeLayer.appendChild(rect);
return;
}
if (entry.type === 'floor-box') {
const rect = svgElement('rect');
rect.setAttribute('x', String(entry.x));
rect.setAttribute('y', String(entry.y));
rect.setAttribute('width', String(entry.width));
rect.setAttribute('height', String(entry.height));
rect.setAttribute('rx', '8');
rect.setAttribute('class', 'topology-floor-box');
nodeLayer.appendChild(rect);
return;
}
if (entry.type === 'location-label') {
const text = svgElement('text');
text.setAttribute('x', String(entry.x));
text.setAttribute('y', String(entry.y));
text.setAttribute('class', 'topology-location-label');
text.textContent = entry.label;
nodeLayer.appendChild(text);
return;
}
if (entry.type === 'building-label') {
const text = svgElement('text');
text.setAttribute('x', String(entry.x));
text.setAttribute('y', String(entry.y));
text.setAttribute('class', 'topology-building-label');
text.textContent = entry.label;
nodeLayer.appendChild(text);
return;
}
if (entry.type === 'floor-label') { if (entry.type === 'floor-label') {
const text = svgElement('text'); const text = svgElement('text');
text.setAttribute('x', String(entry.x)); text.setAttribute('x', String(entry.x));
@@ -436,6 +742,30 @@ $topologyPayload = array_map(static function (array $row): array {
nodeLayer.appendChild(circle); nodeLayer.appendChild(circle);
}); });
rackLinks.forEach((link) => {
const fromRackId = Number(link.from_rack_id || 0);
const toRackId = Number(link.to_rack_id || 0);
const count = Math.max(1, Number(link.count || 1));
const fromPoint = layout.rackCenters.get(fromRackId);
const toPoint = layout.rackCenters.get(toRackId);
if (!fromPoint || !toPoint) {
return;
}
const line = svgElement('line');
line.setAttribute('x1', String(fromPoint.x));
line.setAttribute('y1', String(fromPoint.y));
line.setAttribute('x2', String(toPoint.x));
line.setAttribute('y2', String(toPoint.y));
line.setAttribute('class', 'topology-connection-line');
line.setAttribute('stroke-width', String(Math.min(8, 1 + count)));
const title = svgElement('title');
title.textContent = `${link.from_rack_name} <-> ${link.to_rack_name}: ${count} Verbindungen`;
line.appendChild(title);
connectionLayer.appendChild(line);
});
} }
svg.addEventListener('wheel', (event) => { svg.addEventListener('wheel', (event) => {
@@ -659,12 +989,42 @@ $topologyPayload = array_map(static function (array $row): array {
stroke-width: 1; stroke-width: 1;
} }
.topology-floor-label { .topology-location-box {
fill: none;
stroke: #a9c0de;
stroke-width: 2;
}
.topology-building-box {
fill: none;
stroke: #c3d3e8;
stroke-width: 1.5;
}
.topology-floor-box {
fill: none;
stroke: #d0deef;
stroke-width: 1.2;
}
.topology-location-label {
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
fill: #23304a; fill: #23304a;
} }
.topology-building-label {
font-size: 18px;
font-weight: 700;
fill: #2a3d5c;
}
.topology-floor-label {
font-size: 14px;
font-weight: 700;
fill: #3f5679;
}
.topology-rack-box { .topology-rack-box {
fill: #ffffff; fill: #ffffff;
stroke: #bfd0e6; stroke: #bfd0e6;
@@ -672,11 +1032,17 @@ $topologyPayload = array_map(static function (array $row): array {
} }
.topology-rack-label { .topology-rack-label {
font-size: 16px; font-size: 14px;
font-weight: 600; font-weight: 600;
fill: #2a3d5c; fill: #2a3d5c;
} }
.topology-connection-line {
stroke: #426da4;
stroke-opacity: 0.55;
stroke-linecap: round;
}
.topology-node { .topology-node {
fill: #1f73c9; fill: #1f73c9;
stroke: #ffffff; stroke: #ffffff;