Compare commits
10 commits
12c10f0337
...
2534dbbe05
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2534dbbe05 | ||
|
|
a334dbd95a | ||
|
|
6931181916 | ||
|
|
585cb83ed0 | ||
|
|
d4ab1da5aa | ||
|
|
fa74ceb5d8 | ||
|
|
62c1b8cd5c | ||
|
|
883a8146c5 | ||
|
|
f11bd4f6c4 | ||
|
|
aff12a4d81 |
30 changed files with 2827 additions and 265 deletions
|
|
@ -34,14 +34,15 @@ Bereits vorhanden:
|
|||
|
||||
- fachliche Architektur- und Betriebskonzepte
|
||||
- relationaler Schema-Entwurf in `docs/SCHEMA.md`
|
||||
- erstes Go-Geruest fuer `server/backend`
|
||||
- erstes Go-Geruest fuer `player/agent`
|
||||
- funktionales Go-Backend (`server/backend`) mit REST-API, PostgreSQL-Anbindung und Datenverwaltung
|
||||
- funktionaler Go-Agent (`player/agent`) mit Server-Registrierung, Playlist-Management und Heartbeat-Sync
|
||||
- vollstaendiger Compose-Stack in `compose/server-stack.yml` mit PostgreSQL, Mosquitto und Backend-Service
|
||||
|
||||
Noch nicht vorhanden:
|
||||
|
||||
- produktive API-Endpunkte mit Datenbankanbindung
|
||||
- Player-Sync und Playlist-Management
|
||||
- Compose-Stack fuer lokale Serverdienste (Grundgeruest liegt in `compose/`)
|
||||
- admin-seitige Benutzerautentifizierung und Zugriffskontrolle
|
||||
- Multi-Tenancy-Isolation auf API-Ebene
|
||||
- produktives SSL/TLS-Handling fuer Deployment
|
||||
|
||||
## Voraussetzungen auf dem Entwicklungsrechner
|
||||
|
||||
|
|
@ -307,30 +308,43 @@ Die Datei `/tmp/screen-status.json` enthaelt nach dem ersten Heartbeat den persi
|
|||
|
||||
## Ergaenzt seit dem ersten Geruest:
|
||||
|
||||
- `message_wall`-Resolver im Backend
|
||||
**Backend-REST-API (statusseite.py, playerstatus.go, messagewall.go):**
|
||||
- `message_wall`-Resolver im Backend fuer Szenen-Aufloesung
|
||||
- Basisendpunkte und `message_wall`-Validierung im Backend testseitig breiter abgedeckt
|
||||
- erster `POST /api/v1/player/status`-Endpunkt im Backend
|
||||
- letzter bekannter Player-Status wird im Backend pro Screen in-memory vorgehalten und lesbar gemacht
|
||||
- Backend ergaenzt den Read-Pfad um `received_at` und eine einfache `stale`-Ableitung
|
||||
- Backend bietet zusaetzlich eine kleine Uebersicht aller zuletzt meldenden Screens
|
||||
- Backend validiert den Statuspfad jetzt enger auf erlaubte Lifecycle-/Connectivity-Werte und leitet `stale` aus dem gemeldeten Intervall ab
|
||||
- Backend leitet im Read-Pfad zusaetzlich ein kompaktes `derived_state` fuer Diagnosekonsumenten ab
|
||||
- Backend liefert unter `/status` eine erste sichtbare HTML-Diagnoseseite auf Basis derselben Statusdaten, inklusive Auto-Refresh, leichten Filtern und JSON-Drill-down
|
||||
- Backend unterstuetzt `q=` (Screen-ID-Substring), `derived_state=`, `server_connectivity=`, `stale=`, `updated_since=`, `limit=` als Query-Filter
|
||||
- Backend leitet `derived_state` (online / degraded / offline) aus `stale`, `server_connectivity` und `status` ab
|
||||
- Backend erlaubt das Loeschen einzelner Screen-Eintraege via `DELETE /api/v1/screens/{screenId}/status`
|
||||
- Backend persistiert den Status-Store optional in einer JSON-Datei (`MORZ_INFOBOARD_STATUS_STORE_PATH`)
|
||||
- dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides
|
||||
- `POST /api/v1/player/status` mit Laufzeit-, Stale- und Server-Konnektivitaets-Tracking
|
||||
- Player-Status wird im Backend pro Screen in-memory vorgehalten und ueber Endpunkte lesbar gemacht
|
||||
- `GET /api/v1/screens/status` fuer Gesamt-Uebersicht mit Query-Filtern
|
||||
- `GET /api/v1/screens/{screenId}/status` fuer Einzelscreen-Details
|
||||
- `DELETE /api/v1/screens/{screenId}/status` zum Loeschen von Screen-Eintraegen
|
||||
- HTML-Diagnoseseite unter `/status` mit Auto-Refresh, Filterung und JSON-Drill-down
|
||||
- Query-Parameter: `q=` (Screen-ID-Substring), `derived_state=`, `server_connectivity=`, `stale=`, `updated_since=`, `limit=`
|
||||
- `derived_state` (online / degraded / offline) aus `stale`, `server_connectivity` und `status` abgeleitet
|
||||
- JSON-Persistenz optional in Datei (`MORZ_INFOBOARD_STATUS_STORE_PATH`)
|
||||
|
||||
**Backend-Datenverwaltung (manage/register.go, manage/playlist.go, manage/media.go):**
|
||||
- PostgreSQL-Anbindung mit `DATABASE_URL`-Konfiguration
|
||||
- Agent-Selbstregistrierung ueber `POST /api/v1/manage/register`
|
||||
- Playlist-Verwaltung und -Abruf (`GET /api/v1/manage/playlists/...`)
|
||||
- Medien-Upload und Speicherverwaltung in `MORZ_INFOBOARD_UPLOAD_DIR`
|
||||
- Admin-UI mit HTML-Templates fuer Playlist und Medien-Management
|
||||
|
||||
**Agent-Funktionalitaeten (player/agent/):**
|
||||
- dateibasierte Agent-Konfiguration (`config.json`) zusaetzlich zu Env-Overrides
|
||||
- strukturierte Agent-Logs mit internem Health-Snapshot und signalgesteuertem Shutdown
|
||||
- erster periodischer HTTP-Status-Reporter im Agent
|
||||
- Server-Connectivity-Zustand im Agent (`unknown`, `online`, `degraded`, `offline`) auf Basis der Report-Ergebnisse
|
||||
- der HTTP-Statuspfad transportiert jetzt neben `status` auch `server_connectivity`
|
||||
- lokales Compose-Grundgeruest fuer PostgreSQL und Mosquitto
|
||||
- MQTT-Heartbeat im Agent (optional; wird uebersprungen wenn kein Broker konfiguriert)
|
||||
- periodischer HTTP-Status-Reporter fuer Server-Registrierung
|
||||
- Server-Connectivity-Zustand (`unknown`, `online`, `degraded`, `offline`)
|
||||
- HTTP-Statuspfad transportiert `status` und `server_connectivity`
|
||||
- MQTT-Heartbeat (optional; wird uebersprungen wenn kein Broker konfiguriert)
|
||||
- MQTT-Authentifizierung mit Username und Password
|
||||
- Player-UI im Agent (`/player` HTML-Kiosk, `/api/now-playing` JSON)
|
||||
- Player-UI unter `/player` (HTML-Kiosk) und `/api/now-playing` (JSON)
|
||||
- Playlist-Rotation und lokale Playlist-Verwaltung
|
||||
|
||||
**Infrastruktur und Deployment:**
|
||||
- vollstaendiger Compose-Stack (`compose/server-stack.yml`) mit PostgreSQL 17, Mosquitto, Backend-Service und Health-Checks
|
||||
- Ansible-Rollen `signage_player` und `signage_display` fuer vollstaendiges Deployment
|
||||
- journald auf volatile Storage konfiguriert (SD-Karte schonen)
|
||||
- Cross-Compile fuer ARM64 im Ansible-Playbook
|
||||
- systemd-Units fuer Agent und Kiosk-Display
|
||||
|
||||
## Arbeitsweise
|
||||
|
||||
|
|
|
|||
55
README.md
55
README.md
|
|
@ -38,15 +38,50 @@ Die Trennung von `/srv/docker/infoboard-netboot` ist sinnvoll, damit:
|
|||
|
||||
## Aktueller Implementierungsstand
|
||||
|
||||
- `server/backend/` enthaelt ein lauffaehiges Go-Grundgeruest mit erster Tool-API fuer `message_wall` und einem ersten `player/status`-Endpunkt
|
||||
- `player/agent/` enthaelt ein Go-Grundgeruest mit dateibasierter/env-basierter Konfiguration, strukturierten Logs, internem Health-Modell und erstem HTTP-Status-Reporter
|
||||
- `compose/` enthaelt ein lokales Grundgeruest fuer PostgreSQL und Mosquitto
|
||||
- `ansible/` enthaelt erste Platzhalter fuer Inventory und Playbook-Struktur
|
||||
### Backend (`server/backend/`)
|
||||
- Vollstaendiges PostgreSQL-Backend mit Datenbank-Migrations
|
||||
- Store-Layer fuer Datenverwaltung
|
||||
- REST-API mit Endpunkten:
|
||||
- `message_wall` fuer Kampagnenausgabe
|
||||
- `player/status` fuer Player-Statusverwaltung
|
||||
- `/manage/` API fuer Media-Upload, Playlist-Verwaltung und Vorlagen
|
||||
- `/meta` fuer Systemmetadaten
|
||||
- HTML-Statusseiten fuer Diagnose
|
||||
- Admin-UI mit Medien-Upload und Playlist-Management
|
||||
- Tenant-aware Architektur mit Lebenszyklusverwaltung
|
||||
|
||||
## Naechste sinnvolle Inhalte in der Struktur
|
||||
### Player-Agent (`player/agent/`)
|
||||
- Funktionaler Go-Agent mit:
|
||||
- Selbstregistrierung beim Backend
|
||||
- Playlist-Rotation mit Heartbeat
|
||||
- Dateibasierter/env-basierter Konfiguration
|
||||
- Strukturierte Logs mit journald-Integration
|
||||
- Internes Health-Modell fuer Diagnosewerte
|
||||
- HTTP-Status-Reporter mit Lebenszyklusstatus
|
||||
- MQTT-Integration (optional) mit Authentifizierung
|
||||
- Binaries fuer Linux ARM64 vorhanden
|
||||
|
||||
- `docs/` fuer weitere technische Detaildokumente
|
||||
- `server/` fuer API, Admin-UI und Tenant-UI
|
||||
- `player/` fuer `player-agent`, `player-ui` und lokale Startlogik
|
||||
- `ansible/` fuer Rollen und Inventories
|
||||
- `compose/` fuer den zentralen Server-Stack
|
||||
### Ansible-Automatisierung (`ansible/`)
|
||||
- Zwei funktionale Rollen:
|
||||
- `signage_player`: Agent-Deployment mit Systemd-Units, Tasks, Templates und Handlers
|
||||
- `signage_display`: Display-Kiosk-Setup mit Systemd-Units, Tasks, Templates und Handlers
|
||||
- Konfigurierbare Defaults und Host-Variablen
|
||||
- Unterstützung für Bildschirm-Setup mit Ansible-Anleitung
|
||||
|
||||
### Infrastruktur (`compose/`)
|
||||
- Docker Compose Setup mit:
|
||||
- PostgreSQL-Datenbank
|
||||
- Mosquitto MQTT-Broker
|
||||
- Backend-Service Integration
|
||||
|
||||
## Implementierte Features
|
||||
|
||||
- DB-Migrations und Schema-Management
|
||||
- Media-Upload und Speicherverwaltung
|
||||
- Playlist-Management und Rotation
|
||||
- Player-Registrierung und Status-Tracking
|
||||
- Admin-UI für Screensetup
|
||||
- Ansible-automatisiertes Deployment auf Raspberry Pi
|
||||
- Kiosk-Display-Modus
|
||||
- MQTT-basierte Kommunikation
|
||||
- HTML-Diagnoseseiten
|
||||
|
|
|
|||
132
TODO.md
132
TODO.md
|
|
@ -2,38 +2,38 @@
|
|||
|
||||
## Phase 0 - Projektbasis
|
||||
|
||||
- [ ] Projektverzeichnisstruktur unter `/srv/docker/info-board-neu` festlegen
|
||||
- [ ] Namenskonventionen fuer Server, Player, Rollen und Pakete definieren
|
||||
- [ ] Dokumentationsstruktur fuer Architektur, Betrieb und Deployment anlegen
|
||||
- [ ] Entscheidung fuer Server-Tech-Stack dokumentieren
|
||||
- [ ] Entscheidung fuer Player-Implementierung dokumentieren
|
||||
- [ ] Sprachentscheidung dokumentieren: `Go` als bevorzugte Sprache fuer Agent und moeglichst viele Backend-Komponenten
|
||||
- [x] Projektverzeichnisstruktur unter `/srv/docker/info-board-neu` festlegen
|
||||
- [x] Namenskonventionen fuer Server, Player, Rollen und Pakete definieren
|
||||
- [x] Dokumentationsstruktur fuer Architektur, Betrieb und Deployment anlegen
|
||||
- [x] Entscheidung fuer Server-Tech-Stack dokumentieren
|
||||
- [x] Entscheidung fuer Player-Implementierung dokumentieren
|
||||
- [x] Sprachentscheidung dokumentieren: `Go` als bevorzugte Sprache fuer Agent und moeglichst viele Backend-Komponenten
|
||||
|
||||
## Phase 1 - Fachliches Fundament
|
||||
|
||||
- [ ] Rollenmodell fuer `admin` und monitorgebundene Nutzer final festschreiben
|
||||
- [ ] Datenmodell fuer `tenant`, `screen`, `user`, `media_asset`, `playlist`, `playlist_item`, `screen_status`, `screen_snapshot` definieren
|
||||
- [ ] Playlist-Semantik mit `duration`, `valid_from`, `valid_until`, `load_timeout`, `cache_policy`, `on_error` spezifizieren
|
||||
- [ ] Fallback-Regel fuer ungeplante oder leere Inhalte verbindlich definieren
|
||||
- [ ] Statusmodell fuer Online/Offline/Degraded/Error definieren
|
||||
- [ ] Kommandokatalog fuer Admin-Aktionen finalisieren
|
||||
- [ ] Template- und Kampagnenmodell fuer globale monitoruebergreifende Uebersteuerung finalisieren
|
||||
- [ ] Prioritaetsregel `campaign > tenant_playlist > fallback` verbindlich festschreiben
|
||||
- [x] Rollenmodell fuer `admin` und monitorgebundene Nutzer final festschreiben
|
||||
- [x] Datenmodell fuer `tenant`, `screen`, `user`, `media_asset`, `playlist`, `playlist_item`, `screen_status`, `screen_snapshot` definieren
|
||||
- [x] Playlist-Semantik mit `duration`, `valid_from`, `valid_until`, `load_timeout`, `cache_policy`, `on_error` spezifizieren
|
||||
- [x] Fallback-Regel fuer ungeplante oder leere Inhalte verbindlich definieren
|
||||
- [x] Statusmodell fuer Online/Offline/Degraded/Error definieren
|
||||
- [x] Kommandokatalog fuer Admin-Aktionen finalisieren
|
||||
- [x] Template- und Kampagnenmodell fuer globale monitoruebergreifende Uebersteuerung finalisieren
|
||||
- [x] Prioritaetsregel `campaign > tenant_playlist > fallback` verbindlich festschreiben
|
||||
- [x] Entscheidung dokumentieren, dass `playlist_items.screen_id` entfernt wird
|
||||
- [x] Entscheidung dokumentieren, dass Gruppen bei Kampagnen serverseitig in Einzel-Assignments expandiert werden
|
||||
|
||||
## Phase 2 - Technische Zielarchitektur
|
||||
|
||||
- [ ] Server-Komponentenliste finalisieren
|
||||
- [ ] API-Schnittstellen grob definieren
|
||||
- [ ] MQTT-Topic-Struktur finalisieren
|
||||
- [ ] HTTPS- und MQTT-Aufgabentrennung dokumentieren
|
||||
- [ ] Screenshot-/Vorschaustrategie spezifizieren
|
||||
- [ ] Offline- und Cache-Strategie bis auf Dateiebene festlegen
|
||||
- [ ] Sicherheitsmodell fuer Uploads, Login und Rechte pruefen
|
||||
- [ ] API fuer Templates, Kampagnen, Aktivierung und Deaktivierung ausarbeiten
|
||||
- [ ] Provisionierungs-Workflow fuer neue Screens technisch durchplanen
|
||||
- [ ] Secret-Handling fuer initiale Root-Passwoerter oder Bootstrap-Zugaenge definieren
|
||||
- [x] Server-Komponentenliste finalisieren
|
||||
- [x] API-Schnittstellen grob definieren
|
||||
- [x] MQTT-Topic-Struktur finalisieren
|
||||
- [x] HTTPS- und MQTT-Aufgabentrennung dokumentieren
|
||||
- [x] Screenshot-/Vorschaustrategie spezifizieren
|
||||
- [x] Offline- und Cache-Strategie bis auf Dateiebene festlegen
|
||||
- [x] Sicherheitsmodell fuer Uploads, Login und Rechte pruefen
|
||||
- [x] API fuer Templates, Kampagnen, Aktivierung und Deaktivierung ausarbeiten
|
||||
- [x] Provisionierungs-Workflow fuer neue Screens technisch durchplanen
|
||||
- [x] Secret-Handling fuer initiale Root-Passwoerter oder Bootstrap-Zugaenge definieren
|
||||
- [x] API-Fehlermodell und gemeinsame Fehlerantworten festlegen
|
||||
- [x] ACK-Timeout-Strategie fuer `device_command` festlegen
|
||||
- [x] `message_wall`-Rendering serverseitig verbindlich entscheiden
|
||||
|
|
@ -44,50 +44,53 @@
|
|||
- [x] Minimalen Paketbedarf fuer den Player auf Raspberry Pi OS Debian 13 ermitteln
|
||||
- [x] X11-Minimalkonzept fuer Chromium-Kiosk dokumentieren
|
||||
- [x] Startmechanismus fuer Chromium ohne Desktop-Umgebung definieren
|
||||
- [ ] Verzeichnislayout auf dem Player festlegen
|
||||
- [x] Verzeichnislayout auf dem Player festlegen
|
||||
- [x] `player-agent` fachlich zuschneiden
|
||||
- [x] `player-ui` fachlich zuschneiden (lokale Kiosk-Seite mit Splash + Sysinfo-Overlay)
|
||||
- [ ] Watchdog-Konzept fuer Browser und Agent definieren
|
||||
- [ ] Offline-Overlay-Verhalten spezifizieren
|
||||
- [ ] Fehlerbehandlung fuer Web-Inhalte und Timeouts ausarbeiten
|
||||
- [ ] Display-Steuerung fuer An/Aus, Rotation und Neustart planen
|
||||
- [ ] Sysinfo-Overlay erweitern: load, freier RAM, IP-Adresse(n) anzeigen
|
||||
- [x] Offline-Overlay-Verhalten spezifizieren
|
||||
- [x] Fehlerbehandlung fuer Web-Inhalte und Timeouts ausarbeiten
|
||||
- [x] Display-Steuerung fuer An/Aus, Rotation und Neustart planen
|
||||
- [x] Sysinfo-Overlay erweitern: load, freier RAM, IP-Adresse(n) anzeigen
|
||||
|
||||
## Phase 4 - Server-Design
|
||||
|
||||
- [ ] API-Backend fachlich schneiden
|
||||
- [ ] Admin-Oberflaeche in Hauptbereiche aufteilen
|
||||
- [x] API-Backend fachlich schneiden
|
||||
- [x] Admin-Oberflaeche in Hauptbereiche aufteilen
|
||||
- [ ] Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen
|
||||
- [ ] Storage-Konzept fuer Uploads, Cache-Dateien und Screenshots festlegen
|
||||
- [ ] Authentifizierungskonzept festlegen
|
||||
- [ ] Mandantentrennung im Datenmodell und in den APIs absichern
|
||||
- [x] Storage-Konzept fuer Uploads, Cache-Dateien und Screenshots festlegen
|
||||
- [x] Authentifizierungskonzept festlegen
|
||||
- [x] Mandantentrennung im Datenmodell und in den APIs absichern
|
||||
- [ ] Logging- und Monitoring-Konzept definieren
|
||||
- [ ] Template-Editor fuer globale Kampagnen fachlich schneiden
|
||||
- [ ] Aktivierungsoberflaeche fuer saisonale oder temporäre Kampagnen planen
|
||||
- [ ] Gruppierung oder Slot-Modell fuer monitoruebergreifende Layouts planen
|
||||
- [ ] Provisionierungs-UI fuer neue Screens fachlich und technisch schneiden
|
||||
- [x] Provisionierungs-UI fuer neue Screens fachlich und technisch schneiden
|
||||
- [ ] Jobrunner-Konzept fuer Ansible-gestuetzte Erstinstallation planen
|
||||
|
||||
## Phase 5 - Prototyping
|
||||
|
||||
- [ ] Minimalen Server-Prototyp bauen
|
||||
- [ ] Minimalen Player-Agent-Prototyp bauen
|
||||
- [ ] Minimale Player-UI bauen
|
||||
- [x] Minimalen Server-Prototyp bauen
|
||||
- [x] Minimalen Player-Agent-Prototyp bauen
|
||||
- [x] Minimale Player-UI bauen
|
||||
- [ ] Lokale Test-Playlist mit Bild, Video, PDF und Webseite anlegen
|
||||
- [ ] Fallback-Verzeichnisbetrieb demonstrieren
|
||||
- [x] **BUG**: Datei `120papag.mpg` ist als `type: image` gespeichert, muss `type: video` sein – Player-UI versucht `<img>`-Laden, was fehlschlägt
|
||||
- [x] Fallback-Verzeichnisbetrieb demonstrieren
|
||||
- [ ] `valid_from`/`valid_until` im Prototyp pruefen
|
||||
- [ ] Offline-Sync mit lokalem Cache pruefen
|
||||
- [x] Offline-Sync mit lokalem Cache pruefen
|
||||
- [x] MQTT-Topic `signage/screen/{screenSlug}/playlist-changed` spezifiziert und dokumentiert
|
||||
- [ ] MQTT-Kommandos `reload`, `restart_player`, `reboot`, `display_on`, `display_off` testweise durchspielen
|
||||
- [ ] globale Kampagne testen, die tenantbezogenen Content temporär ueberschreibt
|
||||
- [ ] Rueckfall auf Normalbetrieb nach manueller Deaktivierung pruefen
|
||||
|
||||
## Phase 6 - Betriebsfaehigkeit
|
||||
|
||||
- [ ] Docker-Compose-Setup fuer den Server anlegen
|
||||
- [x] Docker-Compose-Setup fuer den Server anlegen
|
||||
- [x] systemd-Units fuer den Player erstellen
|
||||
- [x] Chromium-Kiosk-Startskript erstellen
|
||||
- [ ] Screenshot-Erzeugung auf dem Player integrieren
|
||||
- [x] Heartbeat- und Statusmeldungen integrieren
|
||||
- [x] MQTT-Playlist-Change-Synchronisation mit Backend-Debounce (2s) und Agent-Debounce (3s) implementiert
|
||||
- [ ] Fehler- und Wiederanlaufverhalten verifizieren
|
||||
|
||||
## Phase 7 - Ansible-Automatisierung
|
||||
|
|
@ -101,7 +104,7 @@
|
|||
- [x] Screen-spezifische Variablen wie `screen_id`, Rotation und Aufloesung abbilden
|
||||
- [x] Erstinstallation eines neuen Players automatisieren
|
||||
- [x] Update-Rollout eines bestehenden Players automatisieren
|
||||
- [ ] Bootstrap ueber Root-Passwort auf SSH-Key und dauerhafte Verwaltung umstellen
|
||||
- [x] Bootstrap ueber Root-Passwort auf SSH-Key und dauerhafte Verwaltung umstellen
|
||||
|
||||
## Phase 8 - Pilotbetrieb
|
||||
|
||||
|
|
@ -133,6 +136,33 @@
|
|||
- [ ] Update- und Release-Prozess festlegen
|
||||
- [ ] Langfristige Wayland-Neubewertung fuer spaetere Version vormerken
|
||||
|
||||
## UX-Verbesserungen (Gestaltungsplan)
|
||||
|
||||
### Hohe Prioritaet
|
||||
|
||||
- [x] Flash-Messages nach Aktionen in Manage-UI (Upload, Loeschen, Speichern) — Feedback fuer den Nutzer
|
||||
- [x] Screen-Online/Offline-Status in Admin-Tabelle anzeigen (aus /status-Endpoint befuellen)
|
||||
- [x] Playlist-Tabelle in overflow-x Wrapper einwickeln (Responsive auf kleinen Screens)
|
||||
- [x] PDF-Darstellung: Sidebar und Toolbar im Chromium PDF-Viewer ausblenden (URL-Parameter navpanes=0, toolbar=0)
|
||||
- [ ] PDF-Darstellung: PDF.js fuer automatisches Seitendurchblaettern integrieren
|
||||
|
||||
### Mittlere Prioritaet
|
||||
|
||||
- [x] Loesch-Bestaetigung: Bulma-Modal statt browser-nativer confirm()-Dialog
|
||||
- [x] Status-Page: Sprache von Englisch auf Deutsch vereinheitlichen
|
||||
- [x] Status-Page: Relative Zeitstempel statt RFC3339 ("vor 2 Minuten")
|
||||
- [x] Querlinks zwischen Admin-UI und Status-Page (Navigation)
|
||||
- [x] Bulma und SortableJS als lokale Assets einbetten statt CDN
|
||||
- [x] Player-UI: CSS-Transitions fuer sanfte Content-Wechsel (Fade statt abrupt)
|
||||
- [x] Player-UI: Erweitertes Sysinfo-Overlay (aktueller Titel, Playlist-Laenge)
|
||||
- [x] Aria-Labels fuer Loesch-Buttons und Drag-Handles (Accessibility)
|
||||
|
||||
### Niedrige Prioritaet
|
||||
|
||||
- [x] Upload-Fortschrittsbalken in Manage-UI
|
||||
- [x] vars.yml Download-Button in Provision-UI statt Copy-Paste
|
||||
- [x] Toggle-Switch statt Ja/Nein-Select fuer Enabled-Feld
|
||||
|
||||
## Querschnittsthemen
|
||||
|
||||
- [ ] Datensicherung fuer Datenbank und Medien einplanen
|
||||
|
|
@ -144,13 +174,13 @@
|
|||
|
||||
## Erste konkrete Abarbeitungsreihenfolge
|
||||
|
||||
- [ ] 1. Projektstruktur im neuen Verzeichnis vervollstaendigen
|
||||
- [ ] 2. Datenmodell in eigener Datei ausformulieren
|
||||
- [ ] 3. API- und MQTT-Vertrag definieren
|
||||
- [ ] 4. Player-Minimalkonzept fuer Raspberry Pi OS Debian 13 festzurren
|
||||
- [ ] 5. Server-Compose-Grundgeruest erstellen
|
||||
- [ ] 6. Player-Prototyp mit lokalem Browser-Renderer bauen
|
||||
- [ ] 7. Offline-Cache und Fallback robust machen
|
||||
- [ ] 8. UIs fuer Admin und Firmen schrittweise aufbauen
|
||||
- [ ] 9. Ansible-Rollen erstellen
|
||||
- [x] 1. Projektstruktur im neuen Verzeichnis vervollstaendigen
|
||||
- [x] 2. Datenmodell in eigener Datei ausformulieren
|
||||
- [x] 3. API- und MQTT-Vertrag definieren
|
||||
- [x] 4. Player-Minimalkonzept fuer Raspberry Pi OS Debian 13 festzurren
|
||||
- [x] 5. Server-Compose-Grundgeruest erstellen
|
||||
- [x] 6. Player-Prototyp mit lokalem Browser-Renderer bauen
|
||||
- [x] 7. Offline-Cache und Fallback robust machen
|
||||
- [x] 8. UIs fuer Admin und Firmen schrittweise aufbauen
|
||||
- [x] 9. Ansible-Rollen erstellen
|
||||
- [ ] 10. Pilotmonitor migrieren
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ ansible_user: admin
|
|||
screen_id: info01-dev
|
||||
screen_name: "Info01 Entwicklung"
|
||||
screen_orientation: landscape
|
||||
morz_server_base_url: "http://192.168.64.1:8080"
|
||||
|
|
|
|||
|
|
@ -32,11 +32,14 @@ services:
|
|||
MORZ_INFOBOARD_HTTP_ADDR: ":8080"
|
||||
MORZ_INFOBOARD_DATABASE_URL: "postgres://morz_infoboard:morz_infoboard@postgres:5432/morz_infoboard?sslmode=disable"
|
||||
MORZ_INFOBOARD_UPLOAD_DIR: "/uploads"
|
||||
MORZ_INFOBOARD_MQTT_BROKER: "tcp://mosquitto:1883"
|
||||
volumes:
|
||||
- uploads:/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
mosquitto:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
|
|
|||
836
docs/API-ENDPOINTS.md
Normal file
836
docs/API-ENDPOINTS.md
Normal file
|
|
@ -0,0 +1,836 @@
|
|||
# Info-Board Neu - API-Endpoints Vollständig
|
||||
|
||||
## Überblick
|
||||
|
||||
Die Backend-API unterteilt sich in mehrere Bereiche:
|
||||
|
||||
- **Health & Meta**: System-Status und API-Informationen
|
||||
- **Player Status**: Status-Ingest und Diagnose vom Player
|
||||
- **Screen Management**: CRUD und Registrierung von Screens
|
||||
- **Playlists**: Abruf und Verwaltung von Wiedergabelisten
|
||||
- **Media**: Upload und Verwaltung von Medien-Assets
|
||||
- **Message Wall**: Auflösung von Nachrichten-Wand-Anfragen
|
||||
- **Admin & UI**: Web-Formulare und Provisionierung
|
||||
- **Provisioning**: Erstinstallation neuer Screens (geplant)
|
||||
|
||||
---
|
||||
|
||||
## Health & Meta
|
||||
|
||||
### GET /healthz
|
||||
|
||||
Health-Check für Monitoring.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "morz-infoboard-backend"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1
|
||||
|
||||
API-Entrypoint mit Tools-Übersicht.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"name": "morz-infoboard-backend",
|
||||
"version": "dev",
|
||||
"tools": [
|
||||
"message-wall-resolve",
|
||||
"screen-status-list",
|
||||
"screen-status-detail",
|
||||
"player-status-ingest",
|
||||
"screen-status-delete"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/meta
|
||||
|
||||
Zusätzliche Metainformationen (noch nicht spezifiziert).
|
||||
|
||||
---
|
||||
|
||||
## Player Status (Diagnose)
|
||||
|
||||
Siehe separate Dokumentation in `PLAYER-STATUS-HTTP.md`.
|
||||
|
||||
Endpoints:
|
||||
- `POST /api/v1/player/status` — Status-Ingest vom Player-Agent
|
||||
- `GET /api/v1/screens/status` — Übersicht aller Screen-Status
|
||||
- `GET /api/v1/screens/{screenId}/status` — Einzelner Screen-Status
|
||||
- `DELETE /api/v1/screens/{screenId}/status` — Status löschen
|
||||
|
||||
### POST /api/v1/player/status
|
||||
|
||||
Der Player-Agent sendet seinen aktuellen Status an den Server.
|
||||
|
||||
**Request-Body:**
|
||||
```json
|
||||
{
|
||||
"screen_id": "info01-dev",
|
||||
"ts": "2026-03-22T16:00:00Z",
|
||||
"status": "running",
|
||||
"server_connectivity": "online",
|
||||
"server_url": "http://127.0.0.1:8080",
|
||||
"mqtt_broker": "tcp://127.0.0.1:1883",
|
||||
"heartbeat_every_seconds": 30,
|
||||
"started_at": "2026-03-22T15:59:30Z",
|
||||
"last_heartbeat_at": "2026-03-22T16:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "status": "accepted" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen Management (JSON API)
|
||||
|
||||
### POST /api/v1/screens/register
|
||||
|
||||
Agent-Selbstregistrierung — wird vom Player-Agent beim Hochfahren aufgerufen.
|
||||
|
||||
Der Agent upsert den Screen automatisch im Default-Tenant ("morz"), so dass alle deployt Screen automatisch im Admin-UI erscheinen.
|
||||
|
||||
**Request-Body:**
|
||||
```json
|
||||
{
|
||||
"slug": "info10",
|
||||
"name": "Info10 Bildschirm",
|
||||
"orientation": "landscape"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid...",
|
||||
"tenant_id": "uuid...",
|
||||
"slug": "info10",
|
||||
"name": "Info10 Bildschirm",
|
||||
"orientation": "landscape",
|
||||
"created_at": "2026-03-22T16:00:00Z",
|
||||
"updated_at": "2026-03-22T16:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `200 OK` — Screen wurde erzeugt oder aktualisiert (upsert)
|
||||
- `400 Bad Request` — Slug fehlt oder ungültig
|
||||
- `500 Internal Server Error` — DB-Fehler oder Default-Tenant nicht vorhanden
|
||||
|
||||
---
|
||||
|
||||
### GET /api/v1/tenants/{tenantSlug}/screens
|
||||
|
||||
Listet alle Screens eines Tenants auf.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid...",
|
||||
"tenant_id": "uuid...",
|
||||
"slug": "info10",
|
||||
"name": "Info10 Bildschirm",
|
||||
"orientation": "landscape",
|
||||
"created_at": "2026-03-22T16:00:00Z",
|
||||
"updated_at": "2026-03-22T16:00:00Z"
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `200 OK` — Liste erfolgreich abrufen
|
||||
- `404 Not Found` — Tenant nicht vorhanden
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
---
|
||||
|
||||
### POST /api/v1/tenants/{tenantSlug}/screens
|
||||
|
||||
Erstellt einen neuen Screen für einen Tenant (Admin-API).
|
||||
|
||||
**Request-Body:**
|
||||
```json
|
||||
{
|
||||
"slug": "new-screen",
|
||||
"name": "New Display",
|
||||
"orientation": "portrait"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid...",
|
||||
"tenant_id": "uuid...",
|
||||
"slug": "new-screen",
|
||||
"name": "New Display",
|
||||
"orientation": "portrait",
|
||||
"created_at": "2026-03-22T16:00:00Z",
|
||||
"updated_at": "2026-03-22T16:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `201 Created` — Screen erstellt
|
||||
- `400 Bad Request` — Slug oder Name fehlt
|
||||
- `404 Not Found` — Tenant nicht vorhanden
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
---
|
||||
|
||||
## Playlist Management (JSON API)
|
||||
|
||||
### GET /api/v1/screens/{screenId}/playlist
|
||||
|
||||
Abruf der aktiven Playlist für einen Screen (Player-Sync).
|
||||
|
||||
Der Player ruft diesen Endpoint auf, um die aktuellen Inhalte zu laden.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"playlist_id": "uuid...",
|
||||
"default_duration_seconds": 20,
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid...",
|
||||
"type": "web",
|
||||
"src": "http://example.com/page1",
|
||||
"title": "Startseite",
|
||||
"duration_seconds": 30,
|
||||
"enabled": true,
|
||||
"valid_from": null,
|
||||
"valid_until": null
|
||||
},
|
||||
{
|
||||
"id": "uuid...",
|
||||
"type": "image",
|
||||
"src": "/uploads/banner.jpg",
|
||||
"title": "Werbebanner",
|
||||
"duration_seconds": 20,
|
||||
"enabled": true,
|
||||
"valid_from": null,
|
||||
"valid_until": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Wenn keine Playlist vorhanden ist, wird eine leere Liste zurückgegeben:
|
||||
```json
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `200 OK` — Playlist abrufen
|
||||
- `404 Not Found` — Screen nicht vorhanden
|
||||
|
||||
---
|
||||
|
||||
### GET /api/v1/playlists/{screenId}
|
||||
|
||||
Abrufen einer kompletten Playlist mit Metadaten.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"playlist": {
|
||||
"id": "uuid...",
|
||||
"screen_id": "uuid...",
|
||||
"tenant_id": "uuid...",
|
||||
"name": "Hauptplaylist",
|
||||
"is_active": true,
|
||||
"default_duration_seconds": 20,
|
||||
"fallback_enabled": true,
|
||||
"fallback_dir": "/fallback",
|
||||
"shuffle_enabled": false,
|
||||
"created_at": "2026-03-22T16:00:00Z",
|
||||
"updated_at": "2026-03-22T16:00:00Z"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid...",
|
||||
"playlist_id": "uuid...",
|
||||
"type": "web",
|
||||
"src": "http://example.com",
|
||||
"title": "Example",
|
||||
"duration_seconds": 20,
|
||||
"enabled": true,
|
||||
"created_at": "2026-03-22T16:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `200 OK` — Erfolgreich abrufen
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
---
|
||||
|
||||
### POST /api/v1/playlists/{playlistId}/items
|
||||
|
||||
Fügt ein Item zu einer Playlist hinzu.
|
||||
|
||||
**Request-Body (Optionen A: Aus Media-Library):**
|
||||
```json
|
||||
{
|
||||
"media_asset_id": "uuid...",
|
||||
"title": "Optional überschriebener Titel"
|
||||
}
|
||||
```
|
||||
|
||||
**Request-Body (Optionen B: Direkte URL):**
|
||||
```json
|
||||
{
|
||||
"type": "web",
|
||||
"src": "http://example.com/page",
|
||||
"title": "Example Page",
|
||||
"duration_seconds": 30,
|
||||
"valid_from": "2026-03-22T09:00:00Z",
|
||||
"valid_until": "2026-03-22T17:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid...",
|
||||
"playlist_id": "uuid...",
|
||||
"type": "web",
|
||||
"src": "http://example.com/page",
|
||||
"title": "Example Page",
|
||||
"duration_seconds": 30,
|
||||
"enabled": true,
|
||||
"valid_from": "2026-03-22T09:00:00Z",
|
||||
"valid_until": "2026-03-22T17:00:00Z",
|
||||
"created_at": "2026-03-22T16:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `201 Created` — Item erstellt
|
||||
- `400 Bad Request` — Type oder Src fehlt; Media-Asset nicht vorhanden
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/v1/items/{itemId}
|
||||
|
||||
Aktualisiert ein Playlist-Item (Titel, Dauer, Zeitfenster, aktiviert).
|
||||
|
||||
**Request-Body:**
|
||||
```json
|
||||
{
|
||||
"title": "Neuer Titel",
|
||||
"duration_seconds": 25,
|
||||
"enabled": true,
|
||||
"valid_from": "2026-03-22T09:00:00Z",
|
||||
"valid_until": "2026-03-22T17:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `204 No Content` — Erfolgreich aktualisiert
|
||||
- `400 Bad Request` — Ungültige Dauer
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/v1/items/{itemId}
|
||||
|
||||
Löscht ein Playlist-Item.
|
||||
|
||||
**Status:**
|
||||
- `204 No Content` — Erfolgreich gelöscht
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
---
|
||||
|
||||
### PUT /api/v1/playlists/{playlistId}/order
|
||||
|
||||
Reordnet die Items einer Playlist anhand einer geordneten Liste von Item-IDs.
|
||||
|
||||
**Request-Body:**
|
||||
```json
|
||||
[
|
||||
"item-id-1",
|
||||
"item-id-2",
|
||||
"item-id-3"
|
||||
]
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `204 No Content` — Erfolgreich reordert
|
||||
- `400 Bad Request` — JSON-Array erwartet
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/v1/playlists/{playlistId}/duration
|
||||
|
||||
Setzt die Standard-Dauer für neue Items einer Playlist.
|
||||
|
||||
**Request-Body (Form-Encoded):**
|
||||
```
|
||||
default_duration_seconds=25
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `204 No Content` — Erfolgreich aktualisiert
|
||||
- `400 Bad Request` — Ungültige oder fehlende Dauer
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
---
|
||||
|
||||
## Media Management (JSON API)
|
||||
|
||||
### GET /api/v1/tenants/{tenantSlug}/media
|
||||
|
||||
Listet alle Medien-Assets eines Tenants auf.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid...",
|
||||
"tenant_id": "uuid...",
|
||||
"title": "Banner Image",
|
||||
"description": null,
|
||||
"type": "image",
|
||||
"source_kind": "upload",
|
||||
"storage_path": "/uploads/1234567890_banner.jpg",
|
||||
"original_url": null,
|
||||
"mime_type": "image/jpeg",
|
||||
"size_bytes": 102400,
|
||||
"enabled": true,
|
||||
"created_at": "2026-03-22T16:00:00Z",
|
||||
"updated_at": "2026-03-22T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "uuid...",
|
||||
"tenant_id": "uuid...",
|
||||
"title": "External Website",
|
||||
"type": "web",
|
||||
"source_kind": "remote_url",
|
||||
"original_url": "http://example.com",
|
||||
"storage_path": null,
|
||||
"enabled": true,
|
||||
"created_at": "2026-03-22T16:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Wenn keine Assets vorhanden sind, wird eine leere Liste zurückgegeben.
|
||||
|
||||
**Status:**
|
||||
- `200 OK` — Liste erfolgreich abrufen
|
||||
- `404 Not Found` — Tenant nicht vorhanden
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
---
|
||||
|
||||
### POST /api/v1/tenants/{tenantSlug}/media
|
||||
|
||||
Registriert ein neues Medien-Asset (Datei-Upload oder externe URL).
|
||||
|
||||
**Request-Typ A: Datei-Upload (Multipart)**
|
||||
```
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
type: image (oder video, pdf)
|
||||
title: Mein Bild
|
||||
file: <binary data>
|
||||
```
|
||||
|
||||
**Request-Typ B: Externe URL (Multipart)**
|
||||
```
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
type: web
|
||||
title: Externe Website
|
||||
url: http://example.com
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid...",
|
||||
"tenant_id": "uuid...",
|
||||
"title": "Mein Bild",
|
||||
"type": "image",
|
||||
"source_kind": "upload",
|
||||
"storage_path": "/uploads/1234567890_mein_bild.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"size_bytes": 102400,
|
||||
"enabled": true,
|
||||
"created_at": "2026-03-22T16:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `201 Created` — Asset erstellt
|
||||
- `400 Bad Request` — Ungültiger Type, fehlende Datei/URL, oder Request zu groß (>512 MB)
|
||||
- `404 Not Found` — Tenant nicht vorhanden
|
||||
- `500 Internal Server Error` — Datei-/DB-Fehler
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/v1/media/{id}
|
||||
|
||||
Löscht ein Medien-Asset (und physische Datei falls lokal gespeichert).
|
||||
|
||||
**Status:**
|
||||
- `204 No Content` — Erfolgreich gelöscht
|
||||
- `404 Not Found` — Asset nicht vorhanden
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
---
|
||||
|
||||
## Message Wall
|
||||
|
||||
### POST /api/v1/tools/message-wall/resolve
|
||||
|
||||
Spezialendpoint zur Auflösung von Nachrichten-Wand-Anfragen (noch in Entwicklung).
|
||||
|
||||
---
|
||||
|
||||
## Admin UI (Web-Formulare)
|
||||
|
||||
### GET /admin
|
||||
|
||||
Administrations-Dashboard mit Übersicht aller Screens und Tenants.
|
||||
|
||||
Rückgabe: HTML-Seite mit:
|
||||
- Liste aller Screens
|
||||
- Status-Information
|
||||
- Provisioning-Formulare
|
||||
- Screen-Verwaltung
|
||||
|
||||
---
|
||||
|
||||
### POST /admin/screens/provision
|
||||
|
||||
Startet einen Provisionierungs-Job für einen neuen oder bestehenden Screen.
|
||||
|
||||
**Request-Body (Form-Encoded oder JSON):**
|
||||
```json
|
||||
{
|
||||
"screen_id": "uuid...",
|
||||
"target_ip": "192.168.1.100",
|
||||
"target_port": 22,
|
||||
"remote_user": "root",
|
||||
"auth_mode": "password",
|
||||
"provided_secret_ref": "secret-key-123"
|
||||
}
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `200 OK` — Job erfolgreich erstellt
|
||||
- `400 Bad Request` — Fehlende oder ungültige Parameter
|
||||
- `404 Not Found` — Screen nicht vorhanden
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
**Hinweis:** Der eigentliche Provisionierungs-Job läuft asynchron über einen Worker ab.
|
||||
|
||||
---
|
||||
|
||||
### POST /admin/screens
|
||||
|
||||
Erstellt einen neuen Screen über das Admin-Formular.
|
||||
|
||||
**Request-Body (Form-Encoded):**
|
||||
```
|
||||
slug=new-screen&name=Neuer+Bildschirm&orientation=landscape
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `200 OK` oder `201 Created` — Screen erstellt
|
||||
- `400 Bad Request` — Fehlende Parameter
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
Rückleitung zur Admin-Seite.
|
||||
|
||||
---
|
||||
|
||||
### POST /admin/screens/{screenId}/delete
|
||||
|
||||
Löscht einen Screen.
|
||||
|
||||
**Status:**
|
||||
- `200 OK` — Screen gelöscht
|
||||
- `404 Not Found` — Screen nicht vorhanden
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
Rückleitung zur Admin-Seite.
|
||||
|
||||
---
|
||||
|
||||
## Playlist Management UI (Web-Formulare)
|
||||
|
||||
### GET /manage/{screenSlug}
|
||||
|
||||
Verwaltungs-UI für die Playlist eines Screens.
|
||||
|
||||
Rückgabe: HTML-Seite mit:
|
||||
- Liste der Medien-Assets
|
||||
- Playlist-Editor
|
||||
- Upload-Formular
|
||||
- Item-Verwaltung
|
||||
|
||||
---
|
||||
|
||||
### POST /manage/{screenSlug}/upload
|
||||
|
||||
Datei-Upload über das Manage-Formular.
|
||||
|
||||
**Request (Multipart):**
|
||||
```
|
||||
type: image (oder video, pdf)
|
||||
title: Neues Bild
|
||||
file: <binary data>
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `201 Created` — Asset erfolgreich hochgeladen
|
||||
- `404 Not Found` — Screen nicht vorhanden
|
||||
- `500 Internal Server Error` — Fehler
|
||||
|
||||
Rückleitung zum Manage-Formular.
|
||||
|
||||
---
|
||||
|
||||
### POST /manage/{screenSlug}/items
|
||||
|
||||
Fügt ein Item zur Playlist hinzu (Formular).
|
||||
|
||||
**Request (Form-Encoded):**
|
||||
```
|
||||
media_asset_id=uuid...&duration_seconds=25
|
||||
oder
|
||||
type=web&src=http://example.com&duration_seconds=30
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `201 Created` — Item erstellt
|
||||
- `404 Not Found` — Screen nicht vorhanden
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
Rückleitung zum Manage-Formular.
|
||||
|
||||
---
|
||||
|
||||
### POST /manage/{screenSlug}/items/{itemId}
|
||||
|
||||
Aktualisiert ein Item (Formular).
|
||||
|
||||
**Request (Form-Encoded):**
|
||||
```
|
||||
title=Neuer+Titel&duration_seconds=25&enabled=on
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `200 OK` oder `204 No Content` — Erfolgreich aktualisiert
|
||||
- `404 Not Found` — Item nicht vorhanden
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
Rückleitung zum Manage-Formular.
|
||||
|
||||
---
|
||||
|
||||
### POST /manage/{screenSlug}/items/{itemId}/delete
|
||||
|
||||
Löscht ein Item (Formular).
|
||||
|
||||
**Status:**
|
||||
- `204 No Content` — Erfolgreich gelöscht
|
||||
- `404 Not Found` — Item nicht vorhanden
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
Rückleitung zum Manage-Formular.
|
||||
|
||||
---
|
||||
|
||||
### POST /manage/{screenSlug}/reorder
|
||||
|
||||
Reordert Items (Formular).
|
||||
|
||||
**Request (Form-Encoded mit Array-Syntax):**
|
||||
```
|
||||
items=item-id-1&items=item-id-2&items=item-id-3
|
||||
```
|
||||
|
||||
**Status:**
|
||||
- `204 No Content` — Erfolgreich reordert
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
Rückleitung zum Manage-Formular.
|
||||
|
||||
---
|
||||
|
||||
### POST /manage/{screenSlug}/media/{mediaId}/delete
|
||||
|
||||
Löscht ein Medien-Asset (Formular).
|
||||
|
||||
**Status:**
|
||||
- `204 No Content` — Erfolgreich gelöscht
|
||||
- `404 Not Found` — Asset nicht vorhanden
|
||||
- `500 Internal Server Error` — DB-Fehler
|
||||
|
||||
Rückleitung zum Manage-Formular.
|
||||
|
||||
---
|
||||
|
||||
## Datei-Serving
|
||||
|
||||
### GET /uploads/{filename}
|
||||
|
||||
Stellt hochgeladene Medien-Dateien bereit.
|
||||
|
||||
**Query-Parameter:**
|
||||
- Keine
|
||||
|
||||
**Status:**
|
||||
- `200 OK` — Datei gefunden
|
||||
- `404 Not Found` — Datei nicht vorhanden
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic Pages (HTML)
|
||||
|
||||
### GET /status
|
||||
|
||||
HTML-Diagnoseseite für den Browser mit Übersicht aller Screen-Status.
|
||||
|
||||
- Automatisches Refresh alle 15 Sekunden
|
||||
- Shortcut-Links für Filter
|
||||
- Filterformular (wie JSON-Read-Pfad)
|
||||
- Direkte JSON-Detail-Links pro Screen
|
||||
|
||||
**Query-Parameter:** Dieselben wie `GET /api/v1/screens/status`
|
||||
- `q` — Screen-ID Substring-Suche
|
||||
- `derived_state` — `online`, `degraded`, `offline`
|
||||
- `server_connectivity` — `online`, `degraded`, `offline`, `unknown`
|
||||
- `stale` — `true`, `false`
|
||||
- `updated_since` — RFC3339-Zeitstempel
|
||||
- `limit` — positive Ganzzahl
|
||||
|
||||
---
|
||||
|
||||
### GET /status/{screenId}
|
||||
|
||||
HTML-Detailseite für einen einzelnen Screen.
|
||||
|
||||
Zeigt:
|
||||
- Screen-Information
|
||||
- Derived State
|
||||
- Player Status
|
||||
- Connectivity
|
||||
- Freshness und Timestamps
|
||||
- Endpunkte und Links
|
||||
|
||||
---
|
||||
|
||||
## Player Local UI (Agent)
|
||||
|
||||
### GET /player
|
||||
|
||||
Zeigt die lokale Player-UI (HTML) auf dem Gerät.
|
||||
|
||||
Rückgabe: HTML-Seite mit:
|
||||
- Splash-Screen
|
||||
- Systeminformationen-Overlay
|
||||
- Verbindungsstatus-Punkt
|
||||
- Basis für Playlist-Anzeige
|
||||
|
||||
---
|
||||
|
||||
### GET /api/now-playing
|
||||
|
||||
JSON-API des Player-Agents (lokal).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"playlist": [
|
||||
{
|
||||
"src": "http://backend.local/api/v1/screens/info10/playlist",
|
||||
"type": "web",
|
||||
"title": "Startseite",
|
||||
"duration_seconds": 30
|
||||
}
|
||||
],
|
||||
"status": "running",
|
||||
"connectivity": "online"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/sysinfo
|
||||
|
||||
Systeminformationen des Player-Agents (lokal).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"label": "Hostname",
|
||||
"value": "infoboard-01"
|
||||
},
|
||||
{
|
||||
"label": "Uptime",
|
||||
"value": "5d 2h 30m"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
Alle JSON-Responses folgen diesem Fehlermodell (falls implementiert):
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "error_code_here",
|
||||
"message": "Human-readable error message",
|
||||
"details": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Typische HTTP-Status:
|
||||
- `200 OK` — Erfolgreiche Anfrage (Read)
|
||||
- `201 Created` — Ressource erstellt
|
||||
- `204 No Content` — Erfolgreiche Anfrage ohne Response-Body
|
||||
- `400 Bad Request` — Eingabe-Validierung fehlgeschlagen
|
||||
- `404 Not Found` — Ressource nicht vorhanden
|
||||
- `500 Internal Server Error` — Server-Fehler
|
||||
|
||||
---
|
||||
|
||||
## Änderungshistorie
|
||||
|
||||
- **2026-03-23:** Initiale Dokumentation aller HTTP-Endpoints basierend auf Code-Review
|
||||
- Alle Screen-Management-Endpoints dokumentiert
|
||||
- Alle Playlist-Management-Endpoints dokumentiert
|
||||
- Alle Media-Management-Endpoints dokumentiert
|
||||
- Admin-UI und Manage-UI-Endpoints dokumentiert
|
||||
- Player-Status-Diagnostik dokumentiert
|
||||
- Player-Local-API dokumentiert
|
||||
165
docs/API-INDEX.md
Normal file
165
docs/API-INDEX.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# API Documentation Index
|
||||
|
||||
Vollständige Übersicht der Dokumentation für die morz-infoboard Backend-API.
|
||||
|
||||
---
|
||||
|
||||
## Einstieg
|
||||
|
||||
**Für Schnellzugriff auf spezifische Endpoints:**
|
||||
→ **[API Quick Reference](API-QUICK-REFERENCE.md)**
|
||||
|
||||
Sortiert nach Funktion mit Kurzbeschreibungen und typischen Workflows.
|
||||
|
||||
---
|
||||
|
||||
## Vollständige Dokumentation
|
||||
|
||||
### [API-ENDPOINTS.md](API-ENDPOINTS.md)
|
||||
|
||||
Detaillierte Dokumentation aller HTTP-Endpoints mit:
|
||||
- Request/Response-Beispielen
|
||||
- Query-Parametern und Validierung
|
||||
- HTTP-Status-Codes
|
||||
- Fehlerehandlung
|
||||
|
||||
**Bereiche:**
|
||||
- Health & Meta
|
||||
- Player Status (Diagnose)
|
||||
- Screen Management (JSON API)
|
||||
- Playlist Management (JSON API)
|
||||
- Media Management (JSON API)
|
||||
- Admin UI (Web-Formulare)
|
||||
- Playlist Management UI (Web-Formulare)
|
||||
- Player Local UI (Agent)
|
||||
|
||||
### [PLAYER-STATUS-HTTP.md](PLAYER-STATUS-HTTP.md)
|
||||
|
||||
Spezialisierte Dokumentation für Status-Reporting:
|
||||
- Status-Ingest vom Player-Agent
|
||||
- Status-Abruf und Filterung
|
||||
- Serverseitig abgeleitete Felder (stale, derived_state)
|
||||
- Persistenz-Modell
|
||||
- Agentseitige Connectivity-Ableitung
|
||||
|
||||
---
|
||||
|
||||
## Konzeptdokumentation
|
||||
|
||||
### [SERVER-KONZEPT.md](SERVER-KONZEPT.md)
|
||||
|
||||
Architektur und fachliche Bereiche:
|
||||
- Server-Aufgaben und Komponenten
|
||||
- Mandanten und Benutzer
|
||||
- Screen-, Medien-, Playlist-Verwaltung
|
||||
- Templates und Kampagnen
|
||||
- Provisionierung
|
||||
- Revisionsmodell
|
||||
- Docker-Compose-Struktur
|
||||
|
||||
### [SCHEMA.md](SCHEMA.md)
|
||||
|
||||
Relationales Datenmodell mit:
|
||||
- Tabellen-Definitionen (SQL)
|
||||
- Primary/Foreign Keys
|
||||
- Constraints und Indizes
|
||||
- Prioritätslogik
|
||||
- Zukunftserweiterungen
|
||||
|
||||
---
|
||||
|
||||
## Implementierungsdokumentation
|
||||
|
||||
### Code-Struktur
|
||||
|
||||
Backend-API ist in Go implementiert:
|
||||
```
|
||||
server/backend/internal/httpapi/
|
||||
├── router.go — Haupt-Router, Route-Registrierung
|
||||
├── playerstatus.go — Status-Endpoints
|
||||
├── statuspage.go — HTML-Diagnoseseiten
|
||||
├── messagewall.go — Message-Wall-Auflösung
|
||||
├── manage/
|
||||
│ ├── register.go — Screen-Registrierung
|
||||
│ ├── playlist.go — Playlist-Management
|
||||
│ ├── media.go — Media-Management
|
||||
│ └── ui.go — Admin- & Manage-UI
|
||||
```
|
||||
|
||||
Player-Agent hat lokale UI:
|
||||
```
|
||||
player/agent/internal/playerserver/
|
||||
└── server.go — Lokale Player-UI und Status-APIs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Typische Anfragen
|
||||
|
||||
**Wie registriert sich ein Agent automatisch?**
|
||||
→ Siehe [API-ENDPOINTS.md](API-ENDPOINTS.md) → Screen Management → `POST /api/v1/screens/register`
|
||||
|
||||
**Wie ruft der Player seine Inhalte ab?**
|
||||
→ Siehe [API-ENDPOINTS.md](API-ENDPOINTS.md) → Playlist Management → `GET /api/v1/screens/{screenId}/playlist`
|
||||
|
||||
**Welche Status-Filteroptionen gibt es?**
|
||||
→ Siehe [PLAYER-STATUS-HTTP.md](PLAYER-STATUS-HTTP.md) → Query-Parameter
|
||||
|
||||
**Wie lautet das Datenbankschema?**
|
||||
→ Siehe [SCHEMA.md](SCHEMA.md)
|
||||
|
||||
**Wie läuft die Provisionierung ab?**
|
||||
→ Siehe [SERVER-KONZEPT.md](SERVER-KONZEPT.md) → Provisionierung
|
||||
|
||||
---
|
||||
|
||||
## Änderungshistorie der Dokumentation
|
||||
|
||||
| Datum | Datei | Änderung |
|
||||
|-------|-------|----------|
|
||||
| 2026-03-23 | API-ENDPOINTS.md | Initiale vollständige Endpoint-Dokumentation erstellt |
|
||||
| 2026-03-23 | API-QUICK-REFERENCE.md | Schnellreferenz mit Workflows hinzugefügt |
|
||||
| 2026-03-23 | API-INDEX.md | Dokumentations-Index erstellt |
|
||||
| 2026-03-23 | PLAYER-STATUS-HTTP.md | Link zur vollständigen API-Dokumentation hinzugefügt |
|
||||
|
||||
---
|
||||
|
||||
## Fehlende oder unvollständige Bereiche
|
||||
|
||||
- **MQTT-Integration:** Noch keine dedizierte Dokumentation für MQTT-Topics und Protokolle
|
||||
- **Authentifizierung & Autorisierung:** Noch nicht implementiert (v1)
|
||||
- **Rate Limiting:** Noch nicht spezifiziert
|
||||
- **Transaktions-Semantik:** Noch nicht dokumentiert
|
||||
- **Webhook/Event-System:** Geplant für spätere Phase
|
||||
- **Provisioning Worker Details:** Noch nicht öffentlich dokumentiert
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Für Agent-Implementierung
|
||||
|
||||
1. **Registrierung:** Rufe `POST /api/v1/screens/register` auf dem Startup auf
|
||||
2. **Playlist-Polling:** Hole alle 30 Sekunden `GET /api/v1/screens/{screenId}/playlist` ab
|
||||
3. **Status-Reporting:** Sende alle 30 Sekunden `POST /api/v1/player/status` ab
|
||||
4. **Fehlerbehandlung:** HTTP-Fehler sollten zu lokaler Fallback-Playlist führen
|
||||
|
||||
### Für Admin-Panel
|
||||
|
||||
1. **Status-Überwachung:** Nutze `GET /status` (HTML) oder `GET /api/v1/screens/status` (JSON)
|
||||
2. **Batch-Operations:** Für Multisel-Operationen empfohlen: Mehrere API-Requests statt Formulare
|
||||
3. **Filterung:** Nutze Query-Parameter für `GET /api/v1/screens/status` zur Filterung
|
||||
|
||||
### Für Tenant-UI
|
||||
|
||||
1. **Medien-Verwaltung:** Nutze Multipart-Upload in `POST /api/v1/tenants/{tenantSlug}/media`
|
||||
2. **Playlist-Bearbeitung:** Nutze JSON-API (`/api/v1/playlists/*`) für programmatische Zugriffe
|
||||
3. **Validierung:** Prüfe gültige `duration_seconds` (> 0) und Medientypen
|
||||
|
||||
---
|
||||
|
||||
## Kontakt & Support
|
||||
|
||||
Für Fragen zur API-Dokumentation:
|
||||
- Siehe [OFFENE-ARCHITEKTURFRAGEN.md](OFFENE-ARCHITEKTURFRAGEN.md)
|
||||
- Code-Review in `/server/backend/internal/httpapi/`
|
||||
136
docs/API-MQTT-VERTRAG.md
Normal file
136
docs/API-MQTT-VERTRAG.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# Info-Board Neu - MQTT-Vertrag
|
||||
|
||||
Vertrag zwischen Backend und Agent für die Echtzeit-Synchronisation von Playlist-Änderungen und Gerätebefehlen.
|
||||
|
||||
---
|
||||
|
||||
## Überblick
|
||||
|
||||
Das Messaging-System nutzt MQTT für:
|
||||
|
||||
- **Playlist-Mutations-Benachrichtigungen**: Backend → Agent
|
||||
- **Device-Commands**: Backend → Agent (zukünftig: reload, restart_player, reboot, display_on/off)
|
||||
- **Heartbeat & Status**: Agent → Backend (siehe `PLAYER-STATUS-HTTP.md`)
|
||||
|
||||
Alle Topics folgen dem Naming-Pattern: `signage/{component}/{screenSlug}/{event}`
|
||||
|
||||
---
|
||||
|
||||
## Topics
|
||||
|
||||
### Backend publishes
|
||||
|
||||
#### `signage/screen/{screenSlug}/playlist-changed`
|
||||
|
||||
**Publisher:** Backend
|
||||
**Subscriber:** Agent
|
||||
|
||||
Wird nach jeder Mutation der Playlist gepublished (Add, Remove, Reorder, Enable/Disable Item).
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"ts": 1711268440000
|
||||
}
|
||||
```
|
||||
|
||||
- `ts`: Unix-Zeitstempel in Millisekunden des Änderungsereignisses auf dem Backend
|
||||
|
||||
**Verhalten:**
|
||||
- Backend debounced Änderungen über **2 Sekunden**
|
||||
- Mehrere schnelle Mutationen werden zu einem Event zusammengefasst
|
||||
- Garantiert mindestens ein Event pro logischer Änderung
|
||||
|
||||
**Agent-Reaktion:**
|
||||
- Agent empfängt das Event
|
||||
- Agent debounced die Verarbeitung über **3 Sekunden**
|
||||
- Agent startet sofortiges Playlist-Fetch via HTTP `GET /api/v1/screens/{screenSlug}/playlist`
|
||||
- Agent speichert die Playlist lokal und signalisiert dem Browser einen Reload
|
||||
|
||||
**Implementierung (Agent):**
|
||||
```go
|
||||
// Pseudocode
|
||||
func OnPlaylistChanged(msg PlaylistChangedMessage) {
|
||||
if debounceTimer.running {
|
||||
debounceTimer.reset()
|
||||
} else {
|
||||
debounceTimer.start(3 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func onDebounceExpire() {
|
||||
playlist := fetchPlaylistViaHTTP()
|
||||
saveToLocalCache(playlist)
|
||||
signalBrowserReload()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zukünftige Topics
|
||||
|
||||
Die folgenden Topics sind **geplant** für Phase 5 (Prototyping) und später:
|
||||
|
||||
### `signage/screen/{screenSlug}/device-command`
|
||||
|
||||
**Publisher:** Backend
|
||||
**Subscriber:** Agent
|
||||
|
||||
Befehl-Queue für Device-Steuerung.
|
||||
|
||||
**Payload:**
|
||||
```json
|
||||
{
|
||||
"cmd_id": "uuid",
|
||||
"command": "reload|restart_player|reboot|display_on|display_off",
|
||||
"ts": 1711268440000
|
||||
}
|
||||
```
|
||||
|
||||
**Agent-Reaktion:**
|
||||
- Befehl ausführen
|
||||
- ACK via HTTP POST zu `PUT /api/v1/screens/{screenSlug}/command-ack`
|
||||
|
||||
---
|
||||
|
||||
## Beispiel-Flow: Playlist-Update
|
||||
|
||||
```
|
||||
Admin: Click "Speichern" in Playlist-UI
|
||||
↓
|
||||
Backend: Playlist-Mutation in DB schreiben
|
||||
↓
|
||||
Backend: `playlist-changed` mit ts=now nach 2s Debounce publifyen
|
||||
↓
|
||||
Agent: Event empfangen, 3s Debounce starten
|
||||
↓
|
||||
Agent: Nach 3s → HTTP GET /api/v1/screens/{slug}/playlist
|
||||
↓
|
||||
Backend: Aktuelle Playlist zurückgeben
|
||||
↓
|
||||
Agent: Lokal speichern, Browser signalisieren "reload"
|
||||
↓
|
||||
Browser: Neuer Content geladen und abgespielt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MQTT-Verbindungspezifikation
|
||||
|
||||
(Siehe `PLAYER-KONZEPT.md` und Provisioning-Variablen für Broker-URL, Authentifizierung und Retry-Logik)
|
||||
|
||||
- **Broker-Adresse:** Über Provisioning konfigurierbar (Standard: `tcp://backend:1883`)
|
||||
- **Client-ID:** `{tenantSlug}/{screenSlug}` (eindeutig pro Screen)
|
||||
- **Username/Password:** Device-spezifische Credentials (OAuth-ähnlich)
|
||||
- **QoS-Level:** 1 (At-Least-Once für Critical-Events)
|
||||
- **Retain:** nein (Event-Natur, nicht State)
|
||||
- **Heartbeat:** Separat via HTTP (siehe `PLAYER-STATUS-HTTP.md`)
|
||||
|
||||
---
|
||||
|
||||
## Notizen für Implementierer
|
||||
|
||||
1. **Replay bei Reconnect:** Topics haben `retain: false`, daher entfallen keine Events bei Trennung. Der Agent synchronisiert sich nach Reconnect via regulärem Status-Endpoint.
|
||||
2. **Ordering:** Mehrere Events zu einem Screen sind ordered; Ordering über Screen-Grenzen hinweg ist nicht garantiert.
|
||||
3. **Fehlerbehandlung:** Fehlgeschlagene Playlisten-Fetches werden vom Agent nach Standard-Retry-Logik wiederholt.
|
||||
4. **Version der Spec:** v1.0 (März 2026)
|
||||
297
docs/API-QUICK-REFERENCE.md
Normal file
297
docs/API-QUICK-REFERENCE.md
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
# API Quick Reference
|
||||
|
||||
Schnelle Übersicht aller HTTP-Endpoints nach Zweck sortiert.
|
||||
|
||||
---
|
||||
|
||||
## Agent-Integration
|
||||
|
||||
**Beim Player-Startup:**
|
||||
```http
|
||||
POST /api/v1/screens/register
|
||||
```
|
||||
Automatische Selbstregistrierung des Screens im Default-Tenant "morz".
|
||||
|
||||
**Beim Playlist-Sync (alle ~30s):**
|
||||
```http
|
||||
GET /api/v1/screens/{screenId}/playlist
|
||||
```
|
||||
Abruf der aktuellen Playlist zum Abspielen.
|
||||
|
||||
**Status-Reporting (alle ~30s):**
|
||||
```http
|
||||
POST /api/v1/player/status
|
||||
```
|
||||
Übermittlung des Laufzeitstatus zum Server.
|
||||
|
||||
---
|
||||
|
||||
## Admin-Dashboard
|
||||
|
||||
**Übersicht aller Screens:**
|
||||
```http
|
||||
GET /admin
|
||||
```
|
||||
Web-UI mit Screen-Liste, Status, Provisioning-Formulare.
|
||||
|
||||
**Screen-Verwaltung:**
|
||||
```http
|
||||
POST /admin/screens
|
||||
POST /admin/screens/{screenId}/delete
|
||||
```
|
||||
|
||||
**Provisioning neuer Screens:**
|
||||
```http
|
||||
POST /admin/screens/provision
|
||||
```
|
||||
|
||||
**Diagnose:**
|
||||
```http
|
||||
GET /status
|
||||
GET /status/{screenId}
|
||||
GET /api/v1/screens/status
|
||||
GET /api/v1/screens/{screenId}/status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tenant-Playlist-Management
|
||||
|
||||
**Manage-UI öffnen:**
|
||||
```http
|
||||
GET /manage/{screenSlug}
|
||||
```
|
||||
Web-UI für Playlist-Bearbeitung, Uploads, Item-Verwaltung.
|
||||
|
||||
**Inhalte hinzufügen:**
|
||||
```http
|
||||
POST /manage/{screenSlug}/upload (Datei-Upload)
|
||||
POST /manage/{screenSlug}/items (Item zur Playlist)
|
||||
POST /manage/{screenSlug}/media/{mediaId}/delete
|
||||
```
|
||||
|
||||
**Inhalte bearbeiten:**
|
||||
```http
|
||||
POST /manage/{screenSlug}/items/{itemId}
|
||||
POST /manage/{screenSlug}/items/{itemId}/delete
|
||||
POST /manage/{screenSlug}/reorder
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Media Management (JSON API)
|
||||
|
||||
**Media-Assets auflisten:**
|
||||
```http
|
||||
GET /api/v1/tenants/{tenantSlug}/media
|
||||
```
|
||||
|
||||
**Neue Medien hinzufügen:**
|
||||
```http
|
||||
POST /api/v1/tenants/{tenantSlug}/media
|
||||
```
|
||||
(Datei-Upload oder externe URL)
|
||||
|
||||
**Media löschen:**
|
||||
```http
|
||||
DELETE /api/v1/media/{id}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Playlist Management (JSON API)
|
||||
|
||||
**Playlist abrufen (Admin):**
|
||||
```http
|
||||
GET /api/v1/playlists/{screenId}
|
||||
```
|
||||
|
||||
**Playlist abrufen (Player):**
|
||||
```http
|
||||
GET /api/v1/screens/{screenId}/playlist
|
||||
```
|
||||
(Gibt nur aktive Items zurück)
|
||||
|
||||
**Items verwalten:**
|
||||
```http
|
||||
POST /api/v1/playlists/{playlistId}/items
|
||||
PATCH /api/v1/items/{itemId}
|
||||
DELETE /api/v1/items/{itemId}
|
||||
```
|
||||
|
||||
**Playlist konfigurieren:**
|
||||
```http
|
||||
PUT /api/v1/playlists/{playlistId}/order
|
||||
PATCH /api/v1/playlists/{playlistId}/duration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen Management (JSON API)
|
||||
|
||||
**Self-Registration (Agent):**
|
||||
```http
|
||||
POST /api/v1/screens/register
|
||||
```
|
||||
|
||||
**Screens eines Tenants auflisten:**
|
||||
```http
|
||||
GET /api/v1/tenants/{tenantSlug}/screens
|
||||
```
|
||||
|
||||
**Screen erstellen (Admin):**
|
||||
```http
|
||||
POST /api/v1/tenants/{tenantSlug}/screens
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## System & Health
|
||||
|
||||
**Health-Check:**
|
||||
```http
|
||||
GET /healthz
|
||||
```
|
||||
|
||||
**API-Entrypoint:**
|
||||
```http
|
||||
GET /api/v1
|
||||
```
|
||||
|
||||
**Metainformationen:**
|
||||
```http
|
||||
GET /api/v1/meta
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Player Local UI (auf dem Gerät)
|
||||
|
||||
**Player-Seite öffnen:**
|
||||
```http
|
||||
GET /player
|
||||
```
|
||||
|
||||
**Aktuelle Playlist für lokale Anzeige:**
|
||||
```http
|
||||
GET /api/now-playing
|
||||
```
|
||||
|
||||
**Systeminformationen anzeigen:**
|
||||
```http
|
||||
GET /api/sysinfo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Status-Diagnose (Detailed)
|
||||
|
||||
Siehe `PLAYER-STATUS-HTTP.md` für vollständige Dokumentation der Status-Endpoints:
|
||||
|
||||
```http
|
||||
POST /api/v1/player/status (Status vom Agent)
|
||||
GET /api/v1/screens/status (Alle Status)
|
||||
GET /api/v1/screens/{screenId}/status
|
||||
DELETE /api/v1/screens/{screenId}/status
|
||||
```
|
||||
|
||||
Query-Parameter für Filterung:
|
||||
- `q` — Screen-ID Substring
|
||||
- `derived_state` — online|degraded|offline
|
||||
- `server_connectivity` — online|degraded|offline|unknown
|
||||
- `stale` — true|false
|
||||
- `updated_since` — RFC3339-Zeitstempel
|
||||
- `limit` — Anzahl Items
|
||||
|
||||
---
|
||||
|
||||
## Typische Workflows
|
||||
|
||||
### 1. Neuen Screen provisioning
|
||||
|
||||
```
|
||||
1. POST /admin/screens (oder /api/v1/tenants/morz/screens)
|
||||
→ Neuen Screen-Record anlegen
|
||||
|
||||
2. POST /admin/screens/provision
|
||||
→ Provisionierungs-Job starten
|
||||
|
||||
3. GET /admin (oder /status)
|
||||
→ Status überwachen
|
||||
```
|
||||
|
||||
### 2. Neue Inhalte hochladen und einbinden
|
||||
|
||||
```
|
||||
1. POST /manage/{screenSlug}/upload
|
||||
→ Datei hochladen, Media-Asset erstellen
|
||||
|
||||
2. POST /manage/{screenSlug}/items
|
||||
→ Item zur Playlist hinzufügen (mit media_asset_id)
|
||||
|
||||
3. GET /manage/{screenSlug}
|
||||
→ Playlist-Verwaltung
|
||||
```
|
||||
|
||||
### 4. Playlist bearbeiten
|
||||
|
||||
```
|
||||
1. GET /api/v1/playlists/{screenId}
|
||||
→ Aktuelle Playlist abrufen
|
||||
|
||||
2. PATCH /api/v1/items/{itemId}
|
||||
→ Dauer, Zeitfenster, Titel ändern
|
||||
|
||||
3. PUT /api/v1/playlists/{playlistId}/order
|
||||
→ Items reordnen
|
||||
|
||||
4. GET /api/v1/screens/{screenId}/playlist
|
||||
→ Player holt neue Version beim Sync ab
|
||||
```
|
||||
|
||||
### 5. Status überwachen
|
||||
|
||||
```
|
||||
1. GET /status (Browser)
|
||||
oder
|
||||
GET /api/v1/screens/status (JSON)
|
||||
→ Aktuelle Status aller Screens
|
||||
|
||||
2. GET /status/{screenId}
|
||||
oder
|
||||
GET /api/v1/screens/{screenId}/status
|
||||
→ Detailstatus für einen Screen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
HTTP-Status-Codes:
|
||||
- `200` — OK (Read erfolgreich)
|
||||
- `201` — Created (Ressource erstellt)
|
||||
- `204` — No Content (Write erfolgreich, kein Response-Body)
|
||||
- `400` — Bad Request (Eingabe-Validierungsfehler)
|
||||
- `404` — Not Found (Ressource nicht vorhanden)
|
||||
- `500` — Internal Server Error (Server-Fehler)
|
||||
|
||||
Alle Fehler folgen dem Modell:
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "error_code",
|
||||
"message": "Beschreibung",
|
||||
"details": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vollständige Dokumentation
|
||||
|
||||
Siehe `API-ENDPOINTS.md` für:
|
||||
- Detaillierte Request/Response-Beispiele für jeden Endpoint
|
||||
- Query-Parameter und Validierung
|
||||
- Zeitstempel-Formate (RFC3339)
|
||||
- Serverseitige Logik (z.B. Stale-Detection, Derived-State)
|
||||
|
|
@ -148,6 +148,8 @@ Wenn weder Kampagne noch gueltige Playlist-Inhalte verfuegbar sind:
|
|||
|
||||
- lokal oder aus Cache
|
||||
- Anzeige ueber Browser/PDF-Renderer
|
||||
- Sidebar und Toolbar im Chromium PDF-Viewer ausblenden (URL-Parameter: `navpanes=0&toolbar=0`)
|
||||
- Folgeschritt geplant: PDF.js-Integration fuer automatisches Seitendurchblaettern
|
||||
|
||||
### Webseite
|
||||
|
||||
|
|
|
|||
|
|
@ -180,6 +180,14 @@ Noch nicht Teil dieser Stufe:
|
|||
- Admin-UI-Anzeige des letzten Status
|
||||
- Retry-Queue oder lokale Zwischenspeicherung im Agent
|
||||
|
||||
## Verwandte Endpoints
|
||||
|
||||
Siehe auch die vollständige API-Dokumentation in `API-ENDPOINTS.md`:
|
||||
|
||||
- Screen-Registrierung: `POST /api/v1/screens/register`
|
||||
- Playlist-Abruf: `GET /api/v1/screens/{screenId}/playlist`
|
||||
- Medien-Verwaltung: `GET /api/v1/tenants/{tenantSlug}/media`, `POST /api/v1/tenants/{tenantSlug}/media`
|
||||
|
||||
## Folgeschritte
|
||||
|
||||
Auf diesem Pfad bauen spaeter auf:
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/player/agent/internal/config"
|
||||
"git.az-it.net/az/morz-infoboard/player/agent/internal/mqttheartbeat"
|
||||
"git.az-it.net/az/morz-infoboard/player/agent/internal/mqttsubscriber"
|
||||
"git.az-it.net/az/morz-infoboard/player/agent/internal/playerserver"
|
||||
"git.az-it.net/az/morz-infoboard/player/agent/internal/statusreporter"
|
||||
)
|
||||
|
|
@ -62,6 +64,11 @@ type App struct {
|
|||
// Playlist fetched from the backend (protected by playlistMu).
|
||||
playlistMu sync.RWMutex
|
||||
playlist []playerserver.PlaylistItem
|
||||
|
||||
// mqttFetchC receives a signal whenever a playlist-changed MQTT message
|
||||
// arrives (after debouncing in the subscriber). pollPlaylist listens on
|
||||
// this channel to trigger an immediate fetchPlaylist call.
|
||||
mqttFetchC chan struct{}
|
||||
}
|
||||
|
||||
type statusSender interface {
|
||||
|
|
@ -109,6 +116,7 @@ func newApp(cfg config.Config, logger *log.Logger, now func() time.Time, reporte
|
|||
mqttPub: mqttPub,
|
||||
status: StatusStarting,
|
||||
serverConnectivity: ConnectivityUnknown,
|
||||
mqttFetchC: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,7 +198,28 @@ func (a *App) Run(ctx context.Context) error {
|
|||
// Self-register this screen in the backend (best-effort, non-blocking).
|
||||
go a.registerScreen(ctx)
|
||||
|
||||
// Start polling the backend for playlist updates.
|
||||
// Subscribe to playlist-changed MQTT notifications (optional; fallback = polling).
|
||||
sub := mqttsubscriber.New(
|
||||
a.Config.MQTTBroker,
|
||||
a.Config.ScreenID,
|
||||
a.Config.MQTTUsername,
|
||||
a.Config.MQTTPassword,
|
||||
func() {
|
||||
// Debounced callback: send a non-blocking signal to the fetch channel.
|
||||
select {
|
||||
case a.mqttFetchC <- struct{}{}:
|
||||
default: // already a pending signal — no need to queue another
|
||||
}
|
||||
a.logger.Printf("event=mqtt_playlist_notification screen_id=%s", a.Config.ScreenID)
|
||||
},
|
||||
)
|
||||
if sub != nil {
|
||||
a.logger.Printf("event=mqtt_subscriber_enabled broker=%s screen_id=%s topic=%s",
|
||||
a.Config.MQTTBroker, a.Config.ScreenID, mqttsubscriber.Topic(a.Config.ScreenID))
|
||||
defer sub.Close()
|
||||
}
|
||||
|
||||
// Start polling the backend for playlist updates (60 s fallback + MQTT trigger).
|
||||
go a.pollPlaylist(ctx)
|
||||
|
||||
a.emitHeartbeat()
|
||||
|
|
@ -266,9 +295,12 @@ func (a *App) registerScreen(ctx context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// pollPlaylist fetches the active playlist from the backend periodically.
|
||||
// pollPlaylist fetches the active playlist from the backend.
|
||||
// It fetches immediately on startup, then waits for either:
|
||||
// - an MQTT playlist-changed notification (fast path, debounced by subscriber)
|
||||
// - the 60-second fallback ticker (in case MQTT is unavailable)
|
||||
func (a *App) pollPlaylist(ctx context.Context) {
|
||||
// Fetch immediately on startup, then every 60s.
|
||||
// Fetch immediately on startup.
|
||||
a.fetchPlaylist(ctx)
|
||||
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
|
|
@ -277,6 +309,9 @@ func (a *App) pollPlaylist(ctx context.Context) {
|
|||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-a.mqttFetchC:
|
||||
a.logger.Printf("event=playlist_triggered_by_mqtt screen_id=%s", a.Config.ScreenID)
|
||||
a.fetchPlaylist(ctx)
|
||||
case <-ticker.C:
|
||||
a.fetchPlaylist(ctx)
|
||||
}
|
||||
|
|
@ -313,6 +348,12 @@ func (a *App) fetchPlaylist(ctx context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
for i := range pr.Items {
|
||||
if strings.HasPrefix(pr.Items[i].Src, "/") {
|
||||
pr.Items[i].Src = a.Config.ServerBaseURL + pr.Items[i].Src
|
||||
}
|
||||
}
|
||||
|
||||
a.playlistMu.Lock()
|
||||
a.playlist = pr.Items
|
||||
a.playlistMu.Unlock()
|
||||
|
|
|
|||
118
player/agent/internal/mqttsubscriber/subscriber.go
Normal file
118
player/agent/internal/mqttsubscriber/subscriber.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// Package mqttsubscriber subscribes to playlist-changed MQTT notifications.
|
||||
// It is safe for concurrent use and applies client-side debouncing so that
|
||||
// a burst of messages within a 3-second window triggers at most one callback.
|
||||
package mqttsubscriber
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
const (
|
||||
// debounceDuration is the minimum interval between two callback invocations.
|
||||
// Any MQTT message arriving while the timer is still running resets it.
|
||||
// 500ms reicht aus um Bursts zu absorbieren, ohne die Latenz merklich zu erhöhen.
|
||||
debounceDuration = 500 * time.Millisecond
|
||||
|
||||
// playlistChangedTopicTemplate is the topic the backend publishes to.
|
||||
playlistChangedTopic = "signage/screen/%s/playlist-changed"
|
||||
)
|
||||
|
||||
// PlaylistChangedFunc is called when a debounced playlist-changed notification arrives.
|
||||
type PlaylistChangedFunc func()
|
||||
|
||||
// Subscriber listens for playlist-changed notifications on MQTT and calls the
|
||||
// provided callback at most once per debounceDuration.
|
||||
type Subscriber struct {
|
||||
client mqtt.Client
|
||||
timer *time.Timer
|
||||
onChange PlaylistChangedFunc
|
||||
|
||||
// timerC serializes timer resets through a dedicated goroutine.
|
||||
resetC chan struct{}
|
||||
stopC chan struct{}
|
||||
}
|
||||
|
||||
// Topic returns the MQTT topic for a given screenSlug.
|
||||
func Topic(screenSlug string) string {
|
||||
return "signage/screen/" + screenSlug + "/playlist-changed"
|
||||
}
|
||||
|
||||
// New creates a Subscriber that connects to broker and subscribes to the
|
||||
// playlist-changed topic for screenSlug. onChange is called (in its own
|
||||
// goroutine) at most once per debounceDuration.
|
||||
//
|
||||
// Returns nil when broker is empty — callers must handle nil.
|
||||
func New(broker, screenSlug, username, password string, onChange PlaylistChangedFunc) *Subscriber {
|
||||
if broker == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
s := &Subscriber{
|
||||
onChange: onChange,
|
||||
resetC: make(chan struct{}, 16),
|
||||
stopC: make(chan struct{}),
|
||||
}
|
||||
|
||||
topic := Topic(screenSlug)
|
||||
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(broker).
|
||||
SetClientID("morz-agent-sub-" + screenSlug).
|
||||
SetCleanSession(true).
|
||||
SetAutoReconnect(true).
|
||||
SetConnectRetry(true).
|
||||
SetConnectRetryInterval(10 * time.Second).
|
||||
SetOnConnectHandler(func(c mqtt.Client) {
|
||||
// Re-subscribe after reconnect.
|
||||
c.Subscribe(topic, 0, func(_ mqtt.Client, _ mqtt.Message) { //nolint:errcheck
|
||||
select {
|
||||
case s.resetC <- struct{}{}:
|
||||
default: // channel full — debounce timer will fire anyway
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if username != "" {
|
||||
opts.SetUsername(username)
|
||||
opts.SetPassword(password)
|
||||
}
|
||||
|
||||
s.client = mqtt.NewClient(opts)
|
||||
s.client.Connect() // non-blocking; paho retries in background
|
||||
|
||||
go s.run()
|
||||
return s
|
||||
}
|
||||
|
||||
// run is the debounce loop. It resets a timer on every incoming signal.
|
||||
// When the timer fires the onChange callback is called once in a goroutine.
|
||||
func (s *Subscriber) run() {
|
||||
var timer *time.Timer
|
||||
for {
|
||||
select {
|
||||
case <-s.stopC:
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return
|
||||
case <-s.resetC:
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
timer = time.AfterFunc(debounceDuration, func() {
|
||||
go s.onChange()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close disconnects the MQTT client and stops the debounce loop.
|
||||
func (s *Subscriber) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
close(s.stopC)
|
||||
s.client.Disconnect(250)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -48,14 +49,16 @@ type SysInfo struct {
|
|||
|
||||
// Server serves the local player UI to Chromium.
|
||||
type Server struct {
|
||||
listenAddr string
|
||||
nowFn func() NowPlaying
|
||||
listenAddr string
|
||||
nowFn func() NowPlaying
|
||||
startupToken string // zufälliger Token der sich bei jedem Start ändert
|
||||
}
|
||||
|
||||
// New creates a Server. listenAddr is e.g. "127.0.0.1:8090".
|
||||
// nowFn is called on each request and returns the current playback state.
|
||||
func New(listenAddr string, nowFn func() NowPlaying) *Server {
|
||||
return &Server{listenAddr: listenAddr, nowFn: nowFn}
|
||||
token := fmt.Sprintf("%016x", rand.Uint64())
|
||||
return &Server{listenAddr: listenAddr, nowFn: nowFn, startupToken: token}
|
||||
}
|
||||
|
||||
// Run starts the HTTP server and blocks until ctx is cancelled.
|
||||
|
|
@ -69,6 +72,7 @@ func (s *Server) Run(ctx context.Context) error {
|
|||
mux.HandleFunc("GET /player", s.handlePlayer)
|
||||
mux.HandleFunc("GET /api/now-playing", s.handleNowPlaying)
|
||||
mux.HandleFunc("GET /api/sysinfo", handleSysInfo)
|
||||
mux.HandleFunc("GET /api/startup-token", s.handleStartupToken)
|
||||
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub))))
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
|
|
@ -99,6 +103,14 @@ func (s *Server) handleNowPlaying(w http.ResponseWriter, _ *http.Request) {
|
|||
json.NewEncoder(w).Encode(s.nowFn()) //nolint:errcheck
|
||||
}
|
||||
|
||||
// handleStartupToken gibt einen zufälligen Token zurück der sich bei jedem
|
||||
// Agent-Start ändert. Der Browser erkennt daran, dass der Agent neu gestartet
|
||||
// wurde und lädt die Seite automatisch neu.
|
||||
func (s *Server) handleStartupToken(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"token": s.startupToken}) //nolint:errcheck
|
||||
}
|
||||
|
||||
func handleSysInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(collectSysInfo()) //nolint:errcheck
|
||||
|
|
@ -187,11 +199,38 @@ const playerHTML = `<!DOCTYPE html>
|
|||
font-weight: 500; letter-spacing: 0.03em; color: #fff;
|
||||
}
|
||||
|
||||
/* Inhalts-iframe */
|
||||
#frame {
|
||||
/* Inhalts-Elemente: iframe, img, video */
|
||||
#frame, #img-view, #video-view {
|
||||
position: fixed; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
border: none; display: none; z-index: 10;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
#img-view {
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
#video-view {
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Fehler-Fallback für blockierte iframes */
|
||||
#frame-error {
|
||||
position: fixed; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
display: none; z-index: 10;
|
||||
background: #000;
|
||||
align-items: center; justify-content: center; flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
#frame-error .error-title {
|
||||
font-family: sans-serif; font-size: 2rem; color: rgba(255,255,255,0.7);
|
||||
text-align: center; padding: 0 10%;
|
||||
}
|
||||
#frame-error .error-hint {
|
||||
font-family: sans-serif; font-size: 1rem; color: rgba(255,255,255,0.35);
|
||||
}
|
||||
|
||||
/* Verbindungsstatus-Punkt */
|
||||
|
|
@ -210,13 +249,23 @@ const playerHTML = `<!DOCTYPE html>
|
|||
<div id="splash"></div>
|
||||
<div id="info-overlay"></div>
|
||||
<iframe id="frame" allow="autoplay; fullscreen" allowfullscreen></iframe>
|
||||
<img id="img-view" alt="">
|
||||
<video id="video-view" autoplay muted playsinline></video>
|
||||
<div id="frame-error">
|
||||
<span class="error-title" id="frame-error-title"></span>
|
||||
<span class="error-hint">Seite kann nicht eingebettet werden</span>
|
||||
</div>
|
||||
<div id="dot"></div>
|
||||
|
||||
<script>
|
||||
var splash = document.getElementById('splash');
|
||||
var overlay = document.getElementById('info-overlay');
|
||||
var frame = document.getElementById('frame');
|
||||
var dot = document.getElementById('dot');
|
||||
var splash = document.getElementById('splash');
|
||||
var overlay = document.getElementById('info-overlay');
|
||||
var frame = document.getElementById('frame');
|
||||
var imgView = document.getElementById('img-view');
|
||||
var videoView = document.getElementById('video-view');
|
||||
var frameError = document.getElementById('frame-error');
|
||||
var frameErrorTitle = document.getElementById('frame-error-title');
|
||||
var dot = document.getElementById('dot');
|
||||
|
||||
// ── Splash-Orientierung ───────────────────────────────────────────
|
||||
function updateSplash() {
|
||||
|
|
@ -229,9 +278,30 @@ const playerHTML = `<!DOCTYPE html>
|
|||
window.addEventListener('resize', updateSplash);
|
||||
|
||||
// ── Sysinfo-Overlay ───────────────────────────────────────────────
|
||||
function renderSysInfo(items) {
|
||||
// staticSysItems wird beim pollSysInfo-Callback gesetzt und enthält
|
||||
// Hostname und Uptime vom Server.
|
||||
var staticSysItems = [];
|
||||
|
||||
function renderSysInfo(staticItems) {
|
||||
if (staticItems) { staticSysItems = staticItems; }
|
||||
var all = staticSysItems.slice();
|
||||
|
||||
// Dynamische Einträge aus Playlist-Daten anhängen.
|
||||
if (dynCurrentTitle) {
|
||||
all.push({ label: 'Jetzt', value: dynCurrentTitle });
|
||||
}
|
||||
if (dynPlaylistLength > 0) {
|
||||
all.push({ label: 'Playlist', value: dynPlaylistLength + ' Eintr\u00e4ge' });
|
||||
}
|
||||
if (dynConnectivity) {
|
||||
var connLabel = dynConnectivity === 'online' ? 'Online'
|
||||
: dynConnectivity === 'degraded' ? 'Eingeschränkt'
|
||||
: 'Offline';
|
||||
all.push({ label: 'Netzwerk', value: connLabel });
|
||||
}
|
||||
|
||||
overlay.innerHTML = '';
|
||||
(items || []).forEach(function(item) {
|
||||
all.forEach(function(item) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'info-item';
|
||||
el.innerHTML =
|
||||
|
|
@ -251,6 +321,11 @@ const playerHTML = `<!DOCTYPE html>
|
|||
var currentIdx = 0;
|
||||
var rotateTimer = null;
|
||||
|
||||
// ── Sysinfo-Erweiterung: dynamische Overlay-Daten ─────────────────
|
||||
var dynCurrentTitle = ''; // Titel des aktuell spielenden Items
|
||||
var dynPlaylistLength = 0; // Anzahl Einträge in der Playlist
|
||||
var dynConnectivity = ''; // online / degraded / offline
|
||||
|
||||
// Returns a fingerprint string for change detection.
|
||||
function playlistKey(pl) {
|
||||
return (pl || []).map(function(i) { return i.src + ':' + i.duration_seconds; }).join('|');
|
||||
|
|
@ -260,35 +335,173 @@ const playerHTML = `<!DOCTYPE html>
|
|||
if (rotateTimer) { clearTimeout(rotateTimer); rotateTimer = null; }
|
||||
}
|
||||
|
||||
function showItem(item) {
|
||||
if (!item) { showSplash(); return; }
|
||||
if (frame.src !== item.src) { frame.src = item.src; }
|
||||
frame.style.display = '';
|
||||
// Hide splash overlay while content is visible.
|
||||
overlay.style.display = 'none';
|
||||
// Versteckt alle Content-Elemente vor dem Anzeigen des richtigen Typs.
|
||||
// Blendet zunächst auf opacity:0 aus und entfernt display erst nach der
|
||||
// Transition (500ms), damit der Fade-Out sichtbar ist.
|
||||
//
|
||||
// Race-Condition-Fix: Das setTimeout-Callback prüft vor dem display=none,
|
||||
// ob das Element noch opacity=0 hat. Falls displayItem() das Element
|
||||
// inzwischen wieder auf display=block+opacity=1 gesetzt hat, wird es
|
||||
// nicht fälschlicherweise versteckt.
|
||||
function hideAllContent() {
|
||||
// Laufendes Video sofort stoppen damit kein Audio weiterläuft.
|
||||
videoView.pause();
|
||||
videoView.src = '';
|
||||
|
||||
[frame, imgView, videoView].forEach(function(el) {
|
||||
if (el.style.display !== 'none') {
|
||||
el.style.opacity = '0';
|
||||
(function(e) {
|
||||
setTimeout(function() {
|
||||
// Nur verstecken wenn das Element noch ausgeblendet ist
|
||||
// (opacity=0 oder leer). Falls displayItem() es inzwischen
|
||||
// wieder sichtbar gemacht hat, nicht anfassen.
|
||||
if (e.style.opacity === '0' || e.style.opacity === '') {
|
||||
e.style.display = 'none';
|
||||
}
|
||||
}, 500);
|
||||
})(el);
|
||||
}
|
||||
});
|
||||
frameError.style.display = 'none';
|
||||
}
|
||||
|
||||
// Blendet den Splash-Screen aus (wird aufgerufen wenn echter Content angezeigt wird).
|
||||
function hideSplash() {
|
||||
splash.style.display = 'none';
|
||||
}
|
||||
|
||||
// Blendet den Splash-Screen wieder ein.
|
||||
function showSplashDiv() {
|
||||
splash.style.display = '';
|
||||
}
|
||||
|
||||
function scheduleNext(durationSeconds) {
|
||||
clearRotation();
|
||||
var ms = Math.max((item.duration_seconds || 20), 1) * 1000;
|
||||
var ms = Math.max((durationSeconds || 20), 1) * 1000;
|
||||
rotateTimer = setTimeout(function() {
|
||||
currentIdx = (currentIdx + 1) % items.length;
|
||||
showItem(items[currentIdx]);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
// TRANSITION_MS muss mit der CSS-Transition-Dauer übereinstimmen.
|
||||
var TRANSITION_MS = 500;
|
||||
|
||||
function showItem(item) {
|
||||
if (!item) { showSplash(); return; }
|
||||
|
||||
// Erst Fade-Out des aktuellen Inhalts abwarten, dann neuen anzeigen.
|
||||
hideAllContent();
|
||||
hideSplash();
|
||||
overlay.style.display = 'none';
|
||||
|
||||
setTimeout(function() { displayItem(item); }, TRANSITION_MS);
|
||||
}
|
||||
|
||||
function displayItem(item) {
|
||||
var type = item.type || 'web';
|
||||
|
||||
if (type === 'image') {
|
||||
// display setzen, dann per doppeltem rAF opacity auf 1 für Fade-In.
|
||||
imgView.src = item.src;
|
||||
imgView.style.display = 'block';
|
||||
requestAnimationFrame(function() {
|
||||
requestAnimationFrame(function() { imgView.style.opacity = '1'; });
|
||||
});
|
||||
scheduleNext(item.duration_seconds);
|
||||
|
||||
} else if (type === 'video') {
|
||||
videoView.src = item.src;
|
||||
videoView.style.display = 'block';
|
||||
requestAnimationFrame(function() {
|
||||
requestAnimationFrame(function() { videoView.style.opacity = '1'; });
|
||||
});
|
||||
videoView.load();
|
||||
videoView.play().catch(function() {});
|
||||
// Nach Ablauf der konfigurierten Dauer oder am Ende des Videos rotieren.
|
||||
var advanced = false;
|
||||
function advanceOnce() {
|
||||
if (advanced) return;
|
||||
advanced = true;
|
||||
currentIdx = (currentIdx + 1) % items.length;
|
||||
showItem(items[currentIdx]);
|
||||
}
|
||||
clearRotation();
|
||||
var ms = Math.max((item.duration_seconds || 20), 1) * 1000;
|
||||
rotateTimer = setTimeout(advanceOnce, ms);
|
||||
videoView.onended = advanceOnce;
|
||||
|
||||
} else {
|
||||
// type === 'web', 'pdf' oder unbekannt → iframe
|
||||
if (type === 'pdf') {
|
||||
frame.src = item.src + '#toolbar=0&navpanes=0&scrollbar=0&view=Fit&page=1';
|
||||
} else {
|
||||
if (frame.src !== item.src) { frame.src = item.src; }
|
||||
}
|
||||
frame.style.display = 'block';
|
||||
requestAnimationFrame(function() {
|
||||
requestAnimationFrame(function() { frame.style.opacity = '1'; });
|
||||
});
|
||||
|
||||
// Fehler-Fallback wenn iframe-Laden fehlschlägt (z.B. X-Frame-Options).
|
||||
frame.onerror = null;
|
||||
frame.onload = function() {
|
||||
// Prüfen ob der iframe-Inhalt zugänglich ist. Bei cross-origin-Blockierung
|
||||
// wirft der Zugriff auf contentDocument einen SecurityError – das bedeutet
|
||||
// aber, dass die Seite geladen wurde. Wir können X-Frame-Options-Fehler im
|
||||
// Browser leider nicht direkt erkennen; stattdessen setzen wir einen
|
||||
// kurzen Timeout: Wenn der iframe leer bleibt (about:blank nach dem Load-
|
||||
// Event wegen Blockierung), zeigen wir den Fallback.
|
||||
try {
|
||||
var doc = frame.contentDocument || frame.contentWindow.document;
|
||||
// Wenn der Body keine Kinder hat und URL nicht die gewünschte ist →
|
||||
// Seite wurde blockiert und durch about:blank ersetzt.
|
||||
if (doc && doc.body && doc.body.children.length === 0 &&
|
||||
doc.location && doc.location.href === 'about:blank') {
|
||||
showFrameError(item);
|
||||
}
|
||||
} catch (e) {
|
||||
// Cross-origin SecurityError → Seite wurde tatsächlich geladen, kein Fehler.
|
||||
}
|
||||
};
|
||||
scheduleNext(item.duration_seconds);
|
||||
}
|
||||
}
|
||||
|
||||
function showFrameError(item) {
|
||||
hideAllContent();
|
||||
overlay.style.display = 'none';
|
||||
frameErrorTitle.textContent = item.title || item.src;
|
||||
frameError.style.display = 'flex';
|
||||
// Nach kurzer Wartezeit (3s) zum nächsten Item rotieren.
|
||||
clearRotation();
|
||||
rotateTimer = setTimeout(function() {
|
||||
currentIdx = (currentIdx + 1) % items.length;
|
||||
showItem(items[currentIdx]);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function showSplash() {
|
||||
clearRotation();
|
||||
frame.style.display = 'none';
|
||||
hideAllContent();
|
||||
showSplashDiv();
|
||||
overlay.style.display = '';
|
||||
}
|
||||
|
||||
var lastPlaylistKey = '';
|
||||
|
||||
function applyNowPlaying(data) {
|
||||
// Connectivity-Punkt und dynamische Overlay-Variable aktualisieren.
|
||||
dot.className = data.connectivity || '';
|
||||
dynConnectivity = data.connectivity || '';
|
||||
|
||||
// Legacy single-URL fallback.
|
||||
if (data.url && (!data.playlist || data.playlist.length === 0)) {
|
||||
var key = data.url + ':legacy';
|
||||
dynPlaylistLength = 1;
|
||||
dynCurrentTitle = data.url;
|
||||
renderSysInfo();
|
||||
if (lastPlaylistKey !== key) {
|
||||
lastPlaylistKey = key;
|
||||
items = [{ src: data.url, type: 'web', duration_seconds: 30 }];
|
||||
|
|
@ -299,13 +512,23 @@ const playerHTML = `<!DOCTYPE html>
|
|||
}
|
||||
|
||||
var playlist = data.playlist || [];
|
||||
dynPlaylistLength = playlist.length;
|
||||
if (playlist.length === 0) {
|
||||
dynCurrentTitle = '';
|
||||
renderSysInfo();
|
||||
showSplash();
|
||||
lastPlaylistKey = '';
|
||||
items = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Titel des aktuell laufenden Items ermitteln (currentIdx kann nach
|
||||
// einem Playlist-Wechsel ggf. noch auf dem alten Index stehen – wir
|
||||
// nehmen das Item des zuletzt gesetzten currentIdx, falls vorhanden).
|
||||
var cur = playlist[currentIdx] || playlist[0];
|
||||
dynCurrentTitle = cur.title || cur.src || '';
|
||||
renderSysInfo();
|
||||
|
||||
var key = playlistKey(playlist);
|
||||
if (key === lastPlaylistKey) {
|
||||
return; // unchanged — let current rotation continue
|
||||
|
|
@ -315,6 +538,8 @@ const playerHTML = `<!DOCTYPE html>
|
|||
lastPlaylistKey = key;
|
||||
items = playlist;
|
||||
currentIdx = 0;
|
||||
dynCurrentTitle = items[0].title || items[0].src || '';
|
||||
renderSysInfo();
|
||||
showItem(items[0]);
|
||||
}
|
||||
|
||||
|
|
@ -322,21 +547,79 @@ const playerHTML = `<!DOCTYPE html>
|
|||
function pollSysInfo() {
|
||||
fetch('/api/sysinfo')
|
||||
.then(function(r) { return r.json(); })
|
||||
// Statische Items (Hostname, Uptime) übergeben; renderSysInfo hängt
|
||||
// die dynamischen Daten (Titel, Playlist-Länge, Konnektivität) selbst an.
|
||||
.then(function(d) { renderSysInfo(d.items); })
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
// Bug 1: Fast-Retry beim Start – alle 2s pollen bis erste Playlist vorliegt,
|
||||
// dann auf 30s-Intervall wechseln.
|
||||
var fastRetryTimer = null;
|
||||
var slowPollInterval = null;
|
||||
var playlistReady = false;
|
||||
|
||||
function startSlowPoll() {
|
||||
if (slowPollInterval) return;
|
||||
// Playlist alle 5s prüfen (fängt MQTT-getriggerte Backend-Änderungen schnell ab).
|
||||
// Sysinfo läuft separat alle 30s (weiter unten).
|
||||
slowPollInterval = setInterval(function() {
|
||||
pollNowPlaying();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function pollNowPlaying() {
|
||||
fetch('/api/now-playing')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(applyNowPlaying)
|
||||
.then(function(data) {
|
||||
applyNowPlaying(data);
|
||||
// Sobald eine echte Playlist vorhanden ist, Fast-Retry beenden.
|
||||
if (!playlistReady && items.length > 0) {
|
||||
playlistReady = true;
|
||||
if (fastRetryTimer) { clearInterval(fastRetryTimer); fastRetryTimer = null; }
|
||||
startSlowPoll();
|
||||
}
|
||||
})
|
||||
.catch(function() { dot.className = 'offline'; });
|
||||
}
|
||||
|
||||
pollSysInfo();
|
||||
pollNowPlaying();
|
||||
setInterval(pollSysInfo, 30000); // sysinfo alle 30s
|
||||
setInterval(pollNowPlaying, 30000); // playlist alle 30s
|
||||
setInterval(pollSysInfo, 30000); // sysinfo weiterhin alle 30s
|
||||
|
||||
// Fast-Retry: alle 2s bis Playlist bereit.
|
||||
fastRetryTimer = setInterval(pollNowPlaying, 2000);
|
||||
// Spätestens nach 60s auf langsames Polling wechseln (Fallback).
|
||||
setTimeout(function() {
|
||||
if (!playlistReady) {
|
||||
if (fastRetryTimer) { clearInterval(fastRetryTimer); fastRetryTimer = null; }
|
||||
startSlowPoll();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// ── Auto-Reload bei Agent-Neustart ───────────────────────────────
|
||||
// Der Agent gibt bei jedem Start einen neuen zufälligen Token zurück.
|
||||
// Falls sich der Token ändert, hat der Agent neu gestartet → Seite neu laden.
|
||||
var knownStartupToken = null;
|
||||
|
||||
function pollStartupToken() {
|
||||
fetch('/api/startup-token')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (!d || !d.token) return;
|
||||
if (knownStartupToken === null) {
|
||||
// Erster Aufruf: Token merken, kein Reload.
|
||||
knownStartupToken = d.token;
|
||||
} else if (knownStartupToken !== d.token) {
|
||||
// Token hat sich geändert → Agent wurde neu gestartet.
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function() {}); // Agent offline → ignorieren
|
||||
}
|
||||
|
||||
pollStartupToken();
|
||||
setInterval(pollStartupToken, 5000); // alle 5s prüfen
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ module git.az-it.net/az/morz-infoboard/server/backend
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/net v0.44.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
|
|
@ -11,6 +15,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
|||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
|
|
|
|||
|
|
@ -10,12 +10,14 @@ import (
|
|||
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/db"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
Config config.Config
|
||||
server *http.Server
|
||||
Config config.Config
|
||||
server *http.Server
|
||||
notifier *mqttnotifier.Notifier
|
||||
}
|
||||
|
||||
func New() (*App, error) {
|
||||
|
|
@ -46,26 +48,34 @@ func New() (*App, error) {
|
|||
media := store.NewMediaStore(pool.Pool)
|
||||
playlists := store.NewPlaylistStore(pool.Pool)
|
||||
|
||||
// MQTT notifier (no-op when broker not configured).
|
||||
notifier := mqttnotifier.New(cfg.MQTTBroker, cfg.MQTTUsername, cfg.MQTTPassword)
|
||||
if cfg.MQTTBroker != "" {
|
||||
logger.Printf("event=mqtt_notifier_enabled broker=%s", cfg.MQTTBroker)
|
||||
} else {
|
||||
logger.Printf("event=mqtt_notifier_disabled reason=no_broker_configured")
|
||||
}
|
||||
|
||||
handler := httpapi.NewRouter(httpapi.RouterDeps{
|
||||
StatusStore: statusStore,
|
||||
TenantStore: tenants,
|
||||
ScreenStore: screens,
|
||||
MediaStore: media,
|
||||
PlaylistStore: playlists,
|
||||
Notifier: notifier,
|
||||
UploadDir: cfg.UploadDir,
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
return &App{
|
||||
Config: cfg,
|
||||
server: &http.Server{
|
||||
Addr: cfg.HTTPAddress,
|
||||
Handler: handler,
|
||||
},
|
||||
Config: cfg,
|
||||
server: &http.Server{Addr: cfg.HTTPAddress, Handler: handler},
|
||||
notifier: notifier,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) Run() error {
|
||||
defer a.notifier.Close()
|
||||
err := a.server.ListenAndServe()
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ type Config struct {
|
|||
StatusStorePath string
|
||||
DatabaseURL string
|
||||
UploadDir string
|
||||
// MQTT — optional. When MQTTBroker is empty, notifications are disabled.
|
||||
MQTTBroker string
|
||||
MQTTUsername string
|
||||
MQTTPassword string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
|
|
@ -15,6 +19,9 @@ func Load() Config {
|
|||
StatusStorePath: os.Getenv("MORZ_INFOBOARD_STATUS_STORE_PATH"),
|
||||
DatabaseURL: getenv("MORZ_INFOBOARD_DATABASE_URL", "postgres://morz_infoboard:morz_infoboard@localhost:5432/morz_infoboard?sslmode=disable"),
|
||||
UploadDir: getenv("MORZ_INFOBOARD_UPLOAD_DIR", "/tmp/morz-uploads"),
|
||||
MQTTBroker: os.Getenv("MORZ_INFOBOARD_MQTT_BROKER"),
|
||||
MQTTUsername: os.Getenv("MORZ_INFOBOARD_MQTT_USERNAME"),
|
||||
MQTTPassword: os.Getenv("MORZ_INFOBOARD_MQTT_PASSWORD"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ func HandleUploadMedia(tenants *store.TenantStore, media *store.MediaStore, uplo
|
|||
defer file.Close()
|
||||
|
||||
mimeType := header.Header.Get("Content-Type")
|
||||
if detectedType := mimeToAssetType(mimeType); detectedType != "" {
|
||||
assetType = detectedType
|
||||
}
|
||||
if title == "" {
|
||||
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
||||
}
|
||||
|
|
@ -149,6 +152,21 @@ func HandleDeleteMedia(media *store.MediaStore, uploadDir string) http.HandlerFu
|
|||
}
|
||||
}
|
||||
|
||||
// mimeToAssetType leitet den Asset-Typ aus dem MIME-Type ab.
|
||||
func mimeToAssetType(mime string) string {
|
||||
mime = strings.ToLower(strings.TrimSpace(mime))
|
||||
switch {
|
||||
case strings.HasPrefix(mime, "image/"):
|
||||
return "image"
|
||||
case strings.HasPrefix(mime, "video/"):
|
||||
return "video"
|
||||
case mime == "application/pdf":
|
||||
return "pdf"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func sanitize(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -43,7 +44,7 @@ func HandleGetPlaylist(screens *store.ScreenStore, playlists *store.PlaylistStor
|
|||
}
|
||||
|
||||
// HandleAddItem adds a playlist item (from existing media asset or direct URL).
|
||||
func HandleAddItem(playlists *store.PlaylistStore, media *store.MediaStore) http.HandlerFunc {
|
||||
func HandleAddItem(playlists *store.PlaylistStore, media *store.MediaStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
playlistID := r.PathValue("playlistId")
|
||||
|
||||
|
|
@ -98,6 +99,10 @@ func HandleAddItem(playlists *store.PlaylistStore, media *store.MediaStore) http
|
|||
return
|
||||
}
|
||||
|
||||
if slug, err := playlists.ScreenSlugByPlaylistID(r.Context(), playlistID); err == nil {
|
||||
notifier.NotifyChanged(slug)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(item) //nolint:errcheck
|
||||
|
|
@ -105,7 +110,7 @@ func HandleAddItem(playlists *store.PlaylistStore, media *store.MediaStore) http
|
|||
}
|
||||
|
||||
// HandleUpdateItem updates duration, title, enabled, valid_from, valid_until.
|
||||
func HandleUpdateItem(playlists *store.PlaylistStore) http.HandlerFunc {
|
||||
func HandleUpdateItem(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("itemId")
|
||||
|
||||
|
|
@ -136,24 +141,38 @@ func HandleUpdateItem(playlists *store.PlaylistStore) http.HandlerFunc {
|
|||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if slug, err := playlists.ScreenSlugByItemID(r.Context(), id); err == nil {
|
||||
notifier.NotifyChanged(slug)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeleteItem removes a playlist item.
|
||||
func HandleDeleteItem(playlists *store.PlaylistStore) http.HandlerFunc {
|
||||
func HandleDeleteItem(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("itemId")
|
||||
|
||||
// Resolve slug before delete (item won't exist after).
|
||||
slug, _ := playlists.ScreenSlugByItemID(r.Context(), id)
|
||||
|
||||
if err := playlists.DeleteItem(r.Context(), id); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if slug != "" {
|
||||
notifier.NotifyChanged(slug)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleReorder accepts an ordered list of item IDs and updates order_index.
|
||||
func HandleReorder(playlists *store.PlaylistStore) http.HandlerFunc {
|
||||
func HandleReorder(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
playlistID := r.PathValue("playlistId")
|
||||
|
||||
|
|
@ -167,6 +186,11 @@ func HandleReorder(playlists *store.PlaylistStore) http.HandlerFunc {
|
|||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if slug, err := playlists.ScreenSlugByPlaylistID(r.Context(), playlistID); err == nil {
|
||||
notifier.NotifyChanged(slug)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
server/backend/internal/httpapi/manage/static.go
Normal file
30
server/backend/internal/httpapi/manage/static.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package manage
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed static/bulma.min.css
|
||||
var bulmaCSS []byte
|
||||
|
||||
//go:embed static/Sortable.min.js
|
||||
var sortableJS []byte
|
||||
|
||||
// HandleStaticBulmaCSS serves the embedded Bulma CSS file.
|
||||
func HandleStaticBulmaCSS() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.Write(bulmaCSS) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// HandleStaticSortableJS serves the embedded SortableJS file.
|
||||
func HandleStaticSortableJS() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.Write(sortableJS) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
2
server/backend/internal/httpapi/manage/static/Sortable.min.js
vendored
Normal file
2
server/backend/internal/httpapi/manage/static/Sortable.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
server/backend/internal/httpapi/manage/static/bulma.min.css
vendored
Normal file
3
server/backend/internal/httpapi/manage/static/bulma.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -6,7 +6,7 @@ const provisionTmpl = `<!DOCTYPE html>
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Einrichten – {{.Screen.Name}}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||||
<style>
|
||||
body { background: #f5f5f5; }
|
||||
pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 4px;
|
||||
|
|
@ -62,7 +62,10 @@ ansible_user: {{.SSHUser}}
|
|||
screen_id: {{.Screen.Slug}}
|
||||
screen_name: "{{.Screen.Name}}"
|
||||
screen_orientation: {{.Orientation}}</pre>
|
||||
<button class="button is-small is-light copy-btn mt-2" onclick="copy('hostvars')">📋 Kopieren</button>
|
||||
<div class="buttons mt-2">
|
||||
<button id="copy-btn-hostvars" class="button is-small is-light copy-btn" onclick="copy('hostvars', 'copy-btn-hostvars')">📋 Kopieren</button>
|
||||
<button class="button is-small is-light" onclick="downloadFile(document.getElementById('hostvars').innerText, 'vars.yml')">⬇ Als Datei herunterladen</button>
|
||||
</div>
|
||||
<p class="help mt-2">Tipp: <code>mkdir -p ansible/host_vars/{{.Screen.Slug}}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -115,15 +118,26 @@ ansible-playbook -i ansible/inventory.yml ansible/site.yml --limit {{.Screen.Slu
|
|||
</section>
|
||||
|
||||
<script>
|
||||
function copy(id) {
|
||||
function copy(id, btnId) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
navigator.clipboard.writeText(el.innerText).then(function() {
|
||||
var btn = el.nextElementSibling;
|
||||
var btn = btnId
|
||||
? document.getElementById(btnId)
|
||||
: el.nextElementSibling;
|
||||
if (!btn) return;
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = '✓ Kopiert!';
|
||||
setTimeout(function() { btn.textContent = orig; }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadFile(content, filename) {
|
||||
var a = document.createElement('a');
|
||||
a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(content);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
|
@ -134,30 +148,116 @@ const adminTmpl = `<!DOCTYPE html>
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MORZ Infoboard – Admin</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||||
<style>
|
||||
body { background: #f5f5f5; }
|
||||
.navbar { margin-bottom: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-dark" role="navigation">
|
||||
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<span class="navbar-item"><strong>📺 MORZ Infoboard</strong></span>
|
||||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="adminNavbar">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div id="adminNavbar" class="navbar-menu">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="/status">Diagnose</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Lösch-Bestätigungs-Modal -->
|
||||
<div id="delete-modal" class="modal">
|
||||
<div class="modal-background" onclick="closeDeleteModal()"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Bildschirm löschen?</p>
|
||||
<button class="delete" aria-label="Schließen" onclick="closeDeleteModal()"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<p>Soll <strong id="delete-modal-name"></strong> wirklich gelöscht werden?</p>
|
||||
<p class="has-text-grey is-size-7 mt-2">Alle Playlist-Einträge werden ebenfalls gelöscht.</p>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<form id="delete-modal-form" method="POST">
|
||||
<button class="button is-danger" type="submit">Wirklich löschen</button>
|
||||
</form>
|
||||
<button class="button" onclick="closeDeleteModal()">Abbrechen</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var burger = document.querySelector('.navbar-burger[data-target="adminNavbar"]');
|
||||
if (burger) {
|
||||
burger.addEventListener('click', function() {
|
||||
var target = document.getElementById(burger.dataset.target);
|
||||
burger.classList.toggle('is-active');
|
||||
target.classList.toggle('is-active');
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function openDeleteModal(action, name) {
|
||||
document.getElementById('delete-modal-form').action = action;
|
||||
document.getElementById('delete-modal-name').textContent = name;
|
||||
document.getElementById('delete-modal').classList.add('is-active');
|
||||
}
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('delete-modal').classList.remove('is-active');
|
||||
}
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeDeleteModal();
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
var msg = new URLSearchParams(window.location.search).get('msg');
|
||||
if (!msg) return;
|
||||
var texts = {
|
||||
'uploaded': '✓ Medium erfolgreich hochgeladen.',
|
||||
'deleted': '✓ Erfolgreich gelöscht.',
|
||||
'saved': '✓ Änderungen gespeichert.',
|
||||
'added': '✓ Erfolgreich hinzugefügt.'
|
||||
};
|
||||
var text = texts[msg] || '✓ Aktion erfolgreich.';
|
||||
var n = document.createElement('div');
|
||||
n.className = 'notification is-success';
|
||||
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
|
||||
n.innerHTML = '<button class="delete"></button>' + text;
|
||||
n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
|
||||
document.body.appendChild(n);
|
||||
setTimeout(function() {
|
||||
n.style.transition = 'opacity .5s';
|
||||
n.style.opacity = '0';
|
||||
setTimeout(function() { n.remove(); }, 500);
|
||||
}, 3000);
|
||||
// Clean URL without reloading
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.delete('msg');
|
||||
history.replaceState(null, '', url.toString());
|
||||
})();
|
||||
</script>
|
||||
<section class="section pt-0">
|
||||
<div class="container">
|
||||
|
||||
<div class="box">
|
||||
<h2 class="title is-5">Bildschirme</h2>
|
||||
{{if .Screens}}
|
||||
<div style="overflow-x: auto">
|
||||
<table class="table is-fullwidth is-hoverable is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th>Format</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -167,18 +267,20 @@ const adminTmpl = `<!DOCTYPE html>
|
|||
<td><strong>{{.Name}}</strong></td>
|
||||
<td><code>{{.Slug}}</code></td>
|
||||
<td>{{orientationLabel .Orientation}}</td>
|
||||
<td id="status-{{.Slug}}"><span class="has-text-grey">⚪</span></td>
|
||||
<td>
|
||||
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
|
||||
|
||||
<form method="POST" action="/admin/screens/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Bildschirm löschen?\n\nAlle Playlist-Einträge werden ebenfalls gelöscht.')">
|
||||
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
|
||||
</form>
|
||||
<button class="button is-small is-danger is-outlined"
|
||||
type="button"
|
||||
aria-label="Bildschirm {{.Name}} löschen"
|
||||
onclick="openDeleteModal('/admin/screens/{{.ID}}/delete', '{{.Name}}')">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="has-text-grey">Noch keine Bildschirme angelegt.</p>
|
||||
{{end}}
|
||||
|
|
@ -294,6 +396,24 @@ const adminTmpl = `<!DOCTYPE html>
|
|||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
fetch('/api/v1/screens/status')
|
||||
.then(function(r) { return r.ok ? r.json() : null; })
|
||||
.then(function(data) {
|
||||
if (!data || !data.screens) return;
|
||||
var dots = { 'online': '🟢', 'degraded': '🟡', 'offline': '🔴' };
|
||||
data.screens.forEach(function(s) {
|
||||
var cell = document.getElementById('status-' + s.screen_id);
|
||||
if (cell) {
|
||||
cell.innerHTML = (dots[s.derived_state] || '⚪') + ' <small>' + s.derived_state + '</small>';
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function() {});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
|
|
@ -303,8 +423,8 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Playlist – {{.Screen.Name}}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||||
<script src="/static/Sortable.min.js"></script>
|
||||
<style>
|
||||
body { background: #f5f5f5; }
|
||||
.drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; }
|
||||
|
|
@ -319,7 +439,7 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar is-dark" role="navigation">
|
||||
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/admin">← Admin</a>
|
||||
<span class="navbar-item">
|
||||
|
|
@ -327,9 +447,79 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
|
||||
<span class="tag is-info is-light">{{orientationLabel .Screen.Orientation}}</span>
|
||||
</span>
|
||||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="manageNavbar">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div id="manageNavbar" class="navbar-menu">
|
||||
<div class="navbar-start">
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Lösch-Bestätigungs-Modal -->
|
||||
<div id="manage-delete-modal" class="modal">
|
||||
<div class="modal-background" onclick="closeManageDeleteModal()"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title" id="manage-delete-modal-title">Eintrag entfernen?</p>
|
||||
<button class="delete" aria-label="Schließen" onclick="closeManageDeleteModal()"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<p id="manage-delete-modal-body">Soll der Eintrag wirklich entfernt werden?</p>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<form id="manage-delete-modal-form" method="POST">
|
||||
<button class="button is-danger" type="submit">Wirklich löschen</button>
|
||||
</form>
|
||||
<button class="button" onclick="closeManageDeleteModal()">Abbrechen</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var msg = new URLSearchParams(window.location.search).get('msg');
|
||||
if (!msg) return;
|
||||
var texts = {
|
||||
'uploaded': '✓ Medium erfolgreich hochgeladen.',
|
||||
'deleted': '✓ Erfolgreich gelöscht.',
|
||||
'saved': '✓ Änderungen gespeichert.',
|
||||
'added': '✓ Erfolgreich hinzugefügt.'
|
||||
};
|
||||
var text = texts[msg] || '✓ Aktion erfolgreich.';
|
||||
var n = document.createElement('div');
|
||||
n.className = 'notification is-success';
|
||||
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
|
||||
n.innerHTML = '<button class="delete"></button>' + text;
|
||||
n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
|
||||
document.body.appendChild(n);
|
||||
setTimeout(function() {
|
||||
n.style.transition = 'opacity .5s';
|
||||
n.style.opacity = '0';
|
||||
setTimeout(function() { n.remove(); }, 500);
|
||||
}, 3000);
|
||||
// Clean URL without reloading
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.delete('msg');
|
||||
history.replaceState(null, '', url.toString());
|
||||
})();
|
||||
|
||||
function openManageDeleteModal(action, title, body) {
|
||||
document.getElementById('manage-delete-modal-form').action = action;
|
||||
document.getElementById('manage-delete-modal-title').textContent = title;
|
||||
document.getElementById('manage-delete-modal-body').textContent = body;
|
||||
document.getElementById('manage-delete-modal').classList.add('is-active');
|
||||
}
|
||||
function closeManageDeleteModal() {
|
||||
document.getElementById('manage-delete-modal').classList.remove('is-active');
|
||||
}
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeManageDeleteModal();
|
||||
});
|
||||
</script>
|
||||
<section class="section pt-4">
|
||||
<div class="container">
|
||||
|
||||
|
|
@ -337,6 +527,7 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
<div class="box">
|
||||
<h2 class="title is-5 mb-3">Aktuelle Playlist</h2>
|
||||
{{if .Items}}
|
||||
<div style="overflow-x: auto">
|
||||
<table class="table is-fullwidth" id="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -351,7 +542,7 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
<tbody id="sortable-items">
|
||||
{{range .Items}}
|
||||
<tr id="item-{{.ID}}" class="{{if not .Enabled}}item-disabled{{end}}">
|
||||
<td class="drag-handle" title="Ziehen zum Sortieren">⠿</td>
|
||||
<td class="drag-handle" role="button" aria-label="Reihenfolge ändern" tabindex="0" title="Ziehen zum Sortieren">⠿</td>
|
||||
<td>
|
||||
<span class="tag is-light tag-type">{{typeIcon .Type}} {{.Type}}</span>
|
||||
</td>
|
||||
|
|
@ -369,11 +560,11 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
</td>
|
||||
<td>
|
||||
<button class="button is-small is-info is-outlined" onclick="toggleEdit('{{.ID}}')">Bearbeiten</button>
|
||||
<form method="POST" action="/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete"
|
||||
style="display:inline"
|
||||
onsubmit="return confirm('Eintrag wirklich aus der Playlist entfernen?')">
|
||||
<button class="button is-small is-danger is-outlined" type="submit" title="Entfernen">✕</button>
|
||||
</form>
|
||||
<button class="button is-small is-danger is-outlined"
|
||||
type="button"
|
||||
aria-label="{{if .Title}}{{.Title}}{{else}}Eintrag{{end}} aus Playlist entfernen"
|
||||
title="Entfernen"
|
||||
onclick="openManageDeleteModal('/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete', 'Eintrag entfernen?', 'Eintrag wirklich aus der Playlist entfernen?')">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="edit-{{.ID}}" class="edit-row" style="display:none">
|
||||
|
|
@ -402,11 +593,11 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
</div>
|
||||
<div class="column is-narrow">
|
||||
<label class="label is-small">Aktiv</label>
|
||||
<div class="select is-small">
|
||||
<select name="enabled">
|
||||
<option value="true"{{if .Enabled}} selected{{end}}>Ja</option>
|
||||
<option value="false"{{if not .Enabled}} selected{{end}}>Nein</option>
|
||||
</select>
|
||||
<div class="control" style="padding-top:0.4rem">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{if .Enabled}}checked{{end}}>
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
|
|
@ -423,6 +614,7 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="help has-text-grey mt-2">Einträge per Drag & Drop in der Reihenfolge verschieben.</p>
|
||||
{{else}}
|
||||
<div class="notification is-light">
|
||||
|
|
@ -435,6 +627,7 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
<div class="box">
|
||||
<h2 class="title is-5 mb-3">Medienbibliothek</h2>
|
||||
{{if .Assets}}
|
||||
<div style="overflow-x: auto">
|
||||
<table class="table is-fullwidth is-hoverable is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -464,16 +657,17 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
</form>
|
||||
|
||||
{{end}}
|
||||
<form method="POST" action="/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete"
|
||||
style="display:inline"
|
||||
onsubmit="return confirm('Medium wirklich aus der Bibliothek löschen?\n(Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.)')">
|
||||
<button class="button is-small is-danger is-outlined" type="submit" title="Aus Bibliothek löschen">🗑</button>
|
||||
</form>
|
||||
<button class="button is-small is-danger is-outlined"
|
||||
type="button"
|
||||
aria-label="{{.Title}} aus Bibliothek löschen"
|
||||
title="Aus Bibliothek löschen"
|
||||
onclick="openManageDeleteModal('/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete', 'Medium löschen?', 'Medium wirklich aus der Bibliothek löschen? Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.')">🗑</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="has-text-grey">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</p>
|
||||
{{end}}
|
||||
|
|
@ -491,7 +685,7 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
</div>
|
||||
|
||||
<div id="panel-file" class="tab-panel is-active">
|
||||
<form method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
|
||||
<form id="upload-form" method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column is-2">
|
||||
<div class="field">
|
||||
|
|
@ -516,7 +710,7 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
<div class="field">
|
||||
<label class="label">Datei</label>
|
||||
<div class="control">
|
||||
<input class="input" type="file" name="file" required
|
||||
<input class="input" type="file" name="file" id="upload-file-input" required
|
||||
accept="image/*,video/*,application/pdf">
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -524,10 +718,14 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
<div class="column is-narrow">
|
||||
<div class="field">
|
||||
<label class="label"> </label>
|
||||
<button class="button is-primary" type="submit">Hochladen</button>
|
||||
<button class="button is-primary" type="button" id="upload-btn" onclick="startUpload()">Hochladen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="upload-progress-wrap" style="display:none" class="mt-2">
|
||||
<progress id="upload-progress" class="progress is-primary" value="0" max="100">0%</progress>
|
||||
</div>
|
||||
<div id="upload-error" class="notification is-danger is-light mt-2" style="display:none"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
@ -563,6 +761,18 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
</section>
|
||||
|
||||
<script>
|
||||
// Navbar burger toggle
|
||||
(function() {
|
||||
var burger = document.querySelector('.navbar-burger[data-target="manageNavbar"]');
|
||||
if (burger) {
|
||||
burger.addEventListener('click', function() {
|
||||
var target = document.getElementById(burger.dataset.target);
|
||||
burger.classList.toggle('is-active');
|
||||
target.classList.toggle('is-active');
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function toggleEdit(id) {
|
||||
var row = document.getElementById('edit-' + id);
|
||||
if (row) {
|
||||
|
|
@ -575,6 +785,7 @@ function switchTab(tab) {
|
|||
panels.forEach(function(p) {
|
||||
var panel = document.getElementById('panel-' + p);
|
||||
var tabEl = document.getElementById('tab-' + p);
|
||||
if (!panel || !tabEl) return;
|
||||
if (p === tab) {
|
||||
panel.classList.add('is-active');
|
||||
tabEl.classList.add('is-active');
|
||||
|
|
@ -605,6 +816,62 @@ if (sortableEl) {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
// XHR-Upload mit Fortschrittsbalken
|
||||
function startUpload() {
|
||||
var form = document.getElementById('upload-form');
|
||||
var fileInput = document.getElementById('upload-file-input');
|
||||
var btn = document.getElementById('upload-btn');
|
||||
var progressWrap = document.getElementById('upload-progress-wrap');
|
||||
var progress = document.getElementById('upload-progress');
|
||||
var errorBox = document.getElementById('upload-error');
|
||||
|
||||
errorBox.style.display = 'none';
|
||||
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
errorBox.textContent = 'Bitte zuerst eine Datei auswählen.';
|
||||
errorBox.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
var formData = new FormData(form);
|
||||
var xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.onprogress = function(e) {
|
||||
if (e.lengthComputable) {
|
||||
var pct = Math.round((e.loaded / e.total) * 100);
|
||||
progress.value = pct;
|
||||
progress.textContent = pct + '%';
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onloadstart = function() {
|
||||
btn.style.display = 'none';
|
||||
progressWrap.style.display = '';
|
||||
progress.value = 0;
|
||||
};
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status >= 200 && xhr.status < 400) {
|
||||
window.location.href = '/manage/{{.Screen.Slug}}?msg=uploaded';
|
||||
} else {
|
||||
progressWrap.style.display = 'none';
|
||||
btn.style.display = '';
|
||||
errorBox.textContent = 'Upload fehlgeschlagen (HTTP ' + xhr.status + '): ' + xhr.responseText;
|
||||
errorBox.style.display = '';
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
progressWrap.style.display = 'none';
|
||||
btn.style.display = '';
|
||||
errorBox.textContent = 'Netzwerkfehler beim Upload. Bitte erneut versuchen.';
|
||||
errorBox.style.display = '';
|
||||
};
|
||||
|
||||
xhr.open('POST', form.action);
|
||||
xhr.send(formData);
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -161,7 +162,7 @@ func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore
|
|||
http.Error(w, "Fehler: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin?msg=added", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +224,7 @@ func HandleDeleteScreenUI(screens *store.ScreenStore) http.HandlerFunc {
|
|||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin?msg=deleted", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -289,12 +290,12 @@ func HandleUploadMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
|
|||
http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=uploaded", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAddItemUI handles form POST to add a playlist item, then redirects.
|
||||
func HandleAddItemUI(playlists *store.PlaylistStore, media *store.MediaStore, screens *store.ScreenStore) http.HandlerFunc {
|
||||
func HandleAddItemUI(playlists *store.PlaylistStore, media *store.MediaStore, screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
screenSlug := r.PathValue("screenSlug")
|
||||
if err := r.ParseForm(); err != nil {
|
||||
|
|
@ -353,12 +354,13 @@ func HandleAddItemUI(playlists *store.PlaylistStore, media *store.MediaStore, sc
|
|||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
|
||||
notifier.NotifyChanged(screenSlug)
|
||||
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=added", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeleteItemUI removes a playlist item and redirects back.
|
||||
func HandleDeleteItemUI(playlists *store.PlaylistStore) http.HandlerFunc {
|
||||
func HandleDeleteItemUI(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
screenSlug := r.PathValue("screenSlug")
|
||||
itemID := r.PathValue("itemId")
|
||||
|
|
@ -366,12 +368,13 @@ func HandleDeleteItemUI(playlists *store.PlaylistStore) http.HandlerFunc {
|
|||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
|
||||
notifier.NotifyChanged(screenSlug)
|
||||
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=deleted", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleReorderUI accepts JSON body with ordered IDs (HTMX/fetch).
|
||||
func HandleReorderUI(playlists *store.PlaylistStore, screens *store.ScreenStore) http.HandlerFunc {
|
||||
func HandleReorderUI(playlists *store.PlaylistStore, screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
screenSlug := r.PathValue("screenSlug")
|
||||
screen, err := screens.GetBySlug(r.Context(), screenSlug)
|
||||
|
|
@ -393,12 +396,13 @@ func HandleReorderUI(playlists *store.PlaylistStore, screens *store.ScreenStore)
|
|||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
notifier.NotifyChanged(screenSlug)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUpdateItemUI handles form PATCH/POST to update a single item.
|
||||
func HandleUpdateItemUI(playlists *store.PlaylistStore) http.HandlerFunc {
|
||||
func HandleUpdateItemUI(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
screenSlug := r.PathValue("screenSlug")
|
||||
itemID := r.PathValue("itemId")
|
||||
|
|
@ -411,7 +415,7 @@ func HandleUpdateItemUI(playlists *store.PlaylistStore) http.HandlerFunc {
|
|||
if d, err := strconv.Atoi(strings.TrimSpace(r.FormValue("duration_seconds"))); err == nil && d > 0 {
|
||||
durationSeconds = d
|
||||
}
|
||||
enabled := r.FormValue("enabled") != "false"
|
||||
enabled := r.FormValue("enabled") == "true"
|
||||
validFrom, _ := parseOptionalTime(r.FormValue("valid_from"))
|
||||
validUntil, _ := parseOptionalTime(r.FormValue("valid_until"))
|
||||
|
||||
|
|
@ -419,12 +423,13 @@ func HandleUpdateItemUI(playlists *store.PlaylistStore) http.HandlerFunc {
|
|||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
|
||||
notifier.NotifyChanged(screenSlug)
|
||||
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=saved", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeleteMediaUI deletes media and redirects back.
|
||||
func HandleDeleteMediaUI(media *store.MediaStore, screens *store.ScreenStore, uploadDir string) http.HandlerFunc {
|
||||
func HandleDeleteMediaUI(media *store.MediaStore, screens *store.ScreenStore, uploadDir string, notifier *mqttnotifier.Notifier) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
screenSlug := r.PathValue("screenSlug")
|
||||
mediaID := r.PathValue("mediaId")
|
||||
|
|
@ -435,6 +440,7 @@ func HandleDeleteMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
|
|||
}
|
||||
media.Delete(r.Context(), mediaID) //nolint:errcheck
|
||||
|
||||
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
|
||||
notifier.NotifyChanged(screenSlug)
|
||||
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=deleted", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ type RouterDeps struct {
|
|||
ScreenStore *store.ScreenStore
|
||||
MediaStore *store.MediaStore
|
||||
PlaylistStore *store.PlaylistStore
|
||||
Notifier *mqttnotifier.Notifier
|
||||
UploadDir string
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
|
@ -73,9 +75,19 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
|||
uploadDir = "/tmp/morz-uploads"
|
||||
}
|
||||
|
||||
// Ensure notifier is never nil inside handlers (no-op when broker not configured).
|
||||
notifier := d.Notifier
|
||||
if notifier == nil {
|
||||
notifier = mqttnotifier.New("", "", "")
|
||||
}
|
||||
|
||||
// Serve uploaded files.
|
||||
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir))))
|
||||
|
||||
// Serve embedded static assets (Bulma CSS, SortableJS) — no external CDN needed.
|
||||
mux.HandleFunc("GET /static/bulma.min.css", manage.HandleStaticBulmaCSS())
|
||||
mux.HandleFunc("GET /static/Sortable.min.js", manage.HandleStaticSortableJS())
|
||||
|
||||
// ── Admin UI ──────────────────────────────────────────────────────────
|
||||
mux.HandleFunc("GET /admin", manage.HandleAdminUI(d.TenantStore, d.ScreenStore))
|
||||
mux.HandleFunc("POST /admin/screens/provision", manage.HandleProvisionUI(d.TenantStore, d.ScreenStore))
|
||||
|
|
@ -88,15 +100,15 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
|||
mux.HandleFunc("POST /manage/{screenSlug}/upload",
|
||||
manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))
|
||||
mux.HandleFunc("POST /manage/{screenSlug}/items",
|
||||
manage.HandleAddItemUI(d.PlaylistStore, d.MediaStore, d.ScreenStore))
|
||||
manage.HandleAddItemUI(d.PlaylistStore, d.MediaStore, d.ScreenStore, notifier))
|
||||
mux.HandleFunc("POST /manage/{screenSlug}/items/{itemId}",
|
||||
manage.HandleUpdateItemUI(d.PlaylistStore))
|
||||
manage.HandleUpdateItemUI(d.PlaylistStore, notifier))
|
||||
mux.HandleFunc("POST /manage/{screenSlug}/items/{itemId}/delete",
|
||||
manage.HandleDeleteItemUI(d.PlaylistStore))
|
||||
manage.HandleDeleteItemUI(d.PlaylistStore, notifier))
|
||||
mux.HandleFunc("POST /manage/{screenSlug}/reorder",
|
||||
manage.HandleReorderUI(d.PlaylistStore, d.ScreenStore))
|
||||
manage.HandleReorderUI(d.PlaylistStore, d.ScreenStore, notifier))
|
||||
mux.HandleFunc("POST /manage/{screenSlug}/media/{mediaId}/delete",
|
||||
manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir))
|
||||
manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir, notifier))
|
||||
|
||||
// ── JSON API — screens ────────────────────────────────────────────────
|
||||
// Self-registration: called by agent on startup (must be before /{tenantSlug}/ routes)
|
||||
|
|
@ -121,13 +133,13 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
|||
mux.HandleFunc("GET /api/v1/playlists/{screenId}",
|
||||
manage.HandleGetPlaylist(d.ScreenStore, d.PlaylistStore))
|
||||
mux.HandleFunc("POST /api/v1/playlists/{playlistId}/items",
|
||||
manage.HandleAddItem(d.PlaylistStore, d.MediaStore))
|
||||
manage.HandleAddItem(d.PlaylistStore, d.MediaStore, notifier))
|
||||
mux.HandleFunc("PATCH /api/v1/items/{itemId}",
|
||||
manage.HandleUpdateItem(d.PlaylistStore))
|
||||
manage.HandleUpdateItem(d.PlaylistStore, notifier))
|
||||
mux.HandleFunc("DELETE /api/v1/items/{itemId}",
|
||||
manage.HandleDeleteItem(d.PlaylistStore))
|
||||
manage.HandleDeleteItem(d.PlaylistStore, notifier))
|
||||
mux.HandleFunc("PUT /api/v1/playlists/{playlistId}/order",
|
||||
manage.HandleReorder(d.PlaylistStore))
|
||||
manage.HandleReorder(d.PlaylistStore, notifier))
|
||||
mux.HandleFunc("PATCH /api/v1/playlists/{playlistId}/duration",
|
||||
manage.HandleUpdatePlaylistDuration(d.PlaylistStore))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,9 +239,9 @@ func TestRouterScreenDetailPageRoute(t *testing.T) {
|
|||
"tcp://127.0.0.1:1883",
|
||||
"2026-03-22T16:09:30Z",
|
||||
"/api/v1/screens/info01-dev/status",
|
||||
"← All screens",
|
||||
"Timing",
|
||||
"Endpoints",
|
||||
"← Alle Bildschirme",
|
||||
"Zeitstempel",
|
||||
"Verbindungen",
|
||||
"<meta http-equiv=\"refresh\" content=\"15\">",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
|
|
@ -264,7 +264,7 @@ func TestRouterScreenDetailPageNotFound(t *testing.T) {
|
|||
t.Fatalf("Content-Type = %q, want text/html", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(w.Body.String(), "← Back to Screen Status") {
|
||||
if !strings.Contains(w.Body.String(), "← Zurück zum Bildschirmstatus") {
|
||||
t.Fatal("body missing back link")
|
||||
}
|
||||
}
|
||||
|
|
@ -321,13 +321,13 @@ func TestRouterStatusPageRoute(t *testing.T) {
|
|||
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{
|
||||
"Screen Status",
|
||||
"2 screens",
|
||||
"Bildschirmstatus",
|
||||
"2 Bildschirme",
|
||||
"<meta http-equiv=\"refresh\" content=\"15\">",
|
||||
"Connectivity offline",
|
||||
"Connectivity degraded",
|
||||
"Stale reports",
|
||||
"Fresh reports",
|
||||
"Konnektivität: Offline",
|
||||
"Konnektivität: Eingeschränkt",
|
||||
"Veraltete Meldungen",
|
||||
"Aktuelle Meldungen",
|
||||
"updated_since=2026-03-22T15%3A55%3A00Z",
|
||||
"screen-offline",
|
||||
"offline",
|
||||
|
|
|
|||
|
|
@ -432,15 +432,16 @@ var statusTemplateFuncs = template.FuncMap{
|
|||
"screenDetailHTMLPath": screenDetailHTMLPath,
|
||||
"statusClass": statusClass,
|
||||
"timestampLabel": timestampLabel,
|
||||
"stateLabel": stateLabel,
|
||||
}
|
||||
|
||||
var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="{{.RefreshSeconds}}">
|
||||
<title>Screen Status</title>
|
||||
<title>Bildschirmstatus</title>
|
||||
` + statusPageCSSBlock + `
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -448,19 +449,20 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
<section class="hero">
|
||||
<div class="hero-top">
|
||||
<div>
|
||||
<h1>Screen Status</h1>
|
||||
<p class="lead">A compact browser view of the latest screen reports from the current in-memory status overview. Offline and degraded screens stay at the top for quick diagnostics.</p>
|
||||
<h1>Bildschirmstatus</h1>
|
||||
<p class="lead">Kompakte Übersicht der zuletzt gemeldeten Bildschirmzustände. Offline- und eingeschränkte Bildschirme erscheinen oben für schnelle Diagnose.</p>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div>{{.Overview.Summary.Total}} screens</div>
|
||||
<div>Updated {{.GeneratedAt}}</div>
|
||||
<div>{{.Overview.Summary.Total}} Bildschirme</div>
|
||||
<div>Aktualisiert <time id="generated-at" datetime="{{.GeneratedAt}}">{{.GeneratedAt}}</time></div>
|
||||
<div style="margin-top: 8px;"><a class="meta-chip" href="/admin">← Admin</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-grid">
|
||||
<article class="summary-card">
|
||||
<strong>{{.Overview.Summary.Total}}</strong>
|
||||
<span>Total known screens</span>
|
||||
<span>Bildschirme gesamt</span>
|
||||
</article>
|
||||
<article class="summary-card offline">
|
||||
<strong>{{.Overview.Summary.Offline}}</strong>
|
||||
|
|
@ -468,7 +470,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
</article>
|
||||
<article class="summary-card degraded">
|
||||
<strong>{{.Overview.Summary.Degraded}}</strong>
|
||||
<span>Degraded</span>
|
||||
<span>Eingeschränkt</span>
|
||||
</article>
|
||||
<article class="summary-card online">
|
||||
<strong>{{.Overview.Summary.Online}}</strong>
|
||||
|
|
@ -476,7 +478,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
</article>
|
||||
<article class="summary-card">
|
||||
<strong>{{.Overview.Summary.Stale}}</strong>
|
||||
<span>Stale reports</span>
|
||||
<span>Veraltete Meldungen</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -484,15 +486,15 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Filters and refresh</h2>
|
||||
<p class="panel-copy">This page refreshes every {{.RefreshSeconds}} seconds. Use the shortcut links or the form to narrow the existing connectivity and freshness filters without leaving the lightweight server-rendered flow.</p>
|
||||
<h2>Filter und Aktualisierung</h2>
|
||||
<p class="panel-copy">Diese Seite aktualisiert sich alle {{.RefreshSeconds}} Sekunden. Verwende die Schnellfilter oder das Formular, um die Ansicht einzugrenzen.</p>
|
||||
</div>
|
||||
<a class="meta-chip" href="{{.StatusAPIPath}}">JSON overview</a>
|
||||
<a class="meta-chip" href="{{.StatusAPIPath}}">JSON-Übersicht</a>
|
||||
</div>
|
||||
|
||||
<div class="controls-grid">
|
||||
<div>
|
||||
<h2>Quick views</h2>
|
||||
<h2>Schnellansichten</h2>
|
||||
<div class="quick-filters">
|
||||
{{range .QuickFilters}}
|
||||
<a class="filter-link {{.Class}} {{if .Active}}active{{end}}" href="{{.Href}}">{{.Label}}</a>
|
||||
|
|
@ -502,42 +504,42 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
|
||||
<form class="filter-form" method="get" action="{{.StatusPagePath}}">
|
||||
<div class="field full">
|
||||
<label for="q">Screen ID contains</label>
|
||||
<input id="q" name="q" type="text" placeholder="e.g. info01" value="{{.Filters.ScreenIDFilter}}">
|
||||
<label for="q">Screen-ID enthält</label>
|
||||
<input id="q" name="q" type="text" placeholder="z.B. info01" value="{{.Filters.ScreenIDFilter}}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="server_connectivity">Server connectivity</label>
|
||||
<label for="server_connectivity">Serverkonnektivität</label>
|
||||
<select id="server_connectivity" name="server_connectivity">
|
||||
<option value="" {{if eq .Filters.ServerConnectivity ""}}selected{{end}}>Any</option>
|
||||
<option value="" {{if eq .Filters.ServerConnectivity ""}}selected{{end}}>Alle</option>
|
||||
<option value="online" {{if eq .Filters.ServerConnectivity "online"}}selected{{end}}>Online</option>
|
||||
<option value="degraded" {{if eq .Filters.ServerConnectivity "degraded"}}selected{{end}}>Degraded</option>
|
||||
<option value="degraded" {{if eq .Filters.ServerConnectivity "degraded"}}selected{{end}}>Eingeschränkt</option>
|
||||
<option value="offline" {{if eq .Filters.ServerConnectivity "offline"}}selected{{end}}>Offline</option>
|
||||
<option value="unknown" {{if eq .Filters.ServerConnectivity "unknown"}}selected{{end}}>Unknown</option>
|
||||
<option value="unknown" {{if eq .Filters.ServerConnectivity "unknown"}}selected{{end}}>Unbekannt</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="stale">Freshness</label>
|
||||
<label for="stale">Aktualität</label>
|
||||
<select id="stale" name="stale">
|
||||
<option value="" {{if eq .Filters.Stale ""}}selected{{end}}>Any</option>
|
||||
<option value="true" {{if eq .Filters.Stale "true"}}selected{{end}}>Stale only</option>
|
||||
<option value="false" {{if eq .Filters.Stale "false"}}selected{{end}}>Fresh only</option>
|
||||
<option value="" {{if eq .Filters.Stale ""}}selected{{end}}>Alle</option>
|
||||
<option value="true" {{if eq .Filters.Stale "true"}}selected{{end}}>Nur veraltet</option>
|
||||
<option value="false" {{if eq .Filters.Stale "false"}}selected{{end}}>Nur aktuell</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="derived_state">Derived state</label>
|
||||
<label for="derived_state">Abgeleiteter Status</label>
|
||||
<select id="derived_state" name="derived_state">
|
||||
<option value="" {{if eq .Filters.DerivedState ""}}selected{{end}}>Any</option>
|
||||
<option value="" {{if eq .Filters.DerivedState ""}}selected{{end}}>Alle</option>
|
||||
<option value="online" {{if eq .Filters.DerivedState "online"}}selected{{end}}>Online</option>
|
||||
<option value="degraded" {{if eq .Filters.DerivedState "degraded"}}selected{{end}}>Degraded</option>
|
||||
<option value="degraded" {{if eq .Filters.DerivedState "degraded"}}selected{{end}}>Eingeschränkt</option>
|
||||
<option value="offline" {{if eq .Filters.DerivedState "offline"}}selected{{end}}>Offline</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field full">
|
||||
<label for="updated_since">Updated since (RFC3339)</label>
|
||||
<label for="updated_since">Aktualisiert seit (RFC3339)</label>
|
||||
<input id="updated_since" name="updated_since" type="text" placeholder="2026-03-22T16:05:00Z" value="{{.Filters.UpdatedSince}}">
|
||||
</div>
|
||||
|
||||
|
|
@ -547,8 +549,8 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit">Apply filters</button>
|
||||
<a class="text-link" href="{{.StatusPagePath}}">Clear</a>
|
||||
<button type="submit">Filter anwenden</button>
|
||||
<a class="text-link" href="{{.StatusPagePath}}">Zurücksetzen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -557,23 +559,23 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Latest reports</h2>
|
||||
<p class="panel-copy">Each row links to the HTML detail view and the raw JSON endpoint for a quick drill-down.</p>
|
||||
<h2>Aktuelle Meldungen</h2>
|
||||
<p class="panel-copy">Jede Zeile verlinkt auf die HTML-Detailansicht und den JSON-Endpunkt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<a class="text-link" href="{{.StatusAPIPath}}">Open filtered JSON overview</a>
|
||||
<a class="text-link" href="{{.StatusAPIPath}}">Gefilterte JSON-Übersicht öffnen</a>
|
||||
</div>
|
||||
{{if .Overview.Screens}}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Screen</th>
|
||||
<th>Derived state</th>
|
||||
<th>Player status</th>
|
||||
<th>Server link</th>
|
||||
<th>Received</th>
|
||||
<th>Bildschirm</th>
|
||||
<th>Status</th>
|
||||
<th>Player-Status</th>
|
||||
<th>Server</th>
|
||||
<th>Empfangen</th>
|
||||
<th>Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -582,28 +584,28 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
<tr>
|
||||
<td>
|
||||
<div class="screen">{{.ScreenID}}</div>
|
||||
{{if .MQTTBroker}}<small>{{.MQTTBroker}}</small>{{else if .ServerURL}}<small>{{.ServerURL}}</small>{{else}}<small>No endpoint details</small>{{end}}
|
||||
{{if .MQTTBroker}}<small>{{.MQTTBroker}}</small>{{else if .ServerURL}}<small>{{.ServerURL}}</small>{{else}}<small>Keine Verbindungsdetails</small>{{end}}
|
||||
<div class="screen-links">
|
||||
<a class="filter-link" href="{{screenDetailHTMLPath .ScreenID}}">Details</a>
|
||||
<a class="json-link" href="{{screenDetailPath .ScreenID}}">JSON</a>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="pill {{statusClass .DerivedState}}">{{.DerivedState}}</span></td>
|
||||
<td><span class="pill {{statusClass .DerivedState}}">{{stateLabel .DerivedState}}</span></td>
|
||||
<td>
|
||||
<div>{{.Status}}</div>
|
||||
{{if .Stale}}<small>Marked stale by server freshness check</small>{{else}}<small>Fresh within expected heartbeat window</small>{{end}}
|
||||
{{if .Stale}}<small>Vom Server als veraltet markiert</small>{{else}}<small>Aktuell im erwarteten Heartbeat-Fenster</small>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<span class="state {{statusClass .ServerConnectivity}}">{{connectivityLabel .ServerConnectivity}}</span>
|
||||
{{if .ServerURL}}<small>{{.ServerURL}}</small>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div>{{timestampLabel .ReceivedAt}}</div>
|
||||
{{if .LastHeartbeatAt}}<small>Heartbeat {{timestampLabel .LastHeartbeatAt}}</small>{{end}}
|
||||
<div><time class="reltime" datetime="{{.ReceivedAt}}">{{timestampLabel .ReceivedAt}}</time></div>
|
||||
{{if .LastHeartbeatAt}}<small>Heartbeat <time class="reltime" datetime="{{.LastHeartbeatAt}}">{{timestampLabel .LastHeartbeatAt}}</time></small>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if gt .HeartbeatEverySeconds 0}}{{.HeartbeatEverySeconds}}s{{else}}-{{end}}
|
||||
{{if .StartedAt}}<small>Started {{timestampLabel .StartedAt}}</small>{{end}}
|
||||
{{if .StartedAt}}<small>Gestartet <time class="reltime" datetime="{{.StartedAt}}">{{timestampLabel .StartedAt}}</time></small>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
|
@ -611,21 +613,48 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty">No screen has reported status yet. Once a player posts to the existing status API, it will appear here automatically.</p>
|
||||
<p class="empty">Noch kein Bildschirm hat einen Status gemeldet. Sobald ein Player den Status-API-Endpunkt aufruft, erscheint er hier automatisch.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
(function() {
|
||||
function relativeTime(dateStr) {
|
||||
if (!dateStr || dateStr === '-') return dateStr;
|
||||
var d = new Date(dateStr);
|
||||
if (isNaN(d)) return dateStr;
|
||||
var diff = Math.round((Date.now() - d.getTime()) / 1000);
|
||||
if (diff < 5) return 'gerade eben';
|
||||
if (diff < 60) return 'vor ' + diff + ' Sekunden';
|
||||
var mins = Math.round(diff / 60);
|
||||
if (mins < 60) return 'vor ' + mins + (mins === 1 ? ' Minute' : ' Minuten');
|
||||
var hours = Math.round(diff / 3600);
|
||||
if (hours < 24) return 'vor ' + hours + (hours === 1 ? ' Stunde' : ' Stunden');
|
||||
var days = Math.round(diff / 86400);
|
||||
return 'vor ' + days + (days === 1 ? ' Tag' : ' Tagen');
|
||||
}
|
||||
function updateRelTimes() {
|
||||
document.querySelectorAll('time.reltime').forEach(function(el) {
|
||||
el.textContent = relativeTime(el.getAttribute('datetime'));
|
||||
});
|
||||
var genAt = document.getElementById('generated-at');
|
||||
if (genAt) genAt.textContent = relativeTime(genAt.getAttribute('datetime'));
|
||||
}
|
||||
updateRelTimes();
|
||||
setInterval(updateRelTimes, 30000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
|
||||
var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="{{.RefreshSeconds}}">
|
||||
<title>{{.Record.ScreenID}} – Screen Status</title>
|
||||
<title>{{.Record.ScreenID}} – Bildschirmstatus</title>
|
||||
` + statusPageCSSBlock + `
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -634,12 +663,13 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
|
|||
<div class="hero-top">
|
||||
<div>
|
||||
<h1>{{.Record.ScreenID}}</h1>
|
||||
<p class="lead">Single screen diagnostic view based on the last accepted status report.</p>
|
||||
<p class="lead">Detailansicht auf Basis des zuletzt akzeptierten Status-Reports.</p>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div>Updated {{.GeneratedAt}}</div>
|
||||
<div>Aktualisiert <time id="generated-at" datetime="{{.GeneratedAt}}">{{.GeneratedAt}}</time></div>
|
||||
<div style="margin-top: 8px; display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end;">
|
||||
<a class="meta-chip" href="{{.StatusPagePath}}">← All screens</a>
|
||||
<a class="meta-chip" href="{{.StatusPagePath}}">← Alle Bildschirme</a>
|
||||
<a class="meta-chip" href="/admin">← Admin</a>
|
||||
<a class="meta-chip" href="{{screenDetailPath .Record.ScreenID}}">JSON</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -647,20 +677,20 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
|
|||
|
||||
<div class="summary-grid">
|
||||
<article class="summary-card {{statusClass .Record.DerivedState}}">
|
||||
<strong><span class="state {{statusClass .Record.DerivedState}}">{{.Record.DerivedState}}</span></strong>
|
||||
<span>Derived state</span>
|
||||
<strong><span class="state {{statusClass .Record.DerivedState}}">{{stateLabel .Record.DerivedState}}</span></strong>
|
||||
<span>Abgeleiteter Status</span>
|
||||
</article>
|
||||
<article class="summary-card">
|
||||
<strong>{{.Record.Status}}</strong>
|
||||
<span>Player status</span>
|
||||
<span>Player-Status</span>
|
||||
</article>
|
||||
<article class="summary-card {{statusClass .Record.ServerConnectivity}}">
|
||||
<strong>{{connectivityLabel .Record.ServerConnectivity}}</strong>
|
||||
<span>Server connectivity</span>
|
||||
<span>Serverkonnektivität</span>
|
||||
</article>
|
||||
<article class="summary-card">
|
||||
<strong>{{if .Record.Stale}}stale{{else}}fresh{{end}}</strong>
|
||||
<span>Freshness</span>
|
||||
<strong>{{if .Record.Stale}}Veraltet{{else}}Aktuell{{end}}</strong>
|
||||
<span>Aktualität</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -668,30 +698,30 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
|
|||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Timing</h2>
|
||||
<p class="panel-copy">Timestamps reported by the player and annotated by the server at receive time.</p>
|
||||
<h2>Zeitstempel</h2>
|
||||
<p class="panel-copy">Vom Player gemeldete und vom Server beim Empfang ergänzte Zeitstempel.</p>
|
||||
</div>
|
||||
</div>
|
||||
<table class="detail-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Received at (server)</th>
|
||||
<td>{{timestampLabel .Record.ReceivedAt}}</td>
|
||||
<th>Empfangen (Server)</th>
|
||||
<td><time class="reltime" datetime="{{.Record.ReceivedAt}}">{{timestampLabel .Record.ReceivedAt}}</time></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Player timestamp</th>
|
||||
<td>{{timestampLabel .Record.Timestamp}}</td>
|
||||
<th>Player-Zeitstempel</th>
|
||||
<td><time class="reltime" datetime="{{.Record.Timestamp}}">{{timestampLabel .Record.Timestamp}}</time></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Started at</th>
|
||||
<td>{{timestampLabel .Record.StartedAt}}</td>
|
||||
<th>Gestartet</th>
|
||||
<td><time class="reltime" datetime="{{.Record.StartedAt}}">{{timestampLabel .Record.StartedAt}}</time></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Last heartbeat at</th>
|
||||
<td>{{timestampLabel .Record.LastHeartbeatAt}}</td>
|
||||
<th>Letzter Heartbeat</th>
|
||||
<td><time class="reltime" datetime="{{.Record.LastHeartbeatAt}}">{{timestampLabel .Record.LastHeartbeatAt}}</time></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Heartbeat interval</th>
|
||||
<th>Heartbeat-Intervall</th>
|
||||
<td>{{if gt .Record.HeartbeatEverySeconds 0}}{{.Record.HeartbeatEverySeconds}}s{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -701,34 +731,61 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
|
|||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Endpoints</h2>
|
||||
<p class="panel-copy">Connection details reported by the player in the last accepted status.</p>
|
||||
<h2>Verbindungen</h2>
|
||||
<p class="panel-copy">Verbindungsdetails aus dem zuletzt akzeptierten Status-Report.</p>
|
||||
</div>
|
||||
</div>
|
||||
<table class="detail-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Server URL</th>
|
||||
<th>Server-URL</th>
|
||||
<td>{{if .Record.ServerURL}}{{.Record.ServerURL}}{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>MQTT broker</th>
|
||||
<th>MQTT-Broker</th>
|
||||
<td>{{if .Record.MQTTBroker}}{{.Record.MQTTBroker}}{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
(function() {
|
||||
function relativeTime(dateStr) {
|
||||
if (!dateStr || dateStr === '-') return dateStr;
|
||||
var d = new Date(dateStr);
|
||||
if (isNaN(d)) return dateStr;
|
||||
var diff = Math.round((Date.now() - d.getTime()) / 1000);
|
||||
if (diff < 5) return 'gerade eben';
|
||||
if (diff < 60) return 'vor ' + diff + ' Sekunden';
|
||||
var mins = Math.round(diff / 60);
|
||||
if (mins < 60) return 'vor ' + mins + (mins === 1 ? ' Minute' : ' Minuten');
|
||||
var hours = Math.round(diff / 3600);
|
||||
if (hours < 24) return 'vor ' + hours + (hours === 1 ? ' Stunde' : ' Stunden');
|
||||
var days = Math.round(diff / 86400);
|
||||
return 'vor ' + days + (days === 1 ? ' Tag' : ' Tagen');
|
||||
}
|
||||
function updateRelTimes() {
|
||||
document.querySelectorAll('time.reltime').forEach(function(el) {
|
||||
el.textContent = relativeTime(el.getAttribute('datetime'));
|
||||
});
|
||||
var genAt = document.getElementById('generated-at');
|
||||
if (genAt) genAt.textContent = relativeTime(genAt.getAttribute('datetime'));
|
||||
}
|
||||
updateRelTimes();
|
||||
setInterval(updateRelTimes, 30000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
|
||||
var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Invalid filter – Screen Status</title>
|
||||
<title>Ungültiger Filter – Bildschirmstatus</title>
|
||||
` + statusPageCSSBlock + `
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -736,11 +793,11 @@ var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(s
|
|||
<section class="hero">
|
||||
<div class="hero-top">
|
||||
<div>
|
||||
<h1>Invalid filter</h1>
|
||||
<h1>Ungültiger Filter</h1>
|
||||
<p class="lead">{{.Message}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a class="filter-link" href="{{.StatusPagePath}}">← Back to Screen Status</a>
|
||||
<a class="filter-link" href="{{.StatusPagePath}}">← Zurück zum Bildschirmstatus</a>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
|
|
@ -773,7 +830,7 @@ func handleScreenDetailPage(store playerStatusStore) http.HandlerFunc {
|
|||
record, ok := store.Get(screenID)
|
||||
if !ok {
|
||||
data := statusPageErrorData{
|
||||
Message: "Fuer diesen Screen liegt noch kein Status vor.",
|
||||
Message: "Für diesen Screen liegt noch kein Status vor.",
|
||||
StatusPagePath: "/status",
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
|
@ -826,31 +883,31 @@ func buildStatusQuickFilters(filters statusPageFilters) []statusFilterLink {
|
|||
base := statusPageFilters{ScreenIDFilter: filters.ScreenIDFilter, Limit: filters.Limit, UpdatedSince: filters.UpdatedSince}
|
||||
return []statusFilterLink{
|
||||
{
|
||||
Label: "All screens",
|
||||
Label: "Alle Bildschirme",
|
||||
Href: buildStatusPageHref(base),
|
||||
Class: "",
|
||||
Active: filters.ServerConnectivity == "" && filters.Stale == "",
|
||||
},
|
||||
{
|
||||
Label: "Connectivity offline",
|
||||
Label: "Konnektivität: Offline",
|
||||
Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, ServerConnectivity: "offline", Limit: base.Limit, UpdatedSince: base.UpdatedSince}),
|
||||
Class: "offline",
|
||||
Active: filters.ServerConnectivity == "offline" && filters.Stale == "",
|
||||
},
|
||||
{
|
||||
Label: "Connectivity degraded",
|
||||
Label: "Konnektivität: Eingeschränkt",
|
||||
Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, ServerConnectivity: "degraded", Limit: base.Limit, UpdatedSince: base.UpdatedSince}),
|
||||
Class: "degraded",
|
||||
Active: filters.ServerConnectivity == "degraded" && filters.Stale == "",
|
||||
},
|
||||
{
|
||||
Label: "Stale reports",
|
||||
Label: "Veraltete Meldungen",
|
||||
Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, Stale: "true", Limit: base.Limit, UpdatedSince: base.UpdatedSince}),
|
||||
Class: "",
|
||||
Active: filters.ServerConnectivity == "" && filters.Stale == "true",
|
||||
},
|
||||
{
|
||||
Label: "Fresh reports",
|
||||
Label: "Aktuelle Meldungen",
|
||||
Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, Stale: "false", Limit: base.Limit, UpdatedSince: base.UpdatedSince}),
|
||||
Class: "online",
|
||||
Active: filters.ServerConnectivity == "" && filters.Stale == "false",
|
||||
|
|
@ -935,3 +992,22 @@ func timestampLabel(value string) string {
|
|||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func stateLabel(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "online":
|
||||
return "Online"
|
||||
case "offline":
|
||||
return "Offline"
|
||||
case "degraded":
|
||||
return "Eingeschränkt"
|
||||
case "unknown":
|
||||
return "Unbekannt"
|
||||
default:
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "Unbekannt"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
server/backend/internal/mqttnotifier/notifier.go
Normal file
106
server/backend/internal/mqttnotifier/notifier.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Package mqttnotifier publishes playlist-changed notifications to MQTT.
|
||||
// It is safe for concurrent use and applies per-screen debouncing so that
|
||||
// rapid edits within a 2-second window produce at most one MQTT message.
|
||||
package mqttnotifier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
const (
|
||||
// debounceDuration is the minimum time between two publish calls for the
|
||||
// same screen. Any change arriving within this window resets the timer.
|
||||
debounceDuration = 2 * time.Second
|
||||
)
|
||||
|
||||
// Notifier publishes "playlist-changed" MQTT messages with per-screen debounce.
|
||||
// If no broker URL is configured it behaves as a no-op (all methods are safe
|
||||
// to call and do nothing).
|
||||
type Notifier struct {
|
||||
client mqtt.Client // nil when disabled
|
||||
|
||||
mu sync.Mutex
|
||||
timers map[string]*time.Timer // keyed by screenSlug
|
||||
}
|
||||
|
||||
// New creates a Notifier connected to broker (e.g. "tcp://mosquitto:1883").
|
||||
// username/password may be empty. Returns a no-op Notifier when broker == "".
|
||||
func New(broker, username, password string) *Notifier {
|
||||
n := &Notifier{timers: make(map[string]*time.Timer)}
|
||||
if broker == "" {
|
||||
return n
|
||||
}
|
||||
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(broker).
|
||||
SetClientID("morz-backend").
|
||||
SetCleanSession(true).
|
||||
SetAutoReconnect(true).
|
||||
SetConnectRetry(true).
|
||||
SetConnectRetryInterval(10 * time.Second)
|
||||
|
||||
if username != "" {
|
||||
opts.SetUsername(username)
|
||||
opts.SetPassword(password)
|
||||
}
|
||||
|
||||
n.client = mqtt.NewClient(opts)
|
||||
n.client.Connect() // non-blocking; paho retries in background
|
||||
return n
|
||||
}
|
||||
|
||||
// Topic returns the MQTT topic for a screen's playlist-changed notification.
|
||||
func Topic(screenSlug string) string {
|
||||
return fmt.Sprintf("signage/screen/%s/playlist-changed", screenSlug)
|
||||
}
|
||||
|
||||
// NotifyChanged schedules a publish for screenSlug. If another call for the
|
||||
// same screen arrives within debounceDuration, the timer is reset (debounce).
|
||||
// The method returns immediately; the actual publish happens in a goroutine.
|
||||
func (n *Notifier) NotifyChanged(screenSlug string) {
|
||||
if n.client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
|
||||
// Reset existing timer if present (debounce).
|
||||
if t, ok := n.timers[screenSlug]; ok {
|
||||
t.Stop()
|
||||
}
|
||||
|
||||
n.timers[screenSlug] = time.AfterFunc(debounceDuration, func() {
|
||||
n.mu.Lock()
|
||||
delete(n.timers, screenSlug)
|
||||
n.mu.Unlock()
|
||||
|
||||
n.publish(screenSlug)
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Notifier) publish(screenSlug string) {
|
||||
topic := Topic(screenSlug)
|
||||
payload := []byte(fmt.Sprintf(`{"ts":%d}`, time.Now().UnixMilli()))
|
||||
token := n.client.Publish(topic, 0, false, payload)
|
||||
token.WaitTimeout(3 * time.Second)
|
||||
// Errors are silently dropped — the 60 s polling in the agent is the fallback.
|
||||
}
|
||||
|
||||
// Close disconnects the MQTT client gracefully.
|
||||
func (n *Notifier) Close() {
|
||||
if n.client == nil {
|
||||
return
|
||||
}
|
||||
n.mu.Lock()
|
||||
for _, t := range n.timers {
|
||||
t.Stop()
|
||||
}
|
||||
n.timers = make(map[string]*time.Timer)
|
||||
n.mu.Unlock()
|
||||
n.client.Disconnect(250)
|
||||
}
|
||||
|
|
@ -407,6 +407,29 @@ func (s *PlaylistStore) DeleteItem(ctx context.Context, id string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// ScreenSlugByPlaylistID returns the slug of the screen that owns playlistID.
|
||||
func (s *PlaylistStore) ScreenSlugByPlaylistID(ctx context.Context, playlistID string) (string, error) {
|
||||
var slug string
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`select sc.slug
|
||||
from playlists pl
|
||||
join screens sc on sc.id = pl.screen_id
|
||||
where pl.id = $1`, playlistID).Scan(&slug)
|
||||
return slug, err
|
||||
}
|
||||
|
||||
// ScreenSlugByItemID returns the slug of the screen that owns itemID.
|
||||
func (s *PlaylistStore) ScreenSlugByItemID(ctx context.Context, itemID string) (string, error) {
|
||||
var slug string
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`select sc.slug
|
||||
from playlist_items pi
|
||||
join playlists pl on pl.id = pi.playlist_id
|
||||
join screens sc on sc.id = pl.screen_id
|
||||
where pi.id = $1`, itemID).Scan(&slug)
|
||||
return slug, err
|
||||
}
|
||||
|
||||
// Reorder sets order_index for each item ID in the given slice order.
|
||||
func (s *PlaylistStore) Reorder(ctx context.Context, playlistID string, itemIDs []string) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue