Compare commits
2 Commits
20638cb3a5
...
17a5bc4812
| Author | SHA1 | Date | |
|---|---|---|---|
| 17a5bc4812 | |||
| 4dc1530402 |
175
NEXT_STEPS.md
175
NEXT_STEPS.md
@@ -1,162 +1,21 @@
|
|||||||
# 📋 NÄCHSTE ARBEITSPAKETE
|
# NEXT_STEPS
|
||||||
|
|
||||||
## 🎯 Für die nächsten Sessions
|
## Stand
|
||||||
|
- Letzte Pflege: 18. Februar 2026
|
||||||
|
- Quelle für Issues: lokale Referenzen aus Repository (`NEXT_STEPS.md`, `BUGS.md`, Code-Check)
|
||||||
|
- Hinweis: Live-Abruf via `gitea-issues` war am 18. Februar 2026 nicht möglich (Verbindung zu Gitea verweigert).
|
||||||
|
|
||||||
### Package 1: Fehlerbehandlung & Sessions (1-2h)
|
## Aktive Aufgaben (priorisiert)
|
||||||
- [x] Session-Handling in `bootstrap.php` implementieren
|
- [ ] [#10] Dashboard-Grafik erzeugen (Location/Building/Floor/Verbindungen als Hierarchie)
|
||||||
- [x] Error-Messages in Session speichern (`$_SESSION['error']`, `$_SESSION['success']`)
|
- [ ] [#5] Dashboard als zoombare und verschiebbare SVG-Fläche umsetzen (interaktive Geräte/Ports/Verbindungen)
|
||||||
- [x] Header mit Fehlermeldungen in Layout
|
- [ ] [#14] Hilfslinien der Stockwerkskarten nur im Edit-Mode anzeigen, im Anzeige-Mode ausblenden
|
||||||
- [x] Validierungsfehler anzeigen
|
- [ ] [#11] Encoding- und Umlautfehler bereinigen (inkl. Anzeige in UI-Dateien und Markdown-Dokumenten)
|
||||||
|
- [ ] [#4] `device_types/edit`: Option "Ports automatisch erstellen" nur beim Erstellen anzeigen, nicht beim Editieren
|
||||||
|
|
||||||
### Package 2: Delete-Funktionen (1h)
|
## Verifikation (Status unklar, nicht als erledigt markieren ohne Reproduktion + Commit)
|
||||||
- [x] DELETE-Endpoints für alle Module
|
- [ ] [#15] Neue Verbindung: Netzwerkdose auswählbar (Regressionstest in UI durchführen)
|
||||||
- [x] AJAX-Bestätigung vor Löschen
|
|
||||||
- [x] Kaskadierendes Löschen prüfen (z.B. Floor → Racks)
|
|
||||||
|
|
||||||
### Package 3: Port-Management (2-3h)
|
|
||||||
- [x] Ports zu Device-Types verwalten
|
|
||||||
- [x] Ports zu Devices anzeigen
|
|
||||||
- [x] Port-Status (aktiv/inaktiv)
|
|
||||||
- [x] VLAN-Zuordnung zu Ports
|
|
||||||
|
|
||||||
### 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)
|
|
||||||
- [ ] Dark-Mode (optional)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Code-Referenzen
|
|
||||||
|
|
||||||
### Template für neue CRUD-Module:
|
|
||||||
```php
|
|
||||||
// list.php: Filter + Tabelle
|
|
||||||
// edit.php: Formular
|
|
||||||
// save.php: POST-Handler mit Validierung
|
|
||||||
|
|
||||||
// Immer verwenden:
|
|
||||||
$sql->get() // SELECT mit Bind-Params
|
|
||||||
$sql->single() // SELECT LIMIT 1
|
|
||||||
$sql->set() // INSERT/UPDATE
|
|
||||||
```
|
|
||||||
|
|
||||||
### Filter-Pattern (in allen List-Modules):
|
|
||||||
```php
|
|
||||||
$where = [];
|
|
||||||
$types = '';
|
|
||||||
$params = [];
|
|
||||||
|
|
||||||
if ($search !== '') {
|
|
||||||
$where[] = "name LIKE ?";
|
|
||||||
$types .= "s";
|
|
||||||
$params[] = "%$search%";
|
|
||||||
}
|
|
||||||
|
|
||||||
$whereSql = $where ? "WHERE " . implode(" AND ", $where) : "";
|
|
||||||
// Dann in Query einsetzen
|
|
||||||
```
|
|
||||||
|
|
||||||
### Styling-Pattern:
|
|
||||||
- Buttons: `.button`, `.button-primary`, `.button-danger`, `.button-small`
|
|
||||||
- Tabellen: `.*.list` Klasse mit th/td Styling
|
|
||||||
- Forms: `.edit-form`, `.form-group`, `.form-actions`
|
|
||||||
- States: `.empty-state`, `.filter-form`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Bekannte TODOs im Code
|
|
||||||
|
|
||||||
Alle noch offenen Punkte sind mit `// TODO:` gekennzeichnet:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Alle TODOs finden:
|
|
||||||
grep -r "TODO:" app/modules/ --include="*.php"
|
|
||||||
```
|
|
||||||
|
|
||||||
Wichtigste TODOs:
|
|
||||||
- `index.php:19` - Session starten
|
|
||||||
- `*/save.php` - Fehlerbehandlung
|
|
||||||
- `connections/` - Port-Verknüpfung
|
|
||||||
- `lib/auth.php` - Auth-Logik
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💾 Datenbank-Setup
|
|
||||||
|
|
||||||
Die Datenbank wird automatisch durch `init.sql` initialisiert.
|
|
||||||
|
|
||||||
Wichtige Tabellen:
|
|
||||||
- `locations` - Standorte
|
|
||||||
- `buildings` - Gebäude
|
|
||||||
- `floors` - Stockwerke
|
|
||||||
- `rooms` - Räume
|
|
||||||
- `network_outlets` - Netzwerkdosen
|
|
||||||
- `device_types` - Gerätetypen
|
|
||||||
- `device_type_ports` - Port-Templates
|
|
||||||
- `devices` - konkrete Geräte
|
|
||||||
- `device_ports` - Gerätports
|
|
||||||
- `racks` - Racks
|
|
||||||
- `connections` - Verbindungen zwischen Ports
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing-Checklist
|
|
||||||
|
|
||||||
Bei jeder Änderung checken:
|
|
||||||
- [ ] Formular sendet Daten korrekt
|
|
||||||
- [ ] Daten werden in DB gespeichert
|
|
||||||
- [ ] Liste zeigt neue Daten
|
|
||||||
- [ ] Edit lädt existierende Daten vor
|
|
||||||
- [ ] Filter funktioniert
|
|
||||||
- [ ] Validierungsfehler werden angezeigt
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Design-Richtlinien
|
|
||||||
|
|
||||||
### Farben:
|
|
||||||
- Primary (Buttons): `#007bff` (Blau)
|
|
||||||
- Success (Speichern): `#28a745` (Grün)
|
|
||||||
- Danger (Löschen): `#dc3545` (Rot)
|
|
||||||
- Background: `#f9f9f9` (Hell)
|
|
||||||
- Border: `#ddd` (Hell-Grau)
|
|
||||||
|
|
||||||
### Spacing:
|
|
||||||
- Padding in Forms: `15px` (fieldset), `8px` (input)
|
|
||||||
- Gap zwischen Buttons: `10px`
|
|
||||||
- Margin: `20px` (oben/unten), `0` (inline)
|
|
||||||
|
|
||||||
### Schriftarten:
|
|
||||||
- Erben von HTML (derzeit: System)
|
|
||||||
- Monospace für Code/IDs: `font-family: monospace`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
|
## Hinweise zur Abarbeitung
|
||||||
|
- Vor jeder Änderung an dieser Datei offene Issues erneut laden (`gitea-issues`-Skill).
|
||||||
|
- Aufgaben hier nur mit Issue-Referenz `[#<id>]` führen.
|
||||||
|
- Aufgabe erst auf erledigt setzen, wenn Code umgesetzt und Commit mit `closes #<id>` erstellt wurde.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user