Compare commits

...

25 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
20638cb3a5 agent instructions 2026-02-18 10:39:30 +01:00
77758f71d3 feat: complete package 2 delete flows and package 3 port management 2026-02-18 10:16:24 +01:00
f4ce7f360d feat: implement package 1 session and validation feedback
- add session validation_errors bootstrap initialization

- render global flash + validation messages in header

- remove footer alert-based flash handling

- persist structured validation errors across save handlers

- mark NEXT_STEPS package 1 tasks as done
2026-02-18 09:40:59 +01:00
ec20fa2f96 docs: update open TODO status and patchpanel documentation
- refresh TODO.md section for bugs/docs/status files

- align implementation status for delete/auth progress

- mark patchpanel infrastructure and floor SVG editor status in README

- finalize database patchpanel status notes and init.sql connection rules
2026-02-18 09:29:57 +01:00
c8fb5b140c feat: improve dashboard and connection workflows
- add connection delete endpoint and update connection list handling

- expand dashboard visualization behavior

- update helpers/header and project TODO tracking
2026-02-18 09:23:11 +01:00
463ab97c4b todos check 2026-02-18 09:05:16 +01:00
ce4ef5527f Merge pull request 'todos' (#16) from todos into main
Reviewed-on: #16
2026-02-16 14:46:25 +01:00
4a23713d31 verbings administration 2026-02-16 14:43:15 +01:00
510a248edb div TODOs 2026-02-16 13:56:01 +01:00
71 changed files with 4844 additions and 2413 deletions

View File

@@ -1,55 +1,58 @@
# 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 arbeitet pragmatisch bei Aufgaben aus `NEXT.md` und User-Requests.
Ein Gitea-Issue ist optional.
## 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).
- Datenmodell: zentrales SQL-Schema (`locations`, `device_types`, `devices`, `connections` etc.) mit JSON-Erweiterungsmöglichkeiten.
- Projektphasen (Phase 14) sind im README gelistet, siehe letzte Abschnitte.
## Kernregeln
## Schneller Projektstart
```powershell
docker-compose up -d --build
# danach: http://localhost
1. Ein Issue ist **nicht erforderlich**, um eine Aufgabe umzusetzen.
2. Skills duerfen jederzeit verwendet werden (z. B. `gitea-issues`).
3. Ein `NEXT.md`-Punkt darf erst auf erledigt (`[x]`) gesetzt werden, wenn die Umsetzung im Code erfolgt ist.
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**.
6. Kein `closes #<id>`, wenn das Issue nicht tatsaechlich abgeschlossen ist.
7. `git push` nur auf explizite Aufforderung; standardmaessig nur committen.
## Verbindlicher Ablauf
1. Aufgabe umsetzen (aus `NEXT.md` oder User-Anfrage).
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`
3. `NEXT.md` bei Bedarf aktualisieren (mit oder ohne `[#<id>]`).
4. Commit erstellen.
5. Wenn Issue abgeschlossen wird, Commit-Message mit eigener `closes`-Zeile schreiben.
## Commit-Format bei Issue-Abschluss
Beispiel mit einem Issue:
```text
Kurzbeschreibung der Aenderung
closes #42
```
Das Docker-Setup (Compose + Portainer) liegt in `docker-compose.yml` und `docker-portainer.yml`, ergänzende Infos in `Dockerfile`.
## 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`.
Beispiel mit mehreren Issues:
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.
```text
Kurzbeschreibung der Aenderung
## 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.
closes #12
closes #18
```
## 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.
## Format fuer NEXT.md
## Weitere Ressourcen
- `NEXT_STEPS.md` (aktuelles ToDo / Roadmap).
- `IMPLEMENTATION_STATUS.md` (Status-Tracking).
- `README.md` (Feature- und Architekturübersicht).
- Offen ohne Issue:
- `- [ ] //TODO Backup-Runbook erstellen`
- Offen mit Issue:
- `- [ ] [#42] //TODO Backup-Runbook erstellen`
- Erledigt mit Issue:
- `- [x] [#42] Backup-Runbook erstellen`
- Erledigt ohne Issue:
- `- [x] Backup-Runbook erstellen`
## 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.
## Annahme
## 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
- Gitea ist so konfiguriert, dass `closes #<id>` in Commit-Messages das Issue schliesst.

View File

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

View File

@@ -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

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,142 +0,0 @@
# 📋 NÄCHSTE ARBEITSPAKETE
## 🎯 Für die nächsten Sessions
### 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
### 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)
### 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! 🚀**

View File

@@ -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.
---

243
TODO.md
View File

@@ -1,243 +0,0 @@
# TODO
Zentrale Sammlung aller TODO-Markierungen im Repository (Stand: 13. Februar 2026).
Hinweis: Die Eintraege sind direkt aus den Quelldateien aggregiert.
## app\api\connections.php
- [ ] 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
## app\api\device_type_ports.php
- [ ] 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: <!-- TODO: Details zum ausgewählten Gerät anzeigen -->
- [ ] L207: <!-- TODO: Verbindung bearbeiten / löschen -->
## 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: <!-- TODO: Gerätetyp-Name -->
- [ ] L59: <!-- TODO: Port-Typen verwalten -->
- [ ] L60: <!-- TODO: Import / Export -->
- [ ] L109: <!-- TODO: Port-Nummer -->
- [ ] L113: <!-- TODO: Port-Name -->
- [ ] L117: <!-- TODO: Port-Typ (RJ45, SFP, ...) -->
- [ ] L120: <!-- TODO: Medium (Kupfer, LWL, BNC, Custom) -->
- [ ] L123: <!-- TODO: Modus (Access, Trunk, Custom) -->
- [ ] L126: <!-- TODO: VLANs -->
- [ ] L161: <!-- TODO: SVG des Gerätetyps laden -->
- [ ] 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: <p><small>//TODO: SVG-Editor mit Drag & Drop für diese Objekte erweitern (siehe Stockwerke-Modul).</small></p>
## 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: <!-- TODO: Rack-SVG laden -->
- [ ] L237: <!-- TODO: Löschen, falls edit -->
- [ ] 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: <!-- TODO: Optional: Statusanzeige, Debug-Info, Session-Hinweis -->
- [ ] L17: <!-- TODO: evtl. JS für modale Fenster oder Flash Messages -->
## app\templates\header.php
- [ ] L24: <!-- TODO: Meta-Tags, Favicon -->
## app\templates\layout.php
- [ ] L11: * TODO: In Zukunft: zentrales Template-System (z.B. mit $content)
- [ ] L18: <!-- TODO: Dynamischen Content hier einfügen -->
## 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.

View File

@@ -1,194 +1,381 @@
<?php
/**
* app/api/connections.php
*
* API für Netzwerkverbindungen (Port ↔ Port)
* - Laden der Topologie
* - Anlegen / Bearbeiten / Löschen von Verbindungen
* - Unterstützt freie Verbindungstypen (Kupfer, LWL, BNC, Token Ring, etc.)
*/
require_once __DIR__ . '/../bootstrap.php';
requireAuth();
header('Content-Type: application/json');
// TODO: Single-User-Auth prüfen
// if (!$_SESSION['user']) { http_response_code(403); exit; }
$action = $_GET['action'] ?? 'load';
/* =========================
* Router
* ========================= */
switch ($action) {
case 'load':
loadConnections($sql);
break;
case 'save':
saveConnection($sql);
break;
case 'delete':
deleteConnection($sql);
break;
default:
http_response_code(400);
echo json_encode(['error' => 'Unbekannte Aktion']);
break;
jsonError('Unbekannte Aktion', 400);
}
/* =========================
* Aktionen
* ========================= */
/**
* Lädt alle Geräte + Ports + Verbindungen für eine Netzwerkansicht
*/
function loadConnections($sql)
function jsonError(string $message, int $status = 400): void
{
$contextId = $_GET['context_id'] ?? null;
http_response_code($status);
echo json_encode(['error' => $message]);
exit;
}
if (!$contextId) {
http_response_code(400);
echo json_encode(['error' => 'context_id fehlt']);
function normalizeEndpointType(string $type): ?string
{
$map = [
'device' => 'device',
'device_ports' => 'device',
'module' => 'module',
'module_ports' => 'module',
'outlet' => 'outlet',
'network_outlet_ports' => 'outlet',
'patchpanel' => 'patchpanel',
'floor_patchpanel_ports' => 'patchpanel',
];
$key = strtolower(trim($type));
return $map[$key] ?? null;
}
function endpointExists($sql, string $type, int $id): bool
{
if ($id <= 0) {
return false;
}
if ($type === 'device') {
$row = $sql->single('SELECT id FROM device_ports WHERE id = ?', 'i', [$id]);
return !empty($row);
}
if ($type === 'module') {
$row = $sql->single('SELECT id FROM module_ports WHERE id = ?', 'i', [$id]);
return !empty($row);
}
if ($type === 'outlet') {
$row = $sql->single('SELECT id FROM network_outlet_ports WHERE id = ?', 'i', [$id]);
return !empty($row);
}
if ($type === 'patchpanel') {
$row = $sql->single('SELECT id FROM floor_patchpanel_ports WHERE id = ?', 'i', [$id]);
return !empty($row);
}
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,
];
}
// TODO: Kontext definieren (Standort, Rack, Floor, gesamtes Netz)
$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
{
$contextType = strtolower(trim((string)($_GET['context_type'] ?? 'all')));
$contextId = isset($_GET['context_id']) ? (int)$_GET['context_id'] : 0;
$where = '';
$bindType = '';
$bindValues = [];
if ($contextType !== 'all') {
if ($contextId <= 0) {
jsonError('context_id fehlt oder ist ungueltig', 400);
}
if ($contextType === 'location') {
$where = ' WHERE b.location_id = ?';
$bindType = 'i';
$bindValues = [$contextId];
} elseif ($contextType === 'building') {
$where = ' WHERE f.building_id = ?';
$bindType = 'i';
$bindValues = [$contextId];
} elseif ($contextType === 'floor') {
$where = ' WHERE r.floor_id = ?';
$bindType = 'i';
$bindValues = [$contextId];
} elseif ($contextType === 'rack') {
$where = ' WHERE d.rack_id = ?';
$bindType = 'i';
$bindValues = [$contextId];
} else {
jsonError('Ungueltiger Kontext. Erlaubt: all, location, building, floor, rack', 400);
}
}
/* ---------- Geräte ---------- */
$devices = $sql->get(
"SELECT id, name, device_type_id, pos_x, pos_y
FROM devices
WHERE context_id = ?",
"i",
[$contextId]
"SELECT d.id, d.name, d.device_type_id, d.rack_id, 0 AS pos_x, 0 AS pos_y
FROM devices d
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" . $where,
$bindType,
$bindValues
);
/* ---------- Ports ---------- */
$ports = $sql->get(
"SELECT p.id, p.device_id, p.name, p.port_type_id
FROM ports p
JOIN devices d ON d.id = p.device_id
WHERE d.context_id = ?",
"i",
[$contextId]
"SELECT dp.id, dp.device_id, dp.name, dp.port_type_id
FROM device_ports dp
JOIN devices d ON d.id = dp.device_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" . $where,
$bindType,
$bindValues
);
/* ---------- Verbindungen ---------- */
$connections = $sql->get(
"SELECT
c.id,
c.connection_type_id,
c.port_a_id,
c.port_b_id,
c.vlan,
c.mode,
c.comment
FROM connections c",
"",
"SELECT id, connection_type_id, port_a_type, port_a_id, port_b_type, port_b_id, vlan_config, mode, comment
FROM connections",
'',
[]
);
echo json_encode([
'devices' => $devices,
'ports' => $ports,
'connections' => $connections
'devices' => $devices ?: [],
'ports' => $ports ?: [],
'connections' => $connections ?: []
]);
}
/**
* Speichert eine Verbindung (neu oder Update)
*/
function saveConnection($sql)
function resolveConnectionTypeId($sql, array $data): int
{
$data = json_decode(file_get_contents('php://input'), true);
if (!empty($data['connection_type_id'])) {
$requestedId = (int)$data['connection_type_id'];
$exists = $sql->single('SELECT id FROM connection_types WHERE id = ?', 'i', [$requestedId]);
if (!$exists) {
jsonError('connection_type_id existiert nicht', 400);
}
return $requestedId;
}
if (!$data) {
http_response_code(400);
echo json_encode(['error' => 'Ungültige JSON-Daten']);
$defaultType = $sql->single('SELECT id FROM connection_types ORDER BY id ASC LIMIT 1');
if (!$defaultType) {
jsonError('Kein Verbindungstyp vorhanden', 400);
}
return (int)$defaultType['id'];
}
function saveConnection($sql): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonError('Methode nicht erlaubt', 405);
}
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
jsonError('Ungueltige JSON-Daten', 400);
}
$portAType = normalizeEndpointType((string)($data['port_a_type'] ?? ''));
$portBType = normalizeEndpointType((string)($data['port_b_type'] ?? ''));
$portAId = (int)($data['port_a_id'] ?? 0);
$portBId = (int)($data['port_b_id'] ?? 0);
if ($portAType === null || $portBType === null) {
jsonError('port_a_type/port_b_type ungueltig', 400);
}
if ($portAId <= 0 || $portBId <= 0) {
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) {
jsonError('Port A und Port B duerfen nicht identisch sein', 400);
}
if (!endpointExists($sql, $portAType, $portAId) || !endpointExists($sql, $portBType, $portBId)) {
jsonError('Mindestens ein Endpunkt existiert nicht', 400);
}
$connectionTypeId = resolveConnectionTypeId($sql, $data);
$vlanConfig = $data['vlan_config'] ?? null;
if (is_array($vlanConfig)) {
$vlanConfig = json_encode($vlanConfig);
} elseif (!is_string($vlanConfig) && $vlanConfig !== null) {
jsonError('vlan_config muss String, Array oder null sein', 400);
}
$mode = isset($data['mode']) ? (string)$data['mode'] : null;
$comment = isset($data['comment']) ? (string)$data['comment'] : null;
$connectionId = !empty($data['id']) ? (int)$data['id'] : 0;
$usage = buildEndpointUsageMap($sql, $connectionId);
$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) {
jsonError('Verbindung existiert nicht', 404);
}
$rows = $sql->set(
'UPDATE connections
SET connection_type_id = ?, port_a_type = ?, port_a_id = ?, port_b_type = ?, port_b_id = ?, vlan_config = ?, mode = ?, comment = ?
WHERE id = ?',
'isisisssi',
[$connectionTypeId, $portAType, $portAId, $portBType, $portBId, $vlanConfig, $mode, $comment, $id]
);
if ($rows === false) {
jsonError('Update fehlgeschlagen', 500);
}
echo json_encode(['status' => 'updated', 'rows' => $rows]);
return;
}
// TODO: Validierung
// - port_a_id vorhanden
// - port_b_id vorhanden
// - Verbindungstyp erlaubt
if (!empty($data['id'])) {
// UPDATE
$rows = $sql->set(
"UPDATE connections
SET connection_type_id = ?, port_a_id = ?, port_b_id = ?, vlan = ?, mode = ?, comment = ?
WHERE id = ?",
"iiiissi",
[
$data['connection_type_id'],
$data['port_a_id'],
$data['port_b_id'],
$data['vlan'],
$data['mode'],
$data['comment'],
$data['id']
]
);
echo json_encode([
'status' => 'updated',
'rows' => $rows
]);
} else {
// INSERT
$id = $sql->set(
"INSERT INTO connections
(connection_type_id, port_a_id, port_b_id, vlan, mode, comment)
VALUES (?, ?, ?, ?, ?, ?)",
"iiiiss",
[
$data['connection_type_id'],
$data['port_a_id'],
$data['port_b_id'],
$data['vlan'],
$data['mode'],
$data['comment']
],
'INSERT INTO connections (connection_type_id, port_a_type, port_a_id, port_b_type, port_b_id, vlan_config, mode, comment)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
'isisisss',
[$connectionTypeId, $portAType, $portAId, $portBType, $portBId, $vlanConfig, $mode, $comment],
true
);
echo json_encode([
'status' => 'created',
'id' => $id
]);
}
if ($id === false) {
jsonError('Insert fehlgeschlagen', 500);
}
/**
* Löscht eine Verbindung
*/
function deleteConnection($sql)
echo json_encode(['status' => 'created', 'id' => $id]);
}
function deleteConnection($sql): void
{
$id = $_GET['id'] ?? null;
if (!$id) {
http_response_code(400);
echo json_encode(['error' => 'ID fehlt']);
return;
if ($_SERVER['REQUEST_METHOD'] !== 'POST' && $_SERVER['REQUEST_METHOD'] !== 'DELETE') {
jsonError('Methode nicht erlaubt', 405);
}
// TODO: Prüfen, ob Verbindung existiert
$rows = $sql->set(
"DELETE FROM connections WHERE id = ?",
"i",
[$id]
);
echo json_encode([
'status' => 'deleted',
'rows' => $rows
]);
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id <= 0) {
jsonError('ID fehlt', 400);
}
$existing = $sql->single('SELECT id FROM connections WHERE id = ?', 'i', [$id]);
if (!$existing) {
jsonError('Verbindung existiert nicht', 404);
}
$rows = $sql->set('DELETE FROM connections WHERE id = ?', 'i', [$id]);
if ($rows === false) {
jsonError('Loeschen fehlgeschlagen', 500);
}
echo json_encode(['status' => 'deleted', 'rows' => $rows]);
}

View File

