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:
@@ -1,6 +1,6 @@
|
||||
# 📋 NÄCHSTE ARBEITSPAKETE
|
||||
# 📋 NÄCHSTE ARBEITSPAKETE
|
||||
|
||||
## 🎯 Für die nächsten Sessions
|
||||
## 🎯 Für die nächsten Sessions
|
||||
|
||||
### Package 1: Fehlerbehandlung & Sessions (1-2h)
|
||||
- [ ] Session-Handling in `bootstrap.php` implementieren
|
||||
@@ -9,9 +9,9 @@
|
||||
- [ ] Validierungsfehler anzeigen
|
||||
|
||||
### Package 2: Delete-Funktionen (1h)
|
||||
- [ ] DELETE-Endpoints für alle Module
|
||||
- [ ] AJAX-Bestätigung vor Löschen
|
||||
- [ ] Kaskadierendes Löschen prüfen (z.B. Floor → Racks)
|
||||
- [ ] DELETE-Endpoints für alle Module
|
||||
- [ ] AJAX-Bestätigung vor Löschen
|
||||
- [ ] Kaskadierendes Löschen prüfen (z.B. Floor → Racks)
|
||||
|
||||
### Package 3: Port-Management (2-3h)
|
||||
- [ ] Ports zu Device-Types verwalten
|
||||
@@ -19,23 +19,23 @@
|
||||
- [ ] Port-Status (aktiv/inaktiv)
|
||||
- [ ] VLAN-Zuordnung zu Ports
|
||||
|
||||
### Package 4: SVG-Editor für Floorplans (4-5h)
|
||||
- [ ] Interaktiver SVG-Editor für Rooms
|
||||
### Package 4: SVG-Editor für Floorplans (4-5h)
|
||||
- [ ] Interaktiver SVG-Editor für Rooms
|
||||
- [ ] Netzwerkdosen platzieren
|
||||
- [ ] Dosen nummerieren
|
||||
- [ ] Speicher-Integration
|
||||
|
||||
### Package 5: Navigation & UI (1-2h)
|
||||
- [ ] Breadcrumbs hinzufügen
|
||||
- [ ] Mobile-Menü verbessern
|
||||
- [ ] CSS polieren (Farben, Abstände)
|
||||
- [ ] Breadcrumbs hinzufügen
|
||||
- [ ] Mobile-Menü verbessern
|
||||
- [ ] CSS polieren (Farben, Abstände)
|
||||
- [ ] Dark-Mode (optional)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Code-Referenzen
|
||||
## 📚 Code-Referenzen
|
||||
|
||||
### Template für neue CRUD-Module:
|
||||
### Template für neue CRUD-Module:
|
||||
```php
|
||||
// list.php: Filter + Tabelle
|
||||
// edit.php: Formular
|
||||
@@ -71,7 +71,7 @@ $whereSql = $where ? "WHERE " . implode(" AND ", $where) : "";
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Bekannte TODOs im Code
|
||||
## 🔧 Bekannte TODOs im Code
|
||||
|
||||
Alle noch offenen Punkte sind mit `// TODO:` gekennzeichnet:
|
||||
|
||||
@@ -83,48 +83,48 @@ grep -r "TODO:" app/modules/ --include="*.php"
|
||||
Wichtigste TODOs:
|
||||
- `index.php:19` - Session starten
|
||||
- `*/save.php` - Fehlerbehandlung
|
||||
- `connections/` - Port-Verknüpfung
|
||||
- `connections/` - Port-Verknüpfung
|
||||
- `lib/auth.php` - Auth-Logik
|
||||
|
||||
---
|
||||
|
||||
## 💾 Datenbank-Setup
|
||||
## 💾 Datenbank-Setup
|
||||
|
||||
Die Datenbank wird automatisch durch `init.sql` initialisiert.
|
||||
|
||||
Wichtige Tabellen:
|
||||
- `locations` - Standorte
|
||||
- `buildings` - Gebäude
|
||||
- `buildings` - Gebäude
|
||||
- `floors` - Stockwerke
|
||||
- `rooms` - Räume
|
||||
- `rooms` - Räume
|
||||
- `network_outlets` - Netzwerkdosen
|
||||
- `device_types` - Gerätetypen
|
||||
- `device_types` - Gerätetypen
|
||||
- `device_type_ports` - Port-Templates
|
||||
- `devices` - konkrete Geräte
|
||||
- `device_ports` - Gerätports
|
||||
- `devices` - konkrete Geräte
|
||||
- `device_ports` - Gerätports
|
||||
- `racks` - Racks
|
||||
- `connections` - Verbindungen zwischen Ports
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing-Checklist
|
||||
## 🧪 Testing-Checklist
|
||||
|
||||
Bei jeder Änderung checken:
|
||||
Bei jeder Änderung checken:
|
||||
- [ ] Formular sendet Daten korrekt
|
||||
- [ ] Daten werden in DB gespeichert
|
||||
- [ ] Liste zeigt neue Daten
|
||||
- [ ] Edit lädt existierende Daten vor
|
||||
- [ ] Edit lädt existierende Daten vor
|
||||
- [ ] Filter funktioniert
|
||||
- [ ] Validierungsfehler werden angezeigt
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design-Richtlinien
|
||||
## 🎨 Design-Richtlinien
|
||||
|
||||
### Farben:
|
||||
- Primary (Buttons): `#007bff` (Blau)
|
||||
- Success (Speichern): `#28a745` (Grün)
|
||||
- Danger (Löschen): `#dc3545` (Rot)
|
||||
- Success (Speichern): `#28a745` (Grün)
|
||||
- Danger (Löschen): `#dc3545` (Rot)
|
||||
- Background: `#f9f9f9` (Hell)
|
||||
- Border: `#ddd` (Hell-Grau)
|
||||
|
||||
@@ -135,8 +135,28 @@ Bei jeder Änderung checken:
|
||||
|
||||
### Schriftarten:
|
||||
- Erben von HTML (derzeit: System)
|
||||
- Monospace für Code/IDs: `font-family: monospace`
|
||||
- Monospace für Code/IDs: `font-family: monospace`
|
||||
|
||||
---
|
||||
|
||||
**Happy Coding! 🚀**
|
||||
**Happy Coding! 🚀**
|
||||
|
||||
## Aktuell offene TODOs (Stand: 18. Februar 2026)
|
||||
|
||||
- [ ] #15 Neue Verbindung: Es kann keine Netzwerkdose ausgewahlt werden.
|
||||
- [ ] #14 Hilfslinien der Stockwerkskarten nur im Edit-Mode anzeigen; im Anzeige-Mode ausblenden.
|
||||
- [ ] #11 Encoding- und Umlautfehler beheben (inkl. ae/oe/ue-Themen).
|
||||
- [ ] #10 Dashboard-Grafik erzeugen:
|
||||
- Oberste Ebene: Locations, ggf. mit Unterordnung.
|
||||
- Darunter: Gebaudekomplexe mit Rack-Verbindungen.
|
||||
- Darunter: Stockwerke.
|
||||
- Darunter: Etagenweise Verbindungen.
|
||||
- [ ] #8 Gerate löschen fehlt: Es erfolgt Weiterleitung, aber keine echte Fehlermeldung.
|
||||
- [ ] #7 Letzten Punkt im Floor-Editor löschen:
|
||||
- URL: `http://localhost/?module=floors&action=edit&id=1`
|
||||
- [ ] #5 Dashboard als zoombare und verschiebbare SVG-Flache:
|
||||
- Gerate anordnen.
|
||||
- Gerate, Ports und Verbindungen anklickbar.
|
||||
- Sprechblase mit Infos und Buttons zu Bereichen (editieren, entfernen, ...).
|
||||
- [ ] #4 `device_types/edit`: Option "Ports automatisch erstellen" nur beim Erstellen anzeigen, nicht beim Editieren.
|
||||
|
||||
|
||||
11
TODO.md
11
TODO.md
@@ -1,4 +1,4 @@
|
||||
# TODO
|
||||
# TODO
|
||||
|
||||
Bereinigte und aktuelle TODO-Liste (Stand: 18. Februar 2026).
|
||||
Quelle: vorhandene `TODO`-Marker im Repository plus offene Architekturpunkte.
|
||||
@@ -14,12 +14,9 @@ Quelle: vorhandene `TODO`-Marker im Repository plus offene Architekturpunkte.
|
||||
|
||||
## Offen (direkt im Code markiert)
|
||||
|
||||
- [ ] `app/modules/dashboard/list.php:11`:
|
||||
Grosse zoombare/verschiebbare SVG-Wand mit Punkten + Overlay-Drilldown (z. B. Rack-Ansicht).
|
||||
- [ ] `app/modules/connections/list.php:387`:
|
||||
Verbindung im UI bearbeiten/loeschen.
|
||||
- [ ] `app/lib/helpers.php:300`:
|
||||
Generischer Platzhalter fuer weitere Helper (nur bei konkretem Bedarf ergaenzen).
|
||||
- [x] `app/modules/dashboard/list.php`: zoombare/verschiebbare SVG-Wand mit klickbaren Punkten und Overlay-Drilldown umgesetzt.
|
||||
- [x] `app/modules/connections/list.php`: Detailbereich fuer ausgewaehlte Verbindung inkl. Bearbeiten/Loeschen im UI umgesetzt.
|
||||
- [x] `app/lib/helpers.php`: konkrete allgemeine Helper ergaenzt (`formatDateTime`, `formatBytes`, `generateUuidV4`, `normalizeSvgCoordinate`).
|
||||
|
||||
## Offen (Bugs / Doku / Statusdateien)
|
||||
|
||||
|
||||
@@ -297,8 +297,83 @@ HTML, [
|
||||
* Sonstiges
|
||||
* ========================= */
|
||||
|
||||
// TODO: Weitere Helfer nach Bedarf
|
||||
// - Datum formatieren
|
||||
// - Bytes → MB
|
||||
// - UUID erzeugen
|
||||
// - SVG-Koordinaten normalisieren
|
||||
/**
|
||||
* Formatiert Datum/Uhrzeit robust oder gibt Fallback zurueck.
|
||||
*
|
||||
* @param string|null $value
|
||||
* @param string $format
|
||||
* @param string $fallback
|
||||
* @return string
|
||||
*/
|
||||
function formatDateTime(?string $value, string $format = 'd.m.Y H:i', string $fallback = '-'): string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp === false) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return date($format, $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert Byte-Werte in menschenlesbare Einheit.
|
||||
*
|
||||
* @param int|float $bytes
|
||||
* @param int $precision
|
||||
* @return string
|
||||
*/
|
||||
function formatBytes($bytes, int $precision = 2): string
|
||||
{
|
||||
$value = (float)$bytes;
|
||||
if ($value <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$power = min((int)floor(log($value, 1024)), count($units) - 1);
|
||||
$scaled = $value / (1024 ** $power);
|
||||
|
||||
return number_format($scaled, $precision, '.', '') . ' ' . $units[$power];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt eine UUID v4.
|
||||
*
|
||||
* @return string
|
||||
* @throws Exception
|
||||
*/
|
||||
function generateUuidV4(): string
|
||||
{
|
||||
$bytes = random_bytes(16);
|
||||
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
||||
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
||||
$hex = bin2hex($bytes);
|
||||
|
||||
return sprintf(
|
||||
'%s-%s-%s-%s-%s',
|
||||
substr($hex, 0, 8),
|
||||
substr($hex, 8, 4),
|
||||
substr($hex, 12, 4),
|
||||
substr($hex, 16, 4),
|
||||
substr($hex, 20, 12)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Klemmt eine SVG-Koordinate auf gueltigen Bereich.
|
||||
*
|
||||
* @param float $value
|
||||
* @param float $min
|
||||
* @param float $max
|
||||
* @param int $precision
|
||||
* @return float
|
||||
*/
|
||||
function normalizeSvgCoordinate(float $value, float $min, float $max, int $precision = 2): float
|
||||
{
|
||||
$normalized = max($min, min($max, $value));
|
||||
return round($normalized, $precision);
|
||||
}
|
||||
|
||||
41
app/modules/connections/delete.php
Normal file
41
app/modules/connections/delete.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* app/modules/connections/delete.php
|
||||
*
|
||||
* Loescht eine Verbindung und leitet zur Liste zurueck.
|
||||
*/
|
||||
|
||||
$connectionId = (int)($_GET['id'] ?? $_POST['id'] ?? 0);
|
||||
|
||||
if ($connectionId <= 0) {
|
||||
$_SESSION['error'] = 'Ungueltige Verbindungs-ID';
|
||||
header('Location: ?module=connections&action=list');
|
||||
exit;
|
||||
}
|
||||
|
||||
$connection = $sql->single(
|
||||
"SELECT id FROM connections WHERE id = ?",
|
||||
"i",
|
||||
[$connectionId]
|
||||
);
|
||||
|
||||
if (!$connection) {
|
||||
$_SESSION['error'] = 'Verbindung nicht gefunden';
|
||||
header('Location: ?module=connections&action=list');
|
||||
exit;
|
||||
}
|
||||
|
||||
$rows = $sql->set(
|
||||
"DELETE FROM connections WHERE id = ?",
|
||||
"i",
|
||||
[$connectionId]
|
||||
);
|
||||
|
||||
if ($rows > 0) {
|
||||
$_SESSION['success'] = 'Verbindung geloescht';
|
||||
} else {
|
||||
$_SESSION['error'] = 'Verbindung konnte nicht geloescht werden';
|
||||
}
|
||||
|
||||
header('Location: ?module=connections&action=list');
|
||||
exit;
|
||||
@@ -2,19 +2,13 @@
|
||||
/**
|
||||
* app/modules/connections/list.php
|
||||
*
|
||||
* Übersicht der Netzwerkverbindungen
|
||||
* - Tabellarische Liste aller Verbindungen
|
||||
* - Filter nach Geräten, VLANs, Status
|
||||
* - Später: Visuelle Netzwerk-Topologie
|
||||
* Uebersicht der Netzwerkverbindungen
|
||||
*/
|
||||
|
||||
// =========================
|
||||
// Filter einlesen
|
||||
// =========================
|
||||
$search = trim($_GET['search'] ?? '');
|
||||
$deviceId = (int)($_GET['device_id'] ?? 0);
|
||||
$selectedConnectionId = (int)($_GET['connection_id'] ?? 0);
|
||||
|
||||
// Einheitliche Endpunkt-Aufloesung fuer polymorphe Port-Typen.
|
||||
$endpointUnionSql = "
|
||||
SELECT
|
||||
'device' AS endpoint_type,
|
||||
@@ -60,16 +54,13 @@ $endpointUnionSql = "
|
||||
LEFT JOIN floors f ON f.id = fp.floor_id
|
||||
";
|
||||
|
||||
// =========================
|
||||
// WHERE-Clause bauen
|
||||
// =========================
|
||||
$where = [];
|
||||
$types = '';
|
||||
$params = [];
|
||||
|
||||
if ($search !== '') {
|
||||
$where[] = "(e1.owner_name LIKE ? OR e2.owner_name LIKE ? OR e1.port_name LIKE ? OR e2.port_name LIKE ?)";
|
||||
$types .= "ssss";
|
||||
$types .= 'ssss';
|
||||
$params[] = "%$search%";
|
||||
$params[] = "%$search%";
|
||||
$params[] = "%$search%";
|
||||
@@ -78,16 +69,13 @@ if ($search !== '') {
|
||||
|
||||
if ($deviceId > 0) {
|
||||
$where[] = "(e1.owner_device_id = ? OR e2.owner_device_id = ?)";
|
||||
$types .= "ii";
|
||||
$types .= 'ii';
|
||||
$params[] = $deviceId;
|
||||
$params[] = $deviceId;
|
||||
}
|
||||
|
||||
$whereSql = $where ? "WHERE " . implode(" AND ", $where) : "";
|
||||
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
// =========================
|
||||
// Verbindungen laden
|
||||
// =========================
|
||||
$connections = $sql->get(
|
||||
"SELECT
|
||||
c.id,
|
||||
@@ -125,10 +113,22 @@ $connections = $sql->get(
|
||||
$params
|
||||
);
|
||||
|
||||
// =========================
|
||||
// Filter-Daten
|
||||
// =========================
|
||||
$devices = $sql->get("SELECT id, name FROM devices ORDER BY name", "", []);
|
||||
$devices = $sql->get('SELECT id, name FROM devices ORDER BY name', '', []);
|
||||
|
||||
$selectedConnection = null;
|
||||
if ($selectedConnectionId > 0) {
|
||||
foreach ((array)$connections as $entry) {
|
||||
if ((int)($entry['id'] ?? 0) === $selectedConnectionId) {
|
||||
$selectedConnection = $entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($selectedConnection === null && !empty($connections)) {
|
||||
$selectedConnection = $connections[0];
|
||||
$selectedConnectionId = (int)($selectedConnection['id'] ?? 0);
|
||||
}
|
||||
|
||||
$selectedDevice = null;
|
||||
$selectedDevicePorts = [];
|
||||
@@ -140,7 +140,7 @@ if ($deviceId > 0) {
|
||||
FROM devices d
|
||||
LEFT JOIN device_types dt ON d.device_type_id = dt.id
|
||||
WHERE d.id = ?",
|
||||
"i",
|
||||
'i',
|
||||
[$deviceId]
|
||||
);
|
||||
|
||||
@@ -159,7 +159,7 @@ if ($deviceId > 0) {
|
||||
JOIN device_ports dp ON dp.id = dpm.device_port_id
|
||||
WHERE dp.device_id = ?
|
||||
) p",
|
||||
"ii",
|
||||
'ii',
|
||||
[$deviceId, $deviceId]
|
||||
)['cnt'] ?? 0);
|
||||
|
||||
@@ -187,7 +187,7 @@ if ($deviceId > 0) {
|
||||
OR (c.port_b_type = 'patchpanel' AND e2.endpoint_type = 'floor_patchpanel')
|
||||
)
|
||||
WHERE e1.owner_device_id = ? OR e2.owner_device_id = ?",
|
||||
"ii",
|
||||
'ii',
|
||||
[$deviceId, $deviceId]
|
||||
)['cnt'] ?? 0);
|
||||
|
||||
@@ -207,7 +207,7 @@ if ($deviceId > 0) {
|
||||
) p
|
||||
ORDER BY sort_id
|
||||
LIMIT 12",
|
||||
"ii",
|
||||
'ii',
|
||||
[$deviceId, $deviceId]
|
||||
);
|
||||
|
||||
@@ -230,159 +230,247 @@ if ($deviceId > 0) {
|
||||
}
|
||||
}
|
||||
|
||||
$buildListUrl = static function (array $extra = []) use ($search, $deviceId): string {
|
||||
$query = ['module' => 'connections', 'action' => 'list'];
|
||||
if ($search !== '') {
|
||||
$query['search'] = $search;
|
||||
}
|
||||
if ($deviceId > 0) {
|
||||
$query['device_id'] = $deviceId;
|
||||
}
|
||||
foreach ($extra as $key => $value) {
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
$query[$key] = $value;
|
||||
}
|
||||
return '?' . http_build_query($query);
|
||||
};
|
||||
?>
|
||||
|
||||
<div class="connections-container">
|
||||
<h1>Netzwerkverbindungen</h1>
|
||||
<div class="connections-layout">
|
||||
<div class="connections-container">
|
||||
<h1>Netzwerkverbindungen</h1>
|
||||
|
||||
<!-- =========================
|
||||
Filter-Toolbar
|
||||
========================= -->
|
||||
<div class="filter-form">
|
||||
<form method="GET">
|
||||
<input type="hidden" name="module" value="connections">
|
||||
<input type="hidden" name="action" value="list">
|
||||
<div class="filter-form">
|
||||
<form method="GET">
|
||||
<input type="hidden" name="module" value="connections">
|
||||
<input type="hidden" name="action" value="list">
|
||||
|
||||
<input type="text" name="search" placeholder="Suche nach Gerät oder Port…"
|
||||
value="<?php echo htmlspecialchars($search); ?>" class="search-input">
|
||||
<input type="text" name="search" placeholder="Suche nach Geraet oder Port..."
|
||||
value="<?php echo htmlspecialchars($search); ?>" class="search-input">
|
||||
|
||||
<select name="device_id">
|
||||
<option value="">- Alle Geräte -</option>
|
||||
<?php foreach ($devices as $device): ?>
|
||||
<option value="<?php echo $device['id']; ?>"
|
||||
<?php echo $device['id'] === $deviceId ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($device['name']); ?>
|
||||
</option>
|
||||
<select name="device_id">
|
||||
<option value="">- Alle Geraete -</option>
|
||||
<?php foreach ($devices as $device): ?>
|
||||
<option value="<?php echo (int)$device['id']; ?>"
|
||||
<?php echo ((int)$device['id'] === $deviceId) ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars((string)$device['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<button type="submit" class="button">Filter</button>
|
||||
<a href="?module=connections&action=list" class="button">Reset</a>
|
||||
<a href="?module=connections&action=edit" class="button button-primary">+ Neue Verbindung</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($connections)): ?>
|
||||
<table class="connections-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Von (Geraet -> Port)</th>
|
||||
<th>Nach (Geraet -> Port)</th>
|
||||
<th>VLANs</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($connections as $conn): ?>
|
||||
<?php
|
||||
$connId = (int)($conn['id'] ?? 0);
|
||||
$comment = trim((string)($conn['comment'] ?? ''));
|
||||
$hasMissingInfo = empty($conn['endpoint_a_name']) || empty($conn['endpoint_b_name'])
|
||||
|| empty($conn['port_a_name']) || empty($conn['port_b_name']);
|
||||
$commentLower = mb_strtolower($comment, 'UTF-8');
|
||||
$warningFromComment = preg_match('/warn|achtung|critical/', $commentLower) === 1;
|
||||
$hasWarning = $hasMissingInfo || $warningFromComment;
|
||||
$rowClass = $connId === $selectedConnectionId ? 'connection-row-selected' : '';
|
||||
$vlanList = [];
|
||||
if (!empty($conn['vlan_config'])) {
|
||||
$vlanList = (array)json_decode((string)$conn['vlan_config'], true);
|
||||
}
|
||||
?>
|
||||
<tr class="<?php echo $rowClass; ?>">
|
||||
<td>
|
||||
<strong><?php echo htmlspecialchars((string)($conn['endpoint_a_name'] ?? 'N/A')); ?></strong><br>
|
||||
<small><?php echo htmlspecialchars((string)($conn['port_a_name'] ?? '-')); ?></small>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<strong><?php echo htmlspecialchars((string)($conn['endpoint_b_name'] ?? 'N/A')); ?></strong><br>
|
||||
<small><?php echo htmlspecialchars((string)($conn['port_b_name'] ?? '-')); ?></small>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<small><?php echo !empty($vlanList) ? htmlspecialchars(implode(', ', $vlanList)) : '-'; ?></small>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<small><?php echo htmlspecialchars($comment); ?></small>
|
||||
</td>
|
||||
|
||||
<td class="status-cell">
|
||||
<?php if ($hasWarning): ?>
|
||||
<span class="status-badge status-badge-warning" title="Unvollstaendige oder kritische Verbindung">Warnung</span>
|
||||
<?php else: ?>
|
||||
<span class="status-badge status-badge-ok" title="Verbindung vollstaendig">OK</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
|
||||
<td class="actions">
|
||||
<a href="<?php echo htmlspecialchars($buildListUrl(['connection_id' => $connId])); ?>" class="button button-small">Details</a>
|
||||
<a href="?module=connections&action=edit&id=<?php echo $connId; ?>" class="button button-small">Bearbeiten</a>
|
||||
<a href="?module=connections&action=swap&id=<?php echo $connId; ?>" class="button button-small" onclick="return confirm('Von/Nach fuer diese Verbindung vertauschen?');">Von/Nach tauschen</a>
|
||||
<a href="?module=connections&action=delete&id=<?php echo $connId; ?>" class="button button-small button-danger"
|
||||
data-confirm-delete="true"
|
||||
data-confirm-message="Diese Verbindung wirklich loeschen?">
|
||||
Loeschen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<button type="submit" class="button">Filter</button>
|
||||
<a href="?module=connections&action=list" class="button">Reset</a>
|
||||
<a href="?module=connections&action=edit" class="button button-primary">+ Neue Verbindung</a>
|
||||
</form>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php else: ?>
|
||||
<div class="empty-state">
|
||||
<p>Keine Verbindungen gefunden.</p>
|
||||
<p>
|
||||
<a href="?module=connections&action=edit" class="button button-primary">
|
||||
Erste Verbindung anlegen
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- =========================
|
||||
Verbindungs-Tabelle
|
||||
========================= -->
|
||||
<?php if (!empty($connections)): ?>
|
||||
<table class="connections-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Von (Gerät → Port)</th>
|
||||
<th>Nach (Gerät → Port)</th>
|
||||
<th>VLANs</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($connections as $conn): ?>
|
||||
<?php
|
||||
$comment = trim($conn['comment'] ?? '');
|
||||
$hasMissingInfo = empty($conn['endpoint_a_name']) || empty($conn['endpoint_b_name'])
|
||||
|| empty($conn['port_a_name']) || empty($conn['port_b_name']);
|
||||
$commentLower = mb_strtolower($comment, 'UTF-8');
|
||||
$warningFromComment = preg_match('/warn|achtung|critical/', $commentLower);
|
||||
$hasWarning = $hasMissingInfo || $warningFromComment;
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><?php echo htmlspecialchars($conn['endpoint_a_name'] ?? 'N/A'); ?></strong><br>
|
||||
<small><?php echo htmlspecialchars($conn['port_a_name'] ?? '—'); ?></small>
|
||||
</td>
|
||||
<aside class="connections-sidebar">
|
||||
<section class="sidebar-card">
|
||||
<h3>Ausgewaehlte Verbindung</h3>
|
||||
<?php if ($selectedConnection): ?>
|
||||
<?php
|
||||
$selectedConnId = (int)($selectedConnection['id'] ?? 0);
|
||||
$selectedVlans = [];
|
||||
if (!empty($selectedConnection['vlan_config'])) {
|
||||
$selectedVlans = (array)json_decode((string)$selectedConnection['vlan_config'], true);
|
||||
}
|
||||
?>
|
||||
<p><strong>ID:</strong> #<?php echo $selectedConnId; ?></p>
|
||||
<p><strong>Von:</strong><br>
|
||||
<?php echo htmlspecialchars((string)($selectedConnection['endpoint_a_name'] ?? 'N/A')); ?><br>
|
||||
<small><?php echo htmlspecialchars((string)($selectedConnection['port_a_name'] ?? '-')); ?></small>
|
||||
</p>
|
||||
<p><strong>Nach:</strong><br>
|
||||
<?php echo htmlspecialchars((string)($selectedConnection['endpoint_b_name'] ?? 'N/A')); ?><br>
|
||||
<small><?php echo htmlspecialchars((string)($selectedConnection['port_b_name'] ?? '-')); ?></small>
|
||||
</p>
|
||||
<p><strong>VLANs:</strong> <?php echo !empty($selectedVlans) ? htmlspecialchars(implode(', ', $selectedVlans)) : '-'; ?></p>
|
||||
<p><strong>Kommentar:</strong> <?php echo htmlspecialchars((string)($selectedConnection['comment'] ?? '-')); ?></p>
|
||||
<div class="sidebar-actions">
|
||||
<a href="?module=connections&action=edit&id=<?php echo $selectedConnId; ?>" class="button button-small">Bearbeiten</a>
|
||||
<a href="?module=connections&action=swap&id=<?php echo $selectedConnId; ?>" class="button button-small" onclick="return confirm('Von/Nach fuer diese Verbindung vertauschen?');">Tauschen</a>
|
||||
<a href="?module=connections&action=delete&id=<?php echo $selectedConnId; ?>" class="button button-small button-danger" data-confirm-delete="true" data-confirm-message="Diese Verbindung wirklich loeschen?">Loeschen</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p><em>Keine Verbindung ausgewaehlt.</em></p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<td>
|
||||
<strong><?php echo htmlspecialchars($conn['endpoint_b_name'] ?? 'N/A'); ?></strong><br>
|
||||
<small><?php echo htmlspecialchars($conn['port_b_name'] ?? '—'); ?></small>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<small>
|
||||
<?php
|
||||
if ($conn['vlan_config']) {
|
||||
$vlan = json_decode($conn['vlan_config'], true);
|
||||
echo htmlspecialchars(implode(', ', (array)$vlan));
|
||||
} else {
|
||||
echo '—';
|
||||
}
|
||||
?>
|
||||
</small>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<small><?php echo htmlspecialchars($conn['comment'] ?? ''); ?></small>
|
||||
</td>
|
||||
<td class="status-cell">
|
||||
<?php if ($hasWarning): ?>
|
||||
<span class="status-badge status-badge-warning" title="Unvollständige oder kritische Verbindung">
|
||||
⚠️ Warnung
|
||||
</span>
|
||||
<section class="sidebar-card">
|
||||
<?php if ($selectedDevice): ?>
|
||||
<h3>Ausgewaehltes Geraet</h3>
|
||||
<p><strong><?php echo htmlspecialchars((string)$selectedDevice['name']); ?></strong></p>
|
||||
<p>Typ: <?php echo htmlspecialchars((string)($selectedDevice['type_name'] ?? '-')); ?></p>
|
||||
<p>Ports: <?php echo (int)$selectedDevice['port_count']; ?></p>
|
||||
<p>Verbindungen: <?php echo (int)$selectedDevice['connection_count']; ?></p>
|
||||
<p>
|
||||
VLANs:
|
||||
<?php if (!empty($selectedDeviceVlans)): ?>
|
||||
<?php echo htmlspecialchars(implode(', ', $selectedDeviceVlans)); ?>
|
||||
<?php else: ?>
|
||||
<span class="status-badge status-badge-ok" title="Verbindung vollständig">
|
||||
✔️ OK
|
||||
</span>
|
||||
-
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
|
||||
<td class="actions">
|
||||
<a href="?module=connections&action=edit&id=<?php echo $conn['id']; ?>" class="button button-small">Bearbeiten</a>
|
||||
<a href="?module=connections&action=swap&id=<?php echo $conn['id']; ?>" class="button button-small" onclick="return confirm('Von/Nach fuer diese Verbindung vertauschen?');">Von/Nach tauschen</a>
|
||||
<a href="#" class="button button-small button-danger"
|
||||
data-confirm-delete="true"
|
||||
data-confirm-message="Diese Verbindung wirklich löschen?"
|
||||
data-confirm-feedback="Löschen noch nicht implementiert">
|
||||
Löschen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php else: ?>
|
||||
<div class="empty-state">
|
||||
<p>Keine Verbindungen gefunden.</p>
|
||||
<p>
|
||||
<a href="?module=connections&action=edit" class="button button-primary">
|
||||
Erste Verbindung anlegen
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php if (!empty($selectedDevicePorts)): ?>
|
||||
<h4>Ports (max. 12)</h4>
|
||||
<ul>
|
||||
<?php foreach ($selectedDevicePorts as $port): ?>
|
||||
<li><?php echo htmlspecialchars((string)$port['name']); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<h3>Ausgewaehltes Geraet</h3>
|
||||
<p><em>Bitte ein Geraet im Filter auswaehlen.</em></p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- =========================
|
||||
Sidebar / Details
|
||||
========================= -->
|
||||
<style>
|
||||
.connections-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
<aside class="sidebar">
|
||||
<?php if ($selectedDevice): ?>
|
||||
<h3>Ausgewähltes Gerät</h3>
|
||||
<p><strong><?php echo htmlspecialchars($selectedDevice['name']); ?></strong></p>
|
||||
<p>Typ: <?php echo htmlspecialchars($selectedDevice['type_name'] ?? '—'); ?></p>
|
||||
<p>Ports: <?php echo (int)$selectedDevice['port_count']; ?></p>
|
||||
<p>Verbindungen: <?php echo (int)$selectedDevice['connection_count']; ?></p>
|
||||
<p>
|
||||
VLANs:
|
||||
<?php if (!empty($selectedDeviceVlans)): ?>
|
||||
<?php echo htmlspecialchars(implode(', ', $selectedDeviceVlans)); ?>
|
||||
<?php else: ?>
|
||||
—
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php if (!empty($selectedDevicePorts)): ?>
|
||||
<h4>Ports (max. 12)</h4>
|
||||
<ul>
|
||||
<?php foreach ($selectedDevicePorts as $port): ?>
|
||||
<li><?php echo htmlspecialchars($port['name']); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<p><em>Bitte ein Gerät im Filter auswählen.</em></p>
|
||||
<?php endif; ?>
|
||||
.connections-sidebar {
|
||||
position: sticky;
|
||||
top: 92px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
<!-- TODO: Verbindung bearbeiten / löschen -->
|
||||
</aside>
|
||||
.sidebar-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e6ef;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 8px 24px rgba(15, 26, 45, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-card h3,
|
||||
.sidebar-card h4 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.sidebar-card p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.sidebar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.connection-row-selected {
|
||||
background: #edf5ff;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.connections-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.connections-sidebar {
|
||||
position: static;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
<!-- JS -->
|
||||
<script src="/assets/js/app.js" defer></script>
|
||||
<script src="/assets/js/dashboard.js" defer></script>
|
||||
<script src="/assets/js/svg-editor.js" defer></script>
|
||||
<script src="/assets/js/network-view.js" defer></script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user