diff --git a/AGENTS.md b/AGENTS.md index a9c8fd7..0fa9349 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,11 @@ -# 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 +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. + ## Projektüberblick - 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). @@ -16,19 +19,49 @@ docker-compose up -d --build ``` Das Docker-Setup (Compose + Portainer) liegt in `docker-compose.yml` und `docker-portainer.yml`, ergänzende Infos in `Dockerfile`. +## Verbindliche Regeln +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 `[#]` enthalten. +3. Ein Punkt darf nur als erledigt markiert werden, wenn: + - die Umsetzung im Code erfolgt ist, und + - ein Commit mit `closes #` erstellt wird. +4. Kein „done“ ohne Issue-ID und kein Commit ohne passende `closes #`-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. Offene Issues abrufen (Skill `gitea-issues`): + - `python C:/Users/s.titz/.codex/skills/gitea-issues/scripts/list_issues.py --state open --limit 100 --json` +2. `NEXT_STEPS.md` aktualisieren: + - Punkte mit `[#]` ergänzen oder korrigieren. +3. Umsetzung durchführen. +4. Commit mit Schließ-Referenz erstellen: + - `git commit -m "Kurzbeschreibung der Änderung; closes #"` +5. Prüfen, dass jede als erledigt markierte Aufgabe eine geschlossene Issue-Referenz hat. + +## Formatvorgabe für NEXT_STEPS.md +- Beispiel offen: + - `- [ ] [#42] Backup-Runbook erstellen` +- Beispiel erledigt: + - `- [x] [#42] Backup-Runbook erstellen` + +## Annahmen +- Gitea ist so konfiguriert, dass `closes #` in Commit-Messages das Issue schließt. +- `GITEA_TOKEN` ist gesetzt, damit Issue-Abfragen funktionieren. + ## Skills & Nutzungshinweise - **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. +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; preferiere `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. +- 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). @@ -38,18 +71,18 @@ Wenn ein Skill genannt wird (z. B. `$skill-creator`) oder die Aufgabe exakt zu - 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). +- `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. +- 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 \ No newline at end of file +## Wichtig +- Nutze UTF-8, wenn nicht anders angegeben. diff --git a/IMPLEMENTATION_STATUS.md b/IMPLEMENTATION_STATUS.md index 31143d3..fa00b55 100644 --- a/IMPLEMENTATION_STATUS.md +++ b/IMPLEMENTATION_STATUS.md @@ -70,16 +70,16 @@ 2. **Datenbank-Zugriff** - SQL-Klasse lädt und speichert Daten 3. **Responsive Design** - Alle Formulare und Listen sind formatiert 4. **Filter & Suche** - Alle Module haben Suchfunktionen -5. **CRUD-Operationen** - Create, Read, Update für alle Hauptmodule +5. **CRUD-Operationen** - Create, Read, Update, Delete für alle Hauptmodule --- ## ⚠️ Noch zu machen (Not-Must-Have) ### Höhere Priorität: -- [ ] **Delete-Funktionen** - Löschen noch als TODO (als AJAX implementieren) +- [x] **Delete-Funktionen** - Delete-Endpoints für Kernmodule inkl. `connections` und `floor_infrastructure` umgesetzt - [ ] **Fehlerbehandlung** - Error Pages, Validierungsmeldungen -- [ ] **Session/Auth** - Single-User Auth in bootstrap.php +- [x] **Session/Auth** - Single-User-Auth mit `requireAuth()` und `app/lib/auth.php` eingebunden - [ ] **SVG-Editor** - Interaktiver Floorplan-Editor für Räume/Dosen - [ ] **Port-Management** - Ports zu Geräten zuweisen @@ -106,7 +106,7 @@ app/ ├── lib/ │ ├── _sql.php ✅ DB-Wrapper │ ├── helpers.php ✅ Utility-Funktionen -│ └── auth.php 🚧 TODO: Auth +│ └── auth.php ✅ Auth-Helper + requireAuth() ├── templates/ │ ├── layout.php ✅ HTML-Layout │ ├── header.php ✅ Header/Nav @@ -132,7 +132,7 @@ app/ ## 💡 Nächste Schritte (empfohlen) 1. **Testen Sie die Module** - Probieren Sie Anlegen/Bearbeiten aus -2. **Implementieren Sie Delete-Funktionen** - Mit AJAX oder POST +2. **Delete-Flows prüfen** - Sonderfälle und Fehlermeldungen bei Abhängigkeiten testen 3. **Bessere Fehlerbehandlung** - Sessions für Error-Messages 4. **Mobile-Optimierung** - Responsive Verbesserungen 5. **SVG-Editor für Floorplans** - Visuelles Raumdesign diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 6648520..4fe6dd5 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -1,142 +1,21 @@ -# 📋 NÄCHSTE ARBEITSPAKETE +# NEXT_STEPS -## 🎯 Für die nächsten Sessions +## Stand +- Letzte Pflege: 18. Februar 2026 +- Quelle für Issues: lokale Referenzen aus Repository (`NEXT_STEPS.md`, `BUGS.md`, Code-Check) +- Hinweis: Live-Abruf via `gitea-issues` war am 18. Februar 2026 nicht möglich (Verbindung zu Gitea verweigert). -### Package 1: Fehlerbehandlung & Sessions (1-2h) -- [ ] Session-Handling in `bootstrap.php` implementieren -- [ ] Error-Messages in Session speichern ($SESSION['error'], $SESSION['success']) -- [ ] Header mit Fehlermeldungen in Layout -- [ ] Validierungsfehler anzeigen +## Aktive Aufgaben (priorisiert) +- [ ] [#10] Dashboard-Grafik erzeugen (Location/Building/Floor/Verbindungen als Hierarchie) +- [ ] [#5] Dashboard als zoombare und verschiebbare SVG-Fläche umsetzen (interaktive Geräte/Ports/Verbindungen) +- [ ] [#14] Hilfslinien der Stockwerkskarten nur im Edit-Mode anzeigen, im Anzeige-Mode ausblenden +- [ ] [#11] Encoding- und Umlautfehler bereinigen (inkl. Anzeige in UI-Dateien und Markdown-Dokumenten) +- [ ] [#4] `device_types/edit`: Option "Ports automatisch erstellen" nur beim Erstellen anzeigen, nicht beim Editieren -### Package 2: Delete-Funktionen (1h) -- [ ] DELETE-Endpoints für alle Module -- [ ] AJAX-Bestätigung vor Löschen -- [ ] Kaskadierendes Löschen prüfen (z.B. Floor → Racks) +## Verifikation (Status unklar, nicht als erledigt markieren ohne Reproduktion + Commit) +- [ ] [#15] Neue Verbindung: Netzwerkdose auswählbar (Regressionstest in UI durchführen) -### Package 3: Port-Management (2-3h) -- [ ] Ports zu Device-Types verwalten -- [ ] Ports zu Devices anzeigen -- [ ] Port-Status (aktiv/inaktiv) -- [ ] 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! 🚀** +## Hinweise zur Abarbeitung +- Vor jeder Änderung an dieser Datei offene Issues erneut laden (`gitea-issues`-Skill). +- Aufgaben hier nur mit Issue-Referenz `[#]` führen. +- Aufgabe erst auf erledigt setzen, wenn Code umgesetzt und Commit mit `closes #` erstellt wurde. diff --git a/README.md b/README.md index 085703c..278fa07 100644 --- a/README.md +++ b/README.md @@ -238,19 +238,19 @@ Verbindungen werden: - Die untereinander verbundenen Patchpanels lassen sich direkt auf der SVG-Stockwerkskarte verorten, damit jeder Port physisch nachvollziehbar bleibt. - Verbindungen zu Racks / Switches darstellbar -### TODO: Patchpanel-Infrastruktur -- [ ] Floorplans erweitern, damit Patchpanels als feste Infrastrukturobjekte (nicht als rack-basierte `devices`) angelegt, verschoben und mit `x/y`/Größe verankert werden. -- [ ] Backend und SVG-Editor dahingehend adaptieren, dass Patchpanel-Ports unabhängig von Racks definiert werden können. -- [ ] Patchpanel ↔ Patchpanel- und Patchpanel ↔ Netzwerkbuchse-Verbindungen als permanente Kabel zwischen Floorplan-Objekten darstellen und über die `connections`-Tabelle verwalten. -- [ ] UI/Schema-Dokumentation aktualisieren (README + Datenbank-Docs) sowie neue SQL-Tabellen (`floor_patchpanels` / `floor_patchpanel_ports`) fertigstellen. -- [ ] Floorplan-Filter/Legend leiten die Nutzung dieser Infrastrukturobjekte, Kampagnen und Search & Filter integrieren. +### Patchpanel-Infrastruktur (Status: 18. Februar 2026) +- [x] Floorplans erweitert: Patchpanels können als feste Infrastrukturobjekte (ohne Rack-Device) inkl. `x/y` und Größe verwaltet werden. +- [x] Backend + SVG-Editor angepasst: Patchpanel-Ports werden über `floor_patchpanel_ports` gepflegt. +- [x] Patchpanel ↔ Patchpanel und Patchpanel ↔ Netzwerkbuchse werden über `connections` verwaltet. +- [x] SQL-Tabellen `floor_patchpanels` / `floor_patchpanel_ports` sind im Schema enthalten. +- [ ] Floorplan-Filter/Legend und erweiterte Suche für Infrastrukturobjekte weiter ausbauen. ### Stockwerksinfrastruktur-Modul - Das neue Modul „Stockwerksinfrastruktur“ sammelt Patchpanels und Wandbuchsen an einem Ort. - Patchfelder bekommen feste X/Y-Positionen, Maße, Portanzahl und verknüpfen zu Floorplans. - Wandbuchsen sind direkt mit Räumen verbunden, können aber auch später im SVG verteilt werden. - Ziel: Die Floorplan-Grafik zeigt die permanente Infrastruktur samt fest verlegter Kabelverläufe. -- TODO: SVG-Editor um Drag & Drop für diese Objekte erweitern und Klicks direkt mit dem Modul verbinden. +- [x] SVG-Editor für diese Objekte ist mit Drag & Drop umgesetzt und direkt mit dem Modul verbunden. --- diff --git a/TODO.md b/TODO.md index 8119d0d..8759b0a 100644 --- a/TODO.md +++ b/TODO.md @@ -1,258 +1,36 @@ -# TODO +# TODO -Zentrale Sammlung aller TODO-Markierungen im Repository (Stand: 13. Februar 2026). +Bereinigte und aktuelle TODO-Liste (Stand: 18. Februar 2026). +Quelle: vorhandene `TODO`-Marker im Repository plus offene Architekturpunkte. -Hinweis: Die Eintraege sind direkt aus den Quelldateien aggregiert. +## Erledigt (bereits umgesetzt) -## Arbeitsnotizen (16. Februar 2026) +- [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. -- [x] API-Basis umgesetzt: `app/api/connections.php`, `app/api/device_type_ports.php`, `app/api/upload.php` auf aktuelles Schema gebracht (Auth, Validierung, Existenzpruefungen, Fehlerantworten). -- [x] Bootstrap/Auth/Config/Routing 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 ersetzt: `app/modules/device_types/ports.php` lauffaehig gemacht (anzeigen, hinzufuegen, loeschen). -- [x] TODO-Reste in `header.php`, `footer.php`, `layout.php`, `floor_infrastructure/edit.php` entfernt. +## Offen (direkt im Code markiert) -Offene Blocker / naechste Punkte: -- [ ] `app/modules/connections/list.php`: Detailbereich fuer ausgewaehlte Verbindung sowie Bearbeiten/Loeschen im UI fehlen noch. -- [ ] `app/modules/dashboard/list.php`: grosse zoombare Gesamt-Topologie-Wand (fachlich/grafisch groesseres Feature). -- [ ] `app/lib/helpers.php`: generischer Sammel-TODO ohne konkreten Scope. -- [ ] Vollstaendiger End-to-End-Test aktuell nicht moeglich, da in dieser Shell kein `php` CLI verfuegbar ist. +- [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`). -## app\api\connections.php +## Offen (Bugs / Doku / Statusdateien) -- [ ] L15: // TODO: Single-User-Auth prüfen -- [ ] L61: // TODO: Kontext definieren (Standort, Rack, Floor, gesamtes Netz) -- [ ] L117: // TODO: Validierung -- [ ] L182: // TODO: Prüfen, ob Verbindung existiert +- [ ] `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. -## app\api\device_type_ports.php +## Topologie-Backlog (ausstehend) -- [ ] L15: // TODO: Single-User-Auth prüfen -- [ ] L96: // TODO: Transaktion starten (falls SQL-Klasse das unterstützt) -- [ ] L100: // TODO: Validierung: -- [ ] L163: // TODO: Prüfen, ob Port existiert und nicht verwendet wird - -## app\api\upload.php - -- [ ] L18: // TODO: Single-User-Auth prüfen -- [ ] L25: // TODO: Upload-Basisverzeichnis aus config.php -- [ ] L35: // TODO: Max. Dateigröße festlegen (z.B. 5MB) -- [ ] L77: // TODO: Kategorie definieren (device_types, floors, racks, etc.) -- [ ] L95: // TODO: Eindeutigen Namen besser definieren (UUID?) -- [ ] L114: // TODO: Optional in Tabelle `uploads` speichern - -## app\assets\js\app.js - -- [ ] L15: deviceTypes: [], // TODO: alle Gerätetypen laden -- [ ] L16: devices: [], // TODO: alle Geräte laden -- [ ] L17: racks: [], // TODO: alle Racks laden -- [ ] L18: floors: [], // TODO: alle Floors laden -- [ ] L19: connections: [], // TODO: alle Verbindungen laden -- [ ] L33: // TODO: import / init svg-editor.js -- [ ] L39: // TODO: import / init network-view.js -- [ ] L45: // TODO: init drag & drop logic -- [ ] L59: // TODO: Save-Button Device-Type -- [ ] L64: // TODO: Save Device-Type via AJAX -- [ ] L68: // TODO: Save-Button Device -- [ ] L73: // TODO: Save Device via AJAX -- [ ] L77: // TODO: Save-Button Floor -- [ ] L82: // TODO: Save Floor via AJAX -- [ ] L86: // TODO: Save-Button Rack -- [ ] L91: // TODO: Save Rack via AJAX -- [ ] L95: // TODO: Weitere Event-Handler (Import, Export, Filter) -- [ ] L132: // TODO: weitere Utility-Funktionen (DOM-Helper, SVG-Helper, etc.) - -## app\assets\js\dashboard.js - -- [ ] L75: // TODO: Dashboard-Container ermitteln -- [ ] L78: // TODO: Module rendern -- [ ] L81: // TODO: Optional: Status-Daten laden (Counts, Warnings) -- [ ] L117: // TODO: loadStats() → Anzahl Geräte, offene Ports, unverbundene Dosen -- [ ] L118: // TODO: showWarnings() → unverbundene Ports, VLAN-Konflikte -- [ ] L119: // TODO: RecentChanges() → letzte Änderungen - -## app\assets\js\network-view.js - -- [ ] L20: // TODO: Standort / Rack / View-Kontext vom Backend setzen -- [ ] L23: // TODO: API-Endpunkte definieren -- [ ] L79: // TODO: Datenstruktur validieren -- [ ] L123: // TODO: Gerätetyp (SVG oder JPG) korrekt laden -- [ ] L144: // TODO: Ports als kleine Kreise anlegen (Position aus Portdefinition) -- [ ] L145: // TODO: Ports klickbar machen (für Verbindungs-Erstellung) -- [ ] L157: // TODO: Quell- & Ziel-Port-Koordinaten berechnen -- [ ] L158: // TODO: unterschiedliche Verbindungstypen (Farbe, Strichart, Dicke) -- [ ] L217: // TODO: Positionen optional automatisch speichern -- [ ] L232: // TODO: Sidebar mit Gerätedetails füllen -- [ ] L254: // TODO: Erfolg / Fehler anzeigen -- [ ] L289: // TODO: Delete -> Gerät entfernen? - -## app\assets\js\svg-editor.js - -- [ ] L20: // TODO: vom Backend setzen (z. B. via data-Attribut) -- [ ] L23: // TODO: API-Endpunkte festlegen -- [ ] L74: // TODO: Modifier-Key prüfen (z. B. nur mit SHIFT neuen Port erstellen?) -- [ ] L86: port_type_id: null, // TODO: Default-Porttyp? -- [ ] L134: // TODO: Sidebar-Felder mit Portdaten füllen -- [ ] L178: // TODO: Sicherheitsabfrage (confirm) -- [ ] L184: // TODO: Sidebar zurücksetzen -- [ ] L200: // TODO: Datenformat validieren -- [ ] L222: // TODO: Erfolg / Fehler anzeigen - -## app\bootstrap.php - -- [ ] L16: // TODO: Config-Datei mit DB-Zugang, Pfaden, globalen Settings füllen -- [ ] L22: // TODO: Single-User Auth prüfen -- [ ] L30: // TODO: Host, User, Passwort, DB aus config.php nutzen - -## app\index.php - -- [ ] L19: // TODO: Session starten / Single-User-Auth prüfen -- [ ] L37: // TODO: Fehlerseite anzeigen, nutze renderClientError(...) -- [ ] L42: // TODO: Fehlerseite anzeigen, nutze renderClientError(...) -- [ ] L61: // TODO: Fehlerseite oder 404, nutze renderClientError(...) - -## app\lib\auth.php - -- [ ] L24: // TODO: Session-Variable definieren, z.B. $_SESSION['auth'] === true -- [ ] L40: // TODO: Passwort aus config.php vergleichen -- [ ] L41: // TODO: Passwort-Hash verwenden (password_hash / password_verify) -- [ ] L62: // TODO: Session-Variablen löschen -- [ ] L65: // TODO: Optional komplette Session zerstören -- [ ] L79: // TODO: Redirect auf Login-Seite - -## app\lib\helpers.php - -- [ ] L300: // TODO: Weitere Helfer nach Bedarf - -## app\modules\buildings\edit.php - -- [ ] L176: // TODO: AJAX-Delete implementieren - -## app\modules\buildings\list.php - -- [ ] L245: // TODO: AJAX-Delete implementieren - -## app\modules\connections\list.php - -- [ ] L198: -- [ ] L207: - -## app\modules\device_types\edit.php - -- [ ] L18: //TODO port hinzufügen geht nicht -- [ ] L378: // TODO: AJAX-Delete implementieren - -## app\modules\device_types\list.php - -- [ ] L252: // TODO: AJAX-Delete implementieren - -## app\modules\device_types\ports.php - -- [ ] L12: // TODO: bootstrap laden -- [ ] L15: // TODO: Auth erzwingen -- [ ] L22: // TODO: device_type_id aus GET lesen -- [ ] L25: // TODO: Gerätetyp laden -- [ ] L28: // TODO: Ports dieses Gerätetyps laden -- [ ] L43: -- [ ] L59: -- [ ] L60: -- [ ] L109: -- [ ] L113: -- [ ] L117: -- [ ] L120: -- [ ] L123: -- [ ] L126: -- [ ] L161: -- [ ] L262: * TODO: Replace this mock logic with real AJAX once ports are - -## app\modules\devices\list.php - -- [ ] L206: //TODO löschen geht nicht - -## app\modules\floor_infrastructure\edit.php - -- [ ] L277: //TODO drag an drop auf der stockwerkskarte für die patchfelder und wandbuchsen. buchsen haben eine einheitliche größe, und sind quadratisch, patchfelder sind auch für sich einheitlich, sind rechteckig und breiter als hoch -- [ ] L278: //TODO style in css files einsortieren - -## app\modules\floor_infrastructure\list.php - -- [ ] L143:

//TODO: SVG-Editor mit Drag & Drop für diese Objekte erweitern (siehe Stockwerke-Modul).

- -## app\modules\floors\list.php - -- [ ] L237: // TODO: AJAX-Delete implementieren - -## app\modules\locations\edit.php - -- [ ] L157: // TODO: AJAX-Delete implementieren - -## app\modules\locations\list.php - -- [ ] L134: //TODO design schlecht, mach es hübscher -- [ ] L208: //TODO style in css file - -## app\modules\racks\edit.php - -- [ ] L201: // TODO: AJAX-Delete implementieren -- [ ] L221: -- [ ] L237: -- [ ] L251: // TODO: Rack-ID aus PHP setzen -- [ ] L254: // TODO: Gerätepositionen an JS übergeben - -## app\modules\racks\list.php - -- [ ] L255: // TODO: AJAX-Delete implementieren - -## app\templates\footer.php - -- [ ] L14: -- [ ] L17: - -## app\templates\header.php - -- [ ] L24: - -## app\templates\layout.php - -- [ ] L11: * TODO: In Zukunft: zentrales Template-System (z.B. mit $content) -- [ ] L18: - -## BUGS.md - -- [ ] L3: - [ ] TODO Design vereinheitlichen - -## doc\DATABASE.md - -- [ ] L126: **TODO** - -## IMPLEMENTATION_STATUS.md - -- [ ] L80: - [ ] **Delete-Funktionen** - Löschen noch als TODO (als AJAX implementieren) -- [ ] L109: │ └── auth.php 🚧 TODO: Auth - -## init.sql - -- [ ] L372: -- TODO: Port-Konfiguration (Patchpanel ↔ Patchpanel, Patchpanel ↔ Netzwerkbuchse) wird über die `connections`-Tabelle geregelt. - -## NEXT_STEPS.md - -- [ ] L74: ## 🔧 Bekannte TODOs im Code -- [ ] L76: Alle noch offenen Punkte sind mit `// TODO:` gekennzeichnet: -- [ ] L79: # Alle TODOs finden: -- [ ] L80: grep -r "TODO:" app/modules/ --include="*.php" -- [ ] L83: Wichtigste TODOs: - -## README.md - -- [ ] L241: ### TODO: Patchpanel-Infrastruktur -- [ ] L253: - TODO: SVG-Editor um Drag & Drop für diese Objekte erweitern und Klicks direkt mit dem Modul verbinden. - -- [ ] //TODO infrastruktur patchfelder löschen soll implementiert werden. - -## Topologie-Abgleich (16. Februar 2026) - -- [ ] #TODO: `connections.port_a_type` und `connections.port_b_type` um einen Patchpanel-Port-Typ erweitern (z. B. `patchpanel`) und auf `floor_patchpanel_ports.id` referenzieren. -- [ ] #TODO: Business-Regeln fuer Topologie in der Verbindungs-Validierung hinterlegen: Patchpanel-Port nur mit Patchpanel-Port oder Netzwerkbuchsen-Port verbinden. -- [ ] #TODO: Port-CRUD fuer Patchpanels ergaenzen: `floor_patchpanel_ports` beim Speichern aus `port_count` erzeugen/synchronisieren. -- [ ] #TODO: Port-CRUD fuer Netzwerkbuchsen ergaenzen: `network_outlet_ports` pflegen (mindestens ein Port je Buchse) und fuer Verbindungen nutzbar machen. +- [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. diff --git a/app/assets/css/app.css b/app/assets/css/app.css index fa37a8f..b37b5b6 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -77,6 +77,42 @@ main { min-height: calc(100vh - 200px); } +.flash-stack { + display: grid; + gap: 10px; + margin: 0 auto 18px; + max-width: 1200px; +} + +.flash-message { + border-radius: 8px; + border: 1px solid transparent; + padding: 12px 14px; + background: #f8fafc; +} + +.flash-message--success { + border-color: #99dfba; + background: #ebf9f1; + color: #165938; +} + +.flash-message--error { + border-color: #efb4b4; + background: #fff1f1; + color: #8a1f1f; +} + +.flash-message__text { + margin: 0; + font-weight: 600; +} + +.flash-message__list { + margin: 8px 0 0 18px; + padding: 0; +} + /* Shared components -------------------------------------------------- */ .filter-form { margin: 20px 0; @@ -192,4 +228,4 @@ main { footer>p { margin-bottom: 0; -} \ No newline at end of file +} diff --git a/app/bootstrap.php b/app/bootstrap.php index fbb71df..e19966c 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -12,6 +12,10 @@ if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); } +if (!isset($_SESSION['validation_errors']) || !is_array($_SESSION['validation_errors'])) { + $_SESSION['validation_errors'] = []; +} + require_once __DIR__ . '/lib/_sql.php'; $sql = new SQL(); diff --git a/app/lib/helpers.php b/app/lib/helpers.php index 9746082..cd3d98c 100644 --- a/app/lib/helpers.php +++ b/app/lib/helpers.php @@ -297,8 +297,83 @@ HTML, [ * Sonstiges * ========================= */ -// TODO: Weitere Helfer nach Bedarf -// - Datum formatieren -// - Bytes → MB -// - UUID erzeugen -// - SVG-Koordinaten normalisieren +/** + * Formatiert Datum/Uhrzeit robust oder gibt Fallback zurueck. + * + * @param string|null $value + * @param string $format + * @param string $fallback + * @return string + */ +function formatDateTime(?string $value, string $format = 'd.m.Y H:i', string $fallback = '-'): string +{ + if ($value === null || trim($value) === '') { + return $fallback; + } + + $timestamp = strtotime($value); + if ($timestamp === false) { + return $fallback; + } + + return date($format, $timestamp); +} + +/** + * Formatiert Byte-Werte in menschenlesbare Einheit. + * + * @param int|float $bytes + * @param int $precision + * @return string + */ +function formatBytes($bytes, int $precision = 2): string +{ + $value = (float)$bytes; + if ($value <= 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $power = min((int)floor(log($value, 1024)), count($units) - 1); + $scaled = $value / (1024 ** $power); + + return number_format($scaled, $precision, '.', '') . ' ' . $units[$power]; +} + +/** + * Erzeugt eine UUID v4. + * + * @return string + * @throws Exception + */ +function generateUuidV4(): string +{ + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + $hex = bin2hex($bytes); + + return sprintf( + '%s-%s-%s-%s-%s', + substr($hex, 0, 8), + substr($hex, 8, 4), + substr($hex, 12, 4), + substr($hex, 16, 4), + substr($hex, 20, 12) + ); +} + +/** + * Klemmt eine SVG-Koordinate auf gueltigen Bereich. + * + * @param float $value + * @param float $min + * @param float $max + * @param int $precision + * @return float + */ +function normalizeSvgCoordinate(float $value, float $min, float $max, int $precision = 2): float +{ + $normalized = max($min, min($max, $value)); + return round($normalized, $precision); +} diff --git a/app/modules/buildings/save.php b/app/modules/buildings/save.php index 2aa4b50..61809f6 100644 --- a/app/modules/buildings/save.php +++ b/app/modules/buildings/save.php @@ -25,6 +25,7 @@ if ($locationId <= 0) { if (!empty($errors)) { $_SESSION['error'] = implode(', ', $errors); + $_SESSION['validation_errors'] = $errors; $redirectUrl = $buildingId ? "?module=buildings&action=edit&id=$buildingId" : "?module=buildings&action=edit"; header("Location: $redirectUrl"); exit; diff --git a/app/modules/connections/delete.php b/app/modules/connections/delete.php new file mode 100644 index 0000000..a4d25c8 --- /dev/null +++ b/app/modules/connections/delete.php @@ -0,0 +1,65 @@ + false, 'message' => 'Ungueltige Verbindungs-ID']); + exit; + } + $_SESSION['error'] = 'Ungueltige Verbindungs-ID'; + header('Location: ?module=connections&action=list'); + exit; +} + +$connection = $sql->single( + "SELECT id FROM connections WHERE id = ?", + "i", + [$connectionId] +); + +if (!$connection) { + if ($isPost) { + header('Content-Type: application/json; charset=utf-8'); + http_response_code(404); + echo json_encode(['success' => false, 'message' => 'Verbindung nicht gefunden']); + exit; + } + $_SESSION['error'] = 'Verbindung nicht gefunden'; + header('Location: ?module=connections&action=list'); + exit; +} + +$rows = $sql->set( + "DELETE FROM connections WHERE id = ?", + "i", + [$connectionId] +); + +if ($isPost) { + header('Content-Type: application/json; charset=utf-8'); + if ($rows > 0) { + echo json_encode(['success' => true, 'message' => 'Verbindung geloescht']); + } else { + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'Verbindung konnte nicht geloescht werden']); + } + exit; +} + +if ($rows > 0) { + $_SESSION['success'] = 'Verbindung geloescht'; +} else { + $_SESSION['error'] = 'Verbindung konnte nicht geloescht werden'; +} + +header('Location: ?module=connections&action=list'); +exit; diff --git a/app/modules/connections/list.php b/app/modules/connections/list.php index 192bc8f..abde9c5 100644 --- a/app/modules/connections/list.php +++ b/app/modules/connections/list.php @@ -2,19 +2,13 @@ /** * app/modules/connections/list.php * - * Übersicht der Netzwerkverbindungen - * - Tabellarische Liste aller Verbindungen - * - Filter nach Geräten, VLANs, Status - * - Später: Visuelle Netzwerk-Topologie + * Uebersicht der Netzwerkverbindungen */ -// ========================= -// Filter einlesen -// ========================= $search = trim($_GET['search'] ?? ''); $deviceId = (int)($_GET['device_id'] ?? 0); +$selectedConnectionId = (int)($_GET['connection_id'] ?? 0); -// Einheitliche Endpunkt-Aufloesung fuer polymorphe Port-Typen. $endpointUnionSql = " SELECT 'device' AS endpoint_type, @@ -60,16 +54,13 @@ $endpointUnionSql = " LEFT JOIN floors f ON f.id = fp.floor_id "; -// ========================= -// WHERE-Clause bauen -// ========================= $where = []; $types = ''; $params = []; if ($search !== '') { $where[] = "(e1.owner_name LIKE ? OR e2.owner_name LIKE ? OR e1.port_name LIKE ? OR e2.port_name LIKE ?)"; - $types .= "ssss"; + $types .= 'ssss'; $params[] = "%$search%"; $params[] = "%$search%"; $params[] = "%$search%"; @@ -78,16 +69,13 @@ if ($search !== '') { if ($deviceId > 0) { $where[] = "(e1.owner_device_id = ? OR e2.owner_device_id = ?)"; - $types .= "ii"; + $types .= 'ii'; $params[] = $deviceId; $params[] = $deviceId; } -$whereSql = $where ? "WHERE " . implode(" AND ", $where) : ""; +$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; -// ========================= -// Verbindungen laden -// ========================= $connections = $sql->get( "SELECT c.id, @@ -125,10 +113,22 @@ $connections = $sql->get( $params ); -// ========================= -// Filter-Daten -// ========================= -$devices = $sql->get("SELECT id, name FROM devices ORDER BY name", "", []); +$devices = $sql->get('SELECT id, name FROM devices ORDER BY name', '', []); + +$selectedConnection = null; +if ($selectedConnectionId > 0) { + foreach ((array)$connections as $entry) { + if ((int)($entry['id'] ?? 0) === $selectedConnectionId) { + $selectedConnection = $entry; + break; + } + } +} + +if ($selectedConnection === null && !empty($connections)) { + $selectedConnection = $connections[0]; + $selectedConnectionId = (int)($selectedConnection['id'] ?? 0); +} $selectedDevice = null; $selectedDevicePorts = []; @@ -140,7 +140,7 @@ if ($deviceId > 0) { FROM devices d LEFT JOIN device_types dt ON d.device_type_id = dt.id WHERE d.id = ?", - "i", + 'i', [$deviceId] ); @@ -159,7 +159,7 @@ if ($deviceId > 0) { JOIN device_ports dp ON dp.id = dpm.device_port_id WHERE dp.device_id = ? ) p", - "ii", + 'ii', [$deviceId, $deviceId] )['cnt'] ?? 0); @@ -187,7 +187,7 @@ if ($deviceId > 0) { OR (c.port_b_type = 'patchpanel' AND e2.endpoint_type = 'floor_patchpanel') ) WHERE e1.owner_device_id = ? OR e2.owner_device_id = ?", - "ii", + 'ii', [$deviceId, $deviceId] )['cnt'] ?? 0); @@ -207,7 +207,7 @@ if ($deviceId > 0) { ) p ORDER BY sort_id LIMIT 12", - "ii", + 'ii', [$deviceId, $deviceId] ); @@ -230,159 +230,285 @@ if ($deviceId > 0) { } } +$buildListUrl = static function (array $extra = []) use ($search, $deviceId): string { + $query = ['module' => 'connections', 'action' => 'list']; + if ($search !== '') { + $query['search'] = $search; + } + if ($deviceId > 0) { + $query['device_id'] = $deviceId; + } + foreach ($extra as $key => $value) { + if ($value === null || $value === '') { + continue; + } + $query[$key] = $value; + } + return '?' . http_build_query($query); +}; ?> -
-