@@ -1,175 +1,199 @@
<?php
/**
* app/api/device_type_ports.php
*
* API für Ports von Gerätetypen
* - Laden der Port-Definitionen (SVG-Port-Editor)
* - Speichern (Position, Typ, Name)
* - Löschen einzelner Ports
*/
require_once __DIR__ . '/../bootstrap.php';
requireAuth();
header('Content-Type: application/json');
// TODO: Single-User-Auth prüfen
// if (!$_SESSION['user']) { http_response_code(403); exit; }
$action = $_GET['action'] ?? 'load';
/* =========================
* Router
* ========================= */
switch ($action) {
case 'load':
loadPorts($sql);
break;
case 'save':
savePorts($sql);
break;
case 'delete':
deletePort($sql);
break;
default:
http_response_code(400);
echo json_encode(['error' => 'Unbekannte Aktion']);
break;
jsonError('Unbekannte Aktion', 400);
}
/* =========================
* Aktionen
* ========================= */
/**
* Lädt alle Ports eines Gerätetyps
*/
function loadPorts($sql)
function jsonError(string $message, int $status = 400): void
{
$deviceTypeId = $_GET['device_type_id'] ?? null;
http_response_code($status);
echo json_encode(['error' => $message]);
exit;
}
if (!$deviceTypeId) {
http_response_code(400);
echo json_encode(['error' => 'device_type_id fehlt']);
return;
function loadPorts($sql): void
{
$deviceTypeId = isset($_GET['device_type_id']) ? (int)$_GET['device_type_id'] : 0;
if ($deviceTypeId <= 0) {
jsonError('device_type_id fehlt', 400);
}
$ports = $sql->get(
"SELECT
id,
name,
port_type_id,
pos_x,
pos_y,
comment
'SELECT id, name, port_type_id, x, y, metadata
FROM device_type_ports
WHERE device_type_id = ?
ORDER BY id ASC",
"i",
ORDER BY id ASC',
'i',
[$deviceTypeId]
);
echo json_encode($ports);
echo json_encode($ports ?: []);
}
/**
* Speichert alle Ports eines Gerätetyps
* (Bulk-Save aus dem SVG-Editor)
*/
function savePorts($sql)
function validatePortTypeId($sql, $portTypeId): ?int
{
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || empty($data['device_type_id']) || !is_array($data['ports'])) {
http_response_code(400);
echo json_encode(['error' => 'Ungültige Daten']);
return;
if ($portTypeId === null || $portTypeId === '' || (int)$portTypeId <= 0) {
return null;
}
$deviceTypeId = $data['device_type_id'];
$ports = $data['ports'];
$value = (int)$portTypeId;
$exists = $sql->single('SELECT id FROM port_types WHERE id = ?', 'i', [$value]);
if (!$exists) {
jsonError('port_type_id ist ungueltig', 400);
}
// TODO: Transaktion starten (falls SQL-Klasse das unterstützt)
return $value;
}
foreach ($ports as $port) {
function savePorts($sql): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonError('Methode nicht erlaubt', 405);
}
// TODO: Validierung:
// - name nicht leer
// - pos_x / pos_y numerisch
// - port_type_id erlaubt
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data) || empty($data['device_type_id']) || !isset($data['ports']) || !is_array($data['ports'])) {
jsonError('Ungueltige Daten', 400);
}
if (!empty($port['id']) && !str_starts_with($port['id'], 'tmp_')) {
$deviceTypeId = (int)$data['device_type_id'];
if ($deviceTypeId <= 0) {
jsonError('device_type_id ist ungueltig', 400);
}
/* ---------- UPDATE ---------- */
$sql->set(
"UPDATE device_type_ports
SET name = ?, port_type_id = ?, pos_x = ?, pos_y = ?, comment = ?
WHERE id = ? AND device_type_id = ?",
"siddsii",
[
$port['name'],
$port['port_type_id'],
$port['x'],
$port['y'],
$port['comment'],
$port['id'],
$deviceTypeId
]
$deviceType = $sql->single('SELECT id FROM device_types WHERE id = ?', 'i', [$deviceTypeId]);
if (!$deviceType) {
jsonError('Geraetetyp existiert nicht', 404);
}
$sql->set('START TRANSACTION');
foreach ($data['ports'] as $index => $port) {
if (!is_array($port)) {
$sql->set('ROLLBACK');
jsonError('Port-Eintrag an Position ' . $index . ' ist ungueltig', 400);
}
$name = trim((string)($port['name'] ?? ''));
if ($name === '') {
$sql->set('ROLLBACK');
jsonError('Port-Name darf nicht leer sein', 400);
}
$x = $port['x'] ?? null;
$y = $port['y'] ?? null;
if (!is_numeric($x) || !is_numeric($y)) {
$sql->set('ROLLBACK');
jsonError('x und y muessen numerisch sein', 400);
}
$x = (int)round((float)$x);
$y = (int)round((float)$y);
$portTypeId = validatePortTypeId($sql, $port['port_type_id'] ?? null);
$metadataRaw = $port['metadata'] ?? null;
$metadata = null;
if (is_array($metadataRaw)) {
$metadata = json_encode($metadataRaw);
} elseif (is_string($metadataRaw) && $metadataRaw !== '') {
json_decode($metadataRaw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$sql->set('ROLLBACK');
jsonError('metadata ist kein gueltiges JSON', 400);
}
$metadata = $metadataRaw;
}
$isExisting = !empty($port['id']) && !str_starts_with((string)$port['id'], 'tmp_');
if ($isExisting) {
$portId = (int)$port['id'];
$ok = $sql->set(
'UPDATE device_type_ports
SET name = ?, port_type_id = ?, x = ?, y = ?, metadata = ?
WHERE id = ? AND device_type_id = ?',
'siiisii',
[$name, $portTypeId, $x, $y, $metadata, $portId, $deviceTypeId]
);
} else {
if ($ok === false) {
$sql->set('ROLLBACK');
jsonError('Update fehlgeschlagen', 500);
}
continue;
}
/* ---------- INSERT ---------- */
$sql->set(
"INSERT INTO device_type_ports
(device_type_id, name, port_type_id, pos_x, pos_y, comment)
VALUES (?, ?, ?, ?, ?, ?)",
"isidds",
[
$deviceTypeId,
$port['name'],
$port['port_type_id'],
$port['x'],
$port['y'],
$port['comment']
],
$ok = $sql->set(
'INSERT INTO device_type_ports (device_type_id, name, port_type_id, x, y, metadata)
VALUES (?, ?, ?, ?, ?, ?)',
'isiiis',
[$deviceTypeId, $name, $portTypeId, $x, $y, $metadata],
true
);
if ($ok === false) {
$sql->set('ROLLBACK');
jsonError('Insert fehlgeschlagen', 500);
}
}
echo json_encode([
'status' => 'ok'
]);
$sql->set('COMMIT');
echo json_encode(['status' => 'ok']);
}
/**
* Löscht einen einzelnen Port
*/
function deletePort($sql)
function deletePort($sql): void
{
$id = $_GET['id'] ?? null;
if (!$id) {
http_response_code(400);
echo json_encode(['error' => 'ID fehlt']);
return;
if ($_SERVER['REQUEST_METHOD'] !== 'POST' && $_SERVER['REQUEST_METHOD'] !== 'DELETE') {
jsonError('Methode nicht erlaubt', 405);
}
// TODO: Prüfen, ob Port existiert und nicht verwendet wird
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id <= 0) {
jsonError('ID fehlt', 400);
}
$rows = $sql->set(
"DELETE FROM device_type_ports WHERE id = ?",
"i",
[$id]
$port = $sql->single('SELECT id, device_type_id, name FROM device_type_ports WHERE id = ?', 'i', [$id]);
if (!$port) {
jsonError('Port existiert nicht', 404);
}
$usage = $sql->single(
'SELECT COUNT(*) AS cnt
FROM devices d
JOIN device_ports dp ON dp.device_id = d.id
WHERE d.device_type_id = ? AND dp.name = ?',
'is',
[(int)$port['device_type_id'], (string)$port['name']]
);
echo json_encode([
'status' => 'deleted',
'rows' => $rows
]);
if (!empty($usage) && (int)$usage['cnt'] > 0) {
jsonError('Port wird bereits von realen Geraeten genutzt und kann nicht geloescht werden', 409);
}
$rows = $sql->set('DELETE FROM device_type_ports WHERE id = ?', 'i', [$id]);
if ($rows === false) {
jsonError('Loeschen fehlgeschlagen', 500);
}
echo json_encode(['status' => 'deleted', 'rows' => $rows]);
}

View File

@@ -1,133 +1,89 @@
<?php
/**
* app/api/upload.php
*
* Zentrale Upload-API
* - Gerätetyp-Bilder (SVG / JPG / PNG)
* - Floorpläne (SVG)
* - Rack-Ansichten
*
* KEINE Logik für automatische Zuordnung
* -> Upload + Rückgabe von Pfad / Metadaten
*/
require_once __DIR__ . '/../bootstrap.php';
requireAuth();
header('Content-Type: application/json');
// TODO: Single-User-Auth prüfen
// if (!$_SESSION['user']) { http_response_code(403); exit; }
/* =========================
* Konfiguration
* ========================= */
// TODO: Upload-Basisverzeichnis aus config.php
$baseUploadDir = __DIR__ . '/../uploads';
// Erlaubte Typen
$baseUploadDir = defined('UPLOAD_BASE_DIR') ? UPLOAD_BASE_DIR : (__DIR__ . '/../uploads');
$maxFileSize = defined('UPLOAD_MAX_FILE_SIZE') ? (int)UPLOAD_MAX_FILE_SIZE : (5 * 1024 * 1024);
$allowedCategories = defined('UPLOAD_ALLOWED_CATEGORIES') ? UPLOAD_ALLOWED_CATEGORIES : ['misc'];
$allowedMimeTypes = [
'image/svg+xml',
'image/png',
'image/jpeg'
'image/svg+xml' => 'svg',
'image/png' => 'png',
'image/jpeg' => 'jpg',
];
// TODO: Max. Dateigröße festlegen (z.B. 5MB)
$maxFileSize = 5 * 1024 * 1024;
/* =========================
* Validierung
* ========================= */
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonError('Methode nicht erlaubt', 405);
}
if (empty($_FILES['file'])) {
http_response_code(400);
echo json_encode(['error' => 'Keine Datei hochgeladen']);
exit;
jsonError('Keine Datei hochgeladen', 400);
}
$file = $_FILES['file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['error' => 'Upload-Fehler']);
exit;
if (!is_array($file) || $file['error'] !== UPLOAD_ERR_OK) {
jsonError('Upload-Fehler', 400);
}
if ($file['size'] > $maxFileSize) {
http_response_code(400);
echo json_encode(['error' => 'Datei zu groß']);
exit;
if ((int)$file['size'] > $maxFileSize) {
jsonError('Datei zu gross', 400);
}
// MIME-Type prüfen
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, $allowedMimeTypes)) {
http_response_code(400);
echo json_encode(['error' => 'Dateityp nicht erlaubt']);
exit;
if (!isset($allowedMimeTypes[$mimeType])) {
jsonError('Dateityp nicht erlaubt', 400);
}
/* =========================
* Zielverzeichnis
* ========================= */
// TODO: Kategorie definieren (device_types, floors, racks, etc.)
$category = $_POST['category'] ?? 'misc';
// Zielpfad
$targetDir = $baseUploadDir . '/' . preg_replace('/[^a-z0-9_-]/i', '', $category);
// Verzeichnis anlegen falls nötig
if (!is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
$category = strtolower(trim((string)($_POST['category'] ?? 'misc')));
if ($category === '' || !in_array($category, $allowedCategories, true)) {
jsonError('Ungueltige Kategorie', 400);
}
/* =========================
* Dateiname
* ========================= */
$targetDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR . $category;
if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
jsonError('Upload-Verzeichnis konnte nicht erstellt werden', 500);
}
// Originalname bereinigen
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
// TODO: Eindeutigen Namen besser definieren (UUID?)
$filename = uniqid('upload_', true) . '.' . strtolower($extension);
$targetPath = $targetDir . '/' . $filename;
/* =========================
* Datei speichern
* ========================= */
$extension = $allowedMimeTypes[$mimeType];
$filename = sprintf('%s_%s.%s', $category, bin2hex(random_bytes(16)), $extension);
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $filename;
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
http_response_code(500);
echo json_encode(['error' => 'Datei konnte nicht gespeichert werden']);
exit;
jsonError('Datei konnte nicht gespeichert werden', 500);
}
/* =========================
* Optional: DB-Eintrag
* ========================= */
$publicPath = '/uploads/' . $category . '/' . $filename;
$uploadId = null;
// TODO: Optional in Tabelle `uploads` speichern
// $uploadId = $sql->set(
// "INSERT INTO uploads (filename, path, mime_type, category)
// VALUES (?, ?, ?, ?)",
// "ssss",
// [$filename, $targetPath, $mimeType, $category],
// true
// );
/* =========================
* Antwort
* ========================= */
$uploadTableExists = $sql->single("SHOW TABLES LIKE 'uploads'");
if (!empty($uploadTableExists)) {
$uploadId = $sql->set(
'INSERT INTO uploads (filename, path, mime_type, category) VALUES (?, ?, ?, ?)',
'ssss',
[$filename, $publicPath, $mimeType, $category],
true
);
}
echo json_encode([
'status' => 'ok',
'filename' => $filename,
'path' => str_replace(__DIR__ . '/..', '', $targetPath),
'mime_type' => $mimeType
// 'id' => $uploadId ?? null
'path' => $publicPath,
'mime_type' => $mimeType,
'id' => $uploadId,
]);
function jsonError(string $message, int $status = 400): void
{
http_response_code($status);
echo json_encode(['error' => $message]);
exit;
}

View File

@@ -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;
@@ -172,6 +208,48 @@ main {
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) {
.connections-list th,
.connections-list td {
@@ -179,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) {
.app-header {
flex-direction: column;

View File

@@ -44,17 +44,6 @@
cursor: crosshair;
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 {
position: absolute;
inset: 0;
@@ -63,6 +52,10 @@
z-index: 2;
touch-action: none;
}
.floor-plan-overlay .floor-plan-background {
opacity: 0.75;
pointer-events: none;
}
.floor-plan-overlay .active-marker {
cursor: move;
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#0c4da2"/>
<path d="M14 22h36v6H14zm0 14h36v6H14z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 183 B

View File

@@ -1,135 +1,110 @@
/**
* app/assets/js/app.js
*
* Zentrale JS-Datei für die Webanwendung
* - Initialisiert alle Module
* - SVG-Editor, Netzwerk-Ansicht, Drag & Drop, Floorplan
* - Event-Handler, globale Variablen
*/
// =========================
// Global Variables / Config
// =========================
window.APP = {
deviceTypes: [], // TODO: alle Gerätetypen laden
devices: [], // TODO: alle Geräte laden
racks: [], // TODO: alle Racks laden
floors: [], // TODO: alle Floors laden
connections: [], // TODO: alle Verbindungen laden
state: {
deviceTypes: [],
devices: [],
racks: [],
floors: [],
connections: [],
},
capabilities: {
hasGlobalDataApi: false,
}
};
// =========================
// Init Functions
// =========================
document.addEventListener('DOMContentLoaded', () => {
console.log('App initialized');
// =========================
// SVG-Port-Editor initialisieren
// =========================
// TODO: import / init svg-editor.js
// if (window.SVGEditor) window.SVGEditor.init();
// =========================
// Netzwerk-Ansicht initialisieren
// =========================
// TODO: import / init network-view.js
// if (window.NetworkView) window.NetworkView.init();
// =========================
// Drag & Drop für Floors / Racks / Devices
// =========================
// TODO: init drag & drop logic
// =========================
// Event-Handler für Buttons / Forms
// =========================
initViewModules();
initEventHandlers();
});
// =========================
// Event Handler Setup
// =========================
function initViewModules() {
if (typeof window.Dashboard?.init === 'function') {
window.Dashboard.init();
}
// Both modules are loaded via script tags in header.php.
// They are self-initializing and only run when expected DOM nodes exist.
window.dispatchEvent(new CustomEvent('app:modules-initialized'));
}
function initEventHandlers() {
// TODO: Save-Button Device-Type
const saveDeviceTypeBtn = document.querySelector('#save-device-type');
if (saveDeviceTypeBtn) {
saveDeviceTypeBtn.addEventListener('click', (e) => {
e.preventDefault();
// TODO: Save Device-Type via AJAX
});
}
// TODO: Save-Button Device
const saveDeviceBtn = document.querySelector('#save-device');
if (saveDeviceBtn) {
saveDeviceBtn.addEventListener('click', (e) => {
e.preventDefault();
// TODO: Save Device via AJAX
});
}
// TODO: Save-Button Floor
const saveFloorBtn = document.querySelector('#save-floor');
if (saveFloorBtn) {
saveFloorBtn.addEventListener('click', (e) => {
e.preventDefault();
// TODO: Save Floor via AJAX
});
}
// TODO: Save-Button Rack
const saveRackBtn = document.querySelector('#save-rack');
if (saveRackBtn) {
saveRackBtn.addEventListener('click', (e) => {
e.preventDefault();
// TODO: Save Rack via AJAX
});
}
// TODO: Weitere Event-Handler (Import, Export, Filter)
bindFormSubmitButton('#save-device-type', 'form[action*="module=device_types"][action*="save"]');
bindFormSubmitButton('#save-device', 'form[action*="module=devices"][action*="save"]');
bindFormSubmitButton('#save-floor', 'form[action*="module=floors"][action*="save"]');
bindFormSubmitButton('#save-rack', 'form[action*="module=racks"][action*="save"]');
document.querySelectorAll('[data-confirm-delete]').forEach((btn) => {
btn.addEventListener('click', (event) => {
event.preventDefault();
const message = btn.getAttribute('data-confirm-message') || 'Aktion ausführen?';
const message = btn.getAttribute('data-confirm-message') || 'Aktion ausfuehren?';
if (confirm(message)) {
alert(btn.getAttribute('data-confirm-feedback') || 'Diese Funktion ist noch nicht verfügbar.');
const href = btn.getAttribute('href') || btn.dataset.href;
if (href) {
window.location.href = href;
}
}
});
});
document.querySelectorAll('[data-filter-submit]').forEach((el) => {
el.addEventListener('change', () => {
const form = el.closest('form');
if (form) {
form.requestSubmit();
}
});
});
}
// =========================
// Utility Functions
// =========================
function bindFormSubmitButton(buttonSelector, formSelector) {
const button = document.querySelector(buttonSelector);
if (!button) {
return;
}
/**
* AJAX Request Helper
* @param {string} url
* @param {object} data
* @param {function} callback
*/
function ajaxPost(url, data, callback) {
button.addEventListener('click', (event) => {
event.preventDefault();
const form = button.closest('form') || document.querySelector(formSelector);
if (form) {
form.requestSubmit();
}
});
}
function ajaxPost(url, data, callback, onError) {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
xhr.onload = function() {
xhr.onload = function onLoad() {
if (xhr.status >= 200 && xhr.status < 300) {
callback(JSON.parse(xhr.responseText));
} else {
console.error('AJAX Error:', xhr.statusText);
let parsed = null;
try {
parsed = JSON.parse(xhr.responseText);
} catch (error) {
if (typeof onError === 'function') {
onError(error);
}
return;
}
callback(parsed);
return;
}
if (typeof onError === 'function') {
onError(new Error('AJAX error: ' + xhr.status));
}
};
xhr.onerror = function onXhrError() {
if (typeof onError === 'function') {
onError(new Error('Netzwerkfehler'));
}
};
xhr.send(JSON.stringify(data));
}
// TODO: weitere Utility-Funktionen (DOM-Helper, SVG-Helper, etc.)
// Dashboard initialisieren
if (window.Dashboard) window.Dashboard.init();
window.APP.ajaxPost = ajaxPost;

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', () => {
bindPair('port_a_type', 'port_a_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

@@ -1,99 +1,35 @@
/**
* app/assets/js/dashboard.js
*
* Dashboard-Modul
* - Zentrale Übersicht aller Grundfunktionen
* - Einstiegspunkt für das Tool
* - Kann später Status, Warnungen, Statistiken anzeigen
*/
window.Dashboard = (function () {
// =========================
// Interne Daten
// =========================
const modules = [
{
id: 'device_types',
label: 'Gerätetypen',
description: 'Gerätetypen, Port-Definitionen, Module',
url: '/app/device_types/list.php',
icon: '🔌'
},
{
id: 'devices',
label: 'Geräte',
description: 'Physische Geräte in Racks und Räumen',
url: '/app/devices/list.php',
icon: '🖥️'
},
{
id: 'connections',
label: 'Verbindungen',
description: 'Kabel, Ports, VLANs, Protokolle',
url: '/app/connections/list.php',
icon: '🧵'
},
{
id: 'floors',
label: 'Standorte & Stockwerke',
description: 'Gebäude, Etagen, Räume, Dosen',
url: '/app/floors/list.php',
icon: '🏢'
},
{
id: 'racks',
label: 'Serverschränke',
description: 'Racks, Positionierung, Höheneinheiten',
url: '/app/racks/list.php',
icon: '🗄️'
},
{
id: 'network_view',
label: 'Netzwerk-Ansicht',
description: 'Grafische Netzwerkdarstellung',
url: '/network.php',
icon: '🌐'
},
{
id: 'svg_editor',
label: 'SVG-Port-Editor',
description: 'Ports auf Gerätetypen definieren',
url: '/svg-editor.php',
icon: '✏️'
}
{ id: 'device_types', label: 'Geraetetypen', description: 'Geraetetypen und Port-Definitionen', url: '?module=device_types&action=list', icon: 'DT' },
{ id: 'devices', label: 'Geraete', description: 'Physische Geraete in Racks und Raeumen', url: '?module=devices&action=list', icon: 'DV' },
{ id: 'connections', label: 'Verbindungen', description: 'Kabel, Ports und VLANs', url: '?module=connections&action=list', icon: 'CN' },
{ id: 'floors', label: 'Stockwerke', description: 'Standorte, Gebaeude und Etagen', url: '?module=floors&action=list', icon: 'FL' },
{ id: 'racks', label: 'Racks', description: 'Racks und Positionierung', url: '?module=racks&action=list', icon: 'RK' },
{ id: 'infra', label: 'Infrastruktur', description: 'Patchpanels und Wandbuchsen', url: '?module=floor_infrastructure&action=list', icon: 'IF' }
];
// =========================
// Public API
// =========================
function init() {
console.log('Dashboard initialized');
// TODO: Dashboard-Container ermitteln
// const container = document.querySelector('#dashboard');
// TODO: Module rendern
// renderModules(container);
// TODO: Optional: Status-Daten laden (Counts, Warnings)
const container = document.querySelector('#dashboard-modules');
if (container) {
renderModules(container);
}
// =========================
// Render Functions
// =========================
loadStats();
showWarnings();
renderRecentChanges();
}
function renderModules(container) {
if (!container) return;
container.innerHTML = '';
modules.forEach(module => {
const el = document.createElement('div');
modules.forEach((module) => {
const el = document.createElement('a');
el.className = 'dashboard-tile';
el.href = module.url;
el.innerHTML = `
<div class="dashboard-icon">${module.icon}</div>
<div class="dashboard-content">
@@ -101,30 +37,54 @@ window.Dashboard = (function () {
<p>${module.description}</p>
</div>
`;
el.addEventListener('click', () => {
window.location.href = module.url;
});
container.appendChild(el);
});
}
// =========================
// Optional Erweiterungen
// =========================
// TODO: loadStats() → Anzahl Geräte, offene Ports, unverbundene Dosen
// TODO: showWarnings() → unverbundene Ports, VLAN-Konflikte
// TODO: RecentChanges() → letzte Änderungen
// =========================
// Expose Public Methods
// =========================
return {
init,
// renderModules // optional öffentlich machen
function loadStats() {
const stats = {
devices: countRows('.device-list tbody tr'),
connections: countRows('.connection-list tbody tr'),
outlets: countRows('.infra-table tbody tr')
};
const target = document.querySelector('[data-dashboard-stats]');
if (!target) {
return;
}
target.textContent = `Geraete: ${stats.devices} | Verbindungen: ${stats.connections} | Infrastruktur-Eintraege: ${stats.outlets}`;
}
function showWarnings() {
const target = document.querySelector('[data-dashboard-warnings]');
if (!target) {
return;
}
const warnings = [];
if (countRows('.device-list tbody tr') === 0) {
warnings.push('Noch keine Geraete vorhanden');
}
if (countRows('.connection-list tbody tr') === 0) {
warnings.push('Noch keine Verbindungen vorhanden');
}
target.textContent = warnings.length ? warnings.join(' | ') : 'Keine offenen Warnungen erkannt';
}
function renderRecentChanges() {
const target = document.querySelector('[data-dashboard-recent]');
if (!target) {
return;
}
target.textContent = 'Letzte Aenderungen werden serverseitig noch nicht protokolliert.';
}
function countRows(selector) {
return document.querySelectorAll(selector).length;
}
return { init };
})();

View File

@@ -66,10 +66,24 @@
return;
}
if (window.confirm('Diesen Gerätetyp wirklich löschen? Alle zugeordneten Geräte werden angepasst.')) {
// TODO: Delete-Endpoint/Flow ist noch nicht implementiert.
window.alert('Löschen noch nicht implementiert');
if (!window.confirm('Diesen Geraetetyp wirklich loeschen?')) {
return;
}
fetch('?module=device_types&action=delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: 'id=' + encodeURIComponent(id)
})
.then((response) => response.json())
.then((data) => {
if (data && data.success) {
window.location.href = '?module=device_types&action=list';
return;
}
window.alert((data && data.message) ? data.message : 'Loeschen fehlgeschlagen');
})
.catch(() => window.alert('Loeschen fehlgeschlagen'));
});
deleteButton.dataset.deleteBound = '1';

View File

@@ -13,8 +13,20 @@
}
if (window.confirm('Diesen Geraetetyp wirklich loeschen?')) {
// TODO: AJAX-Delete implementieren
window.alert('Loeschen noch nicht implementiert');
fetch('?module=device_types&action=delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: 'id=' + encodeURIComponent(id)
})
.then((response) => response.json())
.then((data) => {
if (data && data.success) {
window.location.reload();
return;
}
window.alert((data && data.message) ? data.message : 'Loeschen fehlgeschlagen');
})
.catch(() => window.alert('Loeschen fehlgeschlagen'));
}
});

View File

@@ -29,8 +29,26 @@ document.addEventListener('DOMContentLoaded', () => {
let markerX = 0;
let markerY = 0;
let dragging = false;
let panning = false;
let panStart = null;
let dragOffsetX = 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');
activeMarker.classList.add('active-marker');
@@ -45,7 +63,7 @@ document.addEventListener('DOMContentLoaded', () => {
const planSize = { ...DEFAULT_PLAN_SIZE };
const updateOverlayViewBox = () => {
overlay.setAttribute('viewBox', `0 0 ${planSize.width} ${planSize.height}`);
overlay.setAttribute('viewBox', `${viewX} ${viewY} ${viewWidth} ${viewHeight}`);
};
const updatePositionLabel = (x, y) => {
@@ -72,17 +90,57 @@ document.addEventListener('DOMContentLoaded', () => {
};
const toOverlayPoint = (clientX, clientY) => {
const pt = overlay.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
const ctm = overlay.getScreenCTM();
if (!ctm) {
const rect = overlay.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
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 };
};
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 = () => {
setMarkerPosition(Number(xField.value) || 0, Number(yField.value) || 0);
};
@@ -191,10 +249,10 @@ document.addEventListener('DOMContentLoaded', () => {
const panelBuildingSelect = document.getElementById('panel-building-select');
const panelFloorSelect = document.getElementById('panel-floor-select');
const outletRoomSelect = document.getElementById('outlet-room-select');
const floorPlanSvg = document.getElementById('floor-plan-svg');
const panelPlacementFields = document.getElementById('panel-placement-fields');
const panelFloorPlanGroup = document.getElementById('panel-floor-plan-group');
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 floorOptions = panelFloorSelect ? Array.from(panelFloorSelect.options).filter((option) => option.value !== '') : [];
@@ -208,6 +266,32 @@ document.addEventListener('DOMContentLoaded', () => {
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 = () => {
clearRoomHighlight();
clearReferenceMarkers();
@@ -239,35 +323,27 @@ document.addEventListener('DOMContentLoaded', () => {
};
const updateFloorPlanImage = () => {
if (!floorPlanSvg) {
return;
}
const floorOption = panelFloorSelect?.selectedOptions?.[0];
const roomOption = outletRoomSelect?.selectedOptions?.[0];
const svgUrl = floorOption?.dataset?.svgUrl || roomOption?.dataset?.floorSvgUrl || '';
if (svgUrl) {
floorPlanSvg.src = svgUrl;
floorPlanSvg.hidden = false;
backgroundImage.setAttribute('href', svgUrl);
backgroundImage.setAttribute('width', String(planSize.width));
backgroundImage.setAttribute('height', String(planSize.height));
backgroundImage.setAttribute('display', 'block');
loadPlanDimensions(svgUrl);
} else {
floorPlanSvg.removeAttribute('src');
floorPlanSvg.hidden = true;
backgroundImage.removeAttribute('href');
backgroundImage.setAttribute('display', 'none');
planSize.width = DEFAULT_PLAN_SIZE.width;
planSize.height = DEFAULT_PLAN_SIZE.height;
updateOverlayViewBox();
resetView();
}
renderReferenceMarkers();
filterPatchpanelBindOptions();
};
if (floorPlanSvg) {
floorPlanSvg.addEventListener('error', () => {
floorPlanSvg.removeAttribute('src');
floorPlanSvg.hidden = true;
});
}
const loadPlanDimensions = async (svgUrl) => {
if (!svgUrl) {
return;
@@ -291,7 +367,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (parts.length === 4 && parts.every((value) => Number.isFinite(value))) {
planSize.width = Math.max(1, parts[2]);
planSize.height = Math.max(1, parts[3]);
updateOverlayViewBox();
backgroundImage.setAttribute('width', String(planSize.width));
backgroundImage.setAttribute('height', String(planSize.height));
resetView();
renderReferenceMarkers();
updateFromInputs();
return;
@@ -307,13 +385,17 @@ document.addEventListener('DOMContentLoaded', () => {
planSize.width = DEFAULT_PLAN_SIZE.width;
planSize.height = DEFAULT_PLAN_SIZE.height;
}
updateOverlayViewBox();
backgroundImage.setAttribute('width', String(planSize.width));
backgroundImage.setAttribute('height', String(planSize.height));
resetView();
renderReferenceMarkers();
updateFromInputs();
} catch (error) {
planSize.width = DEFAULT_PLAN_SIZE.width;
planSize.height = DEFAULT_PLAN_SIZE.height;
updateOverlayViewBox();
backgroundImage.setAttribute('width', String(planSize.width));
backgroundImage.setAttribute('height', String(planSize.height));
resetView();
renderReferenceMarkers();
updateFromInputs();
}
@@ -382,6 +464,7 @@ document.addEventListener('DOMContentLoaded', () => {
activeMarker.addEventListener('pointerdown', (event) => {
event.preventDefault();
dragging = true;
panning = false;
const point = toOverlayPoint(event.clientX, event.clientY);
if (!point) {
return;
@@ -403,13 +486,19 @@ document.addEventListener('DOMContentLoaded', () => {
});
const stopDrag = (event) => {
if (!dragging) {
return;
}
if (dragging) {
dragging = false;
if (activeMarker.hasPointerCapture(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) => {
@@ -417,6 +506,19 @@ document.addEventListener('DOMContentLoaded', () => {
});
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) {
return;
}
@@ -427,6 +529,32 @@ document.addEventListener('DOMContentLoaded', () => {
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) => {
input.addEventListener('input', () => {
updateFromInputs();
@@ -466,6 +594,7 @@ document.addEventListener('DOMContentLoaded', () => {
updateOverlayViewBox();
updateFromInputs();
filterPatchpanelBindOptions();
if (panelLocationSelect) {
filterBuildingOptions();

View File

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

View File

@@ -21,16 +21,34 @@
.catch(() => alert('Loeschen fehlgeschlagen'));
}
function handleBuildingDelete() {
if (confirm('Dieses Gebaeude wirklich loeschen? Alle Stockwerke werden geloescht.')) {
alert('Loeschen noch nicht implementiert');
function handleBuildingDelete(id) {
if (!confirm('Dieses Gebaeude wirklich loeschen? Alle Stockwerke werden geloescht.')) {
return;
}
postDelete('?module=buildings&action=delete&id=' + encodeURIComponent(id))
.then((data) => {
if (data && (data.success || data.status === 'ok')) {
window.location.reload();
return;
}
alert((data && (data.message || data.error)) ? (data.message || data.error) : 'Loeschen fehlgeschlagen');
})
.catch(() => alert('Loeschen fehlgeschlagen'));
}
function handleFloorDelete() {
if (confirm('Dieses Stockwerk wirklich loeschen? Alle Raeume und Racks werden geloescht.')) {
alert('Loeschen noch nicht implementiert');
function handleFloorDelete(id) {
if (!confirm('Dieses Stockwerk wirklich loeschen? Alle Raeume und Racks werden geloescht.')) {
return;
}
postDelete('?module=floors&action=delete&id=' + encodeURIComponent(id))
.then((data) => {
if (data && data.success) {
window.location.reload();
return;
}
alert((data && data.message) ? data.message : 'Loeschen fehlgeschlagen');
})
.catch(() => alert('Loeschen fehlgeschlagen'));
}
function handleRoomDelete(id) {

View File

@@ -1,61 +1,23 @@
// Netzwerk-Graph-Ansicht (Nodes, Kanten, Filter)
/**
* network-view.js
*
* Darstellung der Netzwerk-Topologie:
* - Geräte als Nodes
* - Ports als Ankerpunkte
* - Verbindungen als Linien
* - Freie / selbstdefinierte Verbindungstypen
*
* Kein Layout-Framework (kein D3, kein Cytoscape)
* -> bewusst simpel & erweiterbar
*/
(() => {
/* =========================
* Konfiguration
* ========================= */
const svgElement = document.querySelector('#network-svg');
if (!svgElement) {
return;
}
// TODO: Standort / Rack / View-Kontext vom Backend setzen
const CONTEXT_ID = null;
// TODO: API-Endpunkte definieren
const API_LOAD_NETWORK = '/api/network_view.php?action=load';
const CONTEXT_TYPE = svgElement.dataset.contextType || 'all';
const CONTEXT_ID = Number(svgElement.dataset.contextId || 0);
const API_LOAD_NETWORK = '/api/connections.php?action=load';
const API_SAVE_POSITIONS = '/api/network_view.php?action=save_positions';
/* =========================
* State
* ========================= */
let svgElement = null;
let devices = []; // Geräte inkl. Position
let connections = []; // Verbindungen zwischen Ports
let devices = [];
let ports = [];
let connections = [];
let selectedDeviceId = null;
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
/* =========================
* Initialisierung
* ========================= */
document.addEventListener('DOMContentLoaded', () => {
svgElement = document.querySelector('#network-svg');
if (!svgElement) {
console.warn('Network View: #network-svg nicht gefunden');
return;
}
bindSvgEvents();
loadNetwork();
});
/* =========================
* Events
* ========================= */
function bindSvgEvents() {
svgElement.addEventListener('mousemove', onMouseMove);
@@ -63,37 +25,40 @@ function bindSvgEvents() {
svgElement.addEventListener('click', onSvgClick);
}
/* =========================
* Laden
* ========================= */
function loadNetwork() {
if (!CONTEXT_ID) {
console.warn('CONTEXT_ID nicht gesetzt');
return;
function buildLoadUrl() {
const params = new URLSearchParams();
params.set('action', 'load');
params.set('context_type', CONTEXT_TYPE);
if (CONTEXT_TYPE !== 'all') {
params.set('context_id', String(CONTEXT_ID));
}
return '/api/connections.php?' + params.toString();
}
fetch(`${API_LOAD_NETWORK}&context_id=${CONTEXT_ID}`)
.then(res => res.json())
.then(data => {
// TODO: Datenstruktur validieren
devices = data.devices || [];
connections = data.connections || [];
function loadNetwork() {
fetch(buildLoadUrl())
.then((res) => res.json())
.then((data) => {
if (!data || !Array.isArray(data.devices) || !Array.isArray(data.connections)) {
throw new Error('Antwortformat ungueltig');
}
devices = data.devices.map((device, index) => ({
...device,
x: Number(device.pos_x ?? device.x ?? 50 + (index % 6) * 150),
y: Number(device.pos_y ?? device.y ?? 60 + Math.floor(index / 6) * 120)
}));
ports = Array.isArray(data.ports) ? data.ports : [];
connections = data.connections;
renderAll();
})
.catch(err => {
.catch((err) => {
console.error('Fehler beim Laden der Netzwerkansicht', err);
});
}
/* =========================
* Rendering
* ========================= */
function renderAll() {
clearSvg();
renderConnections();
renderDevices();
}
@@ -104,34 +69,27 @@ function clearSvg() {
}
}
/* ---------- Geräte ---------- */
function renderDevices() {
devices.forEach(device => renderDevice(device));
devices.forEach((device) => renderDevice(device));
}
function renderDevice(device) {
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.classList.add('device-node');
group.dataset.id = device.id;
group.setAttribute('transform', `translate(${device.x || 0}, ${device.y || 0})`);
group.setAttribute(
'transform',
`translate(${device.x || 0}, ${device.y || 0})`
);
// TODO: Gerätetyp (SVG oder JPG) korrekt laden
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('width', 120);
rect.setAttribute('height', 60);
rect.setAttribute('rx', 6);
rect.classList.add('device-node-rect');
rect.addEventListener('mousedown', (e) => {
startDrag(e, device.id);
e.stopPropagation();
});
// Label
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', 60);
text.setAttribute('y', 35);
@@ -141,40 +99,59 @@ function renderDevice(device) {
group.appendChild(rect);
group.appendChild(text);
// TODO: Ports als kleine Kreise anlegen (Position aus Portdefinition)
// TODO: Ports klickbar machen (für Verbindungs-Erstellung)
const devicePorts = ports.filter((port) => Number(port.device_id) === Number(device.id));
const spacing = 120 / (Math.max(1, devicePorts.length) + 1);
devicePorts.forEach((port, index) => {
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', String(Math.round((index + 1) * spacing)));
dot.setAttribute('cy', '62');
dot.setAttribute('r', '3');
dot.classList.add('device-port-dot');
dot.dataset.portId = String(port.id);
dot.dataset.deviceId = String(device.id);
dot.addEventListener('click', (event) => {
event.stopPropagation();
console.info('Port ausgewaehlt', port.id);
});
group.appendChild(dot);
});
svgElement.appendChild(group);
}
/* ---------- Verbindungen ---------- */
function renderConnections() {
connections.forEach(conn => renderConnection(conn));
connections.forEach((connection) => renderConnection(connection));
}
function renderConnection(connection) {
// TODO: Quell- & Ziel-Port-Koordinaten berechnen
// TODO: unterschiedliche Verbindungstypen (Farbe, Strichart, Dicke)
const sourcePort = ports.find((port) => Number(port.id) === Number(connection.port_a_id));
const targetPort = ports.find((port) => Number(port.id) === Number(connection.port_b_id));
if (!sourcePort || !targetPort) {
return;
}
const sourceDevice = devices.find((device) => Number(device.id) === Number(sourcePort.device_id));
const targetDevice = devices.find((device) => Number(device.id) === Number(targetPort.device_id));
if (!sourceDevice || !targetDevice) {
return;
}
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', String(sourceDevice.x + 60));
line.setAttribute('y1', String(sourceDevice.y + 60));
line.setAttribute('x2', String(targetDevice.x + 60));
line.setAttribute('y2', String(targetDevice.y + 60));
line.setAttribute('x1', 0);
line.setAttribute('y1', 0);
line.setAttribute('x2', 100);
line.setAttribute('y2', 100);
const isFiber = String(connection.mode || '').toLowerCase().includes('fiber');
line.classList.add('connection-line');
line.setAttribute('stroke', isFiber ? '#2f6fef' : '#1f8b4c');
line.setAttribute('stroke-width', isFiber ? '2.5' : '2');
line.setAttribute('stroke-dasharray', isFiber ? '6 4' : '');
svgElement.appendChild(line);
}
/* =========================
* Interaktion
* ========================= */
function onSvgClick(event) {
// Klick ins Leere -> Auswahl aufheben
if (event.target === svgElement) {
selectedDeviceId = null;
updateSelection();
@@ -202,7 +179,6 @@ function onMouseMove(event) {
if (!device) return;
const point = getSvgCoordinates(event);
device.x = point.x + dragOffset.x;
device.y = point.y + dragOffset.y;
@@ -210,31 +186,28 @@ function onMouseMove(event) {
}
function onMouseUp() {
if (!isDragging) return;
if (!isDragging) {
return;
}
isDragging = false;
// TODO: Positionen optional automatisch speichern
}
/* =========================
* Auswahl
* ========================= */
function updateSelection() {
svgElement.querySelectorAll('.device-node').forEach(el => {
el.classList.toggle(
'selected',
el.dataset.id === String(selectedDeviceId)
);
svgElement.querySelectorAll('.device-node').forEach((el) => {
el.classList.toggle('selected', el.dataset.id === String(selectedDeviceId));
});
// TODO: Sidebar mit Gerätedetails füllen
const sidebar = document.querySelector('[data-network-selected-device]');
if (!sidebar) {
return;
}
/* =========================
* Speichern
* ========================= */
const device = getDeviceById(selectedDeviceId);
sidebar.textContent = device
? `${device.name} (ID ${device.id})`
: 'Kein Geraet ausgewaehlt';
}
function savePositions() {
fetch(API_SAVE_POSITIONS, {
@@ -242,27 +215,21 @@ function savePositions() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
context_id: CONTEXT_ID,
devices: devices.map(d => ({
id: d.id,
x: d.x,
y: d.y
}))
devices: devices.map((device) => ({ id: device.id, x: device.x, y: device.y }))
})
})
.then(res => res.json())
.then(data => {
// TODO: Erfolg / Fehler anzeigen
console.log('Positionen gespeichert', data);
.then((res) => res.json())
.then((data) => {
if (data?.error) {
throw new Error(data.error);
}
alert('Positionen gespeichert');
})
.catch(err => {
console.error('Fehler beim Speichern', err);
.catch((err) => {
alert('Positionen konnten nicht gespeichert werden: ' + err.message);
});
}
/* =========================
* Hilfsfunktionen
* ========================= */
function getSvgCoordinates(event) {
const pt = svgElement.createSVGPoint();
pt.x = event.clientX;
@@ -273,19 +240,23 @@ function getSvgCoordinates(event) {
}
function getDeviceById(id) {
return devices.find(d => d.id === id);
return devices.find((device) => Number(device.id) === Number(id));
}
/* =========================
* Keyboard Shortcuts
* ========================= */
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
selectedDeviceId = null;
updateSelection();
return;
}
// TODO: Delete -> Gerät entfernen?
if (event.key === 'Delete' && selectedDeviceId) {
console.warn('Delete von Geraeten ist in der Netzwerkansicht noch nicht implementiert.');
}
if (event.key.toLowerCase() === 's' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
savePositions();
}
});
})();

View File

@@ -1,92 +1,60 @@
// Logik für den SVG-Port-Editor (Klicks, Drag & Drop, Speichern)
/**
* svg-editor.js
*
* Logik für den SVG-Port-Editor:
* - Ports per Klick anlegen
* - Ports auswählen
* - Ports verschieben (Drag & Drop)
* - Ports löschen
* - Ports laden / speichern
*
* Abhängigkeiten: keine (Vanilla JS)
*/
(() => {
/* =========================
* Konfiguration
* ========================= */
const svgElement = document.querySelector('#device-svg');
if (!svgElement) {
return;
}
// TODO: vom Backend setzen (z. B. via data-Attribut)
const DEVICE_TYPE_ID = null;
// TODO: API-Endpunkte festlegen
const DEVICE_TYPE_ID = Number(svgElement.dataset.deviceTypeId || 0);
const API_LOAD_PORTS = '/api/device_type_ports.php?action=load';
const API_SAVE_PORTS = '/api/device_type_ports.php?action=save';
const DEFAULT_PORT_TYPE_ID = null;
/* =========================
* State
* ========================= */
let svgElement = null;
let ports = [];
let selectedPortId = null;
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
/* =========================
* Initialisierung
* ========================= */
document.addEventListener('DOMContentLoaded', () => {
svgElement = document.querySelector('#device-svg');
if (!svgElement) {
console.warn('SVG Editor: #device-svg nicht gefunden');
return;
}
bindSvgEvents();
loadPorts();
});
/* =========================
* SVG Events
* ========================= */
function bindSvgEvents() {
svgElement.addEventListener('click', onSvgClick);
svgElement.addEventListener('mousemove', onSvgMouseMove);
svgElement.addEventListener('mouseup', onSvgMouseUp);
const saveButton = document.querySelector('[data-save-svg-ports]');
if (saveButton) {
saveButton.addEventListener('click', (event) => {
event.preventDefault();
savePorts();
});
}
}
/* =========================
* Port-Erstellung
* ========================= */
function onSvgClick(event) {
// Klick auf bestehenden Port?
if (event.target.classList.contains('port-point')) {
selectPort(event.target.dataset.id);
return;
}
// TODO: Modifier-Key prüfen (z. B. nur mit SHIFT neuen Port erstellen?)
const point = getSvgCoordinates(event);
// New ports are only created while SHIFT is held.
if (!event.shiftKey) {
return;
}
const point = getSvgCoordinates(event);
createPort(point.x, point.y);
}
function createPort(x, y) {
const id = generateTempId();
const port = {
id: id,
id,
name: `Port ${ports.length + 1}`,
port_type_id: null, // TODO: Default-Porttyp?
x: x,
y: y,
comment: ''
port_type_id: DEFAULT_PORT_TYPE_ID,
x,
y,
metadata: null
};
ports.push(port);
@@ -94,13 +62,8 @@ function createPort(x, y) {
selectPort(id);
}
/* =========================
* Rendering
* ========================= */
function renderPort(port) {
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', port.x);
circle.setAttribute('cy', port.y);
circle.setAttribute('r', 6);
@@ -116,27 +79,39 @@ function renderPort(port) {
}
function rerenderPorts() {
svgElement.querySelectorAll('.port-point').forEach(p => p.remove());
svgElement.querySelectorAll('.port-point').forEach((node) => node.remove());
ports.forEach(renderPort);
if (selectedPortId !== null) {
selectPort(selectedPortId);
}
}
/* =========================
* Auswahl
* ========================= */
function selectPort(id) {
selectedPortId = id;
document.querySelectorAll('.port-point').forEach(el => {
el.classList.toggle('selected', el.dataset.id === id);
document.querySelectorAll('.port-point').forEach((el) => {
el.classList.toggle('selected', el.dataset.id === String(id));
});
// TODO: Sidebar-Felder mit Portdaten füllen
const selected = getPortById(id);
fillSidebar(selected);
}
/* =========================
* Drag & Drop
* ========================= */
function fillSidebar(port) {
const nameField = document.querySelector('[data-port-name]');
const typeField = document.querySelector('[data-port-type-id]');
const xField = document.querySelector('[data-port-x]');
const yField = document.querySelector('[data-port-y]');
if (nameField) nameField.value = port?.name || '';
if (typeField) typeField.value = port?.port_type_id || '';
if (xField) xField.value = port ? Math.round(port.x) : '';
if (yField) yField.value = port ? Math.round(port.y) : '';
}
function resetSidebar() {
fillSidebar(null);
}
function startDrag(event, portId) {
const port = getPortById(portId);
@@ -157,7 +132,6 @@ function onSvgMouseMove(event) {
if (!port) return;
const point = getSvgCoordinates(event);
port.x = point.x + dragOffset.x;
port.y = point.y + dragOffset.y;
@@ -168,92 +142,95 @@ function onSvgMouseUp() {
isDragging = false;
}
/* =========================
* Löschen
* ========================= */
function deleteSelectedPort() {
if (!selectedPortId) return;
// TODO: Sicherheitsabfrage (confirm)
ports = ports.filter(p => p.id !== selectedPortId);
selectedPortId = null;
rerenderPorts();
// TODO: Sidebar zurücksetzen
if (!selectedPortId) {
return;
}
/* =========================
* Laden / Speichern
* ========================= */
if (!confirm('Ausgewaehlten Port loeschen?')) {
return;
}
ports = ports.filter((port) => String(port.id) !== String(selectedPortId));
selectedPortId = null;
rerenderPorts();
resetSidebar();
}
function loadPorts() {
if (!DEVICE_TYPE_ID) {
console.warn('DEVICE_TYPE_ID nicht gesetzt');
console.warn('SVG Editor: DEVICE_TYPE_ID fehlt auf #device-svg');
return;
}
fetch(`${API_LOAD_PORTS}&device_type_id=${DEVICE_TYPE_ID}`)
.then(res => res.json())
.then(data => {
// TODO: Datenformat validieren
ports = data;
.then((res) => res.json())
.then((data) => {
if (!Array.isArray(data)) {
throw new Error('Antwortformat ungueltig');
}
ports = data
.filter((entry) => entry && typeof entry === 'object')
.map((entry) => ({
id: entry.id,
name: String(entry.name || ''),
port_type_id: entry.port_type_id ? Number(entry.port_type_id) : null,
x: Number(entry.x || 0),
y: Number(entry.y || 0),
metadata: entry.metadata || null
}));
rerenderPorts();
})
.catch(err => {
.catch((err) => {
console.error('Fehler beim Laden der Ports', err);
});
}
function savePorts() {
if (!DEVICE_TYPE_ID) return;
if (!DEVICE_TYPE_ID) {
return;
}
fetch(API_SAVE_PORTS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_type_id: DEVICE_TYPE_ID,
ports: ports
ports
})
})
.then(res => res.json())
.then(data => {
// TODO: Erfolg / Fehler anzeigen
console.log('Ports gespeichert', data);
.then((res) => res.json())
.then((data) => {
if (data?.error) {
throw new Error(data.error);
}
alert('Ports gespeichert');
})
.catch(err => {
console.error('Fehler beim Speichern', err);
.catch((err) => {
alert('Speichern fehlgeschlagen: ' + err.message);
});
}
/* =========================
* Hilfsfunktionen
* ========================= */
function getSvgCoordinates(event) {
const pt = svgElement.createSVGPoint();
pt.x = event.clientX;
pt.y = event.clientY;
const transformed = pt.matrixTransform(svgElement.getScreenCTM().inverse());
return { x: transformed.x, y: transformed.y };
}
function getPortById(id) {
return ports.find(p => p.id === id);
return ports.find((port) => String(port.id) === String(id));
}
function generateTempId() {
return 'tmp_' + Math.random().toString(36).substr(2, 9);
return 'tmp_' + Math.random().toString(36).slice(2, 11);
}
/* =========================
* Keyboard Shortcuts
* ========================= */
document.addEventListener('keydown', (e) => {
if (e.key === 'Delete') {
document.addEventListener('keydown', (event) => {
if (event.key === 'Delete') {
deleteSelectedPort();
}
});

View File

@@ -2,41 +2,22 @@
/**
* bootstrap.php
*
* Initialisierung der Anwendung
* - Config laden
* - Session starten
* - DB-Verbindung über _sql.php
* - Helper einbinden
* Application initialization.
*/
/* =========================
* Config laden
* ========================= */
require_once __DIR__ . '/config.php';
// TODO: Config-Datei mit DB-Zugang, Pfaden, globalen Settings füllen
date_default_timezone_set(defined('APP_TIMEZONE') ? APP_TIMEZONE : 'UTC');
/* =========================
* Session starten
* ========================= */
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
// TODO: Single-User Auth prüfen
// z.B. $_SESSION['user'] setzen oder Login erzwingen
}
if (!isset($_SESSION['validation_errors']) || !is_array($_SESSION['validation_errors'])) {
$_SESSION['validation_errors'] = [];
}
/* =========================
* DB-Verbindung initialisieren
* ========================= */
require_once __DIR__ . '/lib/_sql.php';
// TODO: Host, User, Passwort, DB aus config.php nutzen
$sql = new SQL();
/* =========================
* Helper laden
* ========================= */
require_once __DIR__ . '/lib/helpers.php';
/* =========================
* Optional: Fehlerbehandlung
* ========================= */
// error_reporting(E_ALL);
// ini_set('display_errors', 1);
require_once __DIR__ . '/lib/auth.php';

View File

@@ -1,2 +1,19 @@
<?php
// Zentrale Konfiguration (DB-Zugangsdaten, Pfade, globale Settings)
define('APP_NAME', 'netwatch');
define('APP_ENV', getenv('APP_ENV') ?: 'development');
define('APP_TIMEZONE', getenv('APP_TIMEZONE') ?: 'Europe/Berlin');
define('DB_HOST', getenv('DB_HOST') ?: 'netdoc_db');
define('DB_USER', getenv('DB_USER') ?: 'netdoc');
define('DB_PASS', getenv('DB_PASS') ?: 'netdoc');
define('DB_NAME', getenv('DB_NAME') ?: 'netdoc');
define('AUTH_REQUIRED', (getenv('AUTH_REQUIRED') ?: '0') === '1');
define('ADMIN_PASSWORD_HASH', getenv('ADMIN_PASSWORD_HASH') ?: '');
define('LOGIN_PATH', getenv('LOGIN_PATH') ?: '/login.php');
define('UPLOAD_BASE_DIR', __DIR__ . '/uploads');
define('UPLOAD_MAX_FILE_SIZE', (int)(getenv('UPLOAD_MAX_FILE_SIZE') ?: 5 * 1024 * 1024));
define('UPLOAD_ALLOWED_CATEGORIES', ['device_types', 'floors', 'racks', 'rooms', 'misc']);

View File

@@ -1,72 +1,37 @@
<?php
// Einstiegspunkt der Anwendung, Routing zur jeweiligen Modul-Seite
require_once __DIR__ . '/bootstrap.php';
requireAuth();
/**
* index.php
*
* Einstiegspunkt der Anwendung
* - Single-User
* - Modulbasiertes Routing
* - Basierend auf _sql.php
* - HTML-Layout via templates/layout.php
*/
/* =========================
* Bootstrap
* ========================= */
require_once __DIR__ . '/bootstrap.php'; // lädt config, DB, helper
// TODO: Session starten / Single-User-Auth prüfen
/* =========================
* Routing
* ========================= */
// Standard-Modul / Aktion
$module = $_GET['module'] ?? 'dashboard';
$action = $_GET['action'] ?? 'list';
// Whitelist der Module
$validModules = ['dashboard', 'locations', 'buildings', 'rooms', 'device_types', 'devices', 'racks', 'floors', 'floor_infrastructure', 'connections', 'port_types'];
$validActions = ['list', 'edit', 'save', 'ports', 'delete', 'swap'];
// Whitelist der Aktionen
$validActions = ['list', 'edit', 'save', 'ports', 'delete'];
// Prüfen auf gültige Werte
if (!in_array($module, $validModules)) {
renderClientError(400, 'Ungültiges Modul');
if (!in_array($module, $validModules, true)) {
renderClientError(400, 'Ungueltiges Modul');
exit;
}
if (!in_array($action, $validActions)) {
// TODO: Fehlerseite anzeigen, nutze renderClientError(...)
die('Ungültige Aktion');
if (!in_array($action, $validActions, true)) {
renderClientError(400, 'Ungueltige Aktion');
exit;
}
/* =========================
* Template-Header laden (nur für View-Aktionen)
* ========================= */
if (!in_array($action, ['save', 'delete'], true)) {
if (!in_array($action, ['save', 'delete', 'swap'], true)) {
require_once __DIR__ . '/templates/header.php';
}
/* =========================
* Modul laden
* ========================= */
$modulePath = __DIR__ . "/modules/$module/$action.php";
if (file_exists($modulePath)) {
require_once $modulePath;
} else {
// TODO: Fehlerseite oder 404, nutze renderClientError(...)
if ($action !== 'save') {
echo "<p>Die Seite existiert noch nicht.</p>".$modulePath;
}
renderClientError(404, 'Die angeforderte Seite existiert nicht.');
}
/* =========================
* Template-Footer laden (nur für View-Aktionen)
* ========================= */
if (!in_array($action, ['save', 'delete'], true)) {
if (!in_array($action, ['save', 'delete', 'swap'], true)) {
require_once __DIR__ . '/templates/footer.php';
}

View File

@@ -8,11 +8,19 @@ class SQL {
public $cnt_get = 0;
public $cnt_set = 0;
public function __construct() {
if (defined('DB_HOST') && defined('DB_USER') && defined('DB_PASS') && defined('DB_NAME')) {
$this->m = [
'host' => DB_HOST,
'user' => DB_USER,
'pass' => DB_PASS,
'data' => DB_NAME
];
} else {
require_once ('secret.php');
$this->m = $_m;
}
$this->h = new mysqli ( $_m ['host'], $_m ['user'], $_m ['pass'], $_m ['data'] );
$this->h = new mysqli ( $this->m ['host'], $this->m ['user'], $this->m ['pass'], $this->m ['data'] );
if ($this->h->connect_errno) {
return false;
}

View File

@@ -2,82 +2,60 @@
/**
* app/lib/auth.php
*
* Single-User-Authentifizierung
* - Login / Logout
* - Session-Check
* - Optional: Passwortschutz für Admin-Tool
*
* KEIN Mehrbenutzer-System
* Single-user authentication helpers.
*/
/* =========================
* Login prüfen
* ========================= */
/**
* Prüft, ob der Benutzer eingeloggt ist
*
* @return bool
*/
function isAuthenticated(): bool
{
// TODO: Session-Variable definieren, z.B. $_SESSION['auth'] === true
if (!defined('AUTH_REQUIRED') || AUTH_REQUIRED === false) {
return true;
}
return isset($_SESSION['auth']) && $_SESSION['auth'] === true;
}
/* =========================
* Login durchführen
* ========================= */
/**
* Führt einen Login durch
*
* @param string $password
* @return bool
*/
function login(string $password): bool
{
// TODO: Passwort aus config.php vergleichen
// TODO: Passwort-Hash verwenden (password_hash / password_verify)
$hash = defined('ADMIN_PASSWORD_HASH') ? trim((string)ADMIN_PASSWORD_HASH) : '';
if ($hash === '') {
return false;
}
/*
if (password_verify($password, ADMIN_PASSWORD_HASH)) {
if (password_verify($password, $hash)) {
$_SESSION['auth'] = true;
$_SESSION['auth_at'] = time();
return true;
}
*/
return false;
}
/* =========================
* Logout
* ========================= */
/**
* Loggt den Benutzer aus
*/
function logout(): void
{
// TODO: Session-Variablen löschen
// unset($_SESSION['auth']);
unset($_SESSION['auth'], $_SESSION['auth_at']);
// TODO: Optional komplette Session zerstören
// session_destroy();
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
}
/* =========================
* Zugriff erzwingen
* ========================= */
/**
* Erzwingt Login, sonst Redirect
*/
function requireAuth(): void
{
if (!defined('AUTH_REQUIRED') || AUTH_REQUIRED === false) {
return;
}
if (!isAuthenticated()) {
// TODO: Redirect auf Login-Seite
// header('Location: /login.php');
$isApiRequest = str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/api/');
if ($isApiRequest) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Nicht authentifiziert']);
exit;
}
$target = defined('LOGIN_PATH') ? LOGIN_PATH : '/login.php';
header('Location: ' . $target);
exit;
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,35 @@
<?php
/**
* app/modules/buildings/delete.php
*/
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Methode nicht erlaubt']);
exit;
}
header('Content-Type: application/json');
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo json_encode(['error' => 'ID fehlt']);
exit;
}
$exists = $sql->single("SELECT id FROM buildings WHERE id = ?", "i", [$id]);
if (!$exists) {
http_response_code(404);
echo json_encode(['error' => 'Gebaeude nicht gefunden']);
exit;
}
$rows = $sql->set("DELETE FROM buildings WHERE id = ?", "i", [$id]);
if ($rows === false) {
http_response_code(500);
echo json_encode(['error' => 'Loeschen fehlgeschlagen']);
exit;
}
echo json_encode(['status' => 'ok', 'success' => true, 'rows' => $rows]);

View File

@@ -47,7 +47,7 @@ $selectedLocationId = $building['location_id'] ?? $prefillLocationId;
<div class="form-group">
<label for="name">Name <span class="required">*</span></label>
<input type="text" id="name" name="name" required
value="<?php echo htmlspecialchars($building['name'] ?? ''); ?>"
value="<?php echo htmlspecialchars($building['name'] ?? '); ?>"
placeholder="z.B. Gebäude A, Verwaltungsgebäude">
</div>
@@ -57,7 +57,7 @@ $selectedLocationId = $building['location_id'] ?? $prefillLocationId;
<option value="">- Wählen -</option>
<?php foreach ($locations as $location): ?>
<option value="<?php echo $location['id']; ?>"
<?php echo ((int)$selectedLocationId === (int)$location['id']) ? 'selected' : ''; ?>>
<?php echo ((int)$selectedLocationId === (int)$location['id']) ? 'selected' : '; ?>>
<?php echo htmlspecialchars($location['name']); ?>
</option>
<?php endforeach; ?>
@@ -67,7 +67,7 @@ $selectedLocationId = $building['location_id'] ?? $prefillLocationId;
<div class="form-group">
<label for="comment">Beschreibung</label>
<textarea id="comment" name="comment" rows="3"
placeholder="Adresse, Besonderheiten, etc."><?php echo htmlspecialchars($building['comment'] ?? ''); ?></textarea>
placeholder="Adresse, Besonderheiten, etc."><?php echo htmlspecialchars($building['comment'] ?? '); ?></textarea>
</div>
</fieldset>
@@ -172,9 +172,25 @@ $selectedLocationId = $building['location_id'] ?? $prefillLocationId;
<script>
function confirmDelete(id) {
if (confirm('Dieses Gebäude wirklich löschen? Alle Stockwerke werden gelöscht.')) {
// TODO: AJAX-Delete implementieren
alert('Löschen noch nicht implementiert');
if (confirm('Dieses Gebaeude wirklich loeschen? Alle Stockwerke werden geloescht.')) {
fetch('?module=buildings&action=delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: 'id=' + encodeURIComponent(id)
})
.then((res) => res.json())
.then((data) => {
if (data && data.status === 'ok') {
window.location.href = '?module=buildings&action=list';
return;
}
alert((data && data.error) ? data.error : 'Loeschen fehlgeschlagen');
})
.catch(() => {
alert('Loeschen fehlgeschlagen');
});
}
}
</script>

View File

@@ -8,17 +8,17 @@
// =========================
// Filter einlesen
// =========================
$search = trim($_GET['search'] ?? '');
$search = trim($_GET['search'] ?? ');
$locationId = (int)($_GET['location_id'] ?? 0);
// =========================
// WHERE-Clause bauen
// =========================
$where = [];
$types = '';
$types = ';
$params = [];
if ($search !== '') {
if ($search !== ') {
$where[] = "b.name LIKE ? OR b.comment LIKE ?";
$types .= "ss";
$params[] = "%$search%";
@@ -70,7 +70,7 @@ $locations = $sql->get("SELECT id, name FROM locations ORDER BY name", "", []);
<option value="">- Alle Standorte -</option>
<?php foreach ($locations as $loc): ?>
<option value="<?php echo $loc['id']; ?>"
<?php echo $loc['id'] === $locationId ? 'selected' : ''; ?>>
<?php echo $loc['id'] === $locationId ? 'selected' : '; ?>>
<?php echo htmlspecialchars($loc['name']); ?>
</option>
<?php endforeach; ?>
@@ -112,7 +112,7 @@ $locations = $sql->get("SELECT id, name FROM locations ORDER BY name", "", []);
</td>
<td>
<small><?php echo htmlspecialchars($building['comment'] ?? ''); ?></small>
<small><?php echo htmlspecialchars($building['comment'] ?? '); ?></small>
</td>
<td class="actions">
@@ -241,9 +241,24 @@ $locations = $sql->get("SELECT id, name FROM locations ORDER BY name", "", []);
<script>
function confirmDelete(id) {
if (confirm('Dieses Gebäude wirklich löschen?')) {
// TODO: AJAX-Delete implementieren
alert('Löschen noch nicht implementiert');
if (confirm('Dieses Gebaeude wirklich loeschen?')) {
fetch('?module=buildings&action=delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: 'id=' + encodeURIComponent(id)
})
.then((res) => res.json())
.then((data) => {
if (data && data.status === 'ok') {
window.location.reload();
return;
}
alert((data && data.error) ? data.error : 'Loeschen fehlgeschlagen');
})
.catch(() => {
alert('Loeschen fehlgeschlagen');
});
}
}
</script>

View File

@@ -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;

View File

@@ -0,0 +1,65 @@
<?php
/**
* app/modules/connections/delete.php
*
* Loescht eine Verbindung (AJAX-POST bevorzugt, GET-Fallback fuer Redirects).
*/
$isPost = ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST';
$connectionId = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
if ($connectionId <= 0) {
if ($isPost) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(400);
echo json_encode(['success' => 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;

View File

@@ -39,6 +39,18 @@ $portBType = $normalizePortType((string)($connection['port_b_type'] ?? 'device')
$portAId = (int)($connection['port_a_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 = [
'device' => [],
'module' => [],
@@ -46,6 +58,77 @@ $endpointOptions = [
'patchpanel' => [],
];
$occupiedStatsByType = [
'device' => [],
'module' => [],
'outlet' => [],
'patchpanel' => [],
];
$occupiedRows = $sql->get(
"SELECT id, port_a_type, port_a_id, port_b_type, port_b_id
FROM connections
WHERE id <> ?",
"i",
[$connectionId]
);
foreach ((array)$occupiedRows as $row) {
$typeA = $normalizePortType((string)($row['port_a_type'] ?? ''));
$idA = (int)($row['port_a_id'] ?? 0);
if ($idA > 0 && isset($occupiedStatsByType[$typeA])) {
if (!isset($occupiedStatsByType[$typeA][$idA])) {
$occupiedStatsByType[$typeA][$idA] = ['total' => 0];
}
$occupiedStatsByType[$typeA][$idA]['total']++;
}
$typeB = $normalizePortType((string)($row['port_b_type'] ?? ''));
$idB = (int)($row['port_b_id'] ?? 0);
if ($idB > 0 && isset($occupiedStatsByType[$typeB])) {
if (!isset($occupiedStatsByType[$typeB][$idB])) {
$occupiedStatsByType[$typeB][$idB] = ['total' => 0];
}
$occupiedStatsByType[$typeB][$idB]['total']++;
}
}
$isEndpointAllowed = static function (string $type, int $id) use ($occupiedStatsByType, $portAType, $portAId, $portBType, $portBId): bool {
if ($id <= 0) {
return false;
}
if ($type === $portAType && $id === $portAId) {
return true;
}
if ($type === $portBType && $id === $portBId) {
return true;
}
$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.
$outletsWithoutPorts = $sql->get(
"SELECT o.id
FROM network_outlets o
LEFT JOIN network_outlet_ports nop ON nop.outlet_id = o.id
GROUP BY o.id
HAVING COUNT(nop.id) = 0",
"",
[]
);
foreach ((array)$outletsWithoutPorts as $outletRow) {
$outletId = (int)($outletRow['id'] ?? 0);
if ($outletId <= 0) {
continue;
}
$sql->set(
"INSERT INTO network_outlet_ports (outlet_id, name) VALUES (?, 'Port 1')",
"i",
[$outletId]
);
}
$devicePorts = $sql->get(
"SELECT dp.id, dp.name, d.name AS owner_name
FROM device_ports dp
@@ -55,8 +138,12 @@ $devicePorts = $sql->get(
[]
);
foreach ($devicePorts as $row) {
$id = (int)$row['id'];
if (!$isEndpointAllowed('device', $id)) {
continue;
}
$endpointOptions['device'][] = [
'id' => (int)$row['id'],
'id' => $id,
'label' => $row['owner_name'] . ' / ' . $row['name'],
];
}
@@ -78,27 +165,42 @@ $modulePorts = $sql->get(
[]
);
foreach ($modulePorts as $row) {
$id = (int)$row['id'];
if (!$isEndpointAllowed('module', $id)) {
continue;
}
$deviceName = trim((string)($row['device_name'] ?? '')) ?: 'Unzugeordnet';
$endpointOptions['module'][] = [
'id' => (int)$row['id'],
'id' => $id,
'label' => $deviceName . ' / ' . $row['module_name'] . ' / ' . $row['name'],
];
}
$outletPorts = $sql->get(
"SELECT nop.id, nop.name, no.name AS outlet_name, r.name AS room_name, f.name AS floor_name
"SELECT nop.id, nop.name, o.name AS outlet_name, r.name AS room_name, f.name AS floor_name
FROM network_outlet_ports nop
JOIN network_outlets no ON no.id = nop.outlet_id
LEFT JOIN rooms r ON r.id = no.room_id
JOIN network_outlets o ON o.id = nop.outlet_id
LEFT JOIN rooms r ON r.id = o.room_id
LEFT JOIN floors f ON f.id = r.floor_id
ORDER BY floor_name, room_name, outlet_name, nop.name",
"",
[]
);
foreach ($outletPorts as $row) {
$parts = array_filter([(string)($row['floor_name'] ?? ''), (string)($row['room_name'] ?? ''), (string)$row['outlet_name'], (string)$row['name']]);
$id = (int)$row['id'];
if (!$isEndpointAllowed('outlet', $id)) {
continue;
}
$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'][] = [
'id' => (int)$row['id'],
'id' => $id,
'label' => implode(' / ', $parts),
];
}
@@ -113,9 +215,13 @@ $patchpanelPorts = $sql->get(
[]
);
foreach ($patchpanelPorts as $row) {
$id = (int)$row['id'];
if (!$isEndpointAllowed('patchpanel', $id)) {
continue;
}
$parts = array_filter([(string)($row['floor_name'] ?? ''), (string)$row['patchpanel_name'], (string)$row['name']]);
$endpointOptions['patchpanel'][] = [
'id' => (int)$row['id'],
'id' => $id,
'label' => implode(' / ', $parts),
];
}

View File

@@ -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,
@@ -42,11 +36,11 @@ $endpointUnionSql = "
'outlet' AS endpoint_type,
nop.id AS endpoint_id,
nop.name AS port_name,
CONCAT(no.name, ' / ', IFNULL(r.name, ''), ' / ', IFNULL(f.name, '')) AS owner_name,
CONCAT(o.name, ' / ', IFNULL(r.name, ''), ' / ', IFNULL(f.name, '')) AS owner_name,
NULL AS owner_device_id
FROM network_outlet_ports nop
JOIN network_outlets no ON no.id = nop.outlet_id
LEFT JOIN rooms r ON r.id = no.room_id
JOIN network_outlets o ON o.id = nop.outlet_id
LEFT JOIN rooms r ON r.id = o.room_id
LEFT JOIN floors f ON f.id = r.floor_id
UNION ALL
SELECT
@@ -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,28 +230,42 @@ if ($deviceId > 0) {
}
}
$buildListUrl = static function (array $extra = []) use ($search, $deviceId): string {
$query = ['module' => 'connections', 'action' => 'list'];
if ($search !== '') {
$query['search'] = $search;
}
if ($deviceId > 0) {
$query['device_id'] = $deviceId;
}
foreach ($extra as $key => $value) {
if ($value === null || $value === '') {
continue;
}
$query[$key] = $value;
}
return '?' . http_build_query($query);
};
?>
<div class="connections-layout">
<div class="connections-container">
<h1>Netzwerkverbindungen</h1>
<!-- =========================
Filter-Toolbar
========================= -->
<div class="filter-form">
<form method="GET">
<input type="hidden" name="module" value="connections">
<input type="hidden" name="action" value="list">
<input type="text" name="search" placeholder="Suche nach Gerät oder Port"
<input type="text" name="search" placeholder="Suche nach Geraet oder Port..."
value="<?php echo htmlspecialchars($search); ?>" class="search-input">
<select name="device_id">
<option value="">- Alle Geräte -</option>
<option value="">- Alle Geraete -</option>
<?php foreach ($devices as $device): ?>
<option value="<?php echo $device['id']; ?>"
<?php echo $device['id'] === $deviceId ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($device['name']); ?>
<option value="<?php echo (int)$device['id']; ?>"
<?php echo ((int)$device['id'] === $deviceId) ? 'selected' : ''; ?>>
<?php echo htmlspecialchars((string)$device['name']); ?>
</option>
<?php endforeach; ?>
</select>
@@ -262,15 +276,12 @@ if ($deviceId > 0) {
</form>
</div>
<!-- =========================
Verbindungs-Tabelle
========================= -->
<?php if (!empty($connections)): ?>
<table class="connections-list">
<thead>
<tr>
<th>Von (Gerät → Port)</th>
<th>Nach (Gerät → Port)</th>
<th>Von (Geraet -> Port)</th>
<th>Nach (Geraet -> Port)</th>
<th>VLANs</th>
<th>Beschreibung</th>
<th>Status</th>
@@ -280,66 +291,61 @@ if ($deviceId > 0) {
<tbody>
<?php foreach ($connections as $conn): ?>
<?php
$comment = trim($conn['comment'] ?? '');
$connId = (int)($conn['id'] ?? 0);
$comment = trim((string)($conn['comment'] ?? ''));
$hasMissingInfo = empty($conn['endpoint_a_name']) || empty($conn['endpoint_b_name'])
|| empty($conn['port_a_name']) || empty($conn['port_b_name']);
$commentLower = mb_strtolower($comment, 'UTF-8');
$warningFromComment = preg_match('/warn|achtung|critical/', $commentLower);
$warningFromComment = preg_match('/warn|achtung|critical/', $commentLower) === 1;
$hasWarning = $hasMissingInfo || $warningFromComment;
?>
<tr>
<td>
<strong><?php echo htmlspecialchars($conn['endpoint_a_name'] ?? 'N/A'); ?></strong><br>
<small><?php echo htmlspecialchars($conn['port_a_name'] ?? '—'); ?></small>
</td>
<td>
<strong><?php echo htmlspecialchars($conn['endpoint_b_name'] ?? 'N/A'); ?></strong><br>
<small><?php echo htmlspecialchars($conn['port_b_name'] ?? '—'); ?></small>
</td>
<td>
<small>
<?php
if ($conn['vlan_config']) {
$vlan = json_decode($conn['vlan_config'], true);
echo htmlspecialchars(implode(', ', (array)$vlan));
} else {
echo '—';
$rowClass = $connId === $selectedConnectionId ? 'connection-row-selected' : '';
$vlanList = [];
if (!empty($conn['vlan_config'])) {
$vlanList = (array)json_decode((string)$conn['vlan_config'], true);
}
?>
</small>
<tr class="<?php echo $rowClass; ?>">
<td>
<strong><?php echo htmlspecialchars((string)($conn['endpoint_a_name'] ?? 'N/A')); ?></strong><br>
<small><?php echo htmlspecialchars((string)($conn['port_a_name'] ?? '-')); ?></small>
</td>
<td>
<small><?php echo htmlspecialchars($conn['comment'] ?? ''); ?></small>
<strong><?php echo htmlspecialchars((string)($conn['endpoint_b_name'] ?? 'N/A')); ?></strong><br>
<small><?php echo htmlspecialchars((string)($conn['port_b_name'] ?? '-')); ?></small>
</td>
<td>
<small><?php echo !empty($vlanList) ? htmlspecialchars(implode(', ', $vlanList)) : '-'; ?></small>
</td>
<td>
<small><?php echo htmlspecialchars($comment); ?></small>
</td>
<td class="status-cell">
<?php if ($hasWarning): ?>
<span class="status-badge status-badge-warning" title="Unvollständige oder kritische Verbindung">
⚠️ Warnung
</span>
<span class="status-badge status-badge-warning" title="Unvollstaendige oder kritische Verbindung">Warnung</span>
<?php else: ?>
<span class="status-badge status-badge-ok" title="Verbindung vollständig">
✔️ OK
</span>
<span class="status-badge status-badge-ok" title="Verbindung vollstaendig">OK</span>
<?php endif; ?>
</td>
<td class="actions">
<a href="?module=connections&action=edit&id=<?php echo $conn['id']; ?>" class="button button-small">Bearbeiten</a>
<a href="#" class="button button-small button-danger"
data-confirm-delete="true"
data-confirm-message="Diese Verbindung wirklich löschen?"
data-confirm-feedback="Löschen noch nicht implementiert">
Löschen
</a>
<a href="<?php echo htmlspecialchars($buildListUrl(['connection_id' => $connId])); ?>" class="button button-small">Details</a>
<a href="?module=connections&action=edit&id=<?php echo $connId; ?>" class="button button-small">Bearbeiten</a>
<a href="?module=connections&action=swap&id=<?php echo $connId; ?>" class="button button-small" onclick="return confirm('Von/Nach fuer diese Verbindung vertauschen?');">Von/Nach tauschen</a>
<button
type="button"
class="button button-small button-danger js-connection-delete"
data-connection-id="<?php echo $connId; ?>">
Loeschen
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<div class="empty-state">
<p>Keine Verbindungen gefunden.</p>
@@ -352,15 +358,48 @@ if ($deviceId > 0) {
<?php endif; ?>
</div>
<!-- =========================
Sidebar / Details
========================= -->
<aside class="connections-sidebar">
<section class="sidebar-card">
<h3>Ausgewaehlte Verbindung</h3>
<?php if ($selectedConnection): ?>
<?php
$selectedConnId = (int)($selectedConnection['id'] ?? 0);
$selectedVlans = [];
if (!empty($selectedConnection['vlan_config'])) {
$selectedVlans = (array)json_decode((string)$selectedConnection['vlan_config'], true);
}
?>
<p><strong>ID:</strong> #<?php echo $selectedConnId; ?></p>
<p><strong>Von:</strong><br>
<?php echo htmlspecialchars((string)($selectedConnection['endpoint_a_name'] ?? 'N/A')); ?><br>
<small><?php echo htmlspecialchars((string)($selectedConnection['port_a_name'] ?? '-')); ?></small>
</p>
<p><strong>Nach:</strong><br>
<?php echo htmlspecialchars((string)($selectedConnection['endpoint_b_name'] ?? 'N/A')); ?><br>
<small><?php echo htmlspecialchars((string)($selectedConnection['port_b_name'] ?? '-')); ?></small>
</p>
<p><strong>VLANs:</strong> <?php echo !empty($selectedVlans) ? htmlspecialchars(implode(', ', $selectedVlans)) : '-'; ?></p>
<p><strong>Kommentar:</strong> <?php echo htmlspecialchars((string)($selectedConnection['comment'] ?? '-')); ?></p>
<div class="sidebar-actions">
<a href="?module=connections&action=edit&id=<?php echo $selectedConnId; ?>" class="button button-small">Bearbeiten</a>
<a href="?module=connections&action=swap&id=<?php echo $selectedConnId; ?>" class="button button-small" onclick="return confirm('Von/Nach fuer diese Verbindung vertauschen?');">Tauschen</a>
<button
type="button"
class="button button-small button-danger js-connection-delete"
data-connection-id="<?php echo $selectedConnId; ?>">
Loeschen
</button>
</div>
<?php else: ?>
<p><em>Keine Verbindung ausgewaehlt.</em></p>
<?php endif; ?>
</section>
<aside class="sidebar">
<section class="sidebar-card">
<?php if ($selectedDevice): ?>
<h3>Ausgewähltes Gerät</h3>
<p><strong><?php echo htmlspecialchars($selectedDevice['name']); ?></strong></p>
<p>Typ: <?php echo htmlspecialchars($selectedDevice['type_name'] ?? '—'); ?></p>
<h3>Ausgewaehltes Geraet</h3>
<p><strong><?php echo htmlspecialchars((string)$selectedDevice['name']); ?></strong></p>
<p>Typ: <?php echo htmlspecialchars((string)($selectedDevice['type_name'] ?? '-')); ?></p>
<p>Ports: <?php echo (int)$selectedDevice['port_count']; ?></p>
<p>Verbindungen: <?php echo (int)$selectedDevice['connection_count']; ?></p>
<p>
@@ -368,20 +407,53 @@ if ($deviceId > 0) {
<?php if (!empty($selectedDeviceVlans)): ?>
<?php echo htmlspecialchars(implode(', ', $selectedDeviceVlans)); ?>
<?php else: ?>
-
<?php endif; ?>
</p>
<?php if (!empty($selectedDevicePorts)): ?>
<h4>Ports (max. 12)</h4>
<ul>
<?php foreach ($selectedDevicePorts as $port): ?>
<li><?php echo htmlspecialchars($port['name']); ?></li>
<li><?php echo htmlspecialchars((string)$port['name']); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php else: ?>
<p><em>Bitte ein Gerät im Filter auswählen.</em></p>
<h3>Ausgewaehltes Geraet</h3>
<p><em>Bitte ein Geraet im Filter auswaehlen.</em></p>
<?php endif; ?>
<!-- TODO: Verbindung bearbeiten / löschen -->
</section>
</aside>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.js-connection-delete').forEach((button) => {
button.addEventListener('click', () => {
const id = Number(button.dataset.connectionId || '0');
if (id <= 0) {
return;
}
if (!confirm('Diese Verbindung wirklich loeschen?')) {
return;
}
fetch('?module=connections&action=delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: 'id=' + encodeURIComponent(id)
})
.then((response) => response.json())
.then((data) => {
if (data && data.success) {
window.location.reload();
return;
}
alert((data && data.message) ? data.message : 'Loeschen fehlgeschlagen');
})
.catch(() => alert('Loeschen fehlgeschlagen'));
});
});
});
</script>

View File

@@ -42,6 +42,18 @@ $normalizePortType = static function (string $value): string {
$portAType = $normalizePortType((string)$portAType);
$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)
// =========================
@@ -50,9 +62,90 @@ $errors = [];
if ($portAId <= 0 || $portBId <= 0) {
$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(
"SELECT id, port_a_type, port_a_id, port_b_type, port_b_id
FROM connections
WHERE id <> ?",
"i",
[$connId]
);
$endpointUsage = [];
$trackUsage = static function (string $endpointType, int $endpointId, string $otherType) use (&$endpointUsage): void {
if ($endpointId <= 0) {
return;
}
if (!isset($endpointUsage[$endpointType][$endpointId])) {
$endpointUsage[$endpointType][$endpointId] = [
'total' => 0,
'fixed' => 0,
'patch' => 0,
];
}
$endpointUsage[$endpointType][$endpointId]['total']++;
if (in_array($endpointType, ['outlet', 'patchpanel'], true)) {
if (in_array($otherType, ['outlet', 'patchpanel'], true)) {
$endpointUsage[$endpointType][$endpointId]['fixed']++;
} elseif (in_array($otherType, ['device', 'module'], true)) {
$endpointUsage[$endpointType][$endpointId]['patch']++;
}
}
};
foreach ((array)$otherConnections as $row) {
$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);
}
$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)) {
$_SESSION['error'] = implode(', ', $errors);
$_SESSION['validation_errors'] = $errors;
$redirectUrl = $connId ? "?module=connections&action=edit&id=$connId" : "?module=connections&action=edit";
header("Location: $redirectUrl");
exit;
@@ -67,15 +160,37 @@ if ($connId > 0) {
// UPDATE
$sql->set(
"UPDATE connections SET port_a_type = ?, port_a_id = ?, port_b_type = ?, port_b_id = ?, vlan_config = ?, comment = ? WHERE id = ?",
"siisisi",
"sisissi",
[$portAType, $portAId, $portBType, $portBId, $vlanJson, $comment, $connId]
);
} else {
$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 connections/save'],
true
);
}
if ($connectionTypeId <= 0) {
$_SESSION['error'] = "Kein Verbindungstyp verfuegbar";
$_SESSION['validation_errors'] = ["Kein Verbindungstyp verfuegbar"];
header("Location: ?module=connections&action=edit");
exit;
}
// INSERT
$sql->set(
"INSERT INTO connections (port_a_type, port_a_id, port_b_type, port_b_id, vlan_config, comment) VALUES (?, ?, ?, ?, ?, ?)",
"siisis",
[$portAType, $portAId, $portBType, $portBId, $vlanJson, $comment]
"INSERT INTO connections (connection_type_id, port_a_type, port_a_id, port_b_type, port_b_id, vlan_config, comment) VALUES (?, ?, ?, ?, ?, ?, ?)",
"isisiss",
[$connectionTypeId, $portAType, $portAId, $portBType, $portBId, $vlanJson, $comment]
);
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* app/modules/connections/swap.php
*
* Vertauscht Endpunkt A und Endpunkt B einer Verbindung.
*/
$connectionId = (int)($_GET['id'] ?? 0);
if ($connectionId <= 0) {
$_SESSION['error'] = 'Ungueltige Verbindungs-ID';
header('Location: ?module=connections&action=list');
exit;
}
$connection = $sql->single(
"SELECT id, port_a_type, port_a_id, port_b_type, port_b_id
FROM connections
WHERE id = ?",
"i",
[$connectionId]
);
if (!$connection) {
$_SESSION['error'] = 'Verbindung nicht gefunden';
header('Location: ?module=connections&action=list');
exit;
}
$sql->set(
"UPDATE connections
SET port_a_type = ?, port_a_id = ?, port_b_type = ?, port_b_id = ?
WHERE id = ?",
"sisii",
[
(string)$connection['port_b_type'],
(int)$connection['port_b_id'],
(string)$connection['port_a_type'],
(int)$connection['port_a_id'],
$connectionId
]
);
$_SESSION['success'] = 'Endpunkte wurden vertauscht';
header('Location: ?module=connections&action=list');
exit;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
<?php
/**
* app/modules/device_types/delete.php
*/
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Methode nicht erlaubt']);
exit;
}
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'ID fehlt']);
exit;
}
$exists = $sql->single("SELECT id FROM device_types WHERE id = ?", "i", [$id]);
if (!$exists) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Geraetetyp nicht gefunden']);
exit;
}
$rows = $sql->set("DELETE FROM device_types WHERE id = ?", "i", [$id]);
if ($rows === false) {
http_response_code(409);
echo json_encode([
'success' => false,
'message' => 'Geraetetyp konnte nicht geloescht werden (wird ggf. noch von Geraeten verwendet)'
]);
exit;
}
echo json_encode(['success' => true, 'message' => 'Geraetetyp geloescht']);

View File

@@ -15,7 +15,6 @@ $deviceTypeId = (int)($_GET['id'] ?? 0);
$deviceType = null;
$ports = [];
//TODO port hinzufügen geht nicht
if ($deviceTypeId > 0) {
$deviceType = $sql->single(
@@ -87,6 +86,7 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
<textarea id="comment" name="comment" rows="3"
placeholder="z.B. Rack-Mount, 48 RJ45 + 4 SFP"><?php echo htmlspecialchars($deviceType['comment'] ?? ''); ?></textarea>
</div>
<?php if (!$isEdit): ?>
<div class="form-group">
<label for="seed_ports">Ports automatisch anlegen</label>
<input type="number" id="seed_ports" name="seed_ports" min="0" step="1"
@@ -107,6 +107,7 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
</select>
<small>Wird beim automatischen Erstellen neuer Ports als Startwert gesetzt.</small>
</div>
<?php endif; ?>
</fieldset>
<!-- =========================
@@ -339,3 +340,4 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
</div>
<script src="/assets/js/device-type-shape-editor.js" defer></script>
<script src="/assets/js/device-type-edit-form.js" defer></script>

View File

@@ -1,265 +1,165 @@
<?php
/**
* app/device_types/ports.php
* app/modules/device_types/ports.php
*
* Verwaltung der Ports eines Gerätetyps
* - Port anlegen / bearbeiten / löschen
* - Port-Typ (RJ45, SFP, BNC, Custom)
* - VLAN / Modus / Medienart
* - Übergabe an SVG-Port-Editor
* Verwaltung der Ports eines Geraetetyps.
*/
// TODO: bootstrap laden
// require_once __DIR__ . '/../../bootstrap.php';
$deviceTypeId = (int)($_GET['id'] ?? ($_GET['device_type_id'] ?? 0));
if ($deviceTypeId <= 0) {
renderClientError(400, 'device_type_id fehlt');
return;
}
// TODO: Auth erzwingen
// requireAuth();
$deviceType = $sql->single(
"SELECT id, name, image_path, image_type FROM device_types WHERE id = ?",
'i',
[$deviceTypeId]
);
// =========================
// Kontext bestimmen
// =========================
if (!$deviceType) {
renderClientError(404, 'Geraetetyp nicht gefunden');
return;
}
// TODO: device_type_id aus GET lesen
// $deviceTypeId = (int)($_GET['id'] ?? 0);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$formAction = $_POST['form_action'] ?? '';
// TODO: Gerätetyp laden
// $deviceType = null;
if ($formAction === 'add_port') {
$name = trim((string)($_POST['name'] ?? ''));
$portTypeId = (int)($_POST['port_type_id'] ?? 0);
$x = (int)($_POST['x'] ?? 0);
$y = (int)($_POST['y'] ?? 0);
// TODO: Ports dieses Gerätetyps laden
// $ports = [];
if ($name !== '') {
if ($portTypeId > 0) {
$sql->set(
"INSERT INTO device_type_ports (device_type_id, name, port_type_id, x, y) VALUES (?, ?, ?, ?, ?)",
"isiii",
[$deviceTypeId, $name, $portTypeId, $x, $y]
);
} else {
$sql->set(
"INSERT INTO device_type_ports (device_type_id, name, port_type_id, x, y) VALUES (?, ?, NULL, ?, ?)",
"isii",
[$deviceTypeId, $name, $x, $y]
);
}
$_SESSION['success'] = 'Port hinzugefuegt';
} else {
$_SESSION['error'] = 'Portname darf nicht leer sein';
}
header('Location: ?module=device_types&action=ports&id=' . $deviceTypeId);
exit;
}
if ($formAction === 'delete_port') {
$portId = (int)($_POST['port_id'] ?? 0);
if ($portId > 0) {
$sql->set(
"DELETE FROM device_type_ports WHERE id = ? AND device_type_id = ?",
"ii",
[$portId, $deviceTypeId]
);
$_SESSION['success'] = 'Port geloescht';
}
header('Location: ?module=device_types&action=ports&id=' . $deviceTypeId);
exit;
}
}
$ports = $sql->get(
"SELECT dtp.id, dtp.name, dtp.port_type_id, dtp.x, dtp.y, pt.name AS port_type_name
FROM device_type_ports dtp
LEFT JOIN port_types pt ON pt.id = dtp.port_type_id
WHERE dtp.device_type_id = ?
ORDER BY dtp.id ASC",
'i',
[$deviceTypeId]
);
$portTypes = $sql->get("SELECT id, name FROM port_types ORDER BY name", '', []);
$svgPath = trim((string)($deviceType['image_path'] ?? ''));
$svgUrl = $svgPath !== '' ? '/' . ltrim($svgPath, '/\\') : '';
?>
<h2>Ports Gerätetyp</h2>
<!-- =========================
Zurück / Kontext
========================= -->
<div class="device-type-ports">
<h2>Ports: <?php echo htmlspecialchars((string)$deviceType['name']); ?></h2>
<div class="breadcrumb">
<a href="/?page=device_types/list">Gerätetypen</a>
<a href="/?page=device_types/edit&id=<?= $deviceTypeId ?>">
<!-- TODO: Gerätetyp-Name -->
Gerätetyp
</a>
Ports
<a href="?module=device_types&action=list">Geraetetypen</a>
->
<a href="?module=device_types&action=edit&id=<?php echo (int)$deviceType['id']; ?>"><?php echo htmlspecialchars((string)$deviceType['name']); ?></a>
-> Ports
</div>
<!-- =========================
Toolbar
========================= -->
<div class="toolbar">
<button id="add-port">
+ Port hinzufügen
</button>
<!-- TODO: Port-Typen verwalten -->
<!-- TODO: Import / Export -->
<a class="button" href="?module=device_types&action=edit&id=<?php echo (int)$deviceType['id']; ?>">Zurueck zum Geraetetyp</a>
</div>
<form id="port-form" class="port-form" aria-hidden="true">
<form method="post" class="port-form">
<input type="hidden" name="form_action" value="add_port">
<div>
<label for="port-name">Portname</label>
<input id="port-name" name="name" required placeholder="z.B. Gi1/0/1">
<label for="name">Portname</label>
<input id="name" name="name" required placeholder="z. B. Gi1/0/1">
</div>
<div>
<label for="port-type">Port-Typ</label>
<input id="port-type" name="type" required placeholder="RJ45, SFP …">
<label for="port_type_id">Port-Typ</label>
<select id="port_type_id" name="port_type_id">
<option value="0">- keiner -</option>
<?php foreach ($portTypes as $portType): ?>
<option value="<?php echo (int)$portType['id']; ?>"><?php echo htmlspecialchars((string)$portType['name']); ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="port-medium">Medium</label>
<input id="port-medium" name="medium" placeholder="Kupfer, LWL …">
<label for="x">X</label>
<input id="x" name="x" type="number" value="0">
</div>
<div>
<label for="port-mode">Modus</label>
<input id="port-mode" name="mode" placeholder="Access, Trunk …">
<label for="y">Y</label>
<input id="y" name="y" type="number" value="0">
</div>
<div>
<label for="port-vlan">VLAN</label>
<input id="port-vlan" name="vlan" placeholder="10, 20-30 …">
</div>
<button type="submit" class="button button-primary">Port hinzufügen</button>
<button type="button" class="button" id="cancel-port">Abbrechen</button>
<button type="submit" class="button button-primary">Port hinzufuegen</button>
</form>
<!-- =========================
Port-Liste
========================= -->
<table class="port-list">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Typ</th>
<th>Medium</th>
<th>Modus</th>
<th>VLAN</th>
<th>X</th>
<th>Y</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="port-list-body">
<?php /* foreach ($ports as $port): */ ?>
<tbody>
<?php foreach ($ports as $port): ?>
<tr>
<td><?php echo (int)$port['id']; ?></td>
<td><?php echo htmlspecialchars((string)$port['name']); ?></td>
<td><?php echo htmlspecialchars((string)($port['port_type_name'] ?? '-')); ?></td>
<td><?php echo (int)$port['x']; ?></td>
<td><?php echo (int)$port['y']; ?></td>
<td>
<!-- TODO: Port-Nummer -->
1
</td>
<td>
<!-- TODO: Port-Name -->
Port 1
</td>
<td>
<!-- TODO: Port-Typ (RJ45, SFP, ...) -->
</td>
<td>
<!-- TODO: Medium (Kupfer, LWL, BNC, Custom) -->
</td>
<td>
<!-- TODO: Modus (Access, Trunk, Custom) -->
</td>
<td>
<!-- TODO: VLANs -->
</td>
<td>
<button>
Bearbeiten
</button>
<button>
Löschen
</button>
<form method="post" onsubmit="return confirm('Port wirklich loeschen?');" style="display:inline;">
<input type="hidden" name="form_action" value="delete_port">
<input type="hidden" name="port_id" value="<?php echo (int)$port['id']; ?>">
<button type="submit" class="button button-small button-danger">Loeschen</button>
</form>
</td>
</tr>
<?php /* endforeach; */ ?>
<?php endforeach; ?>
</tbody>
</table>
<!-- =========================
SVG-Port-Positionierung
========================= -->
<?php if ($svgUrl !== '' && ($deviceType['image_type'] ?? '') === 'svg'): ?>
<section class="svg-port-editor-section">
<h3>Port-Positionen</h3>
<p class="hint">
Ports per Drag & Drop auf dem Gerät platzieren.
</p>
<div class="svg-editor-container">
<svg
id="device-type-svg"
viewBox="0 0 800 400"
width="100%"
height="400"
>
<!-- TODO: SVG des Gerätetyps laden -->
</svg>
</div>
<h3>SVG Vorschau</h3>
<img src="<?php echo htmlspecialchars($svgUrl); ?>" alt="Geraetetyp SVG" style="max-width:100%; height:auto; border:1px solid #ddd;">
</section>
<!-- =========================
JS-Konfiguration
========================= -->
<style>
.port-form {
display: none;
margin: 20px 0;
gap: 10px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 6px;
background: #f9f9f9;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
grid-auto-rows: minmax(50px, auto);
align-items: flex-start;
}
.port-form.visible {
display: grid;
}
.port-form div {
display: flex;
flex-direction: column;
}
.port-form label {
font-size: 0.85rem;
margin-bottom: 4px;
font-weight: bold;
}
.port-form input {
padding: 6px 8px;
border-radius: 4px;
border: 1px solid #bbb;
font-family: inherit;
}
.port-form button {
margin-top: 8px;
}
.port-form .button-primary {
justify-self: flex-start;
}
</style>
<script>
const addPortButton = document.getElementById('add-port');
const portForm = document.getElementById('port-form');
const portListBody = document.getElementById('port-list-body');
const cancelPortButton = document.getElementById('cancel-port');
let portCounter = portListBody.querySelectorAll('tr').length + 1;
function showPortForm(show = true) {
portForm.classList.toggle('visible', show);
portForm.setAttribute('aria-hidden', show ? 'false' : 'true');
if (show) {
portForm.querySelector('input').focus();
}
}
addPortButton.addEventListener('click', (event) => {
event.preventDefault();
showPortForm(portForm.getAttribute('aria-hidden') === 'true');
});
cancelPortButton.addEventListener('click', () => {
showPortForm(false);
portForm.reset();
});
portForm.addEventListener('submit', (event) => {
event.preventDefault();
const data = new FormData(portForm);
const row = document.createElement('tr');
row.innerHTML = `
<td>${portCounter++}</td>
<td>${data.get('name') || '-'}</td>
<td>${data.get('type') || '-'}</td>
<td>${data.get('medium') || '-'}</td>
<td>${data.get('mode') || '-'}</td>
<td>${data.get('vlan') || '-'}</td>
<td>
<button type="button">Bearbeiten</button>
<button type="button">Löschen</button>
</td>
`;
portListBody.appendChild(row);
showPortForm(false);
portForm.reset();
});
/**
* TODO: Replace this mock logic with real AJAX once ports are
* persisted on the backend.
*/
</script>
<?php endif; ?>
</div>

View File

@@ -21,7 +21,8 @@ $deviceTypeId = (int)($_POST['id'] ?? 0);
$name = trim($_POST['name'] ?? '');
$category = $_POST['category'] ?? 'other';
$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);
$portRows = is_array($_POST['port_rows'] ?? null) ? $_POST['port_rows'] : [];
$rawShapes = trim($_POST['shape_definition'] ?? '');
@@ -49,6 +50,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 +69,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 +89,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;
}
@@ -128,7 +132,9 @@ if ($deviceTypeId > 0) {
}
}
if ($isCreate) {
seedDeviceTypePorts($sql, $deviceTypeId, $seedPortCount, $defaultPortTypeId);
}
syncDeviceTypePorts($sql, $deviceTypeId, $portRows);
$_SESSION['success'] = $deviceTypeId ? "Gerätetyp gespeichert" : "Fehler beim Speichern";

View File

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

View File

@@ -47,10 +47,10 @@ if ($isEdit) {
(
SELECT COUNT(*)
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 = ?
))
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 = ?
))
) AS connection_count",
@@ -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", "", []);
</div>
</fieldset>
<fieldset>
<legend>Ports</legend>
<?php if ($isEdit): ?>
<?php if (!empty($devicePorts)): ?>
<p><small>Portstatus und VLAN-Zuordnung koennen hier direkt gepflegt werden (VLANs kommagetrennt, z. B. 10,20,30).</small></p>
<table class="device-port-table">
<thead>
<tr>
<th>Name</th>
<th>Port-Typ</th>
<th>Status</th>
<th>Modus</th>
<th>VLANs</th>
</tr>
</thead>
<tbody>
<?php foreach ($devicePorts as $port): ?>
<?php
$vlanValue = '';
if (!empty($port['vlan_config'])) {
$decodedVlans = json_decode((string)$port['vlan_config'], true);
if (is_array($decodedVlans)) {
$vlanValue = implode(', ', array_map('strval', $decodedVlans));
} else {
$vlanValue = (string)$port['vlan_config'];
}
}
$statusValue = (string)($port['status'] ?? 'active');
if (!in_array($statusValue, ['active', 'disabled'], true)) {
$statusValue = 'active';
}
?>
<tr>
<td>
<input type="text" name="device_ports[<?php echo (int)$port['id']; ?>][name]" value="<?php echo htmlspecialchars((string)$port['name']); ?>">
</td>
<td><?php echo htmlspecialchars((string)($port['port_type_name'] ?? '-')); ?></td>
<td>
<select name="device_ports[<?php echo (int)$port['id']; ?>][status]">
<option value="active" <?php echo $statusValue === 'active' ? 'selected' : ''; ?>>aktiv</option>
<option value="disabled" <?php echo $statusValue === 'disabled' ? 'selected' : ''; ?>>inaktiv</option>
</select>
</td>
<td>
<input type="text" name="device_ports[<?php echo (int)$port['id']; ?>][mode]" value="<?php echo htmlspecialchars((string)($port['mode'] ?? '')); ?>" placeholder="z. B. access/trunk">
</td>
<td>
<input type="text" name="device_ports[<?php echo (int)$port['id']; ?>][vlan_config]" value="<?php echo htmlspecialchars($vlanValue); ?>" placeholder="10,20,30">
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p><small>Zu diesem Geraet sind aktuell keine Ports vorhanden.</small></p>
<?php endif; ?>
<?php else: ?>
<p><small>Ports werden nach dem ersten Speichern automatisch aus dem Geraetetyp erzeugt und koennen dann hier gepflegt werden.</small></p>
<?php endif; ?>
</fieldset>
<!-- =========================
Aktionen
========================= -->
@@ -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;
}
</style>
<script>
function confirmDelete(link, id, connectionCount, portCount, moduleCount) {
if (confirm('Dieses Gerät wirklich löschen?')) {
const hasDependencies = (connectionCount > 0) || (portCount > 0) || (moduleCount > 0);
if (hasDependencies) {
const details = [];
if (connectionCount > 0) {
details.push(connectionCount + ' Verbindungen');
}
if (portCount > 0) {
details.push(portCount + ' Ports');
}
if (moduleCount > 0) {
details.push(moduleCount + ' Port-Module');
}
const dependencyMessage = 'Es gibt abhängige Daten (' + details.join(', ') + '). Diese auch löschen?';
if (!confirm(dependencyMessage)) {
return false;
}
window.location.href = (link && link.href ? link.href : ('?module=devices&action=delete&id=' + encodeURIComponent(id))) + '&force=1';
if (!confirm('Dieses Geraet wirklich loeschen?')) {
return false;
}
return true;
const requestDelete = (forceDelete) => {
const body = ['id=' + encodeURIComponent(id)];
if (forceDelete) {
body.push('force=1');
}
fetch('?module=devices&action=delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: body.join('&')
})
.then((response) => response.json())
.then((data) => {
if (data && data.success) {
window.location.href = '?module=devices&action=list';
return;
}
if (data && data.requires_force) {
if (confirm(data.message || 'Es gibt abhaengige Daten. Trotzdem loeschen?')) {
requestDelete(true);
}
return;
}
alert((data && data.message) ? data.message : 'Loeschen fehlgeschlagen');
})
.catch(() => alert('Loeschen fehlgeschlagen'));
};
requestDelete(false);
return false;
}
</script>

View File

@@ -1,7 +1,7 @@
<?php
/**
* modules/devices/list.php
* Vollständige Geräteübersicht mit Filter
* Vollstaendige Geraeteuebersicht mit Filter
*/
// =========================
@@ -22,34 +22,34 @@ $params = [];
if ($search !== '') {
$where[] = "(d.name LIKE ? OR d.serial_number LIKE ? OR dt.name LIKE ?)";
$types .= "sss";
$types .= 'sss';
$params[] = "%$search%";
$params[] = "%$search%";
$params[] = "%$search%";
}
if ($typeId > 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(
"
@@ -69,6 +69,25 @@ $whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
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
@@ -78,10 +97,10 @@ $whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
(
SELECT COUNT(*)
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
))
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
))
) AS connection_count
@@ -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', '', []);
?>
<div class="devices-container">
<h1>Geräte</h1>
<h1>Geraete</h1>
<!-- =========================
Filter-Toolbar
========================= -->
<form method="get" class="filter-form">
<input type="hidden" name="module" value="devices">
<input type="hidden" name="action" value="list">
<input type="text" name="search" placeholder="Suche nach Name oder Seriennummer"
<input type="text" name="search" placeholder="Suche nach Name oder Seriennummer..."
value="<?php echo htmlspecialchars($search); ?>" class="search-input">
<select name="type_id">
@@ -149,16 +165,13 @@ $racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []);
<a href="?module=devices&action=list" class="button">Reset</a>
<a href="?module=devices&action=edit" class="button button-primary" style="margin-left: auto;">
+ Neues Gerät
+ Neues Geraet
</a>
</form>
<!-- =========================
Geräte-Liste
========================= -->
<?php if (!empty($devices)): ?>
<div class="device-stats">
<p>Gefundene Geräte: <strong><?php echo count($devices); ?></strong></p>
<p>Gefundene Geraete: <strong><?php echo count($devices); ?></strong></p>
</div>
<table class="device-list">
@@ -169,6 +182,7 @@ $racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []);
<th>Stockwerk</th>
<th>Rack</th>
<th>Position (HE)</th>
<th>Ports</th>
<th>Seriennummer</th>
<th>Webconfig</th>
<th>Aktionen</th>
@@ -186,28 +200,37 @@ $racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []);
</td>
<td>
<?php echo htmlspecialchars($d['floor_name'] ?? ''); ?>
<?php echo htmlspecialchars($d['floor_name'] ?? '-'); ?>
</td>
<td>
<?php echo htmlspecialchars($d['rack_name'] ?? ''); ?>
<?php echo htmlspecialchars($d['rack_name'] ?? '-'); ?>
</td>
<td>
<?php
if ($d['rack_position_he']) {
echo $d['rack_position_he'];
echo (int)$d['rack_position_he'];
if ($d['rack_height_he']) {
echo "" . ($d['rack_position_he'] + $d['rack_height_he'] - 1);
echo '-' . ((int)$d['rack_position_he'] + (int)$d['rack_height_he'] - 1);
}
} else {
echo "—";
echo '-';
}
?>
</td>
<td>
<small><?php echo htmlspecialchars($d['serial_number'] ?? '—'); ?></small>
<small>
<?php echo (int)$d['port_count']; ?> gesamt<br>
<?php echo (int)$d['active_port_count']; ?> aktiv /
<?php echo (int)$d['disabled_port_count']; ?> inaktiv<br>
VLAN gesetzt: <?php echo (int)$d['vlan_port_count']; ?>
</small>
</td>
<td>
<small><?php echo htmlspecialchars($d['serial_number'] ?? '-'); ?></small>
</td>
<td>
@@ -216,13 +239,13 @@ $racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []);
Webconfig
</a>
<?php else: ?>
-
<?php endif; ?>
</td>
<td class="actions">
<a href="?module=devices&action=edit&id=<?php echo $d['id']; ?>" class="button button-small">Bearbeiten</a>
<a href="?module=devices&action=delete&id=<?php echo (int)$d['id']; ?>" class="button button-small button-danger" onclick="return confirmDelete(this, <?php echo (int)$d['id']; ?>, <?php echo (int)$d['connection_count']; ?>, <?php echo (int)$d['port_count']; ?>, <?php echo (int)$d['module_count']; ?>)">Löschen</a>
<a href="?module=devices&action=delete&id=<?php echo (int)$d['id']; ?>" class="button button-small button-danger" onclick="return confirmDelete(this, <?php echo (int)$d['id']; ?>, <?php echo (int)$d['connection_count']; ?>, <?php echo (int)$d['port_count']; ?>, <?php echo (int)$d['module_count']; ?>)">Loeschen</a>
</td>
</tr>
<?php endforeach; ?>
@@ -231,10 +254,10 @@ $racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []);
<?php else: ?>
<div class="empty-state">
<p>Keine Geräte gefunden.</p>
<p>Keine Geraete gefunden.</p>
<p>
<a href="?module=devices&action=edit" class="button button-primary">
Erstes Gerät anlegen
Erstes Geraet anlegen
</a>
</p>
</div>
@@ -351,33 +374,41 @@ $racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []);
<script>
function confirmDelete(link, id, connectionCount, portCount, moduleCount) {
if (confirm('Dieses Gerät wirklich löschen?')) {
const hasDependencies = (connectionCount > 0) || (portCount > 0) || (moduleCount > 0);
if (hasDependencies) {
const details = [];
if (connectionCount > 0) {
details.push(connectionCount + ' Verbindungen');
}
if (portCount > 0) {
details.push(portCount + ' Ports');
}
if (moduleCount > 0) {
details.push(moduleCount + ' Port-Module');
}
const dependencyMessage = 'Es gibt abhängige Daten (' + details.join(', ') + '). Diese auch löschen?';
if (!confirm(dependencyMessage)) {
return false;
}
window.location.href = (link && link.href ? link.href : ('?module=devices&action=delete&id=' + encodeURIComponent(id))) + '&force=1';
if (!confirm('Dieses Geraet wirklich loeschen?')) {
return false;
}
return true;
const requestDelete = (forceDelete) => {
const body = ['id=' + encodeURIComponent(id)];
if (forceDelete) {
body.push('force=1');
}
fetch('?module=devices&action=delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: body.join('&')
})
.then((response) => response.json())
.then((data) => {
if (data && data.success) {
window.location.reload();
return;
}
if (data && data.requires_force) {
if (confirm(data.message || 'Es gibt abhaengige Daten. Trotzdem loeschen?')) {
requestDelete(true);
}
return;
}
alert((data && data.message) ? data.message : 'Loeschen fehlgeschlagen');
})
.catch(() => alert('Loeschen fehlgeschlagen'));
};
requestDelete(false);
return false;
}
</script>

View File

@@ -25,6 +25,7 @@ $rackHeightHe = (int)($_POST['rack_height_he'] ?? 1);
$serialNumber = trim($_POST['serial_number'] ?? '');
$comment = trim($_POST['comment'] ?? '');
$webConfigUrl = trim($_POST['web_config_url'] ?? '');
$devicePortRows = is_array($_POST['device_ports'] ?? null) ? $_POST['device_ports'] : [];
if ($webConfigUrl === '') {
$webConfigUrl = null;
}
@@ -57,6 +58,7 @@ if ($rackHeightHe < 1) {
// Falls Fehler: zurück zum Edit-Formular
if (!empty($errors)) {
$_SESSION['error'] = implode(', ', $errors);
$_SESSION['validation_errors'] = $errors;
$redirectUrl = $deviceId ? "?module=devices&action=edit&id=$deviceId" : "?module=devices&action=edit";
header("Location: $redirectUrl");
exit;
@@ -87,6 +89,9 @@ if ($isNewDevice) {
if ($isNewDevice && $deviceId > 0) {
copyDevicePortsFromType($sql, $deviceId, $deviceTypeId);
}
if ($deviceId > 0 && !$isNewDevice && !empty($devicePortRows)) {
syncDevicePorts($sql, $deviceId, $devicePortRows);
}
$_SESSION['success'] = "Gerät gespeichert";
@@ -138,3 +143,105 @@ function copyDevicePortsFromType($sql, $deviceId, $deviceTypeId)
}
}
}
function syncDevicePorts($sql, $deviceId, array $rows)
{
$ports = $sql->get(
"SELECT id, name FROM device_ports WHERE device_id = ?",
"i",
[$deviceId]
);
if (empty($ports)) {
return;
}
$allowedIds = [];
$currentNames = [];
foreach ($ports as $port) {
$portId = (int)($port['id'] ?? 0);
if ($portId <= 0) {
continue;
}
$allowedIds[$portId] = true;
$currentNames[$portId] = (string)($port['name'] ?? '');
}
foreach ($rows as $portIdRaw => $row) {
$portId = (int)$portIdRaw;
if ($portId <= 0 || !isset($allowedIds[$portId]) || !is_array($row)) {
continue;
}
$name = trim((string)($row['name'] ?? ''));
if ($name === '') {
$name = $currentNames[$portId] ?? ('Port ' . $portId);
}
$status = trim((string)($row['status'] ?? 'active'));
if (!in_array($status, ['active', 'disabled'], true)) {
$status = 'active';
}
$mode = trim((string)($row['mode'] ?? ''));
$mode = $mode !== '' ? $mode : null;
$vlanJson = normalizeVlanConfig($row['vlan_config'] ?? '');
if ($mode !== null && $vlanJson !== null) {
$sql->set(
"UPDATE device_ports
SET name = ?, status = ?, mode = ?, vlan_config = ?
WHERE id = ? AND device_id = ?",
"ssssii",
[$name, $status, $mode, $vlanJson, $portId, $deviceId]
);
} elseif ($mode !== null) {
$sql->set(
"UPDATE device_ports
SET name = ?, status = ?, mode = ?, vlan_config = NULL
WHERE id = ? AND device_id = ?",
"sssii",
[$name, $status, $mode, $portId, $deviceId]
);
} elseif ($vlanJson !== null) {
$sql->set(
"UPDATE device_ports
SET name = ?, status = ?, mode = NULL, vlan_config = ?
WHERE id = ? AND device_id = ?",
"sssii",
[$name, $status, $vlanJson, $portId, $deviceId]
);
} else {
$sql->set(
"UPDATE device_ports
SET name = ?, status = ?, mode = NULL, vlan_config = NULL
WHERE id = ? AND device_id = ?",
"ssii",
[$name, $status, $portId, $deviceId]
);
}
}
}
function normalizeVlanConfig($raw)
{
$value = trim((string)$raw);
if ($value === '') {
return null;
}
$parts = preg_split('/[\s,;]+/', $value);
$normalized = [];
foreach ((array)$parts as $part) {
$entry = trim((string)$part);
if ($entry === '') {
continue;
}
$normalized[$entry] = true;
}
if (empty($normalized)) {
return null;
}
return json_encode(array_keys($normalized), JSON_UNESCAPED_UNICODE);
}

View File

@@ -0,0 +1,49 @@
<?php
/**
* app/modules/floor_infrastructure/delete.php
*
* Loescht Patchpanels oder Wandbuchsen (AJAX-POST bevorzugt).
*/
$isPost = ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST';
$type = strtolower(trim((string)($_POST['type'] ?? $_GET['type'] ?? '')));
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
if ($id <= 0 || !in_array($type, ['patchpanel', 'outlet'], true)) {
if ($isPost) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Ungueltige Anfrage']);
exit;
}
header('Location: ?module=floor_infrastructure&action=list');
exit;
}
if ($type === 'patchpanel') {
$rows = $sql->set(
"DELETE FROM floor_patchpanels WHERE id = ?",
"i",
[$id]
);
} else {
$rows = $sql->set(
"DELETE FROM network_outlets WHERE id = ?",
"i",
[$id]
);
}
if ($isPost) {
header('Content-Type: application/json; charset=utf-8');
if ($rows > 0) {
echo json_encode(['success' => true, 'message' => 'Infrastrukturobjekt geloescht']);
} else {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Infrastrukturobjekt nicht gefunden oder bereits geloescht']);
}
exit;
}
header('Location: ?module=floor_infrastructure&action=list');
exit;

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">
@@ -205,10 +257,9 @@ $mapOutlets = $sql->get(
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-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>
</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>
</div>
</div>
@@ -255,6 +306,38 @@ $mapOutlets = $sql->get(
</select>
</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="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-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'); ?>">
<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>
</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>
</div>
</div>
@@ -293,11 +375,8 @@ $mapOutlets = $sql->get(
</form>
</div>
<?php
//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
//TODO style in css files einsortieren
?>
<script src="/assets/js/floor-infrastructure-edit.js" defer></script>

View File

@@ -180,6 +180,14 @@ if ($editorFloor) {
<td><?php echo (int)$panel['port_count']; ?></td>
<td class="actions">
<a href="?module=floor_infrastructure&action=edit&type=patchpanel&id=<?php echo (int)$panel['id']; ?>" class="button button-small">Bearbeiten</a>
<button
type="button"
class="button button-small button-danger js-floor-infra-delete"
data-delete-id="<?php echo (int)$panel['id']; ?>"
data-delete-type="patchpanel"
data-delete-label="<?php echo htmlspecialchars((string)$panel['name'], ENT_QUOTES, 'UTF-8'); ?>">
Loeschen
</button>
</td>
</tr>
<?php endforeach; ?>
@@ -225,6 +233,14 @@ if ($editorFloor) {
<td><?php echo htmlspecialchars((string)($outlet['comment'] ?? '')); ?></td>
<td class="actions">
<a href="?module=floor_infrastructure&action=edit&type=outlet&id=<?php echo (int)$outlet['id']; ?>" class="button button-small">Bearbeiten</a>
<button
type="button"
class="button button-small button-danger js-floor-infra-delete"
data-delete-id="<?php echo (int)$outlet['id']; ?>"
data-delete-type="outlet"
data-delete-label="<?php echo htmlspecialchars((string)$outlet['name'], ENT_QUOTES, 'UTF-8'); ?>">
Loeschen
</button>
</td>
</tr>
<?php endforeach; ?>
@@ -235,3 +251,38 @@ if ($editorFloor) {
<?php endif; ?>
</section>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.js-floor-infra-delete').forEach((button) => {
button.addEventListener('click', () => {
const id = Number(button.dataset.deleteId || '0');
const type = (button.dataset.deleteType || '').trim();
const label = button.dataset.deleteLabel || 'Objekt';
if (id <= 0 || (type !== 'patchpanel' && type !== 'outlet')) {
return;
}
const entityLabel = type === 'patchpanel' ? 'Patchpanel' : 'Wandbuchse';
if (!confirm(entityLabel + ' "' + label + '" wirklich loeschen?')) {
return;
}
fetch('?module=floor_infrastructure&action=delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: 'id=' + encodeURIComponent(id) + '&type=' + encodeURIComponent(type)
})
.then((response) => response.json())
.then((data) => {
if (data && data.success) {
window.location.reload();
return;
}
alert((data && data.message) ? data.message : 'Loeschen fehlgeschlagen');
})
.catch(() => alert('Loeschen fehlgeschlagen'));
});
});
});
</script>

View File

@@ -20,6 +20,26 @@ if ($type === 'patchpanel') {
$height = $fixedPanelHeight;
$portCount = (int)($_POST['port_count'] ?? 0);
$comment = trim($_POST['comment'] ?? '');
$errors = [];
if ($name === '') {
$errors[] = 'Name ist erforderlich';
}
if ($floorId <= 0) {
$errors[] = 'Stockwerk ist erforderlich';
}
if ($portCount < 0) {
$errors[] = 'Port-Anzahl darf nicht negativ sein';
}
if (!empty($errors)) {
$_SESSION['error'] = implode(', ', $errors);
$_SESSION['validation_errors'] = $errors;
$redirectUrl = $id > 0 ? "?module=floor_infrastructure&action=edit&type=patchpanel&id=$id" : "?module=floor_infrastructure&action=edit&type=patchpanel";
header("Location: $redirectUrl");
exit;
}
$panelId = $id;
if ($id > 0) {
$sql->set(
@@ -28,18 +48,55 @@ if ($type === 'patchpanel') {
[$name, $floorId, $posX, $posY, $width, $height, $portCount, $comment, $id]
);
} else {
$sql->set(
$panelId = (int)$sql->set(
"INSERT INTO floor_patchpanels (name, floor_id, pos_x, pos_y, width, height, port_count, comment) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"siiiiiss",
[$name, $floorId, $posX, $posY, $width, $height, $portCount, $comment]
[$name, $floorId, $posX, $posY, $width, $height, $portCount, $comment],
true
);
}
if ($panelId > 0 && $portCount > 0) {
$existingCount = (int)($sql->single(
"SELECT COUNT(*) AS cnt FROM floor_patchpanel_ports WHERE patchpanel_id = ?",
"i",
[$panelId]
)['cnt'] ?? 0);
if ($existingCount < $portCount) {
for ($i = $existingCount + 1; $i <= $portCount; $i++) {
$sql->set(
"INSERT INTO floor_patchpanel_ports (patchpanel_id, name) VALUES (?, ?)",
"is",
[$panelId, 'Port ' . $i]
);
}
}
}
$_SESSION['success'] = $id > 0 ? 'Patchpanel gespeichert' : 'Patchpanel erstellt';
} elseif ($type === 'outlet') {
$name = trim($_POST['name'] ?? '');
$roomId = (int)($_POST['room_id'] ?? 0);
$x = (int)($_POST['x'] ?? 0);
$y = (int)($_POST['y'] ?? 0);
$comment = trim($_POST['comment'] ?? '');
$bindPatchpanelPortId = (int)($_POST['bind_patchpanel_port_id'] ?? 0);
$outletId = $id;
$errors = [];
if ($name === '') {
$errors[] = 'Name ist erforderlich';
}
if ($roomId <= 0) {
$errors[] = 'Raum ist erforderlich';
}
if (!empty($errors)) {
$_SESSION['error'] = implode(', ', $errors);
$_SESSION['validation_errors'] = $errors;
$redirectUrl = $id > 0 ? "?module=floor_infrastructure&action=edit&type=outlet&id=$id" : "?module=floor_infrastructure&action=edit&type=outlet";
header("Location: $redirectUrl");
exit;
}
if ($id > 0) {
$sql->set(
@@ -48,12 +105,159 @@ if ($type === 'patchpanel') {
[$name, $roomId, $x, $y, $comment, $id]
);
} else {
$sql->set(
$outletId = (int)$sql->set(
"INSERT INTO network_outlets (name, room_id, x, y, comment) VALUES (?, ?, ?, ?, ?)",
"siiis",
[$name, $roomId, $x, $y, $comment]
[$name, $roomId, $x, $y, $comment],
true
);
}
if ($outletId > 0) {
$existingPortCount = (int)($sql->single(
"SELECT COUNT(*) AS cnt FROM network_outlet_ports WHERE outlet_id = ?",
"i",
[$outletId]
)['cnt'] ?? 0);
if ($existingPortCount === 0) {
$sql->set(
"INSERT INTO network_outlet_ports (outlet_id, name) VALUES (?, 'Port 1')",
"i",
[$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';
} else {
$_SESSION['error'] = 'Ungueltiger Infrastrukturobjekt-Typ';
$_SESSION['validation_errors'] = ['Ungueltiger Infrastrukturobjekt-Typ'];
}
header('Location: ?module=floor_infrastructure&action=list');

View File

@@ -0,0 +1,85 @@
<?php
/**
* app/modules/floors/delete.php
*/
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Methode nicht erlaubt']);
exit;
}
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'ID fehlt']);
exit;
}
$exists = $sql->single("SELECT id FROM floors WHERE id = ?", "i", [$id]);
if (!$exists) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Stockwerk nicht gefunden']);
exit;
}
$forceDelete = (int)($_POST['force'] ?? $_GET['force'] ?? 0) === 1;
$dependencyCounts = $sql->single(
"SELECT
(SELECT COUNT(*) FROM rooms WHERE floor_id = ?) AS room_count,
(SELECT COUNT(*) FROM racks WHERE floor_id = ?) AS rack_count,
(SELECT COUNT(*) FROM floor_patchpanels WHERE floor_id = ?) AS patchpanel_count",
"iii",
[$id, $id, $id]
);
$roomCount = (int)($dependencyCounts['room_count'] ?? 0);
$rackCount = (int)($dependencyCounts['rack_count'] ?? 0);
$patchpanelCount = (int)($dependencyCounts['patchpanel_count'] ?? 0);
$hasDependencies = $roomCount > 0 || $rackCount > 0 || $patchpanelCount > 0;
if ($hasDependencies && !$forceDelete) {
$parts = [];
if ($roomCount > 0) {
$parts[] = $roomCount . ' Raeume';
}
if ($rackCount > 0) {
$parts[] = $rackCount . ' Racks';
}
if ($patchpanelCount > 0) {
$parts[] = $patchpanelCount . ' Patchpanels';
}
http_response_code(409);
echo json_encode([
'success' => false,
'requires_force' => true,
'message' => 'Beim Loeschen werden abhaengige Daten entfernt (' . implode(', ', $parts) . '). Fortfahren?',
'dependencies' => [
'rooms' => $roomCount,
'racks' => $rackCount,
'patchpanels' => $patchpanelCount
]
]);
exit;
}
$rows = $sql->set("DELETE FROM floors WHERE id = ?", "i", [$id]);
if ($rows === false) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Loeschen fehlgeschlagen']);
exit;
}
echo json_encode([
'success' => true,
'message' => 'Stockwerk geloescht',
'dependencies' => [
'rooms' => $roomCount,
'racks' => $rackCount,
'patchpanels' => $patchpanelCount
]
]);

View File

@@ -10,7 +10,7 @@
// =========================
// Filter einlesen
// =========================
$search = trim($_GET['search'] ?? '');
$search = trim($_GET['search'] ?? ');
// =========================
// Floors laden
@@ -19,7 +19,7 @@ $whereClause = "";
$types = "";
$params = [];
if ($search !== '') {
if ($search !== ') {
$whereClause = "WHERE f.name LIKE ? OR f.comment LIKE ?";
$types = "ss";
$params = ["%$search%", "%$search%"];
@@ -105,7 +105,7 @@ $buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []);
</td>
<td>
<small><?php echo htmlspecialchars($floor['comment'] ?? ''); ?></small>
<small><?php echo htmlspecialchars($floor['comment'] ?? '); ?></small>
</td>
<td class="actions">
@@ -233,9 +233,43 @@ $buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []);
<script>
function confirmDelete(id) {
if (confirm('Dieses Stockwerk wirklich löschen? Alle Räume und Racks werden gelöscht.')) {
// TODO: AJAX-Delete implementieren
alert('Löschen noch nicht implementiert');
if (!confirm('Dieses Stockwerk wirklich loeschen?')) {
return;
}
const requestDelete = (forceDelete) => {
const body = ['id=' + encodeURIComponent(id)];
if (forceDelete) {
body.push('force=1');
}
fetch('?module=floors&action=delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: body.join('&')
})
.then((res) => res.json())
.then((data) => {
if (data && data.success) {
window.location.reload();
return;
}
if (data && data.requires_force) {
if (confirm(data.message || 'Abhaengige Daten ebenfalls loeschen?')) {
requestDelete(true);
}
return;
}
alert((data && data.message) ? data.message : 'Loeschen fehlgeschlagen');
})
.catch(() => {
alert('Loeschen fehlgeschlagen');
});
};
requestDelete(false);
}
</script>

View File

@@ -37,6 +37,7 @@ if ($buildingId <= 0) {
// Falls Fehler: zurück zum Edit-Formular
if (!empty($errors)) {
$_SESSION['error'] = implode(', ', $errors);
$_SESSION['validation_errors'] = $errors;
$redirectUrl = $floorId ? "?module=floors&action=edit&id=$floorId" : "?module=floors&action=edit";
header("Location: $redirectUrl");
exit;
@@ -50,6 +51,7 @@ if ($floorSvgContent !== '') {
$storedSvgPath = storeSvgEditorContent($sql, $floorId, $floorSvgContent);
if ($storedSvgPath === false) {
$_SESSION['error'] = "SVG aus dem Editor konnte nicht gespeichert werden";
$_SESSION['validation_errors'] = ["SVG aus dem Editor konnte nicht gespeichert werden"];
$redirectUrl = $floorId ? "?module=floors&action=edit&id=$floorId" : "?module=floors&action=edit";
header("Location: $redirectUrl");
exit;

View File

@@ -7,6 +7,16 @@
header('Content-Type: application/json; charset=utf-8');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method !== 'POST') {
http_response_code(405);
echo json_encode([
'success' => false,
'message' => 'Methode nicht erlaubt'
]);
exit;
}
$locationId = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
if ($locationId <= 0) {

View File

@@ -42,14 +42,14 @@ $pageTitle = $isEdit ? "Standort bearbeiten: " . htmlspecialchars($location['nam
<div class="form-group">
<label for="name">Name <span class="required">*</span></label>
<input type="text" id="name" name="name" required
value="<?php echo htmlspecialchars($location['name'] ?? ''); ?>"
value="<?php echo htmlspecialchars($location['name'] ?? '); ?>"
placeholder="z.B. Hauptgebäude, Campus A, Außenstelle Berlin">
</div>
<div class="form-group">
<label for="comment">Beschreibung</label>
<textarea id="comment" name="comment" rows="3"
placeholder="Adresse, Kontaktinformationen, Besonderheiten"><?php echo htmlspecialchars($location['comment'] ?? ''); ?></textarea>
placeholder="Adresse, Kontaktinformationen, Besonderheiten"><?php echo htmlspecialchars($location['comment'] ?? '); ?></textarea>
</div>
</fieldset>
@@ -153,9 +153,24 @@ $pageTitle = $isEdit ? "Standort bearbeiten: " . htmlspecialchars($location['nam
<script>
function confirmDelete(id) {
if (confirm('Diesen Standort wirklich löschen? Alle Gebäude werden gelöscht.')) {
// TODO: AJAX-Delete implementieren
alert('Löschen noch nicht implementiert');
if (confirm('Diesen Standort wirklich loeschen? Alle Gebaeude werden geloescht.')) {
fetch('?module=locations&action=delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: 'id=' + encodeURIComponent(id)
})
.then((res) => res.json())
.then((data) => {
if (data && data.success) {
window.location.href = '?module=locations&action=list';
return;
}
alert((data && data.message) ? data.message : 'Loeschen fehlgeschlagen');
})
.catch(() => {
alert('Loeschen fehlgeschlagen');
});
}
}
</script>

View File

@@ -20,6 +20,7 @@ if (empty($name)) {
if (!empty($errors)) {
$_SESSION['error'] = implode(', ', $errors);
$_SESSION['validation_errors'] = $errors;
$redirectUrl = $locationId ? "?module=locations&action=edit&id=$locationId" : "?module=locations&action=edit";
header("Location: $redirectUrl");
exit;

View File

@@ -0,0 +1,84 @@
<?php
/**
* app/modules/port_types/delete.php
*
* Loescht einen Porttyp per AJAX (POST).
*/
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Methode nicht erlaubt']);
exit;
}
$portTypeId = (int)($_POST['id'] ?? 0);
if ($portTypeId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Ungueltige Porttyp-ID']);
exit;
}
$portType = $sql->single(
"SELECT id, name FROM port_types WHERE id = ?",
"i",
[$portTypeId]
);
if (!$portType) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Porttyp nicht gefunden']);
exit;
}
$usage = $sql->single(
"SELECT
(SELECT COUNT(*) FROM device_type_ports WHERE port_type_id = ?) AS device_type_ports_count,
(SELECT COUNT(*) FROM device_ports WHERE port_type_id = ?) AS device_ports_count,
(SELECT COUNT(*) FROM module_ports WHERE port_type_id = ?) AS module_ports_count,
(SELECT COUNT(*) FROM network_outlet_ports WHERE port_type_id = ?) AS outlet_ports_count,
(SELECT COUNT(*) FROM floor_patchpanel_ports WHERE port_type_id = ?) AS patchpanel_ports_count",
"iiiii",
[$portTypeId, $portTypeId, $portTypeId, $portTypeId, $portTypeId]
);
$references = [
'Geraetetyp-Ports' => (int)($usage['device_type_ports_count'] ?? 0),
'Geraete-Ports' => (int)($usage['device_ports_count'] ?? 0),
'Modul-Ports' => (int)($usage['module_ports_count'] ?? 0),
'Netzwerkdosen-Ports' => (int)($usage['outlet_ports_count'] ?? 0),
'Patchpanel-Ports' => (int)($usage['patchpanel_ports_count'] ?? 0),
];
$inUse = array_filter($references, static fn ($count) => $count > 0);
if (!empty($inUse)) {
$parts = [];
foreach ($inUse as $label => $count) {
$parts[] = $label . ': ' . $count;
}
http_response_code(409);
echo json_encode([
'success' => false,
'message' => 'Porttyp wird noch verwendet (' . implode(', ', $parts) . ')'
]);
exit;
}
$deleted = $sql->set(
"DELETE FROM port_types WHERE id = ?",
"i",
[$portTypeId]
);
if ($deleted <= 0) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Porttyp konnte nicht geloescht werden']);
exit;
}
echo json_encode([
'success' => true,
'message' => 'Porttyp geloescht: ' . (string)$portType['name']
]);
exit;

View File

@@ -23,20 +23,11 @@ $isEdit = !empty($portType);
$pageTitle = $isEdit ? "Porttyp bearbeiten: " . htmlspecialchars($portType['name']) : "Neuen Porttyp anlegen";
$mediaOptions = ['copper' => 'Kupfer', 'fiber' => 'Lichtwelle', 'coax' => 'Koax', 'other' => 'Sonstiges'];
$error = $_SESSION['error'] ?? '';
unset($_SESSION['error']);
?>
<div class="port-type-edit">
<h1><?php echo $pageTitle; ?></h1>
<?php if ($error): ?>
<div class="error-message">
<?php echo htmlspecialchars($error); ?>
</div>
<?php endif; ?>
<form method="post" action="?module=port_types&action=save" class="edit-form">
<?php if ($isEdit): ?>
@@ -157,12 +148,4 @@ unset($_SESSION['error']);
opacity: 0.8;
}
.error-message {
background: #ffe3e3;
color: #a73737;
border: 1px solid #f5c2c2;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
}
</style>

View File

@@ -34,20 +34,11 @@ $portTypes = $sql->get(
$params
);
$success = $_SESSION['success'] ?? '';
unset($_SESSION['success']);
?>
<div class="port-types-container">
<h1>Porttypen</h1>
<?php if ($success): ?>
<div class="success-message">
<?php echo htmlspecialchars($success); ?>
</div>
<?php endif; ?>
<div class="toolbar">
<form method="GET" class="filter-form">
<input type="hidden" name="module" value="port_types">
@@ -78,6 +69,13 @@ unset($_SESSION['success']);
<td><small><?php echo htmlspecialchars($pt['comment'] ?? ''); ?></small></td>
<td class="actions">
<a href="?module=port_types&action=edit&id=<?php echo (int)$pt['id']; ?>" class="button button-small">Bearbeiten</a>
<button
type="button"
class="button button-small button-danger js-port-type-delete"
data-port-type-id="<?php echo (int)$pt['id']; ?>"
data-port-type-name="<?php echo htmlspecialchars((string)$pt['name'], ENT_QUOTES, 'UTF-8'); ?>">
Loeschen
</button>
</td>
</tr>
<?php endforeach; ?>
@@ -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;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.js-port-type-delete').forEach((button) => {
button.addEventListener('click', () => {
const id = Number(button.dataset.portTypeId || '0');
const name = button.dataset.portTypeName || 'Porttyp';
if (id <= 0) {
return;
}
if (!confirm('Porttyp "' + name + '" wirklich loeschen?')) {
return;
}
fetch('?module=port_types&action=delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: 'id=' + encodeURIComponent(id)
})
.then((response) => response.json())
.then((data) => {
if (data && data.success) {
window.location.reload();
return;
}
alert((data && data.message) ? data.message : 'Loeschen fehlgeschlagen');
})
.catch(() => alert('Loeschen fehlgeschlagen'));
});
});
});
</script>

View File

@@ -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;

View File

@@ -0,0 +1,36 @@
<?php
/**
* app/modules/racks/delete.php
*/
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Methode nicht erlaubt']);
exit;
}
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'ID fehlt']);
exit;
}
$exists = $sql->single("SELECT id FROM racks WHERE id = ?", "i", [$id]);
if (!$exists) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Rack nicht gefunden']);
exit;
}
$rows = $sql->set("DELETE FROM racks WHERE id = ?", "i", [$id]);
if ($rows === false) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Loeschen fehlgeschlagen']);
exit;
}
echo json_encode(['success' => true, 'message' => 'Rack geloescht']);

View File

@@ -1,16 +1,8 @@
<?php
/**
* app/modules/racks/edit.php
*
* Rack anlegen oder bearbeiten
* - Name, Beschreibung
* - Zugehöriges Stockwerk (Floor)
* - Höhe in Höheneinheiten (HE)
*/
// =========================
// Kontext bestimmen
// =========================
$rackId = (int)($_GET['id'] ?? 0);
$rack = null;
@@ -23,83 +15,61 @@ if ($rackId > 0) {
}
$isEdit = !empty($rack);
$pageTitle = $isEdit ? "Rack bearbeiten: " . htmlspecialchars($rack['name']) : "Neues Rack";
// =========================
// Floors laden
// =========================
$floors = $sql->get("SELECT id, name FROM floors ORDER BY name", "", []);
$pageTitle = $isEdit ? 'Rack bearbeiten: ' . htmlspecialchars((string)$rack['name']) : 'Neues Rack';
$floors = $sql->get("SELECT id, name FROM floors ORDER BY name", '', []);
?>
<div class="rack-edit">
<h1><?php echo $pageTitle; ?></h1>
<form method="post" action="?module=racks&action=save" class="edit-form">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?php echo $rackId; ?>">
<input type="hidden" name="id" value="<?php echo (int)$rackId; ?>">
<?php endif; ?>
<!-- =========================
Basisdaten
========================= -->
<fieldset>
<legend>Allgemein</legend>
<div class="form-group">
<label for="name">Name <span class="required">*</span></label>
<input type="text" id="name" name="name" required
value="<?php echo htmlspecialchars($rack['name'] ?? ''); ?>"
placeholder="z.B. Rack A1">
<input type="text" id="name" name="name" required value="<?php echo htmlspecialchars((string)($rack['name'] ?? '')); ?>" placeholder="z.B. Rack A1">
</div>
<div class="form-group">
<label for="comment">Beschreibung</label>
<textarea id="comment" name="comment" rows="3"
placeholder="z.B. Standort, Besonderheiten"><?php echo htmlspecialchars($rack['comment'] ?? ''); ?></textarea>
<textarea id="comment" name="comment" rows="3" placeholder="z.B. Standort, Besonderheiten"><?php echo htmlspecialchars((string)($rack['comment'] ?? '')); ?></textarea>
</div>
</fieldset>
<!-- =========================
Standort & Höhe
========================= -->
<fieldset>
<legend>Standort & Größe</legend>
<legend>Standort und Groesse</legend>
<div class="form-group">
<label for="floor_id">Stockwerk <span class="required">*</span></label>
<select id="floor_id" name="floor_id" required>
<option value="">- Wählen -</option>
<option value="">- Waehlen -</option>
<?php foreach ($floors as $floor): ?>
<option value="<?php echo $floor['id']; ?>"
<?php echo ($rack['floor_id'] ?? 0) == $floor['id'] ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($floor['name']); ?>
<option value="<?php echo (int)$floor['id']; ?>" <?php echo ((int)($rack['floor_id'] ?? 0) === (int)$floor['id']) ? 'selected' : ''; ?>>
<?php echo htmlspecialchars((string)$floor['name']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="height_he">Höhe in Höheneinheiten (HE) <span class="required">*</span></label>
<input type="number" id="height_he" name="height_he" required min="1" max="100"
value="<?php echo htmlspecialchars($rack['height_he'] ?? '42'); ?>"
placeholder="z.B. 42">
<small>Standard: 42 HE (ca. 2 Meter)</small>
<label for="height_he">Hoehe in Hoeheneinheiten (HE) <span class="required">*</span></label>
<input type="number" id="height_he" name="height_he" required min="1" max="100" value="<?php echo (int)($rack['height_he'] ?? 42); ?>">
<small>Standard: 42 HE</small>
</div>
</fieldset>
<!-- =========================
Aktionen
========================= -->
<fieldset class="form-actions">
<button type="submit" class="button button-primary">Speichern</button>
<a href="?module=racks&action=list" class="button">Abbrechen</a>
<?php if ($isEdit): ?>
<a href="#" class="button button-danger" onclick="confirmDelete(<?php echo $rackId; ?>)">Löschen</a>
<a href="#" class="button button-danger" onclick="return confirmDelete(<?php echo (int)$rackId; ?>)">Loeschen</a>
<?php endif; ?>
</fieldset>
</form>
</div>
@@ -197,60 +167,25 @@ $floors = $sql->get("SELECT id, name FROM floors ORDER BY name", "", []);
<script>
function confirmDelete(id) {
if (confirm('Dieses Rack wirklich löschen? Alle Geräte werden aus dem Rack entfernt.')) {
// TODO: AJAX-Delete implementieren
alert('Löschen noch nicht implementiert');
if (!confirm('Dieses Rack wirklich loeschen? Alle Geraete werden aus dem Rack entfernt.')) {
return false;
}
fetch('?module=racks&action=delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: 'id=' + encodeURIComponent(id)
})
.then((response) => response.json())
.then((data) => {
if (data && data.success) {
window.location.href = '?module=racks&action=list';
return;
}
alert((data && data.message) ? data.message : 'Loeschen fehlgeschlagen');
})
.catch(() => alert('Loeschen fehlgeschlagen'));
return false;
}
</script>
<!-- =========================
Rack-SVG / Gerätepositionen
========================= -->
<fieldset>
<legend>Rack-Layout</legend>
<div class="svg-editor-container">
<svg
id="rack-svg"
viewBox="0 0 200 1000"
width="100%"
height="600"
>
<!-- TODO: Rack-SVG laden -->
</svg>
</div>
<p class="hint">
Geräte per Drag & Drop im Rack positionieren.
</p>
</fieldset>
<!-- =========================
Aktionen
========================= -->
<fieldset>
<button type="submit">Speichern</button>
<button type="button" onclick="history.back()">Abbrechen</button>
<!-- TODO: Löschen, falls edit -->
</fieldset>
</form>
<!-- =========================
JS-Konfiguration
========================= -->
<script>
/**
* Konfiguration für Rack-SVG-Editor
*/
// TODO: Rack-ID aus PHP setzen
// window.RACK_ID = <?= (int)$rackId ?>;
// TODO: Gerätepositionen an JS übergeben
// window.RACK_DEVICES = <?= json_encode($rackDevices ?? []) ?>;
</script>

View File

@@ -2,44 +2,30 @@
/**
* app/modules/racks/list.php
*
* Übersicht aller Racks
* - Anzeigen, Bearbeiten, Löschen
* - Zugehöriges Stockwerk anzeigen
* - Gerätecount
* Uebersicht aller Racks.
*/
// =========================
// Filter einlesen
// =========================
$search = trim($_GET['search'] ?? '');
$search = trim((string)($_GET['search'] ?? ''));
$floorId = (int)($_GET['floor_id'] ?? 0);
//TODO racks beim editieren auf der stockwerkkarte platzieren und verschieben können
// =========================
// WHERE-Clause bauen
// =========================
$where = [];
$types = '';
$params = [];
if ($search !== '') {
$where[] = "r.name LIKE ?";
$types .= "s";
$where[] = 'r.name LIKE ?';
$types .= 's';
$params[] = "%$search%";
}
if ($floorId > 0) {
$where[] = "r.floor_id = ?";
$types .= "i";
$where[] = 'r.floor_id = ?';
$types .= 'i';
$params[] = $floorId;
}
$whereSql = $where ? "WHERE " . implode(" AND ", $where) : "";
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
// =========================
// Racks laden
// =========================
$racks = $sql->get(
"SELECT r.*, f.name AS floor_name, COUNT(d.id) AS device_count
FROM racks r
@@ -52,33 +38,24 @@ $racks = $sql->get(
$params
);
// =========================
// Filter-Daten laden
// =========================
$floors = $sql->get("SELECT id, name FROM floors ORDER BY name", "", []);
$floors = $sql->get('SELECT id, name FROM floors ORDER BY name', '', []);
?>
<div class="racks-container">
<h1>Racks</h1>
<!-- =========================
Toolbar
========================= -->
<div class="filter-form">
<form method="GET" style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;">
<input type="hidden" name="module" value="racks">
<input type="hidden" name="action" value="list">
<input type="text" name="search" placeholder="Suche nach Name…"
value="<?php echo htmlspecialchars($search); ?>" class="search-input">
<input type="text" name="search" placeholder="Suche nach Name..." value="<?php echo htmlspecialchars($search); ?>" class="search-input">
<select name="floor_id">
<option value="">- Alle Stockwerke -</option>
<?php foreach ($floors as $floor): ?>
<option value="<?php echo $floor['id']; ?>"
<?php echo $floor['id'] === $floorId ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($floor['name']); ?>
<option value="<?php echo (int)$floor['id']; ?>" <?php echo ((int)$floor['id'] === $floorId) ? 'selected' : ''; ?>>
<?php echo htmlspecialchars((string)$floor['name']); ?>
</option>
<?php endforeach; ?>
</select>
@@ -89,17 +66,14 @@ $floors = $sql->get("SELECT id, name FROM floors ORDER BY name", "", []);
</form>
</div>
<!-- =========================
Racks-Tabelle
========================= -->
<?php if (!empty($racks)): ?>
<table class="rack-list">
<thead>
<tr>
<th>Name</th>
<th>Stockwerk</th>
<th>Höhe (HE)</th>
<th>Geräte</th>
<th>Hoehe (HE)</th>
<th>Geraete</th>
<th>Beschreibung</th>
<th>Aktionen</th>
</tr>
@@ -107,157 +81,68 @@ $floors = $sql->get("SELECT id, name FROM floors ORDER BY name", "", []);
<tbody>
<?php foreach ($racks as $rack): ?>
<tr>
<td>
<strong><?php echo htmlspecialchars($rack['name']); ?></strong>
</td>
<td>
<?php echo htmlspecialchars($rack['floor_name'] ?? '—'); ?>
</td>
<td>
<?php echo $rack['height_he']; ?> HE
</td>
<td>
<?php echo $rack['device_count']; ?>
</td>
<td>
<small><?php echo htmlspecialchars($rack['comment'] ?? ''); ?></small>
</td>
<td><strong><?php echo htmlspecialchars((string)$rack['name']); ?></strong></td>
<td><?php echo htmlspecialchars((string)($rack['floor_name'] ?? '-')); ?></td>
<td><?php echo (int)$rack['height_he']; ?> HE</td>
<td><?php echo (int)$rack['device_count']; ?></td>
<td><small><?php echo htmlspecialchars((string)($rack['comment'] ?? '')); ?></small></td>
<td class="actions">
<a href="?module=racks&action=edit&id=<?php echo $rack['id']; ?>" class="button button-small">Bearbeiten</a>
<a href="#" class="button button-small button-danger" onclick="confirmDelete(<?php echo $rack['id']; ?>)">Löschen</a>
<a href="?module=racks&action=edit&id=<?php echo (int)$rack['id']; ?>" class="button button-small">Bearbeiten</a>
<a href="#" class="button button-small button-danger" onclick="return confirmDelete(<?php echo (int)$rack['id']; ?>)">Loeschen</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<div class="empty-state">
<p>Keine Racks gefunden.</p>
<p>
<a href="?module=racks&action=edit" class="button button-primary">
Erstes Rack anlegen
</a>
</p>
<p><a href="?module=racks&action=edit" class="button button-primary">Erstes Rack anlegen</a></p>
</div>
<?php endif; ?>
</div>
<style>
.racks-container {
padding: 20px;
max-width: 1000px;
margin: 0 auto;
}
.filter-form {
margin: 20px 0;
}
.filter-form form {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.filter-form input,
.filter-form select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
.search-input {
flex: 1;
min-width: 250px;
}
.rack-list {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}
.rack-list th {
background: #f5f5f5;
padding: 12px;
text-align: left;
border-bottom: 2px solid #ddd;
font-weight: bold;
}
.rack-list td {
padding: 12px;
border-bottom: 1px solid #ddd;
}
.rack-list tr:hover {
background: #f9f9f9;
}
.actions {
white-space: nowrap;
}
.button {
display: inline-block;
padding: 8px 12px;
background: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 0.9em;
}
.button:hover {
background: #0056b3;
}
.button-primary {
background: #28a745;
}
.button-primary:hover {
background: #218838;
}
.button-small {
padding: 4px 8px;
font-size: 0.85em;
}
.button-danger {
background: #dc3545;
}
.button-danger:hover {
background: #c82333;
}
.empty-state {
text-align: center;
padding: 40px 20px;
background: #f9f9f9;
border: 1px solid #eee;
border-radius: 8px;
}
.racks-container { padding: 20px; max-width: 1000px; margin: 0 auto; }
.filter-form { margin: 20px 0; }
.filter-form input, .filter-form select { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; }
.search-input { flex: 1; min-width: 250px; }
.rack-list { width: 100%; border-collapse: collapse; margin: 15px 0; }
.rack-list th { background: #f5f5f5; padding: 12px; text-align: left; border-bottom: 2px solid #ddd; font-weight: bold; }
.rack-list td { padding: 12px; border-bottom: 1px solid #ddd; }
.rack-list tr:hover { background: #f9f9f9; }
.actions { white-space: nowrap; }
.button { display: inline-block; padding: 8px 12px; background: #007bff; color: #fff; text-decoration: none; border-radius: 4px; border: none; cursor: pointer; font-size: 0.9em; }
.button:hover { background: #0056b3; }
.button-primary { background: #28a745; }
.button-primary:hover { background: #218838; }
.button-small { padding: 4px 8px; font-size: 0.85em; }
.button-danger { background: #dc3545; }
.button-danger:hover { background: #c82333; }
.empty-state { text-align: center; padding: 40px 20px; background: #f9f9f9; border: 1px solid #eee; border-radius: 8px; }
</style>
<script>
function confirmDelete(id) {
if (confirm('Dieses Rack wirklich löschen?')) {
// TODO: AJAX-Delete implementieren
alert('Löschen noch nicht implementiert');
if (!confirm('Dieses Rack wirklich loeschen?')) {
return false;
}
fetch('?module=racks&action=delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: 'id=' + encodeURIComponent(id)
})
.then((res) => res.json())
.then((data) => {
if (data && data.success) {
window.location.reload();
return;
}
alert((data && data.message) ? data.message : 'Loeschen fehlgeschlagen');
})
.catch(() => alert('Loeschen fehlgeschlagen'));
return false;
}
</script>
</div>
<?php /* endif; */ ?>

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,19 +1,16 @@
<?php
/**
* footer.php
*
* HTML-Footer, Scripts, evtl. Modale oder Notifications
* Wird am Ende jeder Seite eingebunden
*/
?>
</main>
<footer>
<p>&copy; <?php echo date('Y'); ?> Troy Grunt - NDT</p>
<!-- TODO: Optional: Statusanzeige, Debug-Info, Session-Hinweis -->
<p class="footer-meta">
Umgebung: <?php echo defined('APP_ENV') ? htmlspecialchars(APP_ENV) : 'unknown'; ?>
| Session: <?php echo session_id() !== '' ? 'aktiv' : 'inaktiv'; ?>
</p>
</footer>
<!-- TODO: evtl. JS für modale Fenster oder Flash Messages -->
</body>
</html>

View File

@@ -10,7 +10,10 @@
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Netwatch - Netzwerk-Dokumentation und Verkabelungsverwaltung">
<title>Netzwerk-Dokumentation</title>
<link rel="icon" type="image/svg+xml" href="/assets/icons/favicon.svg">
<!-- CSS -->
<link rel="stylesheet" href="/assets/css/app.css">
@@ -18,10 +21,10 @@
<!-- JS -->
<script src="/assets/js/app.js" defer></script>
<script src="/assets/js/dashboard.js" defer></script>
<script src="/assets/js/svg-editor.js" defer></script>
<script src="/assets/js/network-view.js" defer></script>
<!-- TODO: Meta-Tags, Favicon -->
</head>
<body>
<header class="app-header">
@@ -61,4 +64,60 @@
</nav>
</header>
<?php
$flashMessages = [];
$successMessage = trim((string)($_SESSION['success'] ?? ''));
if ($successMessage !== '') {
$flashMessages[] = [
'type' => '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']);
?>
<main>
<?php if (!empty($flashMessages)): ?>
<section class="flash-stack" aria-live="polite">
<?php foreach ($flashMessages as $message): ?>
<article class="flash-message flash-message--<?php echo htmlspecialchars((string)$message['type']); ?>">
<p class="flash-message__text"><?php echo htmlspecialchars((string)($message['text'] ?? '')); ?></p>
<?php if (!empty($message['details']) && is_array($message['details'])): ?>
<ul class="flash-message__list">
<?php foreach ($message['details'] as $detail): ?>
<li><?php echo htmlspecialchars((string)$detail); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</article>
<?php endforeach; ?>
</section>
<?php endif; ?>

View File

@@ -2,25 +2,18 @@
/**
* layout.php
*
* Grundlayout: Header + Content + Footer
* Kann als Basis-Template dienen, falls Module HTML ausgeben
*
* Beispiel-Aufruf in Modul:
* include __DIR__ . '/../templates/layout.php';
*
* TODO: In Zukunft: zentrales Template-System (z.B. mit $content)
* Basislayout fuer Header + Content + Footer.
*/
?>
<?php include __DIR__ . '/header.php'; ?>
<div class="content-wrapper">
<!-- TODO: Dynamischen Content hier einfügen -->
<?php
if (isset($content)) {
echo $content;
} else {
echo "<p>Inhalt fehlt</p>";
echo '<p>Kein Inhalt uebergeben.</p>';
}
?>
</div>

View File

@@ -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.
---

View File

@@ -44,9 +44,9 @@ INSERT INTO `buildings` (`id`, `location_id`, `name`, `comment`) VALUES
CREATE TABLE `connections` (
`id` int(11) NOT NULL,
`connection_type_id` int(11) NOT NULL,
`port_a_type` enum('device','module','outlet') NOT NULL,
`port_a_type` enum('device','module','outlet','patchpanel') NOT NULL,
`port_a_id` int(11) NOT NULL,
`port_b_type` enum('device','module','outlet') NOT NULL,
`port_b_type` enum('device','module','outlet','patchpanel') NOT NULL,
`port_b_id` int(11) NOT NULL,
`vlan_config` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`vlan_config`)),
`mode` varchar(50) DEFAULT NULL,
@@ -70,6 +70,13 @@ CREATE TABLE `connection_types` (
`comment` text DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci;
--
-- Daten fuer Tabelle `connection_types`
--
INSERT INTO `connection_types` (`id`, `name`, `medium`, `duplex`, `max_speed`, `color`, `line_style`, `comment`) VALUES
(1, 'Default', 'copper', 'custom', NULL, NULL, 'solid', 'Auto-created default type');
-- --------------------------------------------------------
--
@@ -369,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.
-- --------------------------------------------------------