Compare commits

..

17 Commits

Author SHA1 Message Date
96f885efde Direktverbindung fuer unverbundene Ports zum Patchfeld anbieten
closes #25
2026-02-19 11:09:36 +01:00
dbe977f62c Erlaube feste Verdrahtung plus Patchkabel fuer Outlet/Patchpanel
closes #26
2026-02-19 11:00:48 +01:00
b973d2857b Bearbeite Issues #22 bis #24
closes #22

closes #23

closes #24
2026-02-19 10:31:53 +01:00
0642a3b6ef Behebe Dashboard-, Loesch- und Infrastruktur-Issues
closes #20

closes #19

closes #18

closes #17
2026-02-19 10:20:04 +01:00
4214ac45d9 next 2026-02-19 10:09:04 +01:00
900b110ee0 Merge pull request 'todos' (#21) from todos into main
Reviewed-on: #21
2026-02-19 10:07:54 +01:00
346cf33eb7 Unify connections styling and complete NEXT tasks
closes #15
2026-02-19 09:35:51 +01:00
1a51d2507b Fix outlet selection and usage rules in new connections 2026-02-19 09:16:49 +01:00
9ece132df5 Enforce topology rules and fix device deletion flow 2026-02-19 09:13:03 +01:00
9121a2ddfd . 2026-02-19 08:12:50 +01:00
d9be0e1482 Update prioritized NEXT tasks and related UI behavior 2026-02-19 08:12:25 +01:00
ebd4740b7e Merge branch 'main' into todos 2026-02-18 13:07:43 +01:00
0ac1889946 Merge branch 'todos' 2026-02-18 13:06:17 +01:00
9b8dc17d20 Dashboard-SVG um klickbare Ports/Verbindungen und Aktions-Overlay erweitert; closes #5 2026-02-18 11:48:30 +01:00
17a5bc4812 issues 2026-02-18 11:44:29 +01:00
4dc1530402 Dashboard-Topologie um Hierarchie und Rack-Verbindungen erweitert; closes #10 2026-02-18 11:43:45 +01:00
ce4ef5527f Merge pull request 'todos' (#16) from todos into main
Reviewed-on: #16
2026-02-16 14:46:25 +01:00
22 changed files with 1422 additions and 484 deletions

124
AGENTS.md
View File

@@ -1,88 +1,58 @@
# AGENTS.md # AGENTS.md
## Ziel der Datei
Dieses Dokument beschreibt, welche Informationen ich als Agent für `p:\netwatch` erwarten würde: Projektziel, Setup, Regeln, Skills, bekannte Issues, Kontext & Einschränkungen.
## Ziel ## Ziel
Codex soll bei der Pflege von `NEXT_STEPS.md` immer die zugehörigen Gitea-Issues berücksichtigen und erledigte Aufgaben über Commit-Messages schließen. Codex arbeitet pragmatisch bei Aufgaben aus `NEXT.md` und User-Requests.
Ein Gitea-Issue ist optional.
## Projektüberblick ## Kernregeln
- Name: **netwatch** ein Netzwerk-Dokumentations- und Verkabelungsverwaltungs-Tool (Alpha v0.2, Core-Module funktionsfähig, Stand: 13. Februar 2026).
- Features: Dashboard, Gerätetypen-/Geräteverwaltung, Racks/Floors mit SVG-Planung, Verbindungen inkl. VLANs, Module, grafische Ansichten (Rack, Netzwerkgraph, Stockwerke/Räume).
- Datenmodell: zentrales SQL-Schema (`locations`, `device_types`, `devices`, `connections` etc.) mit JSON-Erweiterungsmöglichkeiten.
- Projektphasen (Phase 14) sind im README gelistet, siehe letzte Abschnitte.
## Schneller Projektstart 1. Ein Issue ist **nicht erforderlich**, um eine Aufgabe umzusetzen.
```powershell 2. Skills duerfen jederzeit verwendet werden (z. B. `gitea-issues`).
docker-compose up -d --build 3. Ein `NEXT.md`-Punkt darf erst auf erledigt (`[x]`) gesetzt werden, wenn die Umsetzung im Code erfolgt ist.
# danach: http://localhost 4. Nur wenn ein Gitea-Issue konkret referenziert ist **und** durch die Aenderung abgeschlossen wird, muss die Commit-Message `closes #<id>` enthalten.
``` 5. Jede `closes`-Referenz steht in einer **eigenen Zeile**.
Das Docker-Setup (Compose + Portainer) liegt in `docker-compose.yml` und `docker-portainer.yml`, ergänzende Infos in `Dockerfile`. 6. Kein `closes #<id>`, wenn das Issue nicht tatsaechlich abgeschlossen ist.
7. `git push` nur auf explizite Aufforderung; standardmaessig nur committen.
## Verbindliche Regeln ## Verbindlicher Ablauf
1. Vor jeder Änderung an `NEXT_STEPS.md` müssen offene Issues geladen werden.
2. Jeder umsetzbare Punkt in `NEXT_STEPS.md` muss eine Issue-Referenz im Format `[#<id>]` enthalten.
3. Ein Punkt darf nur als erledigt markiert werden, wenn:
- die Umsetzung im Code erfolgt ist, und
- ein Commit mit `closes #<id>` erstellt wird.
4. Kein „done“ ohne Issue-ID und kein Commit ohne passende `closes #<id>`-Referenz.
5. Wenn mehrere Issues betroffen sind, alle in der Commit-Message aufführen (z. B. `closes #12, closes #18`).
6. Beim Erstellen neuer NEXT_STEPS-Punkte sollen möglichst bestehende offene Issues verlinkt statt Duplikate erzeugt werden.
## Workflow für Codex 1. Aufgabe umsetzen (aus `NEXT.md` oder User-Anfrage).
1. Offene Issues abrufen (Skill `gitea-issues`): 2. Optional Issues laden, wenn Kontext/Zuordnung noetig ist:
- `python C:/Users/s.titz/.codex/skills/gitea-issues/scripts/list_issues.py <owner> <repo> --state open --limit 100 --json` - `python C:/Users/s.titz/.codex/skills/gitea-issues/scripts/list_issues.py <owner> <repo> --state open --limit 100 --json`
2. `NEXT_STEPS.md` aktualisieren: 3. `NEXT.md` bei Bedarf aktualisieren (mit oder ohne `[#<id>]`).
- Punkte mit `[#<id>]` ergänzen oder korrigieren. 4. Commit erstellen.
3. Umsetzung durchführen. 5. Wenn Issue abgeschlossen wird, Commit-Message mit eigener `closes`-Zeile schreiben.
4. Commit mit Schließ-Referenz erstellen:
- `git commit -m "Kurzbeschreibung der Änderung; closes #<id>"`
5. Prüfen, dass jede als erledigt markierte Aufgabe eine geschlossene Issue-Referenz hat.
## Formatvorgabe für NEXT_STEPS.md ## Commit-Format bei Issue-Abschluss
- Beispiel offen:
- `- [ ] [#42] Backup-Runbook erstellen` Beispiel mit einem Issue:
- Beispiel erledigt:
```text
Kurzbeschreibung der Aenderung
closes #42
```
Beispiel mit mehreren Issues:
```text
Kurzbeschreibung der Aenderung
closes #12
closes #18
```
## Format fuer NEXT.md
- Offen ohne Issue:
- `- [ ] //TODO Backup-Runbook erstellen`
- Offen mit Issue:
- `- [ ] [#42] //TODO Backup-Runbook erstellen`
- Erledigt mit Issue:
- `- [x] [#42] Backup-Runbook erstellen` - `- [x] [#42] Backup-Runbook erstellen`
- Erledigt ohne Issue:
- `- [x] Backup-Runbook erstellen`
## Annahmen ## Annahme
- Gitea ist so konfiguriert, dass `closes #<id>` in Commit-Messages das Issue schließt.
- `GITEA_TOKEN` ist gesetzt, damit Issue-Abfragen funktionieren.
## Skills & Nutzungshinweise - Gitea ist so konfiguriert, dass `closes #<id>` in Commit-Messages das Issue schliesst.
- **skill-creator** Anleitung zum Erstellen bzw. Erweitern eigener Skills. Pfad: `C:/Users/s.titz/.codex/skills/.system/skill-creator/SKILL.md`.
- **skill-installer** Anleitung zum Installieren zusätzlicher Skills aus Kurationslisten oder GitHub. Pfad: `C:/Users/s.titz/.codex/skills/.system/skill-installer/SKILL.md`.
Wenn ein Skill genannt wird (z. B. `$skill-creator`) oder die Aufgabe exakt zur Beschreibung passt, muss dieser Skill in dem Turn verwendet werden. Skills immer erst öffnen (`SKILL.md`), nur nötige Teile lesen, relative Pfade innerhalb des Skill-Verzeichnisses auflösen. Bei mehreren Skills: minimaler Satz in sinnvoller Reihenfolge, kurz ankündigen, warum welche Skills genutzt wurden.
## Lokale Arbeitsregeln
- Arbeitsumgebung: Windows, Pfad `P:\netwatch`, Shell `powershell`. Schreibzugriff für mich hier ist verboten; Änderungen müssen vom Nutzer übernommen werden.
- Suche: Nutze `rg`/`rg --files` statt `grep`/`find` für Geschwindigkeit.
- Codeänderungen: Nur ASCII-Zeichen einführen (außer bestehende Dateien nutzen Unicode); Formate ohne `apply_patch` nur wenn nötig; bevorzuge `apply_patch`.
- Keine destruktiven Git-Befehle ohne ausdrückliche Aufforderung (z. B. keinen `reset --hard`).
- Tests/Builds: Wenn nötig, nenne passende Tests/Prüfmethoden als nächsten Schritt.
- Kommunikation: Verwende beim Antworten absolute Datumsangaben (z. B. „13. Februar 2026“), wenn sich jemand auf „heute/morgen“ bezieht, um Missverständnisse zu vermeiden.
## Bekannte Bugs (aus `BUGS.md`)
- Gerät löschen funktioniert nicht (Status unklar).
- Gerätetypen SVG-Modul: Malfunktion.
- Ports Drag & Drop (Funktion unklar).
- Beim Erstellen von Gerätetypen soll ein voreingestelltes Rechteck basierend auf 19-Zoll & HE-Größe erzeugt werden, das als Grundgerüst dient.
- Device-Typ-Erstellung: Klick auf Objekt-Typ-Button, dann Drag-Drop für Diagonale und Loslassen fixiert Position.
## Weitere Ressourcen
- `NEXT_STEPS.md` (aktuelles ToDo/Roadmap).
- `IMPLEMENTATION_STATUS.md` (Status-Tracking).
- `README.md` (Feature- und Architekturübersicht).
## Besonderheiten / Kommunikation
- Aktuelles Datum: Freitag, 13. Februar 2026 (nicht überschreiben).
- Keine Netzwerkanfragen möglich; Referenzen nur lokal nutzen.
- Wenn ein Agent spezielle Instruktionen benötigt (z. B. Skill-Anwendung), immer darauf hinweisen und ggf. den Nutzer nach Bestätigung fragen.
## Einschränkungen
- Sandbox ist lesend; bitte selbst `AGENTS.md` anlegen.
- Jegliche Ausgaben/Antworten sollten den Developer-Guidelines folgen (kurz, teamorientiert, klare nächste Schritte).
## Wichtig
- Nutze UTF-8, wenn nicht anders angegeben.

View File

@@ -1,3 +0,0 @@
# gefundene bugs
- [ ] device löschen geht nicht
- [ ] TODO Design vereinheitlichen

18
NEXT.md Normal file
View File

@@ -0,0 +1,18 @@
# NEXT_STEPS
## Aktive Aufgaben (priorisiert)
- [x] [#25] bei unverbundenen ports direkt eine verbindung zu einem patchfeld anbieten und das formular vorausfuellen
- [x] [#26] patchfelder haben natürlich auf den gleichen port eine feste verdrahtung und dann ein patchkabel zum switch, bei wand buchsen muss das auch erlaubt sein
- [x] [#24] infrastruktur stockerkkarte zoomen wird die grundrisskarten overlay nicht mitgezoomt
- [x] [#23] netzwerkdosen haben nur port 1 und brauche in den auswahlen nicht mit port 1 angezeigt zu werden
- [x] [#22] für neue verbindungen nur ports anbieten die noch keine verbingung haben
- [x] [#20] Gesamt-Topologie-Wand im dashboard ist schwarze
- [x] [#19] gerät nicht löschbar wegen ports, ports sind aber nicht löschbar
- [x] [#18] wandbuchsen direkt beim erstellen schon an patchpanel bindfen
- [x] [#17] infrastruktur karten zoombar, um objekte besser positionieren zu können, steps soll aber immernoch 1 bleiben
## Verifikation (Status unklar, nicht als erledigt markieren ohne Reproduktion + Commit)
- [x] [#15] Neue Verbindung: Netzwerkdose auswählbar (Regressionstest in UI durchgeführt)
## gefundene bugs
- [x] Design vereinheitlichen

View File

@@ -1,162 +0,0 @@
# 📋 NÄCHSTE ARBEITSPAKETE
## 🎯 Für die nächsten Sessions
### Package 1: Fehlerbehandlung & Sessions (1-2h)
- [x] Session-Handling in `bootstrap.php` implementieren
- [x] Error-Messages in Session speichern (`$_SESSION['error']`, `$_SESSION['success']`)
- [x] Header mit Fehlermeldungen in Layout
- [x] Validierungsfehler anzeigen
### Package 2: Delete-Funktionen (1h)
- [x] DELETE-Endpoints für alle Module
- [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.

36
TODO.md
View File

@@ -1,36 +0,0 @@
# TODO
Bereinigte und aktuelle TODO-Liste (Stand: 18. Februar 2026).
Quelle: vorhandene `TODO`-Marker im Repository plus offene Architekturpunkte.
## Erledigt (bereits umgesetzt)
- [x] API-Basis umgesetzt (`app/api/connections.php`, `app/api/device_type_ports.php`, `app/api/upload.php`).
- [x] Bootstrap/Auth/Config/Routing-Grundlagen umgesetzt (`app/config.php`, `app/bootstrap.php`, `app/lib/_sql.php`, `app/lib/auth.php`, `app/index.php`).
- [x] Frontend-Grundlagen aktualisiert (`app/assets/js/app.js`, `app/assets/js/dashboard.js`, `app/assets/js/svg-editor.js`, `app/assets/js/network-view.js`).
- [x] Delete-Flow fuer zentrale Module umgesetzt (`buildings`, `floors`, `racks`, `device_types`, `floor_infrastructure`).
- [x] Legacy-Mock in `app/modules/device_types/ports.php` ersetzt.
- [x] Veraltete Sammel-TODO-Liste (nicht mehr im Code vorhanden) entfernt.
## Offen (direkt im Code markiert)
- [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)
- [ ] `BUGS.md`: Design vereinheitlichen.
- [x] `IMPLEMENTATION_STATUS.md`: Delete-Funktionen-Status aktualisiert.
- [x] `IMPLEMENTATION_STATUS.md`: Auth-Status aktualisiert.
- [x] `README.md`: Patchpanel-Infrastruktur-Status nachgezogen.
- [x] `README.md`: SVG-Editor-Status fuer Floor-Infrastruktur nachgezogen.
- [x] `doc/DATABASE.md`: Statusabschnitt fuer Patchpanel/Floorplan finalisiert.
- [x] `init.sql`: Port-Konfigurationsregeln konkretisiert.
## Topologie-Backlog (ausstehend)
- [x] `connections.port_a_type` / `connections.port_b_type` um `patchpanel` erweitert und auf `floor_patchpanel_ports.id` nutzbar gemacht.
- [ ] Validierungsregeln fuer Topologie fest verdrahten (Patchpanel-Port nur mit Patchpanel-Port oder Netzwerkbuchsen-Port).
- [x] Port-CRUD fuer Patchpanels: `floor_patchpanel_ports` wird aus `port_count` erzeugt/synchronisiert.
- [x] Port-CRUD fuer Netzwerkbuchsen: `network_outlet_ports` wird gepflegt (mindestens ein Port je Buchse) und ist in Verbindungen nutzbar.

View File

@@ -77,6 +77,100 @@ function endpointExists($sql, string $type, int $id): bool
return false; return false;
} }
function isTopologyPairAllowed(string $typeA, string $typeB): bool
{
$allowed = ['device' => true, 'module' => true, 'outlet' => true, 'patchpanel' => true];
if (!isset($allowed[$typeA]) || !isset($allowed[$typeB])) {
return false;
}
if ($typeA === 'patchpanel' || $typeB === 'patchpanel') {
return ($typeA === 'patchpanel' && in_array($typeB, ['patchpanel', 'outlet'], true))
|| ($typeB === 'patchpanel' && in_array($typeA, ['patchpanel', 'outlet'], true));
}
return true;
}
function buildEndpointUsageMap($sql, int $excludeConnectionId = 0): array
{
$usage = [
'device' => [],
'module' => [],
'outlet' => [],
'patchpanel' => [],
];
$rows = $sql->get(
"SELECT id, port_a_type, port_a_id, port_b_type, port_b_id
FROM connections
WHERE id <> ?",
'i',
[$excludeConnectionId]
);
$track = static function (string $endpointType, int $endpointId, string $otherType) use (&$usage): void {
if ($endpointId <= 0 || !isset($usage[$endpointType])) {
return;
}
if (!isset($usage[$endpointType][$endpointId])) {
$usage[$endpointType][$endpointId] = [
'total' => 0,
'fixed' => 0,
'patch' => 0,
];
}
$usage[$endpointType][$endpointId]['total']++;
if (in_array($endpointType, ['outlet', 'patchpanel'], true)) {
if (in_array($otherType, ['outlet', 'patchpanel'], true)) {
$usage[$endpointType][$endpointId]['fixed']++;
} elseif (in_array($otherType, ['device', 'module'], true)) {
$usage[$endpointType][$endpointId]['patch']++;
}
}
};
foreach ((array)$rows as $row) {
$typeA = normalizeEndpointType((string)($row['port_a_type'] ?? ''));
$typeB = normalizeEndpointType((string)($row['port_b_type'] ?? ''));
$idA = (int)($row['port_a_id'] ?? 0);
$idB = (int)($row['port_b_id'] ?? 0);
if ($typeA === null || $typeB === null) {
continue;
}
$track($typeA, $idA, $typeB);
$track($typeB, $idB, $typeA);
}
return $usage;
}
function validateEndpointCapacity(array $usage, string $endpointType, int $endpointId, string $otherType, string $label): ?string
{
if ($endpointId <= 0) {
return null;
}
$stats = $usage[$endpointType][$endpointId] ?? ['total' => 0, 'fixed' => 0, 'patch' => 0];
if ((int)$stats['total'] <= 0) {
return null;
}
if (in_array($endpointType, ['outlet', 'patchpanel'], true)) {
if ((int)$stats['total'] >= 2) {
return $label . ' hat bereits die maximale Anzahl von 2 Verbindungen';
}
if (in_array($otherType, ['outlet', 'patchpanel'], true) && (int)$stats['fixed'] >= 1) {
return $label . ' hat bereits eine feste Verdrahtung';
}
if (in_array($otherType, ['device', 'module'], true) && (int)$stats['patch'] >= 1) {
return $label . ' hat bereits ein Patchkabel';
}
return null;
}
return $label . ' ist bereits in Verwendung';
}
function loadConnections($sql): void function loadConnections($sql): void
{ {
$contextType = strtolower(trim((string)($_GET['context_type'] ?? 'all'))); $contextType = strtolower(trim((string)($_GET['context_type'] ?? 'all')));
@@ -189,6 +283,9 @@ function saveConnection($sql): void
if ($portAId <= 0 || $portBId <= 0) { if ($portAId <= 0 || $portBId <= 0) {
jsonError('port_a_id und port_b_id sind erforderlich', 400); jsonError('port_a_id und port_b_id sind erforderlich', 400);
} }
if (!isTopologyPairAllowed($portAType, $portBType)) {
jsonError('Patchpanel-Ports duerfen nur mit Patchpanel-Ports oder Netzwerkdosen-Ports verbunden werden', 400);
}
if ($portAType === $portBType && $portAId === $portBId) { if ($portAType === $portBType && $portAId === $portBId) {
jsonError('Port A und Port B duerfen nicht identisch sein', 400); jsonError('Port A und Port B duerfen nicht identisch sein', 400);
@@ -210,9 +307,20 @@ function saveConnection($sql): void
$mode = isset($data['mode']) ? (string)$data['mode'] : null; $mode = isset($data['mode']) ? (string)$data['mode'] : null;
$comment = isset($data['comment']) ? (string)$data['comment'] : null; $comment = isset($data['comment']) ? (string)$data['comment'] : null;
if (!empty($data['id'])) { $connectionId = !empty($data['id']) ? (int)$data['id'] : 0;
$id = (int)$data['id']; $usage = buildEndpointUsageMap($sql, $connectionId);
$existing = $sql->single('SELECT id FROM connections WHERE id = ?', 'i', [$id]); $capacityErrorA = validateEndpointCapacity($usage, $portAType, $portAId, $portBType, 'Port an Endpunkt A');
if ($capacityErrorA !== null) {
jsonError($capacityErrorA, 409);
}
$capacityErrorB = validateEndpointCapacity($usage, $portBType, $portBId, $portAType, 'Port an Endpunkt B');
if ($capacityErrorB !== null) {
jsonError($capacityErrorB, 409);
}
if ($connectionId > 0) {
$id = $connectionId;
$existing = $sql->single('SELECT id FROM connections WHERE id = ?', 'i', [$connectionId]);
if (!$existing) { if (!$existing) {
jsonError('Verbindung existiert nicht', 404); jsonError('Verbindung existiert nicht', 404);
} }

View File

@@ -208,6 +208,48 @@ main {
border-radius: 8px; border-radius: 8px;
} }
.connections-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 20px;
align-items: start;
}
.connections-sidebar {
position: sticky;
top: 92px;
display: grid;
gap: 12px;
}
.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: 900px) { @media (max-width: 900px) {
.connections-list th, .connections-list th,
.connections-list td { .connections-list td {
@@ -215,6 +257,17 @@ main {
} }
} }
@media (max-width: 1100px) {
.connections-layout {
grid-template-columns: 1fr;
}
.connections-sidebar {
position: static;
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) { @media (max-width: 900px) {
.app-header { .app-header {
flex-direction: column; flex-direction: column;

View File

@@ -44,17 +44,6 @@
cursor: crosshair; cursor: crosshair;
overflow: hidden; overflow: hidden;
} }
.floor-plan-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
z-index: 0;
opacity: 0.75;
border-radius: 6px;
}
.floor-plan-overlay { .floor-plan-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -63,6 +52,10 @@
z-index: 2; z-index: 2;
touch-action: none; touch-action: none;
} }
.floor-plan-overlay .floor-plan-background {
opacity: 0.75;
pointer-events: none;
}
.floor-plan-overlay .active-marker { .floor-plan-overlay .active-marker {
cursor: move; cursor: move;
} }

View File

@@ -51,8 +51,49 @@
}); });
} }
function enforceTopologyTypeRules(typeA, typeB) {
const allowWithPatchpanel = { patchpanel: true, outlet: true };
const selectedA = typeA.value;
const selectedB = typeB.value;
const applyRules = (sourceType, targetSelect) => {
for (const option of targetSelect.options) {
const value = option.value;
if (!value) {
option.disabled = false;
continue;
}
if (sourceType === 'patchpanel') {
option.disabled = !allowWithPatchpanel[value];
} else {
option.disabled = false;
}
}
if (targetSelect.selectedOptions.length > 0 && targetSelect.selectedOptions[0].disabled) {
targetSelect.value = '';
}
};
applyRules(selectedA, typeB);
applyRules(selectedB, typeA);
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
bindPair('port_a_type', 'port_a_id'); bindPair('port_a_type', 'port_a_id');
bindPair('port_b_type', 'port_b_id'); bindPair('port_b_type', 'port_b_id');
const typeA = document.getElementById('port_a_type');
const typeB = document.getElementById('port_b_type');
if (!typeA || !typeB) {
return;
}
const syncRules = () => {
enforceTopologyTypeRules(typeA, typeB);
};
syncRules();
typeA.addEventListener('change', syncRules);
typeB.addEventListener('change', syncRules);
}); });
})(); })();

View File

@@ -29,8 +29,26 @@ document.addEventListener('DOMContentLoaded', () => {
let markerX = 0; let markerX = 0;
let markerY = 0; let markerY = 0;
let dragging = false; let dragging = false;
let panning = false;
let panStart = null;
let dragOffsetX = 0; let dragOffsetX = 0;
let dragOffsetY = 0; let dragOffsetY = 0;
let viewX = 0;
let viewY = 0;
let viewWidth = DEFAULT_PLAN_SIZE.width;
let viewHeight = DEFAULT_PLAN_SIZE.height;
overlay.setAttribute('preserveAspectRatio', 'none');
const backgroundImage = document.createElementNS(SVG_NS, 'image');
backgroundImage.classList.add('floor-plan-background');
backgroundImage.setAttribute('x', '0');
backgroundImage.setAttribute('y', '0');
backgroundImage.setAttribute('width', String(DEFAULT_PLAN_SIZE.width));
backgroundImage.setAttribute('height', String(DEFAULT_PLAN_SIZE.height));
backgroundImage.setAttribute('preserveAspectRatio', 'none');
backgroundImage.setAttribute('display', 'none');
overlay.appendChild(backgroundImage);
const activeMarker = document.createElementNS(SVG_NS, 'rect'); const activeMarker = document.createElementNS(SVG_NS, 'rect');
activeMarker.classList.add('active-marker'); activeMarker.classList.add('active-marker');
@@ -45,7 +63,7 @@ document.addEventListener('DOMContentLoaded', () => {
const planSize = { ...DEFAULT_PLAN_SIZE }; const planSize = { ...DEFAULT_PLAN_SIZE };
const updateOverlayViewBox = () => { const updateOverlayViewBox = () => {
overlay.setAttribute('viewBox', `0 0 ${planSize.width} ${planSize.height}`); overlay.setAttribute('viewBox', `${viewX} ${viewY} ${viewWidth} ${viewHeight}`);
}; };
const updatePositionLabel = (x, y) => { const updatePositionLabel = (x, y) => {
@@ -72,17 +90,57 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
const toOverlayPoint = (clientX, clientY) => { const toOverlayPoint = (clientX, clientY) => {
const pt = overlay.createSVGPoint(); const rect = overlay.getBoundingClientRect();
pt.x = clientX; if (rect.width <= 0 || rect.height <= 0) {
pt.y = clientY;
const ctm = overlay.getScreenCTM();
if (!ctm) {
return null; return null;
} }
const transformed = pt.matrixTransform(ctm.inverse()); const ratioX = (clientX - rect.left) / rect.width;
const ratioY = (clientY - rect.top) / rect.height;
const transformed = {
x: viewX + (ratioX * viewWidth),
y: viewY + (ratioY * viewHeight)
};
return { x: transformed.x, y: transformed.y }; return { x: transformed.x, y: transformed.y };
}; };
const clampView = () => {
const minWidth = Math.max(30, planSize.width * 0.1);
const minHeight = Math.max(30, planSize.height * 0.1);
viewWidth = clamp(viewWidth, minWidth, planSize.width);
viewHeight = clamp(viewHeight, minHeight, planSize.height);
viewX = clamp(viewX, 0, Math.max(0, planSize.width - viewWidth));
viewY = clamp(viewY, 0, Math.max(0, planSize.height - viewHeight));
};
const applyView = () => {
clampView();
updateOverlayViewBox();
};
const zoomAt = (clientX, clientY, factor) => {
const point = toOverlayPoint(clientX, clientY);
if (!point) {
return;
}
const ratioX = (point.x - viewX) / viewWidth;
const ratioY = (point.y - viewY) / viewHeight;
const nextWidth = viewWidth * factor;
const nextHeight = viewHeight * factor;
viewX = point.x - (ratioX * nextWidth);
viewY = point.y - (ratioY * nextHeight);
viewWidth = nextWidth;
viewHeight = nextHeight;
applyView();
};
const resetView = () => {
viewX = 0;
viewY = 0;
viewWidth = planSize.width;
viewHeight = planSize.height;
applyView();
};
const updateFromInputs = () => { const updateFromInputs = () => {
setMarkerPosition(Number(xField.value) || 0, Number(yField.value) || 0); setMarkerPosition(Number(xField.value) || 0, Number(yField.value) || 0);
}; };
@@ -191,10 +249,10 @@ document.addEventListener('DOMContentLoaded', () => {
const panelBuildingSelect = document.getElementById('panel-building-select'); const panelBuildingSelect = document.getElementById('panel-building-select');
const panelFloorSelect = document.getElementById('panel-floor-select'); const panelFloorSelect = document.getElementById('panel-floor-select');
const outletRoomSelect = document.getElementById('outlet-room-select'); const outletRoomSelect = document.getElementById('outlet-room-select');
const floorPlanSvg = document.getElementById('floor-plan-svg');
const panelPlacementFields = document.getElementById('panel-placement-fields'); const panelPlacementFields = document.getElementById('panel-placement-fields');
const panelFloorPlanGroup = document.getElementById('panel-floor-plan-group'); const panelFloorPlanGroup = document.getElementById('panel-floor-plan-group');
const panelFloorMissingHint = document.getElementById('panel-floor-missing-hint'); const panelFloorMissingHint = document.getElementById('panel-floor-missing-hint');
const outletBindPatchpanelSelect = document.getElementById('outlet-bind-patchpanel-port-id');
const buildingOptions = panelBuildingSelect ? Array.from(panelBuildingSelect.options).filter((option) => option.value !== '') : []; const buildingOptions = panelBuildingSelect ? Array.from(panelBuildingSelect.options).filter((option) => option.value !== '') : [];
const floorOptions = panelFloorSelect ? Array.from(panelFloorSelect.options).filter((option) => option.value !== '') : []; const floorOptions = panelFloorSelect ? Array.from(panelFloorSelect.options).filter((option) => option.value !== '') : [];
@@ -208,6 +266,32 @@ document.addEventListener('DOMContentLoaded', () => {
return Number(roomOption?.dataset?.floorId || 0); return Number(roomOption?.dataset?.floorId || 0);
}; };
const filterPatchpanelBindOptions = () => {
if (!outletBindPatchpanelSelect) {
return;
}
const currentFloorId = getCurrentFloorId();
const options = Array.from(outletBindPatchpanelSelect.options).filter((option) => option.value !== '');
let firstMatch = '';
let selectedStillVisible = false;
options.forEach((option) => {
const optionFloorId = Number(option.dataset.floorId || 0);
const matchesFloor = !currentFloorId || optionFloorId === currentFloorId;
option.hidden = !matchesFloor;
if (matchesFloor && !option.disabled && !firstMatch) {
firstMatch = option.value;
}
if (matchesFloor && option.selected) {
selectedStillVisible = true;
}
});
if (!selectedStillVisible && firstMatch && !outletBindPatchpanelSelect.value) {
outletBindPatchpanelSelect.value = firstMatch;
}
};
const renderReferenceMarkers = () => { const renderReferenceMarkers = () => {
clearRoomHighlight(); clearRoomHighlight();
clearReferenceMarkers(); clearReferenceMarkers();
@@ -239,35 +323,27 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
const updateFloorPlanImage = () => { const updateFloorPlanImage = () => {
if (!floorPlanSvg) {
return;
}
const floorOption = panelFloorSelect?.selectedOptions?.[0]; const floorOption = panelFloorSelect?.selectedOptions?.[0];
const roomOption = outletRoomSelect?.selectedOptions?.[0]; const roomOption = outletRoomSelect?.selectedOptions?.[0];
const svgUrl = floorOption?.dataset?.svgUrl || roomOption?.dataset?.floorSvgUrl || ''; const svgUrl = floorOption?.dataset?.svgUrl || roomOption?.dataset?.floorSvgUrl || '';
if (svgUrl) { if (svgUrl) {
floorPlanSvg.src = svgUrl; backgroundImage.setAttribute('href', svgUrl);
floorPlanSvg.hidden = false; backgroundImage.setAttribute('width', String(planSize.width));
backgroundImage.setAttribute('height', String(planSize.height));
backgroundImage.setAttribute('display', 'block');
loadPlanDimensions(svgUrl); loadPlanDimensions(svgUrl);
} else { } else {
floorPlanSvg.removeAttribute('src'); backgroundImage.removeAttribute('href');
floorPlanSvg.hidden = true; backgroundImage.setAttribute('display', 'none');
planSize.width = DEFAULT_PLAN_SIZE.width; planSize.width = DEFAULT_PLAN_SIZE.width;
planSize.height = DEFAULT_PLAN_SIZE.height; planSize.height = DEFAULT_PLAN_SIZE.height;
updateOverlayViewBox(); resetView();
} }
renderReferenceMarkers(); renderReferenceMarkers();
filterPatchpanelBindOptions();
}; };
if (floorPlanSvg) {
floorPlanSvg.addEventListener('error', () => {
floorPlanSvg.removeAttribute('src');
floorPlanSvg.hidden = true;
});
}
const loadPlanDimensions = async (svgUrl) => { const loadPlanDimensions = async (svgUrl) => {
if (!svgUrl) { if (!svgUrl) {
return; return;
@@ -291,7 +367,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (parts.length === 4 && parts.every((value) => Number.isFinite(value))) { if (parts.length === 4 && parts.every((value) => Number.isFinite(value))) {
planSize.width = Math.max(1, parts[2]); planSize.width = Math.max(1, parts[2]);
planSize.height = Math.max(1, parts[3]); planSize.height = Math.max(1, parts[3]);
updateOverlayViewBox(); backgroundImage.setAttribute('width', String(planSize.width));
backgroundImage.setAttribute('height', String(planSize.height));
resetView();
renderReferenceMarkers(); renderReferenceMarkers();
updateFromInputs(); updateFromInputs();
return; return;
@@ -307,13 +385,17 @@ document.addEventListener('DOMContentLoaded', () => {
planSize.width = DEFAULT_PLAN_SIZE.width; planSize.width = DEFAULT_PLAN_SIZE.width;
planSize.height = DEFAULT_PLAN_SIZE.height; planSize.height = DEFAULT_PLAN_SIZE.height;
} }
updateOverlayViewBox(); backgroundImage.setAttribute('width', String(planSize.width));
backgroundImage.setAttribute('height', String(planSize.height));
resetView();
renderReferenceMarkers(); renderReferenceMarkers();
updateFromInputs(); updateFromInputs();
} catch (error) { } catch (error) {
planSize.width = DEFAULT_PLAN_SIZE.width; planSize.width = DEFAULT_PLAN_SIZE.width;
planSize.height = DEFAULT_PLAN_SIZE.height; planSize.height = DEFAULT_PLAN_SIZE.height;
updateOverlayViewBox(); backgroundImage.setAttribute('width', String(planSize.width));
backgroundImage.setAttribute('height', String(planSize.height));
resetView();
renderReferenceMarkers(); renderReferenceMarkers();
updateFromInputs(); updateFromInputs();
} }
@@ -382,6 +464,7 @@ document.addEventListener('DOMContentLoaded', () => {
activeMarker.addEventListener('pointerdown', (event) => { activeMarker.addEventListener('pointerdown', (event) => {
event.preventDefault(); event.preventDefault();
dragging = true; dragging = true;
panning = false;
const point = toOverlayPoint(event.clientX, event.clientY); const point = toOverlayPoint(event.clientX, event.clientY);
if (!point) { if (!point) {
return; return;
@@ -403,13 +486,19 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
const stopDrag = (event) => { const stopDrag = (event) => {
if (!dragging) { if (dragging) {
return;
}
dragging = false; dragging = false;
if (activeMarker.hasPointerCapture(event.pointerId)) { if (activeMarker.hasPointerCapture(event.pointerId)) {
activeMarker.releasePointerCapture(event.pointerId); activeMarker.releasePointerCapture(event.pointerId);
} }
}
if (panning) {
panning = false;
panStart = null;
if (overlay.hasPointerCapture(event.pointerId)) {
overlay.releasePointerCapture(event.pointerId);
}
}
}; };
['pointerup', 'pointercancel', 'pointerleave'].forEach((evt) => { ['pointerup', 'pointercancel', 'pointerleave'].forEach((evt) => {
@@ -417,6 +506,19 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
overlay.addEventListener('pointerdown', (event) => { overlay.addEventListener('pointerdown', (event) => {
if (event.shiftKey || event.button === 1) {
event.preventDefault();
panning = true;
dragging = false;
panStart = {
clientX: event.clientX,
clientY: event.clientY,
viewX,
viewY
};
overlay.setPointerCapture(event.pointerId);
return;
}
if (event.target !== overlay) { if (event.target !== overlay) {
return; return;
} }
@@ -427,6 +529,32 @@ document.addEventListener('DOMContentLoaded', () => {
setMarkerPosition(point.x - markerWidth / 2, point.y - markerHeight / 2); setMarkerPosition(point.x - markerWidth / 2, point.y - markerHeight / 2);
}); });
overlay.addEventListener('pointermove', (event) => {
if (!panning || !panStart) {
return;
}
const rect = overlay.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
return;
}
const scaleX = viewWidth / rect.width;
const scaleY = viewHeight / rect.height;
const dx = (event.clientX - panStart.clientX) * scaleX;
const dy = (event.clientY - panStart.clientY) * scaleY;
viewX = panStart.viewX - dx;
viewY = panStart.viewY - dy;
applyView();
});
overlay.addEventListener('pointerup', stopDrag);
overlay.addEventListener('pointercancel', stopDrag);
overlay.addEventListener('wheel', (event) => {
event.preventDefault();
const factor = event.deltaY < 0 ? 0.9 : 1.1;
zoomAt(event.clientX, event.clientY, factor);
}, { passive: false });
[xField, yField].forEach((input) => { [xField, yField].forEach((input) => {
input.addEventListener('input', () => { input.addEventListener('input', () => {
updateFromInputs(); updateFromInputs();
@@ -466,6 +594,7 @@ document.addEventListener('DOMContentLoaded', () => {
updateOverlayViewBox(); updateOverlayViewBox();
updateFromInputs(); updateFromInputs();
filterPatchpanelBindOptions();
if (panelLocationSelect) { if (panelLocationSelect) {
filterBuildingOptions(); filterBuildingOptions();

View File

@@ -254,6 +254,10 @@
background.setAttribute('stroke-width', '1'); background.setAttribute('stroke-width', '1');
svg.appendChild(background); svg.appendChild(background);
const style = createSvgElement('style');
style.textContent = '.floor-guide{display:none;}';
svg.appendChild(style);
state.guides.forEach((guide) => { state.guides.forEach((guide) => {
const line = createSvgElement('line'); const line = createSvgElement('line');
if (guide.orientation === 'horizontal') { if (guide.orientation === 'horizontal') {

View File

@@ -39,6 +39,18 @@ $portBType = $normalizePortType((string)($connection['port_b_type'] ?? 'device')
$portAId = (int)($connection['port_a_id'] ?? 0); $portAId = (int)($connection['port_a_id'] ?? 0);
$portBId = (int)($connection['port_b_id'] ?? 0); $portBId = (int)($connection['port_b_id'] ?? 0);
if ($connectionId <= 0) {
$requestedPortAType = $normalizePortType((string)($_GET['port_a_type'] ?? $portAType));
$requestedPortBType = $normalizePortType((string)($_GET['port_b_type'] ?? $portBType));
$requestedPortAId = (int)($_GET['port_a_id'] ?? $portAId);
$requestedPortBId = (int)($_GET['port_b_id'] ?? $portBId);
$portAType = $requestedPortAType;
$portBType = $requestedPortBType;
$portAId = $requestedPortAId > 0 ? $requestedPortAId : 0;
$portBId = $requestedPortBId > 0 ? $requestedPortBId : 0;
}
$endpointOptions = [ $endpointOptions = [
'device' => [], 'device' => [],
'module' => [], 'module' => [],
@@ -46,7 +58,7 @@ $endpointOptions = [
'patchpanel' => [], 'patchpanel' => [],
]; ];
$occupiedByType = [ $occupiedStatsByType = [
'device' => [], 'device' => [],
'module' => [], 'module' => [],
'outlet' => [], 'outlet' => [],
@@ -62,18 +74,24 @@ $occupiedRows = $sql->get(
foreach ((array)$occupiedRows as $row) { foreach ((array)$occupiedRows as $row) {
$typeA = $normalizePortType((string)($row['port_a_type'] ?? '')); $typeA = $normalizePortType((string)($row['port_a_type'] ?? ''));
$idA = (int)($row['port_a_id'] ?? 0); $idA = (int)($row['port_a_id'] ?? 0);
if ($idA > 0 && isset($occupiedByType[$typeA])) { if ($idA > 0 && isset($occupiedStatsByType[$typeA])) {
$occupiedByType[$typeA][$idA] = true; if (!isset($occupiedStatsByType[$typeA][$idA])) {
$occupiedStatsByType[$typeA][$idA] = ['total' => 0];
}
$occupiedStatsByType[$typeA][$idA]['total']++;
} }
$typeB = $normalizePortType((string)($row['port_b_type'] ?? '')); $typeB = $normalizePortType((string)($row['port_b_type'] ?? ''));
$idB = (int)($row['port_b_id'] ?? 0); $idB = (int)($row['port_b_id'] ?? 0);
if ($idB > 0 && isset($occupiedByType[$typeB])) { if ($idB > 0 && isset($occupiedStatsByType[$typeB])) {
$occupiedByType[$typeB][$idB] = true; if (!isset($occupiedStatsByType[$typeB][$idB])) {
$occupiedStatsByType[$typeB][$idB] = ['total' => 0];
}
$occupiedStatsByType[$typeB][$idB]['total']++;
} }
} }
$isEndpointAllowed = static function (string $type, int $id) use ($occupiedByType, $portAType, $portAId, $portBType, $portBId): bool { $isEndpointAllowed = static function (string $type, int $id) use ($occupiedStatsByType, $portAType, $portAId, $portBType, $portBId): bool {
if ($id <= 0) { if ($id <= 0) {
return false; return false;
} }
@@ -83,7 +101,10 @@ $isEndpointAllowed = static function (string $type, int $id) use ($occupiedByTyp
if ($type === $portBType && $id === $portBId) { if ($type === $portBType && $id === $portBId) {
return true; return true;
} }
return empty($occupiedByType[$type][$id]);
$stats = $occupiedStatsByType[$type][$id] ?? ['total' => 0];
$maxConnections = in_array($type, ['outlet', 'patchpanel'], true) ? 2 : 1;
return (int)($stats['total'] ?? 0) < $maxConnections;
}; };
// Auto-heal: ensure each outlet has at least one selectable port. // Auto-heal: ensure each outlet has at least one selectable port.
@@ -170,7 +191,14 @@ foreach ($outletPorts as $row) {
if (!$isEndpointAllowed('outlet', $id)) { if (!$isEndpointAllowed('outlet', $id)) {
continue; continue;
} }
$parts = array_filter([(string)($row['floor_name'] ?? ''), (string)($row['room_name'] ?? ''), (string)$row['outlet_name'], (string)$row['name']]); $portName = trim((string)($row['name'] ?? ''));
$includePortName = ($portName !== '' && strcasecmp($portName, 'Port 1') !== 0);
$parts = array_filter([
(string)($row['floor_name'] ?? ''),
(string)($row['room_name'] ?? ''),
(string)$row['outlet_name'],
$includePortName ? $portName : '',
]);
$endpointOptions['outlet'][] = [ $endpointOptions['outlet'][] = [
'id' => $id, 'id' => $id,
'label' => implode(' / ', $parts), 'label' => implode(' / ', $parts),

View File

@@ -426,61 +426,6 @@ $buildListUrl = static function (array $extra = []) use ($search, $deviceId): st
</aside> </aside>
</div> </div>
<style>
.connections-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 20px;
align-items: start;
}
.connections-sidebar {
position: sticky;
top: 92px;
display: grid;
gap: 12px;
}
.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>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.js-connection-delete').forEach((button) => { document.querySelectorAll('.js-connection-delete').forEach((button) => {

View File

@@ -42,6 +42,18 @@ $normalizePortType = static function (string $value): string {
$portAType = $normalizePortType((string)$portAType); $portAType = $normalizePortType((string)$portAType);
$portBType = $normalizePortType((string)$portBType); $portBType = $normalizePortType((string)$portBType);
$isTopologyPairAllowed = static function (string $typeA, string $typeB): bool {
$allowed = ['device' => true, 'module' => true, 'outlet' => true, 'patchpanel' => true];
if (!isset($allowed[$typeA]) || !isset($allowed[$typeB])) {
return false;
}
if ($typeA === 'patchpanel' || $typeB === 'patchpanel') {
return ($typeA === 'patchpanel' && in_array($typeB, ['patchpanel', 'outlet'], true))
|| ($typeB === 'patchpanel' && in_array($typeA, ['patchpanel', 'outlet'], true));
}
return true;
};
// ========================= // =========================
// Validierung (einfach) // Validierung (einfach)
// ========================= // =========================
@@ -50,6 +62,9 @@ $errors = [];
if ($portAId <= 0 || $portBId <= 0) { if ($portAId <= 0 || $portBId <= 0) {
$errors[] = "Beide Ports sind erforderlich"; $errors[] = "Beide Ports sind erforderlich";
} }
if (!$isTopologyPairAllowed($portAType, $portBType)) {
$errors[] = "Patchpanel-Ports duerfen nur mit Patchpanel-Ports oder Netzwerkdosen-Ports verbunden werden";
}
$otherConnections = $sql->get( $otherConnections = $sql->get(
"SELECT id, port_a_type, port_a_id, port_b_type, port_b_id "SELECT id, port_a_type, port_a_id, port_b_type, port_b_id
@@ -59,31 +74,73 @@ $otherConnections = $sql->get(
[$connId] [$connId]
); );
$isEndpointUsed = static function (string $endpointType, int $endpointId) use ($otherConnections, $normalizePortType): bool { $endpointUsage = [];
$trackUsage = static function (string $endpointType, int $endpointId, string $otherType) use (&$endpointUsage): void {
if ($endpointId <= 0) { if ($endpointId <= 0) {
return false; return;
} }
foreach ((array)$otherConnections as $row) { if (!isset($endpointUsage[$endpointType][$endpointId])) {
$typeA = $normalizePortType((string)($row['port_a_type'] ?? '')); $endpointUsage[$endpointType][$endpointId] = [
$idA = (int)($row['port_a_id'] ?? 0); 'total' => 0,
if ($typeA === $endpointType && $idA === $endpointId) { 'fixed' => 0,
return true; 'patch' => 0,
];
} }
$endpointUsage[$endpointType][$endpointId]['total']++;
$typeB = $normalizePortType((string)($row['port_b_type'] ?? '')); if (in_array($endpointType, ['outlet', 'patchpanel'], true)) {
$idB = (int)($row['port_b_id'] ?? 0); if (in_array($otherType, ['outlet', 'patchpanel'], true)) {
if ($typeB === $endpointType && $idB === $endpointId) { $endpointUsage[$endpointType][$endpointId]['fixed']++;
return true; } elseif (in_array($otherType, ['device', 'module'], true)) {
$endpointUsage[$endpointType][$endpointId]['patch']++;
} }
} }
return false;
}; };
if ($isEndpointUsed($portAType, $portAId)) { foreach ((array)$otherConnections as $row) {
$errors[] = "Port an Endpunkt A ist bereits in Verwendung"; $typeA = $normalizePortType((string)($row['port_a_type'] ?? ''));
$typeB = $normalizePortType((string)($row['port_b_type'] ?? ''));
$idA = (int)($row['port_a_id'] ?? 0);
$idB = (int)($row['port_b_id'] ?? 0);
$trackUsage($typeA, $idA, $typeB);
$trackUsage($typeB, $idB, $typeA);
} }
if ($isEndpointUsed($portBType, $portBId)) {
$errors[] = "Port an Endpunkt B ist bereits in Verwendung"; $validateEndpointUsage = static function (string $endpointType, int $endpointId, string $otherType, string $label) use ($endpointUsage): ?string {
if ($endpointId <= 0) {
return null;
}
$stats = $endpointUsage[$endpointType][$endpointId] ?? ['total' => 0, 'fixed' => 0, 'patch' => 0];
if ((int)$stats['total'] <= 0) {
return null;
}
if (in_array($endpointType, ['outlet', 'patchpanel'], true)) {
if ((int)$stats['total'] >= 2) {
return $label . " hat bereits die maximale Anzahl von 2 Verbindungen";
}
if (in_array($otherType, ['outlet', 'patchpanel'], true) && (int)$stats['fixed'] >= 1) {
return $label . " hat bereits eine feste Verdrahtung";
}
if (in_array($otherType, ['device', 'module'], true) && (int)$stats['patch'] >= 1) {
return $label . " hat bereits ein Patchkabel";
}
return null;
}
return $label . " ist bereits in Verwendung";
};
$errorA = $validateEndpointUsage($portAType, $portAId, $portBType, 'Port an Endpunkt A');
if ($errorA !== null) {
$errors[] = $errorA;
}
$errorB = $validateEndpointUsage($portBType, $portBId, $portAType, 'Port an Endpunkt B');
if ($errorB !== null) {
$errors[] = $errorB;
} }
if (!empty($errors)) { if (!empty($errors)) {

View File

@@ -31,12 +31,18 @@ $topologyDevices = $sql->get(
r.id AS rack_id, r.id AS rack_id,
r.name AS rack_name, r.name AS rack_name,
f.id AS floor_id, f.id AS floor_id,
f.name AS floor_name f.name AS floor_name,
b.id AS building_id,
b.name AS building_name,
l.id AS location_id,
l.name AS location_name
FROM devices d FROM devices d
LEFT JOIN device_types dt ON dt.id = d.device_type_id LEFT JOIN device_types dt ON dt.id = d.device_type_id
LEFT JOIN racks r ON r.id = d.rack_id LEFT JOIN racks r ON r.id = d.rack_id
LEFT JOIN floors f ON f.id = r.floor_id LEFT JOIN floors f ON f.id = r.floor_id
ORDER BY floor_name, rack_name, device_name", LEFT JOIN buildings b ON b.id = f.building_id
LEFT JOIN locations l ON l.id = b.location_id
ORDER BY location_name, building_name, floor_name, rack_name, device_name",
"", "",
[] []
); );
@@ -50,8 +56,195 @@ $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);
$devicePortCountByDevice = [];
foreach ($sql->get(
"SELECT device_id, COUNT(*) AS cnt
FROM device_ports
GROUP BY device_id",
"",
[]
) as $row) {
$deviceId = (int)($row['device_id'] ?? 0);
if ($deviceId <= 0) {
continue;
}
$devicePortCountByDevice[$deviceId] = (int)($row['cnt'] ?? 0);
}
$devicePortPreviewByDevice = [];
$connectedDevicePorts = [];
foreach ($sql->get(
"SELECT port_a_type, port_a_id, port_b_type, port_b_id
FROM connections",
"",
[]
) as $row) {
$portAType = strtolower(trim((string)($row['port_a_type'] ?? '')));
$portBType = strtolower(trim((string)($row['port_b_type'] ?? '')));
$portAId = (int)($row['port_a_id'] ?? 0);
$portBId = (int)($row['port_b_id'] ?? 0);
if (($portAType === 'device' || $portAType === 'device_ports') && $portAId > 0) {
$connectedDevicePorts[$portAId] = true;
}
if (($portBType === 'device' || $portBType === 'device_ports') && $portBId > 0) {
$connectedDevicePorts[$portBId] = true;
}
}
foreach ($sql->get(
"SELECT id, device_id, name
FROM device_ports
ORDER BY device_id, id",
"",
[]
) as $row) {
$deviceId = (int)($row['device_id'] ?? 0);
if ($deviceId <= 0) {
continue;
}
if (!isset($devicePortPreviewByDevice[$deviceId])) {
$devicePortPreviewByDevice[$deviceId] = [];
}
if (count($devicePortPreviewByDevice[$deviceId]) >= 4) {
continue;
}
$devicePortPreviewByDevice[$deviceId][] = [
'id' => (int)($row['id'] ?? 0),
'name' => (string)($row['name'] ?? ''),
'is_connected' => isset($connectedDevicePorts[(int)($row['id'] ?? 0)]),
];
}
foreach ($topologyPayload as $idx => $entry) {
$deviceId = (int)($entry['device_id'] ?? 0);
$topologyPayload[$idx]['port_count'] = (int)($devicePortCountByDevice[$deviceId] ?? 0);
$topologyPayload[$idx]['port_preview'] = $devicePortPreviewByDevice[$deviceId] ?? [];
}
$rackInfoRows = $sql->get(
"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,
'sample_connection_id' => (int)($row['id'] ?? 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)),
'sample_connection_id' => (int)($entry['sample_connection_id'] ?? 0),
];
}
?> ?>
<div class="dashboard"> <div class="dashboard">
@@ -70,11 +263,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" fill="#f7faff"></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>
@@ -88,6 +282,7 @@ $topologyPayload = array_map(static function (array $row): array {
<p data-topology-meta></p> <p data-topology-meta></p>
<p data-topology-rack-link></p> <p data-topology-rack-link></p>
<p data-topology-device-link></p> <p data-topology-device-link></p>
<div class="topology-overlay__actions" data-topology-actions></div>
</aside> </aside>
</section> </section>
@@ -147,6 +342,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 +351,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');
@@ -163,20 +361,37 @@ $topologyPayload = array_map(static function (array $row): array {
const overlayMeta = overlay ? overlay.querySelector('[data-topology-meta]') : null; const overlayMeta = overlay ? overlay.querySelector('[data-topology-meta]') : null;
const overlayRackLink = overlay ? overlay.querySelector('[data-topology-rack-link]') : null; const overlayRackLink = overlay ? overlay.querySelector('[data-topology-rack-link]') : null;
const overlayDeviceLink = overlay ? overlay.querySelector('[data-topology-device-link]') : null; const overlayDeviceLink = overlay ? overlay.querySelector('[data-topology-device-link]') : null;
const overlayActions = overlay ? overlay.querySelector('[data-topology-actions]') : null;
const closeButton = overlay ? overlay.querySelector('[data-topology-close]') : null; const closeButton = overlay ? overlay.querySelector('[data-topology-close]') : null;
const dataTag = document.getElementById('dashboard-topology-data'); const dataTag = document.getElementById('dashboard-topology-data');
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 };
let drag = null; let drag = null;
function clearActiveSelections() {
nodeLayer.querySelectorAll('.topology-node.active, .topology-port-node.active').forEach((node) => {
node.classList.remove('active');
});
connectionLayer.querySelectorAll('.topology-connection-line.active').forEach((line) => {
line.classList.remove('active');
});
}
function clampView() { function clampView() {
view.width = Math.max(220, Math.min(scene.width, view.width)); view.width = Math.max(220, Math.min(scene.width, view.width));
view.height = Math.max(180, Math.min(scene.height, view.height)); view.height = Math.max(180, Math.min(scene.height, view.height));
@@ -261,59 +476,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,46 +599,125 @@ $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 || '',
}); port_count: Number(device.port_count || 0),
}); port_preview: Array.isArray(device.port_preview) ? device.port_preview : []
}); });
}); });
return positioned; rackCursorY += rackHeight + 16;
});
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(item) {
if (!overlay || !overlayTitle || !overlayMeta || !overlayRackLink || !overlayDeviceLink) { if (!overlay || !overlayTitle || !overlayMeta || !overlayRackLink || !overlayDeviceLink || !overlayActions) {
return; return;
} }
overlayTitle.textContent = node.rack_name || 'Ohne Rack'; overlayRackLink.textContent = '';
const typeLabel = node.device_type_name ? ` (${node.device_type_name})` : ''; overlayDeviceLink.textContent = '';
overlayMeta.textContent = `Stockwerk: ${node.floor_name} | Geraet: ${node.device_name}${typeLabel}`; overlayActions.innerHTML = '';
if (node.rack_id > 0) { if (item.kind === 'connection') {
overlayRackLink.innerHTML = `<a href="?module=racks&action=edit&id=${node.rack_id}">Rack bearbeiten</a>`; overlayTitle.textContent = 'Verbindung';
overlayMeta.textContent = `${item.from_name} <-> ${item.to_name} | Anzahl: ${item.count}`;
if (item.sample_connection_id > 0) {
overlayRackLink.innerHTML = `<a href="?module=connections&action=edit&id=${item.sample_connection_id}">Verbindung bearbeiten</a>`;
overlayDeviceLink.innerHTML = `<a href="?module=connections&action=delete&id=${item.sample_connection_id}" onclick="return confirm('Diese Verbindung wirklich loeschen?');">Verbindung entfernen</a>`;
}
overlay.hidden = false;
return;
}
if (item.kind === 'port') {
overlayTitle.textContent = `Port: ${item.port_name}`;
overlayMeta.textContent = `Geraet: ${item.device_name} | Rack: ${item.rack_name} | Stockwerk: ${item.floor_name}`;
if (item.device_id > 0) {
overlayRackLink.innerHTML = `<a href="?module=devices&action=edit&id=${item.device_id}">Geraet bearbeiten</a>`;
if (item.port_id > 0 && !item.is_connected) {
overlayDeviceLink.innerHTML = `<a href="?module=connections&action=edit&port_a_type=device&port_a_id=${item.port_id}&port_b_type=patchpanel">Mit Patchfeld verbinden</a>`;
} else {
overlayDeviceLink.textContent = 'Port ist bereits verbunden';
}
}
if (item.port_id > 0) {
overlayActions.innerHTML = `<a class="button button-small" href="?module=devices&action=edit&id=${item.device_id}">Port im Geraet aendern</a>`;
}
overlay.hidden = false;
return;
}
overlayTitle.textContent = item.rack_name || 'Ohne Rack';
const typeLabel = item.device_type_name ? ` (${item.device_type_name})` : '';
overlayMeta.textContent = `Standort: ${item.location_name} | Gebaeude: ${item.building_name} | Stockwerk: ${item.floor_name} | Geraet: ${item.device_name}${typeLabel}`;
if (item.rack_id > 0) {
overlayRackLink.innerHTML = `<a href="?module=racks&action=edit&id=${item.rack_id}">Rack bearbeiten</a>`;
} else { } else {
overlayRackLink.textContent = 'Kein Rack verknuepft'; overlayRackLink.textContent = 'Kein Rack verknuepft';
} }
if (node.device_id > 0) { if (item.device_id > 0) {
overlayDeviceLink.innerHTML = `<a href="?module=devices&action=edit&id=${node.device_id}">Geraet bearbeiten</a>`; overlayDeviceLink.innerHTML = `<a href="?module=devices&action=edit&id=${item.device_id}">Geraet bearbeiten</a>`;
} else { overlayActions.innerHTML = `
overlayDeviceLink.textContent = ''; <a class="button button-small" href="?module=devices&action=edit&id=${item.device_id}">Editieren</a>
<a class="button button-small button-danger" href="?module=devices&action=delete&id=${item.device_id}&force=1" onclick="return confirm('Dieses Geraet wirklich loeschen?');">Entfernen</a>
`;
} }
overlay.hidden = false; overlay.hidden = false;
} }
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 +728,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));
@@ -418,12 +840,10 @@ $topologyPayload = array_map(static function (array $row): array {
circle.appendChild(title); circle.appendChild(title);
const activate = () => { const activate = () => {
nodeLayer.querySelectorAll('.topology-node.active').forEach((node) => { clearActiveSelections();
node.classList.remove('active');
});
circle.classList.add('active'); circle.classList.add('active');
zoomToNode(entry.x, entry.y); zoomToNode(entry.x, entry.y);
showOverlay(entry); showOverlay({ ...entry, kind: 'device' });
}; };
circle.addEventListener('click', activate); circle.addEventListener('click', activate);
@@ -435,6 +855,92 @@ $topologyPayload = array_map(static function (array $row): array {
}); });
nodeLayer.appendChild(circle); nodeLayer.appendChild(circle);
const portPreview = Array.isArray(entry.port_preview) ? entry.port_preview : [];
portPreview.forEach((port, portIndex) => {
const portNode = svgElement('circle');
portNode.setAttribute('cx', String(entry.x + 16 + (portIndex * 10)));
portNode.setAttribute('cy', String(entry.y - 16));
portNode.setAttribute('r', '5');
portNode.setAttribute('tabindex', '0');
portNode.setAttribute('class', 'topology-port-node');
portNode.setAttribute('data-port-id', String(Number(port.id || 0)));
portNode.setAttribute('data-device-id', String(entry.device_id));
const portTitle = svgElement('title');
portTitle.textContent = `${port.name || 'Port'} (${entry.device_name})`;
portNode.appendChild(portTitle);
const activatePort = () => {
clearActiveSelections();
portNode.classList.add('active');
showOverlay({
kind: 'port',
port_id: Number(port.id || 0),
port_name: port.name || `Port ${portIndex + 1}`,
is_connected: !!port.is_connected,
device_id: entry.device_id,
device_name: entry.device_name,
rack_name: entry.rack_name,
floor_name: entry.floor_name
});
};
portNode.addEventListener('click', activatePort);
portNode.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
activatePort();
}
});
nodeLayer.appendChild(portNode);
});
});
rackLinks.forEach((link) => {
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)));
line.setAttribute('tabindex', '0');
const title = svgElement('title');
title.textContent = `${link.from_rack_name} <-> ${link.to_rack_name}: ${count} Verbindungen`;
line.appendChild(title);
const activateConnection = () => {
clearActiveSelections();
line.classList.add('active');
showOverlay({
kind: 'connection',
from_name: link.from_rack_name || `Rack ${fromRackId}`,
to_name: link.to_rack_name || `Rack ${toRackId}`,
count,
sample_connection_id: Number(link.sample_connection_id || 0)
});
};
line.addEventListener('click', activateConnection);
line.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
activateConnection();
}
});
connectionLayer.appendChild(line);
}); });
} }
@@ -446,6 +952,11 @@ $topologyPayload = array_map(static function (array $row): array {
}, { passive: false }); }, { passive: false });
svg.addEventListener('pointerdown', (event) => { svg.addEventListener('pointerdown', (event) => {
const target = event.target;
if (target instanceof Element && (target.closest('.topology-node') || target.closest('.topology-port-node') || target.closest('.topology-connection-line'))) {
drag = null;
return;
}
drag = { drag = {
startX: event.clientX, startX: event.clientX,
startY: event.clientY, startY: event.clientY,
@@ -497,6 +1008,7 @@ $topologyPayload = array_map(static function (array $row): array {
if (closeButton && overlay) { if (closeButton && overlay) {
closeButton.addEventListener('click', () => { closeButton.addEventListener('click', () => {
overlay.hidden = true; overlay.hidden = true;
clearActiveSelections();
}); });
} }
@@ -659,12 +1171,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,15 +1214,31 @@ $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;
cursor: pointer;
}
.topology-connection-line:hover,
.topology-connection-line:focus,
.topology-connection-line.active {
stroke: #d2612a;
stroke-opacity: 0.85;
outline: none;
}
.topology-node { .topology-node {
fill: #1f73c9; fill: #1f73c9;
stroke: #ffffff; stroke: #ffffff;
stroke-width: 3; stroke-width: 3;
cursor: pointer;
} }
.topology-node:hover, .topology-node:hover,
@@ -690,6 +1248,20 @@ $topologyPayload = array_map(static function (array $row): array {
outline: none; outline: none;
} }
.topology-port-node {
fill: #3d9640;
stroke: #ffffff;
stroke-width: 2;
cursor: pointer;
}
.topology-port-node:hover,
.topology-port-node:focus,
.topology-port-node.active {
fill: #f18d32;
outline: none;
}
.topology-empty { .topology-empty {
margin-top: 12px; margin-top: 12px;
padding: 10px; padding: 10px;
@@ -725,6 +1297,13 @@ $topologyPayload = array_map(static function (array $row): array {
margin: 8px 0; margin: 8px 0;
} }
.topology-overlay__actions {
margin-top: 10px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
@media (max-width: 900px) { @media (max-width: 900px) {
#dashboard-topology-svg { #dashboard-topology-svg {
height: 360px; height: 360px;

View File

@@ -86,6 +86,7 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
<textarea id="comment" name="comment" rows="3" <textarea id="comment" name="comment" rows="3"
placeholder="z.B. Rack-Mount, 48 RJ45 + 4 SFP"><?php echo htmlspecialchars($deviceType['comment'] ?? ''); ?></textarea> placeholder="z.B. Rack-Mount, 48 RJ45 + 4 SFP"><?php echo htmlspecialchars($deviceType['comment'] ?? ''); ?></textarea>
</div> </div>
<?php if (!$isEdit): ?>
<div class="form-group"> <div class="form-group">
<label for="seed_ports">Ports automatisch anlegen</label> <label for="seed_ports">Ports automatisch anlegen</label>
<input type="number" id="seed_ports" name="seed_ports" min="0" step="1" <input type="number" id="seed_ports" name="seed_ports" min="0" step="1"
@@ -106,6 +107,7 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
</select> </select>
<small>Wird beim automatischen Erstellen neuer Ports als Startwert gesetzt.</small> <small>Wird beim automatischen Erstellen neuer Ports als Startwert gesetzt.</small>
</div> </div>
<?php endif; ?>
</fieldset> </fieldset>
<!-- ========================= <!-- =========================

View File

@@ -21,7 +21,8 @@ $deviceTypeId = (int)($_POST['id'] ?? 0);
$name = trim($_POST['name'] ?? ''); $name = trim($_POST['name'] ?? '');
$category = $_POST['category'] ?? 'other'; $category = $_POST['category'] ?? 'other';
$comment = trim($_POST['comment'] ?? ''); $comment = trim($_POST['comment'] ?? '');
$seedPortCount = max(0, (int)($_POST['seed_ports'] ?? 0)); $isCreate = $deviceTypeId <= 0;
$seedPortCount = $isCreate ? max(0, (int)($_POST['seed_ports'] ?? 0)) : 0;
$defaultPortTypeId = normalizeNullableInt($_POST['default_port_type_id'] ?? null); $defaultPortTypeId = normalizeNullableInt($_POST['default_port_type_id'] ?? null);
$portRows = is_array($_POST['port_rows'] ?? null) ? $_POST['port_rows'] : []; $portRows = is_array($_POST['port_rows'] ?? null) ? $_POST['port_rows'] : [];
$rawShapes = trim($_POST['shape_definition'] ?? ''); $rawShapes = trim($_POST['shape_definition'] ?? '');
@@ -131,7 +132,9 @@ if ($deviceTypeId > 0) {
} }
} }
if ($isCreate) {
seedDeviceTypePorts($sql, $deviceTypeId, $seedPortCount, $defaultPortTypeId); seedDeviceTypePorts($sql, $deviceTypeId, $seedPortCount, $defaultPortTypeId);
}
syncDeviceTypePorts($sql, $deviceTypeId, $portRows); syncDeviceTypePorts($sql, $deviceTypeId, $portRows);
$_SESSION['success'] = $deviceTypeId ? "Gerätetyp gespeichert" : "Fehler beim Speichern"; $_SESSION['success'] = $deviceTypeId ? "Gerätetyp gespeichert" : "Fehler beim Speichern";

View File

@@ -56,10 +56,10 @@ $dependencies = $sql->single(
( (
SELECT COUNT(*) SELECT COUNT(*)
FROM connections c FROM connections c
WHERE (c.port_a_type = 'device' AND c.port_a_id IN ( WHERE ((c.port_a_type = 'device' OR c.port_a_type = 'device_ports') AND c.port_a_id IN (
SELECT dp3.id FROM device_ports dp3 WHERE dp3.device_id = ? SELECT dp3.id FROM device_ports dp3 WHERE dp3.device_id = ?
)) ))
OR (c.port_b_type = 'device' AND c.port_b_id IN ( OR ((c.port_b_type = 'device' OR c.port_b_type = 'device_ports') AND c.port_b_id IN (
SELECT dp4.id FROM device_ports dp4 WHERE dp4.device_id = ? SELECT dp4.id FROM device_ports dp4 WHERE dp4.device_id = ?
)) ))
) AS connection_count", ) AS connection_count",
@@ -108,8 +108,8 @@ if ($hasDependencies && !$forceDelete) {
// Connections referenzieren device_ports nur logisch, daher manuell entfernen. // Connections referenzieren device_ports nur logisch, daher manuell entfernen.
$sql->set( $sql->set(
"DELETE FROM connections "DELETE FROM connections
WHERE (port_a_type = 'device' AND port_a_id IN (SELECT id FROM device_ports WHERE device_id = ?)) WHERE ((port_a_type = 'device' OR port_a_type = 'device_ports') AND port_a_id IN (SELECT id FROM device_ports WHERE device_id = ?))
OR (port_b_type = 'device' AND port_b_id IN (SELECT id FROM device_ports WHERE device_id = ?))", OR ((port_b_type = 'device' OR port_b_type = 'device_ports') AND port_b_id IN (SELECT id FROM device_ports WHERE device_id = ?))",
"ii", "ii",
[$deviceId, $deviceId] [$deviceId, $deviceId]
); );

View File

@@ -47,10 +47,10 @@ if ($isEdit) {
( (
SELECT COUNT(*) SELECT COUNT(*)
FROM connections c FROM connections c
WHERE (c.port_a_type = 'device' AND c.port_a_id IN ( WHERE ((c.port_a_type = 'device' OR c.port_a_type = 'device_ports') AND c.port_a_id IN (
SELECT dp3.id FROM device_ports dp3 WHERE dp3.device_id = ? SELECT dp3.id FROM device_ports dp3 WHERE dp3.device_id = ?
)) ))
OR (c.port_b_type = 'device' AND c.port_b_id IN ( OR ((c.port_b_type = 'device' OR c.port_b_type = 'device_ports') AND c.port_b_id IN (
SELECT dp4.id FROM device_ports dp4 WHERE dp4.device_id = ? SELECT dp4.id FROM device_ports dp4 WHERE dp4.device_id = ?
)) ))
) AS connection_count", ) AS connection_count",

View File

@@ -97,10 +97,10 @@ $devices = $sql->get(
( (
SELECT COUNT(*) SELECT COUNT(*)
FROM connections c FROM connections c
WHERE (c.port_a_type = 'device' AND c.port_a_id IN ( WHERE ((c.port_a_type = 'device' OR c.port_a_type = 'device_ports') AND c.port_a_id IN (
SELECT dp3.id FROM device_ports dp3 WHERE dp3.device_id = d.id SELECT dp3.id FROM device_ports dp3 WHERE dp3.device_id = d.id
)) ))
OR (c.port_b_type = 'device' AND c.port_b_id IN ( OR ((c.port_b_type = 'device' OR c.port_b_type = 'device_ports') AND c.port_b_id IN (
SELECT dp4.id FROM device_ports dp4 WHERE dp4.device_id = d.id SELECT dp4.id FROM device_ports dp4 WHERE dp4.device_id = d.id
)) ))
) AS connection_count ) AS connection_count

View File

@@ -125,6 +125,58 @@ $mapOutlets = $sql->get(
"", "",
[] []
); );
$patchpanelPortOptions = $sql->get(
"SELECT
fpp.id,
fpp.name,
fp.name AS patchpanel_name,
fp.floor_id,
f.name AS floor_name,
EXISTS(
SELECT 1
FROM connections c
WHERE
((c.port_a_type = 'patchpanel' OR c.port_a_type = 'floor_patchpanel_ports') AND c.port_a_id = fpp.id)
OR
((c.port_b_type = 'patchpanel' OR c.port_b_type = 'floor_patchpanel_ports') AND c.port_b_id = fpp.id)
) AS is_occupied
FROM floor_patchpanel_ports fpp
JOIN floor_patchpanels fp ON fp.id = fpp.patchpanel_id
LEFT JOIN floors f ON f.id = fp.floor_id
ORDER BY f.name, fp.name, fpp.name",
"",
[]
);
$selectedBindPatchpanelPortId = 0;
if ($type === 'outlet' && $id > 0) {
$selectedBindPatchpanelPortId = (int)($sql->single(
"SELECT
CASE
WHEN (c.port_a_type = 'patchpanel' OR c.port_a_type = 'floor_patchpanel_ports') THEN c.port_a_id
WHEN (c.port_b_type = 'patchpanel' OR c.port_b_type = 'floor_patchpanel_ports') THEN c.port_b_id
ELSE 0
END AS patchpanel_port_id
FROM connections c
JOIN network_outlet_ports nop
ON (
((c.port_a_type = 'outlet' OR c.port_a_type = 'network_outlet_ports') AND c.port_a_id = nop.id)
OR
((c.port_b_type = 'outlet' OR c.port_b_type = 'network_outlet_ports') AND c.port_b_id = nop.id)
)
WHERE nop.outlet_id = ?
AND (
c.port_a_type = 'patchpanel' OR c.port_a_type = 'floor_patchpanel_ports'
OR
c.port_b_type = 'patchpanel' OR c.port_b_type = 'floor_patchpanel_ports'
)
ORDER BY c.id
LIMIT 1",
"i",
[$id]
)['patchpanel_port_id'] ?? 0);
}
?> ?>
<div class="floor-infra-edit"> <div class="floor-infra-edit">
@@ -205,10 +257,9 @@ $mapOutlets = $sql->get(
data-active-id="<?php echo (int)($panel['id'] ?? 0); ?>" data-active-id="<?php echo (int)($panel['id'] ?? 0); ?>"
data-reference-panels="<?php echo htmlspecialchars(json_encode($mapPatchPanels, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>" data-reference-panels="<?php echo htmlspecialchars(json_encode($mapPatchPanels, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"
data-reference-outlets="<?php echo htmlspecialchars(json_encode($mapOutlets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"> data-reference-outlets="<?php echo htmlspecialchars(json_encode($mapOutlets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>">
<img id="floor-plan-svg" class="floor-plan-svg" alt="Stockwerksplan">
<svg id="floor-plan-overlay" class="floor-plan-overlay" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg> <svg id="floor-plan-overlay" class="floor-plan-overlay" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
</div> </div>
<p class="floor-plan-hint">Nur das aktuell bearbeitete Patchpanel ist verschiebbar. Andere Objekte werden als Referenz halbtransparent angezeigt. Neue Objekte starten bei Position 30 x 30.</p> <p class="floor-plan-hint">Nur das aktuell bearbeitete Patchpanel ist verschiebbar. Andere Objekte werden als Referenz halbtransparent angezeigt. Neue Objekte starten bei Position 30 x 30. Zoom mit Mausrad, verschieben mit Shift + Drag.</p>
<p class="floor-plan-position">Koordinate: <span id="floor-plan-position"></span></p> <p class="floor-plan-position">Koordinate: <span id="floor-plan-position"></span></p>
</div> </div>
</div> </div>
@@ -255,6 +306,38 @@ $mapOutlets = $sql->get(
</select> </select>
</div> </div>
<div class="form-group">
<label for="outlet-bind-patchpanel-port-id">Direkt mit Patchpanel-Port verbinden</label>
<select name="bind_patchpanel_port_id" id="outlet-bind-patchpanel-port-id">
<option value="">- Kein direkter Link -</option>
<?php foreach ($patchpanelPortOptions as $portOption): ?>
<?php
$portId = (int)($portOption['id'] ?? 0);
$isSelected = $selectedBindPatchpanelPortId === $portId;
$isOccupied = ((int)($portOption['is_occupied'] ?? 0) === 1);
$isDisabled = $isOccupied && !$isSelected;
$labelParts = array_filter([
(string)($portOption['floor_name'] ?? ''),
(string)($portOption['patchpanel_name'] ?? ''),
(string)($portOption['name'] ?? ''),
]);
$label = implode(' / ', $labelParts);
if ($isOccupied && !$isSelected) {
$label .= ' (belegt)';
}
?>
<option
value="<?php echo $portId; ?>"
data-floor-id="<?php echo (int)($portOption['floor_id'] ?? 0); ?>"
<?php echo $isSelected ? 'selected' : ''; ?>
<?php echo $isDisabled ? 'disabled' : ''; ?>>
<?php echo htmlspecialchars($label); ?>
</option>
<?php endforeach; ?>
</select>
<small>Nur Ports vom gewaehlten Stockwerk sind auswaehlbar. Beim Speichern wird die Verbindung automatisch erstellt.</small>
</div>
<input type="hidden" name="x" value="<?php echo (int)($outlet['x'] ?? 30); ?>"> <input type="hidden" name="x" value="<?php echo (int)($outlet['x'] ?? 30); ?>">
<input type="hidden" name="y" value="<?php echo (int)($outlet['y'] ?? 30); ?>"> <input type="hidden" name="y" value="<?php echo (int)($outlet['y'] ?? 30); ?>">
@@ -270,10 +353,9 @@ $mapOutlets = $sql->get(
data-active-id="<?php echo (int)($outlet['id'] ?? 0); ?>" data-active-id="<?php echo (int)($outlet['id'] ?? 0); ?>"
data-reference-panels="<?php echo htmlspecialchars(json_encode($mapPatchPanels, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>" data-reference-panels="<?php echo htmlspecialchars(json_encode($mapPatchPanels, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"
data-reference-outlets="<?php echo htmlspecialchars(json_encode($mapOutlets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>"> data-reference-outlets="<?php echo htmlspecialchars(json_encode($mapOutlets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); ?>">
<img id="floor-plan-svg" class="floor-plan-svg" alt="Stockwerksplan">
<svg id="floor-plan-overlay" class="floor-plan-overlay" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg> <svg id="floor-plan-overlay" class="floor-plan-overlay" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet" aria-hidden="true"></svg>
</div> </div>
<p class="floor-plan-hint">Nur die aktuell bearbeitete Wandbuchse ist verschiebbar. Blau = Patchpanel, Gruen = Dosen-Referenz, Orange = gewaehlter Raum. Netzwerkdosen sind immer 10 x 10.</p> <p class="floor-plan-hint">Nur die aktuell bearbeitete Wandbuchse ist verschiebbar. Blau = Patchpanel, Gruen = Dosen-Referenz, Orange = gewaehlter Raum. Netzwerkdosen sind immer 10 x 10. Zoom mit Mausrad, verschieben mit Shift + Drag.</p>
<p class="floor-plan-position">Koordinate: <span id="floor-plan-position"></span></p> <p class="floor-plan-position">Koordinate: <span id="floor-plan-position"></span></p>
</div> </div>
</div> </div>

View File

@@ -80,6 +80,7 @@ if ($type === 'patchpanel') {
$x = (int)($_POST['x'] ?? 0); $x = (int)($_POST['x'] ?? 0);
$y = (int)($_POST['y'] ?? 0); $y = (int)($_POST['y'] ?? 0);
$comment = trim($_POST['comment'] ?? ''); $comment = trim($_POST['comment'] ?? '');
$bindPatchpanelPortId = (int)($_POST['bind_patchpanel_port_id'] ?? 0);
$outletId = $id; $outletId = $id;
$errors = []; $errors = [];
@@ -126,6 +127,132 @@ if ($type === 'patchpanel') {
[$outletId] [$outletId]
); );
} }
if ($bindPatchpanelPortId > 0) {
$roomFloorId = (int)($sql->single(
"SELECT floor_id FROM rooms WHERE id = ?",
"i",
[$roomId]
)['floor_id'] ?? 0);
$patchpanelPort = $sql->single(
"SELECT
fpp.id,
fp.floor_id
FROM floor_patchpanel_ports fpp
JOIN floor_patchpanels fp ON fp.id = fpp.patchpanel_id
WHERE fpp.id = ?",
"i",
[$bindPatchpanelPortId]
);
if (!$patchpanelPort) {
$_SESSION['error'] = 'Gewaehlter Patchpanel-Port existiert nicht';
$_SESSION['validation_errors'] = ['Gewaehlter Patchpanel-Port existiert nicht'];
header('Location: ?module=floor_infrastructure&action=edit&type=outlet&id=' . $outletId);
exit;
}
if ($roomFloorId <= 0 || (int)$patchpanelPort['floor_id'] !== $roomFloorId) {
$_SESSION['error'] = 'Patchpanel-Port und Raum muessen auf demselben Stockwerk liegen';
$_SESSION['validation_errors'] = ['Patchpanel-Port und Raum muessen auf demselben Stockwerk liegen'];
header('Location: ?module=floor_infrastructure&action=edit&type=outlet&id=' . $outletId);
exit;
}
$outletPortId = (int)($sql->single(
"SELECT id
FROM network_outlet_ports
WHERE outlet_id = ?
ORDER BY id
LIMIT 1",
"i",
[$outletId]
)['id'] ?? 0);
if ($outletPortId <= 0) {
$_SESSION['error'] = 'Wandbuchsen-Port konnte nicht ermittelt werden';
$_SESSION['validation_errors'] = ['Wandbuchsen-Port konnte nicht ermittelt werden'];
header('Location: ?module=floor_infrastructure&action=edit&type=outlet&id=' . $outletId);
exit;
}
$existingPatchpanelUsage = $sql->single(
"SELECT
id,
port_a_type,
port_a_id,
port_b_type,
port_b_id
FROM connections
WHERE
((port_a_type = 'patchpanel' OR port_a_type = 'floor_patchpanel_ports') AND port_a_id = ?)
OR
((port_b_type = 'patchpanel' OR port_b_type = 'floor_patchpanel_ports') AND port_b_id = ?)
LIMIT 1",
"ii",
[$bindPatchpanelPortId, $bindPatchpanelPortId]
);
if ($existingPatchpanelUsage) {
$sameOutletConnection = (
(
(($existingPatchpanelUsage['port_a_type'] ?? '') === 'outlet' || ($existingPatchpanelUsage['port_a_type'] ?? '') === 'network_outlet_ports')
&& (int)($existingPatchpanelUsage['port_a_id'] ?? 0) === $outletPortId
)
||
(
(($existingPatchpanelUsage['port_b_type'] ?? '') === 'outlet' || ($existingPatchpanelUsage['port_b_type'] ?? '') === 'network_outlet_ports')
&& (int)($existingPatchpanelUsage['port_b_id'] ?? 0) === $outletPortId
)
);
if (!$sameOutletConnection) {
$_SESSION['error'] = 'Gewaehlter Patchpanel-Port ist bereits verbunden';
$_SESSION['validation_errors'] = ['Gewaehlter Patchpanel-Port ist bereits verbunden'];
header('Location: ?module=floor_infrastructure&action=edit&type=outlet&id=' . $outletId);
exit;
}
}
$sql->set(
"DELETE FROM connections
WHERE
((port_a_type = 'outlet' OR port_a_type = 'network_outlet_ports') AND port_a_id = ? AND (port_b_type = 'patchpanel' OR port_b_type = 'floor_patchpanel_ports'))
OR
((port_b_type = 'outlet' OR port_b_type = 'network_outlet_ports') AND port_b_id = ? AND (port_a_type = 'patchpanel' OR port_a_type = 'floor_patchpanel_ports'))",
"ii",
[$outletPortId, $outletPortId]
);
$connectionTypeId = (int)($sql->single(
"SELECT id FROM connection_types ORDER BY id LIMIT 1",
"",
[]
)['id'] ?? 0);
if ($connectionTypeId <= 0) {
$connectionTypeId = (int)$sql->set(
"INSERT INTO connection_types (name, medium, duplex, line_style, comment) VALUES (?, ?, ?, ?, ?)",
"sssss",
['Default', 'copper', 'custom', 'solid', 'Auto-created by floor_infrastructure/save'],
true
);
}
if ($connectionTypeId <= 0) {
$_SESSION['error'] = 'Kein Verbindungstyp fuer automatische Bindung verfuegbar';
$_SESSION['validation_errors'] = ['Kein Verbindungstyp fuer automatische Bindung verfuegbar'];
header('Location: ?module=floor_infrastructure&action=edit&type=outlet&id=' . $outletId);
exit;
}
$sql->set(
"INSERT INTO connections (connection_type_id, port_a_type, port_a_id, port_b_type, port_b_id, vlan_config, comment)
VALUES (?, 'outlet', ?, 'patchpanel', ?, NULL, ?)",
"iiis",
[$connectionTypeId, $outletPortId, $bindPatchpanelPortId, 'Auto-Link bei Wandbuchsen-Erstellung']
);
}
} }
$_SESSION['success'] = $id > 0 ? 'Wandbuchse gespeichert' : 'Wandbuchse erstellt'; $_SESSION['success'] = $id > 0 ? 'Wandbuchse gespeichert' : 'Wandbuchse erstellt';
} else { } else {