Netzwerkverbindungen

+
+
+

Netzwerkverbindungen

- -
-
- - +
+ + + - + - + + + + + + + + Reset + + Neue Verbindung + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - Reset - + Neue Verbindung - + +
Von (Geraet -> Port)Nach (Geraet -> Port)VLANsBeschreibungStatusAktionen
+
+ +
+
+ +
+ + + + + + Warnung + + OK + + + Details + Bearbeiten + Von/Nach tauschen + +
+ +
+

Keine Verbindungen gefunden.

+

+ + Erste Verbindung anlegen + +

+
+
- - - - - - - - - - - - - - - - - - + - - - - - - - - - - -
Von (Gerät → Port)Nach (Gerät → Port)VLANsBeschreibungStatusAktionen
-
- -
-
- -
- - - - - - - - - ⚠️ Warnung - + - Bearbeiten - Von/Nach tauschen - - Löschen - -
- - -
-

Keine Verbindungen gefunden.

-

- - Erste Verbindung anlegen - -

-
- +

+ +

Ports (max. 12)

+
    + +
  • + +
+ + +

Ausgewaehltes Geraet

+

Bitte ein Geraet im Filter auswaehlen.

+ + +
- + + + diff --git a/app/modules/connections/save.php b/app/modules/connections/save.php index d56efd2..8f0a51d 100644 --- a/app/modules/connections/save.php +++ b/app/modules/connections/save.php @@ -88,6 +88,7 @@ if ($isEndpointUsed($portBType, $portBId)) { if (!empty($errors)) { $_SESSION['error'] = implode(', ', $errors); + $_SESSION['validation_errors'] = $errors; $redirectUrl = $connId ? "?module=connections&action=edit&id=$connId" : "?module=connections&action=edit"; header("Location: $redirectUrl"); exit; @@ -123,6 +124,7 @@ if ($connId > 0) { if ($connectionTypeId <= 0) { $_SESSION['error'] = "Kein Verbindungstyp verfuegbar"; + $_SESSION['validation_errors'] = ["Kein Verbindungstyp verfuegbar"]; header("Location: ?module=connections&action=edit"); exit; } diff --git a/app/modules/dashboard/list.php b/app/modules/dashboard/list.php index 71c4593..1ae9296 100644 --- a/app/modules/dashboard/list.php +++ b/app/modules/dashboard/list.php @@ -1,15 +1,9 @@ $sql->single("SELECT COUNT(*) as cnt FROM devices", "", [])['cnt'] ?? 0, 'device_types' => $sql->single("SELECT COUNT(*) as cnt FROM device_types", "", [])['cnt'] ?? 0, @@ -18,7 +12,6 @@ $stats = [ 'locations' => $sql->single("SELECT COUNT(*) as cnt FROM locations", "", [])['cnt'] ?? 0, ]; -// Recent devices $recentDevices = $sql->get( "SELECT d.id, d.name, dt.name as type_name, r.name as rack_name, f.name as floor_name FROM devices d @@ -26,12 +19,213 @@ $recentDevices = $sql->get( LEFT JOIN racks r ON d.rack_id = r.id LEFT JOIN floors f ON r.floor_id = f.id ORDER BY d.id DESC LIMIT 5", - "", [] + "", + [] ); +$topologyDevices = $sql->get( + "SELECT + d.id AS device_id, + d.name AS device_name, + dt.name AS device_type_name, + r.id AS rack_id, + r.name AS rack_name, + f.id AS floor_id, + f.name AS floor_name, + b.id AS building_id, + b.name AS building_name, + l.id AS location_id, + l.name AS location_name + FROM devices d + LEFT JOIN device_types dt ON dt.id = d.device_type_id + LEFT JOIN racks r ON r.id = d.rack_id + LEFT JOIN floors f ON f.id = r.floor_id + 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", + "", + [] +); + +$topologyPayload = array_map(static function (array $row): array { + return [ + 'device_id' => (int)($row['device_id'] ?? 0), + 'device_name' => (string)($row['device_name'] ?? ''), + 'device_type_name' => (string)($row['device_type_name'] ?? ''), + 'rack_id' => (int)($row['rack_id'] ?? 0), + 'rack_name' => (string)($row['rack_name'] ?? ''), + 'floor_id' => (int)($row['floor_id'] ?? 0), + 'floor_name' => (string)($row['floor_name'] ?? ''), + '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); + +$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 = []; +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'] ?? '') + ]; +} + +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), + ]; +} ?> -

Dashboard

@@ -39,35 +233,65 @@ $recentDevices = $sql->get(

- +
+
+

Gesamt-Topologie-Wand

+
+ + + +
+
+

Hierarchie: Standort → Gebaeude → Stockwerk → Rack → Geraet. Linien zeigen Rack-Verbindungen (dicker = mehr Links).

+ + + + + + + + + + + +
+
-

+

Standorte

- Verwalten → + Verwalten ->
-

-

Gerätetypen

- Verwalten → +

+

Geraetetypen

+ Verwalten ->
-

-

Geräte

- Verwalten → +

+

Geraete

+ Verwalten ->
-

+

Racks

- Verwalten → + Verwalten ->
- -

Zuletzt hinzugefügt

+

Zuletzt hinzugefuegt

@@ -82,20 +306,691 @@ $recentDevices = $sql->get( - - - - - + + + + +
BearbeitenBearbeiten
-

Noch keine Geräte vorhanden. Starten Sie mit Gerätetypen.

+

Noch keine Geraete vorhanden. Starten Sie mit Geraetetypen.

+ + + + diff --git a/app/modules/device_types/save.php b/app/modules/device_types/save.php index ec6bfc5..c7c1432 100644 --- a/app/modules/device_types/save.php +++ b/app/modules/device_types/save.php @@ -49,6 +49,7 @@ if (!in_array($category, ['switch', 'server', 'patchpanel', 'other'])) { // Falls Fehler: zurück zum Edit-Formular if (!empty($errors)) { $_SESSION['error'] = implode(', ', $errors); + $_SESSION['validation_errors'] = $errors; header('Location: ?module=device_types&action=edit' . ($deviceTypeId ? "&id=$deviceTypeId" : "")); exit; } @@ -67,6 +68,7 @@ if (!empty($_FILES['image']['name'])) { // Nur SVG, JPG, PNG erlaubt if (!in_array($fileExt, ['svg', 'jpg', 'jpeg', 'png'])) { $_SESSION['error'] = "Nur SVG, JPG und PNG sind erlaubt"; + $_SESSION['validation_errors'] = ["Nur SVG, JPG und PNG sind erlaubt"]; header('Location: ?module=device_types&action=edit' . ($deviceTypeId ? "&id=$deviceTypeId" : "")); exit; } @@ -86,6 +88,7 @@ if (!empty($_FILES['image']['name'])) { $imageType = $fileExt === 'svg' ? 'svg' : 'bitmap'; } else { $_SESSION['error'] = "Datei-Upload fehlgeschlagen"; + $_SESSION['validation_errors'] = ["Datei-Upload fehlgeschlagen"]; header('Location: ?module=device_types&action=edit' . ($deviceTypeId ? "&id=$deviceTypeId" : "")); exit; } diff --git a/app/modules/devices/delete.php b/app/modules/devices/delete.php index 12992be..a4e66da 100644 --- a/app/modules/devices/delete.php +++ b/app/modules/devices/delete.php @@ -3,12 +3,20 @@ * app/modules/devices/delete.php * * Loescht ein Geraet. Bei Abhaengigkeiten ist force=1 erforderlich. + * Unterstuetzt GET-Redirects und AJAX-POST. */ -$deviceId = (int)($_GET['id'] ?? 0); -$forceDelete = (int)($_GET['force'] ?? 0) === 1; +$isPost = ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST'; +$deviceId = (int)($_POST['id'] ?? $_GET['id'] ?? 0); +$forceDelete = (int)($_POST['force'] ?? $_GET['force'] ?? 0) === 1; if ($deviceId <= 0) { + if ($isPost) { + header('Content-Type: application/json; charset=utf-8'); + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'Ungueltige Geraete-ID']); + exit; + } $_SESSION['error'] = "Ungueltige Geraete-ID"; header('Location: ?module=devices&action=list'); exit; @@ -21,6 +29,12 @@ $device = $sql->single( ); if (!$device) { + if ($isPost) { + header('Content-Type: application/json; charset=utf-8'); + http_response_code(404); + echo json_encode(['success' => false, 'message' => 'Geraet nicht gefunden']); + exit; + } $_SESSION['error'] = "Geraet nicht gefunden"; header('Location: ?module=devices&action=list'); exit; @@ -70,7 +84,23 @@ if ($hasDependencies && !$forceDelete) { $parts[] = $moduleCount . ' Port-Module'; } - $_SESSION['error'] = "Geraet hat abhaengige Daten (" . implode(', ', $parts) . "). Loeschen bitte bestaetigen."; + $dependencyMessage = "Geraet hat abhaengige Daten (" . implode(', ', $parts) . "). Loeschen bitte bestaetigen."; + if ($isPost) { + header('Content-Type: application/json; charset=utf-8'); + http_response_code(409); + echo json_encode([ + 'success' => false, + 'requires_force' => true, + 'message' => $dependencyMessage, + 'dependencies' => [ + 'connections' => $connectionCount, + 'ports' => $portCount, + 'modules' => $moduleCount + ] + ]); + exit; + } + $_SESSION['error'] = $dependencyMessage; header('Location: ?module=devices&action=edit&id=' . urlencode((string)$deviceId)); exit; } @@ -91,8 +121,19 @@ $deleted = $sql->set( ); if ($deleted > 0) { + if ($isPost) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => true, 'message' => "Geraet geloescht: " . $device['name']]); + exit; + } $_SESSION['success'] = "Geraet geloescht: " . $device['name']; } else { + if ($isPost) { + header('Content-Type: application/json; charset=utf-8'); + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'Geraet konnte nicht geloescht werden']); + exit; + } $_SESSION['error'] = "Geraet konnte nicht geloescht werden"; } diff --git a/app/modules/devices/edit.php b/app/modules/devices/edit.php index 192e4a1..72d87cf 100644 --- a/app/modules/devices/edit.php +++ b/app/modules/devices/edit.php @@ -64,6 +64,19 @@ if ($isEdit) { // ========================= $deviceTypes = $sql->get("SELECT id, name, category FROM device_types ORDER BY name", "", []); $racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []); +$devicePorts = []; + +if ($isEdit) { + $devicePorts = $sql->get( + "SELECT dp.id, dp.name, dp.status, dp.mode, dp.vlan_config, pt.name AS port_type_name + FROM device_ports dp + LEFT JOIN port_types pt ON pt.id = dp.port_type_id + WHERE dp.device_id = ? + ORDER BY dp.id", + "i", + [$deviceId] + ); +} ?> @@ -160,6 +173,67 @@ $racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []);
+
+ Ports + + +

Portstatus und VLAN-Zuordnung koennen hier direkt gepflegt werden (VLANs kommagetrennt, z. B. 10,20,30).

+ + + + + + + + + + + + + + + + + + + + + + +
NamePort-TypStatusModusVLANs
+ + + + + + + +
+ +

Zu diesem Geraet sind aktuell keine Ports vorhanden.

+ + +

Ports werden nach dem ersten Speichern automatisch aus dem Geraetetyp erzeugt und koennen dann hier gepflegt werden.

+ +
+ @@ -264,34 +338,64 @@ $racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []); .button:hover { opacity: 0.8; } + +.device-port-table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; +} + +.device-port-table th, +.device-port-table td { + border-bottom: 1px solid #ddd; + padding: 8px; + text-align: left; +} + +.device-port-table input, +.device-port-table select { + width: 100%; + min-width: 120px; +} diff --git a/app/modules/devices/list.php b/app/modules/devices/list.php index 39ebcdf..c41a448 100644 --- a/app/modules/devices/list.php +++ b/app/modules/devices/list.php @@ -1,7 +1,7 @@ 0) { - $where[] = "d.device_type_id = ?"; - $types .= "i"; + $where[] = 'd.device_type_id = ?'; + $types .= 'i'; $params[] = $typeId; } if ($floorId > 0) { - $where[] = "f.id = ?"; - $types .= "i"; + $where[] = 'f.id = ?'; + $types .= 'i'; $params[] = $floorId; } if ($rackId > 0) { - $where[] = "d.rack_id = ?"; - $types .= "i"; + $where[] = 'd.rack_id = ?'; + $types .= 'i'; $params[] = $rackId; } $whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; // ========================= -// Geräte laden +// Geraete laden // ========================= - $devices = $sql->get( - " - SELECT - d.id, - d.name, - d.serial_number, - d.rack_position_he, - d.rack_height_he, - d.web_config_url, - dt.name AS device_type, - dt.image_path, - f.name AS floor_name, - r.name AS rack_name, - ( - SELECT COUNT(*) - FROM device_ports dp - WHERE dp.device_id = d.id - ) AS port_count, - ( - SELECT COUNT(*) - FROM device_port_modules dpm - JOIN device_ports dp2 ON dp2.id = dpm.device_port_id - WHERE dp2.device_id = d.id - ) AS module_count, - ( - SELECT COUNT(*) - FROM connections c - WHERE (c.port_a_type = 'device' AND c.port_a_id IN ( - 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 ( - SELECT dp4.id FROM device_ports dp4 WHERE dp4.device_id = d.id - )) - ) AS connection_count +$devices = $sql->get( + " + SELECT + d.id, + d.name, + d.serial_number, + d.rack_position_he, + d.rack_height_he, + d.web_config_url, + dt.name AS device_type, + dt.image_path, + f.name AS floor_name, + r.name AS rack_name, + ( + SELECT COUNT(*) + FROM device_ports dp + WHERE dp.device_id = d.id + ) AS port_count, + ( + SELECT COUNT(*) + FROM device_ports dp + WHERE dp.device_id = d.id + AND dp.status = 'active' + ) AS active_port_count, + ( + SELECT COUNT(*) + FROM device_ports dp + WHERE dp.device_id = d.id + AND dp.status = 'disabled' + ) AS disabled_port_count, + ( + SELECT COUNT(*) + FROM device_ports dp + WHERE dp.device_id = d.id + AND dp.vlan_config IS NOT NULL + AND dp.vlan_config <> '[]' + ) AS vlan_port_count, + ( + SELECT COUNT(*) + FROM device_port_modules dpm + JOIN device_ports dp2 ON dp2.id = dpm.device_port_id + WHERE dp2.device_id = d.id + ) AS module_count, + ( + SELECT COUNT(*) + FROM connections c + WHERE (c.port_a_type = 'device' AND c.port_a_id IN ( + 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 ( + SELECT dp4.id FROM device_ports dp4 WHERE dp4.device_id = d.id + )) + ) AS connection_count FROM devices d JOIN device_types dt ON dt.id = d.device_type_id LEFT JOIN racks r ON r.id = d.rack_id @@ -99,22 +118,19 @@ $whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; // ========================= // Filter-Daten laden // ========================= -$deviceTypes = $sql->get("SELECT id, name FROM device_types ORDER BY name", "", []); -$floors = $sql->get("SELECT id, name FROM floors ORDER BY name", "", []); -$racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []); +$deviceTypes = $sql->get('SELECT id, name FROM device_types ORDER BY name', '', []); +$floors = $sql->get('SELECT id, name FROM floors ORDER BY name', '', []); +$racks = $sql->get('SELECT id, name FROM racks ORDER BY name', '', []); ?>
-

Geräte

+

Geraete

-
- @@ -78,6 +69,13 @@ unset($_SESSION['success']); Bearbeiten + @@ -164,8 +162,7 @@ unset($_SESSION['success']); font-weight: bold; } -.empty-state, -.success-message { +.empty-state { margin: 20px 0; padding: 15px; border-radius: 6px; @@ -177,13 +174,40 @@ unset($_SESSION['success']); text-align: center; } -.success-message { - background: #e9f8f1; - border: 1px solid #c7eedc; - color: #2c7d59; -} - .actions { white-space: nowrap; } + + diff --git a/app/modules/port_types/save.php b/app/modules/port_types/save.php index 9461db3..124233e 100644 --- a/app/modules/port_types/save.php +++ b/app/modules/port_types/save.php @@ -29,6 +29,7 @@ if (!in_array($medium, $allowedMediums, true)) { if (!empty($errors)) { $_SESSION['error'] = implode(', ', $errors); + $_SESSION['validation_errors'] = $errors; $redirect = $portTypeId ? "?module=port_types&action=edit&id=$portTypeId" : "?module=port_types&action=edit"; header("Location: $redirect"); exit; diff --git a/app/modules/racks/save.php b/app/modules/racks/save.php index 6dc0d59..edc0fe3 100644 --- a/app/modules/racks/save.php +++ b/app/modules/racks/save.php @@ -40,6 +40,7 @@ if ($heightHe < 1) { // Falls Fehler: zurück zum Edit-Formular if (!empty($errors)) { $_SESSION['error'] = implode(', ', $errors); + $_SESSION['validation_errors'] = $errors; $redirectUrl = $rackId ? "?module=racks&action=edit&id=$rackId" : "?module=racks&action=edit"; header("Location: $redirectUrl"); exit; diff --git a/app/modules/rooms/save.php b/app/modules/rooms/save.php index c4cf750..bf01a73 100644 --- a/app/modules/rooms/save.php +++ b/app/modules/rooms/save.php @@ -18,6 +18,15 @@ $comment = trim((string)($_POST['comment'] ?? '')); $rawPolygon = trim((string)($_POST['polygon_points'] ?? '')); if ($name === '' || $floorId <= 0) { + $errors = []; + if ($name === '') { + $errors[] = 'Name ist erforderlich'; + } + if ($floorId <= 0) { + $errors[] = 'Stockwerk ist erforderlich'; + } + $_SESSION['error'] = implode(', ', $errors); + $_SESSION['validation_errors'] = $errors; $redirect = $roomId > 0 ? "?module=rooms&action=edit&id=$roomId" : "?module=rooms&action=edit&floor_id=$floorId"; header("Location: $redirect"); exit; @@ -100,6 +109,7 @@ if (roomsHasPolygonColumn($sql)) { } } +$_SESSION['success'] = $roomId > 0 ? 'Raum gespeichert' : 'Raum erstellt'; header('Location: ?module=locations&action=list'); exit; diff --git a/app/templates/footer.php b/app/templates/footer.php index 6e333c5..172e10d 100644 --- a/app/templates/footer.php +++ b/app/templates/footer.php @@ -12,21 +12,5 @@ | Session:

- - - - - diff --git a/app/templates/header.php b/app/templates/header.php index 12e43fd..6478ff4 100644 --- a/app/templates/header.php +++ b/app/templates/header.php @@ -21,6 +21,7 @@ + @@ -63,4 +64,60 @@ + 'success', + 'text' => $successMessage, + ]; +} + +$errorMessage = trim((string)($_SESSION['error'] ?? '')); +if ($errorMessage !== '') { + $flashMessages[] = [ + 'type' => 'error', + 'text' => $errorMessage, + ]; +} + +$validationErrors = $_SESSION['validation_errors'] ?? []; +if (!is_array($validationErrors)) { + $validationErrors = []; +} +$validationErrors = array_values(array_filter(array_map(static function ($entry) { + return trim((string)$entry); +}, $validationErrors), static function ($entry) { + return $entry !== ''; +})); + +if (!empty($validationErrors)) { + $flashMessages[] = [ + 'type' => 'error', + 'text' => 'Bitte pruefe die Eingaben:', + 'details' => $validationErrors, + ]; +} + +unset($_SESSION['success'], $_SESSION['error'], $_SESSION['validation_errors']); +?> +
+ +
+ +
+

+ +
    + +
  • + +
+ +
+ +
+ diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 893cfe6..62888d6 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -124,10 +124,10 @@ Die Bühne für Patchpanel-Objekte auf dem Stockwerkplan. - Attributes: Panel-Referenz, `name`, `port_type_id`, optionale VLAN- bzw. Status-Attribute. - Ports werden über `connections` sowohl mit anderen Patchpanels als auch mit Netzwerkbuchsen (`network_outlet_ports`) oder Gerätports verbunden; dadurch lassen sich Router-Kabel grafisch darstellen. -**TODO** -- [ ] Floorplan- und CRUD-Module so erweitern, dass Patchpanels als Floor-Objekte verwaltet und deren Ports gepflegt werden können (`floor_patchpanels`, `floor_patchpanel_ports`). -- [ ] Verbindungen zwischen Patchpanel ↔ Patchpanel und Patchpanel ↔ Netzwerkbuchse standardisiert in der `connections`-Logik abbilden. -- [ ] UI/CSV/Export/Dokumentation nachziehen, damit Planer sofort sehen, wo die permanent installierten Kabel verlaufen. +**Status (18. Februar 2026)** +- [x] Floorplan- und CRUD-Module wurden für Patchpanels als Floor-Objekte inkl. Port-Pflege erweitert (`floor_patchpanels`, `floor_patchpanel_ports`). +- [x] Verbindungen zwischen Patchpanel ↔ Patchpanel und Patchpanel ↔ Netzwerkbuchse sind in der `connections`-Logik abbildbar. +- [ ] UI/CSV/Export-Dokumentation weiter ausbauen, damit Planer Kabelverläufe direkt auswerten können. --- diff --git a/init.sql b/init.sql index 4b0252b..fd77e2d 100644 --- a/init.sql +++ b/init.sql @@ -376,7 +376,10 @@ CREATE TABLE `floor_patchpanel_ports` ( `comment` text DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci; --- TODO: Port-Konfiguration (Patchpanel ↔ Patchpanel, Patchpanel ↔ Netzwerkbuchse) wird über die `connections`-Tabelle geregelt. +-- Port-Regeln fuer Patchpanel-Verbindungen: +-- 1) `connections.port_a_type` / `connections.port_b_type` nutzen den Wert `patchpanel` fuer IDs aus `floor_patchpanel_ports`. +-- 2) Patchpanel-Ports duerfen mit Patchpanel-Ports, Netzwerkbuchsen-Ports (`outlet`) oder Geraete-/Modulports verbunden werden. +-- 3) Die fachliche Validierung erfolgt in den Save-Handlern der Verbindungs-Module; das Schema bleibt polymorph. -- --------------------------------------------------------