feat: improve dashboard and connection workflows

- add connection delete endpoint and update connection list handling

- expand dashboard visualization behavior

- update helpers/header and project TODO tracking
This commit is contained in:
2026-02-18 09:23:11 +01:00
parent 463ab97c4b
commit c8fb5b140c
7 changed files with 998 additions and 236 deletions

View File

@@ -1,15 +1,9 @@
<?php
/**
* modules/dashboard/list.php
* Dashboard / Startseite - Übersicht über alle Komponenten
* Dashboard / Startseite - Uebersicht ueber alle Komponenten
*/
// =========================
// Statistiken aus DB laden
// =========================
//TODO eine große zoombare verschiebbare svg wand machen, mit allen punkten drauf. anklicken der punkte erzeugt ein overlay, mit der reingezoomten ansicht zb einem rack
$stats = [
'devices' => $sql->single("SELECT COUNT(*) as cnt FROM devices", "", [])['cnt'] ?? 0,
'device_types' => $sql->single("SELECT COUNT(*) as cnt FROM device_types", "", [])['cnt'] ?? 0,
@@ -18,7 +12,6 @@ $stats = [
'locations' => $sql->single("SELECT COUNT(*) as cnt FROM locations", "", [])['cnt'] ?? 0,
];
// Recent devices
$recentDevices = $sql->get(
"SELECT d.id, d.name, dt.name as type_name, r.name as rack_name, f.name as floor_name
FROM devices d
@@ -26,12 +19,41 @@ $recentDevices = $sql->get(
LEFT JOIN racks r ON d.rack_id = r.id
LEFT JOIN floors f ON r.floor_id = f.id
ORDER BY d.id DESC LIMIT 5",
"", []
"",
[]
);
$topologyDevices = $sql->get(
"SELECT
d.id AS device_id,
d.name AS device_name,
dt.name AS device_type_name,
r.id AS rack_id,
r.name AS rack_name,
f.id AS floor_id,
f.name AS floor_name
FROM devices d
LEFT JOIN device_types dt ON dt.id = d.device_type_id
LEFT JOIN racks r ON r.id = d.rack_id
LEFT JOIN floors f ON f.id = r.floor_id
ORDER BY floor_name, rack_name, device_name",
"",
[]
);
$topologyPayload = array_map(static function (array $row): array {
return [
'device_id' => (int)($row['device_id'] ?? 0),
'device_name' => (string)($row['device_name'] ?? ''),
'device_type_name' => (string)($row['device_type_name'] ?? ''),
'rack_id' => (int)($row['rack_id'] ?? 0),
'rack_name' => (string)($row['rack_name'] ?? ''),
'floor_id' => (int)($row['floor_id'] ?? 0),
'floor_name' => (string)($row['floor_name'] ?? ''),
];
}, $topologyDevices);
?>
<!-- Dashboard / Übersicht -->
<div class="dashboard">
<h1>Dashboard</h1>
<div id="dashboard-modules" class="dashboard-modules"></div>
@@ -39,35 +61,63 @@ $recentDevices = $sql->get(
<p data-dashboard-warnings class="dashboard-inline-status"></p>
<p data-dashboard-recent class="dashboard-inline-status"></p>
<!-- Statistik-Karten -->
<section class="topology-wall" id="dashboard-topology-wall">
<div class="topology-wall__header">
<h2>Gesamt-Topologie-Wand</h2>
<div class="topology-wall__tools">
<button type="button" class="button button-small" data-topology-zoom="in">+</button>
<button type="button" class="button button-small" data-topology-zoom="out">-</button>
<button type="button" class="button button-small" data-topology-zoom="reset">Reset</button>
</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>
<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>
<g id="dashboard-topology-grid"></g>
<g id="dashboard-topology-layer"></g>
</svg>
<div id="dashboard-topology-empty" class="topology-empty" hidden>Keine Geraete vorhanden. Bitte zuerst Geraete erfassen.</div>
<aside id="dashboard-topology-overlay" class="topology-overlay" hidden>
<div class="topology-overlay__header">
<h3 data-topology-title>Rack-Detail</h3>
<button type="button" class="button button-small" data-topology-close>Schliessen</button>
</div>
<p data-topology-meta></p>
<p data-topology-rack-link></p>
<p data-topology-device-link></p>
</aside>
</section>
<div class="stats-grid">
<div class="stat-card">
<h3><?php echo $stats['locations']; ?></h3>
<h3><?php echo (int)$stats['locations']; ?></h3>
<p>Standorte</p>
<a href="?module=floors&action=list">Verwalten </a>
<a href="?module=floors&action=list">Verwalten -></a>
</div>
<div class="stat-card">
<h3><?php echo $stats['device_types']; ?></h3>
<p>Gerätetypen</p>
<a href="?module=device_types&action=list">Verwalten </a>
<h3><?php echo (int)$stats['device_types']; ?></h3>
<p>Geraetetypen</p>
<a href="?module=device_types&action=list">Verwalten -></a>
</div>
<div class="stat-card">
<h3><?php echo $stats['devices']; ?></h3>
<p>Geräte</p>
<a href="?module=devices&action=list">Verwalten </a>
<h3><?php echo (int)$stats['devices']; ?></h3>
<p>Geraete</p>
<a href="?module=devices&action=list">Verwalten -></a>
</div>
<div class="stat-card">
<h3><?php echo $stats['racks']; ?></h3>
<h3><?php echo (int)$stats['racks']; ?></h3>
<p>Racks</p>
<a href="?module=racks&action=list">Verwalten </a>
<a href="?module=racks&action=list">Verwalten -></a>
</div>
</div>
<!-- Zuletzt hinzugefügte Geräte -->
<h2>Zuletzt hinzugefügt</h2>
<h2>Zuletzt hinzugefuegt</h2>
<?php if (!empty($recentDevices)): ?>
<table class="recent-devices">
<thead>
@@ -82,20 +132,379 @@ $recentDevices = $sql->get(
<tbody>
<?php foreach ($recentDevices as $device): ?>
<tr>
<td><?php echo htmlspecialchars($device['name']); ?></td>
<td><?php echo htmlspecialchars($device['type_name'] ?? '-'); ?></td>
<td><?php echo htmlspecialchars($device['rack_name'] ?? '-'); ?></td>
<td><?php echo htmlspecialchars($device['floor_name'] ?? '-'); ?></td>
<td><a href="?module=devices&action=edit&id=<?php echo $device['id']; ?>">Bearbeiten</a></td>
<td><?php echo htmlspecialchars((string)$device['name']); ?></td>
<td><?php echo htmlspecialchars((string)($device['type_name'] ?? '-')); ?></td>
<td><?php echo htmlspecialchars((string)($device['rack_name'] ?? '-')); ?></td>
<td><?php echo htmlspecialchars((string)($device['floor_name'] ?? '-')); ?></td>
<td><a href="?module=devices&action=edit&id=<?php echo (int)$device['id']; ?>">Bearbeiten</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p><em>Noch keine Geräte vorhanden. <a href="?module=device_types&action=list">Starten Sie mit Gerätetypen</a>.</em></p>
<p><em>Noch keine Geraete vorhanden. <a href="?module=device_types&action=list">Starten Sie mit Geraetetypen</a>.</em></p>
<?php endif; ?>
</div>
<script id="dashboard-topology-data" type="application/json"><?php echo json_encode($topologyPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?></script>
<script>
(function () {
const root = document.getElementById('dashboard-topology-wall');
if (!root) {
return;
}
const svg = document.getElementById('dashboard-topology-svg');
const gridLayer = document.getElementById('dashboard-topology-grid');
const nodeLayer = document.getElementById('dashboard-topology-layer');
const emptyNode = document.getElementById('dashboard-topology-empty');
const overlay = document.getElementById('dashboard-topology-overlay');
const overlayTitle = overlay ? overlay.querySelector('[data-topology-title]') : null;
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 closeButton = overlay ? overlay.querySelector('[data-topology-close]') : null;
const dataTag = document.getElementById('dashboard-topology-data');
let nodes = [];
try {
nodes = JSON.parse((dataTag && dataTag.textContent) || '[]');
} catch (error) {
nodes = [];
}
const scene = { width: 2400, height: 1400 };
let view = { x: 0, y: 0, width: scene.width, height: scene.height };
let drag = null;
function clampView() {
view.width = Math.max(220, Math.min(scene.width, view.width));
view.height = Math.max(180, Math.min(scene.height, view.height));
view.x = Math.max(0, Math.min(scene.width - view.width, view.x));
view.y = Math.max(0, Math.min(scene.height - view.height, view.y));
}
function applyView() {
clampView();
svg.setAttribute('viewBox', `${view.x} ${view.y} ${view.width} ${view.height}`);
}
function toSvgPoint(clientX, clientY) {
const rect = svg.getBoundingClientRect();
const x = view.x + ((clientX - rect.left) / rect.width) * view.width;
const y = view.y + ((clientY - rect.top) / rect.height) * view.height;
return { x, y };
}
function zoomAt(anchorX, anchorY, factor) {
const nextWidth = view.width * factor;
const nextHeight = view.height * factor;
const ratioX = (anchorX - view.x) / view.width;
const ratioY = (anchorY - view.y) / view.height;
view = {
x: anchorX - ratioX * nextWidth,
y: anchorY - ratioY * nextHeight,
width: nextWidth,
height: nextHeight
};
applyView();
}
function resetView() {
view = { x: 0, y: 0, width: scene.width, height: scene.height };
applyView();
}
function zoomToNode(x, y) {
view = {
x: x - 250,
y: y - 170,
width: 500,
height: 340
};
applyView();
}
function clearLayer(layer) {
while (layer.firstChild) {
layer.removeChild(layer.firstChild);
}
}
function svgElement(name) {
return document.createElementNS('http://www.w3.org/2000/svg', name);
}
function drawGrid() {
clearLayer(gridLayer);
for (let x = 0; x <= scene.width; x += 100) {
const line = svgElement('line');
line.setAttribute('x1', String(x));
line.setAttribute('y1', '0');
line.setAttribute('x2', String(x));
line.setAttribute('y2', String(scene.height));
line.setAttribute('class', 'topology-grid-line');
gridLayer.appendChild(line);
}
for (let y = 0; y <= scene.height; y += 100) {
const line = svgElement('line');
line.setAttribute('x1', '0');
line.setAttribute('y1', String(y));
line.setAttribute('x2', String(scene.width));
line.setAttribute('y2', String(y));
line.setAttribute('class', 'topology-grid-line');
gridLayer.appendChild(line);
}
}
function buildLayout(data) {
const floors = new Map();
data.forEach((entry) => {
const floorName = (entry.floor_name || '').trim() || 'Ohne Stockwerk';
const rackId = Number(entry.rack_id || 0);
const rackName = (entry.rack_name || '').trim() || 'Ohne Rack';
const rackKey = `${rackId}:${rackName}`;
if (!floors.has(floorName)) {
floors.set(floorName, new Map());
}
const racks = floors.get(floorName);
if (!racks.has(rackKey)) {
racks.set(rackKey, []);
}
racks.get(rackKey).push(entry);
});
const floorNames = Array.from(floors.keys()).sort((a, b) => a.localeCompare(b));
const positioned = [];
floorNames.forEach((floorName, floorIndex) => {
const rackMap = floors.get(floorName);
const rackKeys = Array.from(rackMap.keys()).sort((a, b) => a.localeCompare(b));
const floorX = 140 + floorIndex * 540;
positioned.push({ type: 'floor-label', x: floorX, y: 70, label: floorName });
rackKeys.forEach((rackKey, rackIndex) => {
const devices = rackMap.get(rackKey);
const [rackIdPart, rackNamePart] = rackKey.split(':');
const rackId = Number(rackIdPart || 0);
const rackName = rackNamePart || 'Ohne Rack';
const rackTop = 120 + rackIndex * 180;
const rowCount = Math.ceil(Math.max(1, devices.length) / 4);
const rackHeight = Math.max(90, 40 + rowCount * 45);
positioned.push({
type: 'rack-box',
x: floorX - 50,
y: rackTop - 30,
width: 380,
height: rackHeight,
label: rackName,
rack_id: rackId
});
devices.forEach((device, deviceIndex) => {
const col = deviceIndex % 4;
const row = Math.floor(deviceIndex / 4);
const x = floorX + col * 85;
const y = rackTop + row * 40;
positioned.push({
type: 'node',
x,
y,
rack_id: Number(device.rack_id || 0),
rack_name: device.rack_name || 'Ohne Rack',
floor_name: device.floor_name || 'Ohne Stockwerk',
device_id: Number(device.device_id || 0),
device_name: device.device_name || 'Unbenannt',
device_type_name: device.device_type_name || ''
});
});
});
});
return positioned;
}
function showOverlay(node) {
if (!overlay || !overlayTitle || !overlayMeta || !overlayRackLink || !overlayDeviceLink) {
return;
}
overlayTitle.textContent = node.rack_name || 'Ohne Rack';
const typeLabel = node.device_type_name ? ` (${node.device_type_name})` : '';
overlayMeta.textContent = `Stockwerk: ${node.floor_name} | Geraet: ${node.device_name}${typeLabel}`;
if (node.rack_id > 0) {
overlayRackLink.innerHTML = `<a href="?module=racks&action=edit&id=${node.rack_id}">Rack bearbeiten</a>`;
} else {
overlayRackLink.textContent = 'Kein Rack verknuepft';
}
if (node.device_id > 0) {
overlayDeviceLink.innerHTML = `<a href="?module=devices&action=edit&id=${node.device_id}">Geraet bearbeiten</a>`;
} else {
overlayDeviceLink.textContent = '';
}
overlay.hidden = false;
}
function renderTopology() {
drawGrid();
clearLayer(nodeLayer);
if (!nodes.length) {
if (emptyNode) {
emptyNode.hidden = false;
}
return;
}
if (emptyNode) {
emptyNode.hidden = true;
}
const entries = buildLayout(nodes);
entries.forEach((entry) => {
if (entry.type === 'floor-label') {
const text = svgElement('text');
text.setAttribute('x', String(entry.x));
text.setAttribute('y', String(entry.y));
text.setAttribute('class', 'topology-floor-label');
text.textContent = entry.label;
nodeLayer.appendChild(text);
return;
}
if (entry.type === 'rack-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-rack-box');
nodeLayer.appendChild(rect);
const label = svgElement('text');
label.setAttribute('x', String(entry.x + 12));
label.setAttribute('y', String(entry.y + 22));
label.setAttribute('class', 'topology-rack-label');
label.textContent = entry.label;
nodeLayer.appendChild(label);
return;
}
const circle = svgElement('circle');
circle.setAttribute('cx', String(entry.x));
circle.setAttribute('cy', String(entry.y));
circle.setAttribute('r', '10');
circle.setAttribute('tabindex', '0');
circle.setAttribute('class', 'topology-node');
circle.setAttribute('data-device-id', String(entry.device_id));
circle.setAttribute('data-rack-id', String(entry.rack_id));
const title = svgElement('title');
title.textContent = `${entry.device_name} (${entry.floor_name} / ${entry.rack_name})`;
circle.appendChild(title);
const activate = () => {
nodeLayer.querySelectorAll('.topology-node.active').forEach((node) => {
node.classList.remove('active');
});
circle.classList.add('active');
zoomToNode(entry.x, entry.y);
showOverlay(entry);
};
circle.addEventListener('click', activate);
circle.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
activate();
}
});
nodeLayer.appendChild(circle);
});
}
svg.addEventListener('wheel', (event) => {
event.preventDefault();
const point = toSvgPoint(event.clientX, event.clientY);
const factor = event.deltaY < 0 ? 0.9 : 1.1;
zoomAt(point.x, point.y, factor);
}, { passive: false });
svg.addEventListener('pointerdown', (event) => {
drag = {
startX: event.clientX,
startY: event.clientY,
baseX: view.x,
baseY: view.y
};
svg.setPointerCapture(event.pointerId);
});
svg.addEventListener('pointermove', (event) => {
if (!drag) {
return;
}
const rect = svg.getBoundingClientRect();
const scaleX = view.width / rect.width;
const scaleY = view.height / rect.height;
const dx = (event.clientX - drag.startX) * scaleX;
const dy = (event.clientY - drag.startY) * scaleY;
view.x = drag.baseX - dx;
view.y = drag.baseY - dy;
applyView();
});
svg.addEventListener('pointerup', (event) => {
drag = null;
svg.releasePointerCapture(event.pointerId);
});
svg.addEventListener('pointercancel', (event) => {
drag = null;
svg.releasePointerCapture(event.pointerId);
});
root.querySelectorAll('[data-topology-zoom]').forEach((button) => {
button.addEventListener('click', () => {
const action = button.getAttribute('data-topology-zoom');
if (action === 'in') {
zoomAt(view.x + view.width / 2, view.y + view.height / 2, 0.85);
return;
}
if (action === 'out') {
zoomAt(view.x + view.width / 2, view.y + view.height / 2, 1.15);
return;
}
resetView();
});
});
if (closeButton && overlay) {
closeButton.addEventListener('click', () => {
overlay.hidden = true;
});
}
renderTopology();
applyView();
})();
</script>
<style>
.stats-grid {
display: grid;
@@ -186,7 +595,8 @@ $recentDevices = $sql->get(
border-collapse: collapse;
}
.recent-devices th, .recent-devices td {
.recent-devices th,
.recent-devices td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
@@ -195,4 +605,134 @@ $recentDevices = $sql->get(
.recent-devices th {
background: #f0f0f0;
}
.topology-wall {
margin: 18px 0 26px;
border: 1px solid #d6e1f0;
border-radius: 10px;
background: #fff;
padding: 14px;
position: relative;
}
.topology-wall__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.topology-wall__header h2 {
margin: 0;
}
.topology-wall__tools {
display: flex;
gap: 6px;
}
.topology-wall__hint {
margin: 8px 0 10px;
color: #445067;
}
#dashboard-topology-svg {
width: 100%;
height: 460px;
border: 1px solid #d7dee9;
border-radius: 8px;
background: #f8fbff;
cursor: grab;
touch-action: none;
}
#dashboard-topology-svg:active {
cursor: grabbing;
}
.topology-bg {
fill: #f7faff;
}
.topology-grid-line {
stroke: #e4ebf5;
stroke-width: 1;
}
.topology-floor-label {
font-size: 28px;
font-weight: 700;
fill: #23304a;
}
.topology-rack-box {
fill: #ffffff;
stroke: #bfd0e6;
stroke-width: 2;
}
.topology-rack-label {
font-size: 16px;
font-weight: 600;
fill: #2a3d5c;
}
.topology-node {
fill: #1f73c9;
stroke: #ffffff;
stroke-width: 3;
}
.topology-node:hover,
.topology-node:focus,
.topology-node.active {
fill: #e5572e;
outline: none;
}
.topology-empty {
margin-top: 12px;
padding: 10px;
background: #f4f7fb;
border: 1px dashed #b8c7dd;
border-radius: 8px;
}
.topology-overlay {
position: absolute;
right: 16px;
bottom: 16px;
width: min(360px, calc(100% - 32px));
border: 1px solid #cad8ea;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 12px 34px rgba(12, 42, 84, 0.2);
padding: 12px;
}
.topology-overlay__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.topology-overlay__header h3 {
margin: 0;
}
.topology-overlay p {
margin: 8px 0;
}
@media (max-width: 900px) {
#dashboard-topology-svg {
height: 360px;
}
.topology-wall__header {
flex-direction: column;
align-items: flex-start;
}
}
</style>