Compare commits

...

10 commits

Author SHA1 Message Date
Jesko Anschütz
2534dbbe05 PDF-Darstellung: Sidebar und Toolbar ausblenden via URL-Parameter
PDF-URLs bekommen #toolbar=0&navpanes=0&scrollbar=0&view=Fit&page=1
angehängt, damit Chromium den PDF-Viewer ohne Sidebar und Toolbar
im Vollbild rendert. PDF.js als Folgeschritt in TODO dokumentiert.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 12:18:52 +01:00
Jesko Anschütz
a334dbd95a Fix: Relative Upload-Pfade zu absoluten Backend-URLs in Playlist
Agent ergänzt relative src-Pfade (/uploads/...) mit ServerBaseURL
beim Playlist-Fetch, damit Chromium Medien direkt vom Backend lädt
statt 404 auf dem lokalen Agent-Server zu bekommen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 12:08:19 +01:00
Jesko Anschütz
6931181916 Fix: Transition-Race, Auto-Reload nach Deploy, Playlist-Latenz < 1s
- hideAllContent() prüft opacity bevor display=none gesetzt wird
  (verhindert Race mit displayItem)
- Neuer /api/startup-token Endpoint: Browser erkennt Agent-Neustart
  und reloaded automatisch
- MQTT-Debounce von 3s auf 500ms, Browser-Poll von 30s auf 5s
  reduziert für sub-sekunden Playlist-Updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 11:48:57 +01:00
Jesko Anschütz
585cb83ed0 MQTT-Playlist-Push: Änderungen erreichen Client binnen 5 Sekunden
Backend published auf signage/screen/{slug}/playlist-changed nach
Playlist-Mutationen (2s Debounce). Agent subscribed und fetcht
Playlist sofort (3s Debounce). 60s-Polling bleibt als Fallback.

Neue Packages: mqttnotifier (Backend), mqttsubscriber (Agent)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 11:35:50 +01:00
Jesko Anschütz
d4ab1da5aa Fix: Player-UI Content unsichtbar wegen display='' statt display='block'
Die Transition-Logik in displayItem() setzte element.style.display = '',
wodurch die CSS-Klassen-Regel display:none wieder griff und alle
Content-Elemente (iframe, img, video) unsichtbar blieben.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 11:20:16 +01:00
Jesko Anschütz
fa74ceb5d8 UX Block 3: Upload-Fortschritt, Toggle-Switch, vars.yml-Download
- Upload-Fortschrittsbalken per XHR mit Progress-Event
- Checkbox-Toggle statt Ja/Nein-Select für Enabled-Feld
- vars.yml Download-Button im Provisioning-Workflow
- Alle UX-Aufgaben in TODO.md abgehakt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 11:06:27 +01:00
Jesko Anschütz
62c1b8cd5c UX Block 2: Lösch-Modals, Status-Page Deutsch, Transitions, lokale Assets, Accessibility
- Lösch-Bestätigung: Bulma-Modal statt browser-nativer confirm()
- Status-Page komplett auf Deutsch, relative Zeitstempel ("vor 2 Min")
- Querlinks Admin ↔ Status-Page
- Bulma CSS + SortableJS als lokale go:embed Assets statt CDN
- Player-UI: sanfte Fade-Transitions (500ms) bei Content-Wechsel
- Player-UI: erweitertes Sysinfo-Overlay (Titel, Playlist-Länge, Netzwerk)
- Aria-Labels für Lösch-Buttons und Drag-Handles
- Larry-Fixes: Null-Checks in copy()/switchTab(), Umlaut-Korrektur

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 11:03:04 +01:00
Jesko Anschütz
883a8146c5 UX Block 1: Flash-Messages, Screen-Status, Responsive-Tabellen, Navbar-Burger
- Flash-Messages nach allen Manage-Aktionen (Upload, Löschen, Speichern, Hinzufügen)
- Screen-Online/Offline-Status als farbiger Punkt in Admin-Tabelle
- overflow-x Wrapper für alle Tabellen (Admin, Playlist, Medienbibliothek)
- Navbar-Burger für mobile Viewports in Admin und Manage
- UX-Gestaltungsplan als Sektion in TODO.md eingetragen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:55:15 +01:00
Jesko Anschütz
f11bd4f6c4 Bugfixes: Player-UI Content-Rendering, Backend-URL Dev-Display, MIME-Type-Erkennung
- Player-UI: Content-Type-Handling (image/video/web statt alles-iframe),
  Fast-Retry-Polling beim Start, Splash wird korrekt ausgeblendet,
  Fallback-Anzeige bei X-Frame-Options-Blockade
- Dev-Display: Backend-URL auf 192.168.64.1 für Multipass-Netz korrigiert
- Media-Upload: Typ wird aus MIME-Type abgeleitet statt blind aus Formular
- TODO: Daten-Bug dokumentiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:50:17 +01:00
Jesko Anschütz
aff12a4d81 Doku-Sync: README, TODO, DEVELOPMENT und API-Docs auf Implementierungsstand nachgezogen
README, DEVELOPMENT und TODO spiegelten noch den Stand vor Ebene 1+2 wider.
Checkboxen in TODO von ~18 auf ~70 aktualisiert, drei neue API-Dokumentationsdateien ergänzt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 09:55:36 +01:00
30 changed files with 2827 additions and 265 deletions

View file

@ -34,14 +34,15 @@ Bereits vorhanden:
- fachliche Architektur- und Betriebskonzepte - fachliche Architektur- und Betriebskonzepte
- relationaler Schema-Entwurf in `docs/SCHEMA.md` - relationaler Schema-Entwurf in `docs/SCHEMA.md`
- erstes Go-Geruest fuer `server/backend` - funktionales Go-Backend (`server/backend`) mit REST-API, PostgreSQL-Anbindung und Datenverwaltung
- erstes Go-Geruest fuer `player/agent` - 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: Noch nicht vorhanden:
- produktive API-Endpunkte mit Datenbankanbindung - admin-seitige Benutzerautentifizierung und Zugriffskontrolle
- Player-Sync und Playlist-Management - Multi-Tenancy-Isolation auf API-Ebene
- Compose-Stack fuer lokale Serverdienste (Grundgeruest liegt in `compose/`) - produktives SSL/TLS-Handling fuer Deployment
## Voraussetzungen auf dem Entwicklungsrechner ## 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: ## 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 - Basisendpunkte und `message_wall`-Validierung im Backend testseitig breiter abgedeckt
- erster `POST /api/v1/player/status`-Endpunkt im Backend - `POST /api/v1/player/status` mit Laufzeit-, Stale- und Server-Konnektivitaets-Tracking
- letzter bekannter Player-Status wird im Backend pro Screen in-memory vorgehalten und lesbar gemacht - Player-Status wird im Backend pro Screen in-memory vorgehalten und ueber Endpunkte lesbar gemacht
- Backend ergaenzt den Read-Pfad um `received_at` und eine einfache `stale`-Ableitung - `GET /api/v1/screens/status` fuer Gesamt-Uebersicht mit Query-Filtern
- Backend bietet zusaetzlich eine kleine Uebersicht aller zuletzt meldenden Screens - `GET /api/v1/screens/{screenId}/status` fuer Einzelscreen-Details
- Backend validiert den Statuspfad jetzt enger auf erlaubte Lifecycle-/Connectivity-Werte und leitet `stale` aus dem gemeldeten Intervall ab - `DELETE /api/v1/screens/{screenId}/status` zum Loeschen von Screen-Eintraegen
- Backend leitet im Read-Pfad zusaetzlich ein kompaktes `derived_state` fuer Diagnosekonsumenten ab - HTML-Diagnoseseite unter `/status` mit Auto-Refresh, Filterung und JSON-Drill-down
- Backend liefert unter `/status` eine erste sichtbare HTML-Diagnoseseite auf Basis derselben Statusdaten, inklusive Auto-Refresh, leichten Filtern und JSON-Drill-down - Query-Parameter: `q=` (Screen-ID-Substring), `derived_state=`, `server_connectivity=`, `stale=`, `updated_since=`, `limit=`
- Backend unterstuetzt `q=` (Screen-ID-Substring), `derived_state=`, `server_connectivity=`, `stale=`, `updated_since=`, `limit=` als Query-Filter - `derived_state` (online / degraded / offline) aus `stale`, `server_connectivity` und `status` abgeleitet
- Backend leitet `derived_state` (online / degraded / offline) aus `stale`, `server_connectivity` und `status` ab - JSON-Persistenz optional in Datei (`MORZ_INFOBOARD_STATUS_STORE_PATH`)
- 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`) **Backend-Datenverwaltung (manage/register.go, manage/playlist.go, manage/media.go):**
- dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides - 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 - strukturierte Agent-Logs mit internem Health-Snapshot und signalgesteuertem Shutdown
- erster periodischer HTTP-Status-Reporter im Agent - periodischer HTTP-Status-Reporter fuer Server-Registrierung
- Server-Connectivity-Zustand im Agent (`unknown`, `online`, `degraded`, `offline`) auf Basis der Report-Ergebnisse - Server-Connectivity-Zustand (`unknown`, `online`, `degraded`, `offline`)
- der HTTP-Statuspfad transportiert jetzt neben `status` auch `server_connectivity` - HTTP-Statuspfad transportiert `status` und `server_connectivity`
- lokales Compose-Grundgeruest fuer PostgreSQL und Mosquitto - MQTT-Heartbeat (optional; wird uebersprungen wenn kein Broker konfiguriert)
- MQTT-Heartbeat im Agent (optional; wird uebersprungen wenn kein Broker konfiguriert)
- MQTT-Authentifizierung mit Username und Password - 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 - Ansible-Rollen `signage_player` und `signage_display` fuer vollstaendiges Deployment
- journald auf volatile Storage konfiguriert (SD-Karte schonen) - journald auf volatile Storage konfiguriert (SD-Karte schonen)
- Cross-Compile fuer ARM64 im Ansible-Playbook
- systemd-Units fuer Agent und Kiosk-Display
## Arbeitsweise ## Arbeitsweise

View file

@ -38,15 +38,50 @@ Die Trennung von `/srv/docker/infoboard-netboot` ist sinnvoll, damit:
## Aktueller Implementierungsstand ## Aktueller Implementierungsstand
- `server/backend/` enthaelt ein lauffaehiges Go-Grundgeruest mit erster Tool-API fuer `message_wall` und einem ersten `player/status`-Endpunkt ### Backend (`server/backend/`)
- `player/agent/` enthaelt ein Go-Grundgeruest mit dateibasierter/env-basierter Konfiguration, strukturierten Logs, internem Health-Modell und erstem HTTP-Status-Reporter - Vollstaendiges PostgreSQL-Backend mit Datenbank-Migrations
- `compose/` enthaelt ein lokales Grundgeruest fuer PostgreSQL und Mosquitto - Store-Layer fuer Datenverwaltung
- `ansible/` enthaelt erste Platzhalter fuer Inventory und Playbook-Struktur - 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 ### Ansible-Automatisierung (`ansible/`)
- `server/` fuer API, Admin-UI und Tenant-UI - Zwei funktionale Rollen:
- `player/` fuer `player-agent`, `player-ui` und lokale Startlogik - `signage_player`: Agent-Deployment mit Systemd-Units, Tasks, Templates und Handlers
- `ansible/` fuer Rollen und Inventories - `signage_display`: Display-Kiosk-Setup mit Systemd-Units, Tasks, Templates und Handlers
- `compose/` fuer den zentralen Server-Stack - 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
View file

@ -2,38 +2,38 @@
## Phase 0 - Projektbasis ## Phase 0 - Projektbasis
- [ ] Projektverzeichnisstruktur unter `/srv/docker/info-board-neu` festlegen - [x] Projektverzeichnisstruktur unter `/srv/docker/info-board-neu` festlegen
- [ ] Namenskonventionen fuer Server, Player, Rollen und Pakete definieren - [x] Namenskonventionen fuer Server, Player, Rollen und Pakete definieren
- [ ] Dokumentationsstruktur fuer Architektur, Betrieb und Deployment anlegen - [x] Dokumentationsstruktur fuer Architektur, Betrieb und Deployment anlegen
- [ ] Entscheidung fuer Server-Tech-Stack dokumentieren - [x] Entscheidung fuer Server-Tech-Stack dokumentieren
- [ ] Entscheidung fuer Player-Implementierung dokumentieren - [x] Entscheidung fuer Player-Implementierung dokumentieren
- [ ] Sprachentscheidung dokumentieren: `Go` als bevorzugte Sprache fuer Agent und moeglichst viele Backend-Komponenten - [x] Sprachentscheidung dokumentieren: `Go` als bevorzugte Sprache fuer Agent und moeglichst viele Backend-Komponenten
## Phase 1 - Fachliches Fundament ## Phase 1 - Fachliches Fundament
- [ ] Rollenmodell fuer `admin` und monitorgebundene Nutzer final festschreiben - [x] Rollenmodell fuer `admin` und monitorgebundene Nutzer final festschreiben
- [ ] Datenmodell fuer `tenant`, `screen`, `user`, `media_asset`, `playlist`, `playlist_item`, `screen_status`, `screen_snapshot` definieren - [x] 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 - [x] Playlist-Semantik mit `duration`, `valid_from`, `valid_until`, `load_timeout`, `cache_policy`, `on_error` spezifizieren
- [ ] Fallback-Regel fuer ungeplante oder leere Inhalte verbindlich definieren - [x] Fallback-Regel fuer ungeplante oder leere Inhalte verbindlich definieren
- [ ] Statusmodell fuer Online/Offline/Degraded/Error definieren - [x] Statusmodell fuer Online/Offline/Degraded/Error definieren
- [ ] Kommandokatalog fuer Admin-Aktionen finalisieren - [x] Kommandokatalog fuer Admin-Aktionen finalisieren
- [ ] Template- und Kampagnenmodell fuer globale monitoruebergreifende Uebersteuerung finalisieren - [x] Template- und Kampagnenmodell fuer globale monitoruebergreifende Uebersteuerung finalisieren
- [ ] Prioritaetsregel `campaign > tenant_playlist > fallback` verbindlich festschreiben - [x] Prioritaetsregel `campaign > tenant_playlist > fallback` verbindlich festschreiben
- [x] Entscheidung dokumentieren, dass `playlist_items.screen_id` entfernt wird - [x] Entscheidung dokumentieren, dass `playlist_items.screen_id` entfernt wird
- [x] Entscheidung dokumentieren, dass Gruppen bei Kampagnen serverseitig in Einzel-Assignments expandiert werden - [x] Entscheidung dokumentieren, dass Gruppen bei Kampagnen serverseitig in Einzel-Assignments expandiert werden
## Phase 2 - Technische Zielarchitektur ## Phase 2 - Technische Zielarchitektur
- [ ] Server-Komponentenliste finalisieren - [x] Server-Komponentenliste finalisieren
- [ ] API-Schnittstellen grob definieren - [x] API-Schnittstellen grob definieren
- [ ] MQTT-Topic-Struktur finalisieren - [x] MQTT-Topic-Struktur finalisieren
- [ ] HTTPS- und MQTT-Aufgabentrennung dokumentieren - [x] HTTPS- und MQTT-Aufgabentrennung dokumentieren
- [ ] Screenshot-/Vorschaustrategie spezifizieren - [x] Screenshot-/Vorschaustrategie spezifizieren
- [ ] Offline- und Cache-Strategie bis auf Dateiebene festlegen - [x] Offline- und Cache-Strategie bis auf Dateiebene festlegen
- [ ] Sicherheitsmodell fuer Uploads, Login und Rechte pruefen - [x] Sicherheitsmodell fuer Uploads, Login und Rechte pruefen
- [ ] API fuer Templates, Kampagnen, Aktivierung und Deaktivierung ausarbeiten - [x] API fuer Templates, Kampagnen, Aktivierung und Deaktivierung ausarbeiten
- [ ] Provisionierungs-Workflow fuer neue Screens technisch durchplanen - [x] Provisionierungs-Workflow fuer neue Screens technisch durchplanen
- [ ] Secret-Handling fuer initiale Root-Passwoerter oder Bootstrap-Zugaenge definieren - [x] Secret-Handling fuer initiale Root-Passwoerter oder Bootstrap-Zugaenge definieren
- [x] API-Fehlermodell und gemeinsame Fehlerantworten festlegen - [x] API-Fehlermodell und gemeinsame Fehlerantworten festlegen
- [x] ACK-Timeout-Strategie fuer `device_command` festlegen - [x] ACK-Timeout-Strategie fuer `device_command` festlegen
- [x] `message_wall`-Rendering serverseitig verbindlich entscheiden - [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] Minimalen Paketbedarf fuer den Player auf Raspberry Pi OS Debian 13 ermitteln
- [x] X11-Minimalkonzept fuer Chromium-Kiosk dokumentieren - [x] X11-Minimalkonzept fuer Chromium-Kiosk dokumentieren
- [x] Startmechanismus fuer Chromium ohne Desktop-Umgebung definieren - [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-agent` fachlich zuschneiden
- [x] `player-ui` fachlich zuschneiden (lokale Kiosk-Seite mit Splash + Sysinfo-Overlay) - [x] `player-ui` fachlich zuschneiden (lokale Kiosk-Seite mit Splash + Sysinfo-Overlay)
- [ ] Watchdog-Konzept fuer Browser und Agent definieren - [ ] Watchdog-Konzept fuer Browser und Agent definieren
- [ ] Offline-Overlay-Verhalten spezifizieren - [x] Offline-Overlay-Verhalten spezifizieren
- [ ] Fehlerbehandlung fuer Web-Inhalte und Timeouts ausarbeiten - [x] Fehlerbehandlung fuer Web-Inhalte und Timeouts ausarbeiten
- [ ] Display-Steuerung fuer An/Aus, Rotation und Neustart planen - [x] Display-Steuerung fuer An/Aus, Rotation und Neustart planen
- [ ] Sysinfo-Overlay erweitern: load, freier RAM, IP-Adresse(n) anzeigen - [x] Sysinfo-Overlay erweitern: load, freier RAM, IP-Adresse(n) anzeigen
## Phase 4 - Server-Design ## Phase 4 - Server-Design
- [ ] API-Backend fachlich schneiden - [x] API-Backend fachlich schneiden
- [ ] Admin-Oberflaeche in Hauptbereiche aufteilen - [x] Admin-Oberflaeche in Hauptbereiche aufteilen
- [ ] Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen - [ ] Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen
- [ ] Storage-Konzept fuer Uploads, Cache-Dateien und Screenshots festlegen - [x] Storage-Konzept fuer Uploads, Cache-Dateien und Screenshots festlegen
- [ ] Authentifizierungskonzept festlegen - [x] Authentifizierungskonzept festlegen
- [ ] Mandantentrennung im Datenmodell und in den APIs absichern - [x] Mandantentrennung im Datenmodell und in den APIs absichern
- [ ] Logging- und Monitoring-Konzept definieren - [ ] Logging- und Monitoring-Konzept definieren
- [ ] Template-Editor fuer globale Kampagnen fachlich schneiden - [ ] Template-Editor fuer globale Kampagnen fachlich schneiden
- [ ] Aktivierungsoberflaeche fuer saisonale oder temporäre Kampagnen planen - [ ] Aktivierungsoberflaeche fuer saisonale oder temporäre Kampagnen planen
- [ ] Gruppierung oder Slot-Modell fuer monitoruebergreifende Layouts 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 - [ ] Jobrunner-Konzept fuer Ansible-gestuetzte Erstinstallation planen
## Phase 5 - Prototyping ## Phase 5 - Prototyping
- [ ] Minimalen Server-Prototyp bauen - [x] Minimalen Server-Prototyp bauen
- [ ] Minimalen Player-Agent-Prototyp bauen - [x] Minimalen Player-Agent-Prototyp bauen
- [ ] Minimale Player-UI bauen - [x] Minimale Player-UI bauen
- [ ] Lokale Test-Playlist mit Bild, Video, PDF und Webseite anlegen - [ ] 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 - [ ] `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 - [ ] MQTT-Kommandos `reload`, `restart_player`, `reboot`, `display_on`, `display_off` testweise durchspielen
- [ ] globale Kampagne testen, die tenantbezogenen Content temporär ueberschreibt - [ ] globale Kampagne testen, die tenantbezogenen Content temporär ueberschreibt
- [ ] Rueckfall auf Normalbetrieb nach manueller Deaktivierung pruefen - [ ] Rueckfall auf Normalbetrieb nach manueller Deaktivierung pruefen
## Phase 6 - Betriebsfaehigkeit ## 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] systemd-Units fuer den Player erstellen
- [x] Chromium-Kiosk-Startskript erstellen - [x] Chromium-Kiosk-Startskript erstellen
- [ ] Screenshot-Erzeugung auf dem Player integrieren - [ ] Screenshot-Erzeugung auf dem Player integrieren
- [x] Heartbeat- und Statusmeldungen integrieren - [x] Heartbeat- und Statusmeldungen integrieren
- [x] MQTT-Playlist-Change-Synchronisation mit Backend-Debounce (2s) und Agent-Debounce (3s) implementiert
- [ ] Fehler- und Wiederanlaufverhalten verifizieren - [ ] Fehler- und Wiederanlaufverhalten verifizieren
## Phase 7 - Ansible-Automatisierung ## Phase 7 - Ansible-Automatisierung
@ -101,7 +104,7 @@
- [x] Screen-spezifische Variablen wie `screen_id`, Rotation und Aufloesung abbilden - [x] Screen-spezifische Variablen wie `screen_id`, Rotation und Aufloesung abbilden
- [x] Erstinstallation eines neuen Players automatisieren - [x] Erstinstallation eines neuen Players automatisieren
- [x] Update-Rollout eines bestehenden 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 ## Phase 8 - Pilotbetrieb
@ -133,6 +136,33 @@
- [ ] Update- und Release-Prozess festlegen - [ ] Update- und Release-Prozess festlegen
- [ ] Langfristige Wayland-Neubewertung fuer spaetere Version vormerken - [ ] 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 ## Querschnittsthemen
- [ ] Datensicherung fuer Datenbank und Medien einplanen - [ ] Datensicherung fuer Datenbank und Medien einplanen
@ -144,13 +174,13 @@
## Erste konkrete Abarbeitungsreihenfolge ## Erste konkrete Abarbeitungsreihenfolge
- [ ] 1. Projektstruktur im neuen Verzeichnis vervollstaendigen - [x] 1. Projektstruktur im neuen Verzeichnis vervollstaendigen
- [ ] 2. Datenmodell in eigener Datei ausformulieren - [x] 2. Datenmodell in eigener Datei ausformulieren
- [ ] 3. API- und MQTT-Vertrag definieren - [x] 3. API- und MQTT-Vertrag definieren
- [ ] 4. Player-Minimalkonzept fuer Raspberry Pi OS Debian 13 festzurren - [x] 4. Player-Minimalkonzept fuer Raspberry Pi OS Debian 13 festzurren
- [ ] 5. Server-Compose-Grundgeruest erstellen - [x] 5. Server-Compose-Grundgeruest erstellen
- [ ] 6. Player-Prototyp mit lokalem Browser-Renderer bauen - [x] 6. Player-Prototyp mit lokalem Browser-Renderer bauen
- [ ] 7. Offline-Cache und Fallback robust machen - [x] 7. Offline-Cache und Fallback robust machen
- [ ] 8. UIs fuer Admin und Firmen schrittweise aufbauen - [x] 8. UIs fuer Admin und Firmen schrittweise aufbauen
- [ ] 9. Ansible-Rollen erstellen - [x] 9. Ansible-Rollen erstellen
- [ ] 10. Pilotmonitor migrieren - [ ] 10. Pilotmonitor migrieren

View file

@ -4,3 +4,4 @@ ansible_user: admin
screen_id: info01-dev screen_id: info01-dev
screen_name: "Info01 Entwicklung" screen_name: "Info01 Entwicklung"
screen_orientation: landscape screen_orientation: landscape
morz_server_base_url: "http://192.168.64.1:8080"

View file

@ -32,11 +32,14 @@ services:
MORZ_INFOBOARD_HTTP_ADDR: ":8080" MORZ_INFOBOARD_HTTP_ADDR: ":8080"
MORZ_INFOBOARD_DATABASE_URL: "postgres://morz_infoboard:morz_infoboard@postgres:5432/morz_infoboard?sslmode=disable" MORZ_INFOBOARD_DATABASE_URL: "postgres://morz_infoboard:morz_infoboard@postgres:5432/morz_infoboard?sslmode=disable"
MORZ_INFOBOARD_UPLOAD_DIR: "/uploads" MORZ_INFOBOARD_UPLOAD_DIR: "/uploads"
MORZ_INFOBOARD_MQTT_BROKER: "tcp://mosquitto:1883"
volumes: volumes:
- uploads:/uploads - uploads:/uploads
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
mosquitto:
condition: service_started
restart: unless-stopped restart: unless-stopped
volumes: volumes:

836
docs/API-ENDPOINTS.md Normal file
View 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
View 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
View 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
View 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)

View file

@ -148,6 +148,8 @@ Wenn weder Kampagne noch gueltige Playlist-Inhalte verfuegbar sind:
- lokal oder aus Cache - lokal oder aus Cache
- Anzeige ueber Browser/PDF-Renderer - 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 ### Webseite

View file

@ -180,6 +180,14 @@ Noch nicht Teil dieser Stufe:
- Admin-UI-Anzeige des letzten Status - Admin-UI-Anzeige des letzten Status
- Retry-Queue oder lokale Zwischenspeicherung im Agent - 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 ## Folgeschritte
Auf diesem Pfad bauen spaeter auf: Auf diesem Pfad bauen spaeter auf:

View file

@ -8,11 +8,13 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"sync" "sync"
"time" "time"
"git.az-it.net/az/morz-infoboard/player/agent/internal/config" "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/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/playerserver"
"git.az-it.net/az/morz-infoboard/player/agent/internal/statusreporter" "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). // Playlist fetched from the backend (protected by playlistMu).
playlistMu sync.RWMutex playlistMu sync.RWMutex
playlist []playerserver.PlaylistItem 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 { type statusSender interface {
@ -109,6 +116,7 @@ func newApp(cfg config.Config, logger *log.Logger, now func() time.Time, reporte
mqttPub: mqttPub, mqttPub: mqttPub,
status: StatusStarting, status: StatusStarting,
serverConnectivity: ConnectivityUnknown, 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). // Self-register this screen in the backend (best-effort, non-blocking).
go a.registerScreen(ctx) 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) go a.pollPlaylist(ctx)
a.emitHeartbeat() 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) { func (a *App) pollPlaylist(ctx context.Context) {
// Fetch immediately on startup, then every 60s. // Fetch immediately on startup.
a.fetchPlaylist(ctx) a.fetchPlaylist(ctx)
ticker := time.NewTicker(60 * time.Second) ticker := time.NewTicker(60 * time.Second)
@ -277,6 +309,9 @@ func (a *App) pollPlaylist(ctx context.Context) {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-a.mqttFetchC:
a.logger.Printf("event=playlist_triggered_by_mqtt screen_id=%s", a.Config.ScreenID)
a.fetchPlaylist(ctx)
case <-ticker.C: case <-ticker.C:
a.fetchPlaylist(ctx) a.fetchPlaylist(ctx)
} }
@ -313,6 +348,12 @@ func (a *App) fetchPlaylist(ctx context.Context) {
return 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.playlistMu.Lock()
a.playlist = pr.Items a.playlist = pr.Items
a.playlistMu.Unlock() a.playlistMu.Unlock()

View 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)
}

View file

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"math/rand"
"net" "net"
"net/http" "net/http"
"os" "os"
@ -50,12 +51,14 @@ type SysInfo struct {
type Server struct { type Server struct {
listenAddr string listenAddr string
nowFn func() NowPlaying 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". // 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. // nowFn is called on each request and returns the current playback state.
func New(listenAddr string, nowFn func() NowPlaying) *Server { 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. // 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 /player", s.handlePlayer)
mux.HandleFunc("GET /api/now-playing", s.handleNowPlaying) mux.HandleFunc("GET /api/now-playing", s.handleNowPlaying)
mux.HandleFunc("GET /api/sysinfo", handleSysInfo) 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)))) mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub))))
srv := &http.Server{Handler: mux} 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 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) { func handleSysInfo(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(collectSysInfo()) //nolint:errcheck json.NewEncoder(w).Encode(collectSysInfo()) //nolint:errcheck
@ -187,11 +199,38 @@ const playerHTML = `<!DOCTYPE html>
font-weight: 500; letter-spacing: 0.03em; color: #fff; font-weight: 500; letter-spacing: 0.03em; color: #fff;
} }
/* Inhalts-iframe */ /* Inhalts-Elemente: iframe, img, video */
#frame { #frame, #img-view, #video-view {
position: fixed; inset: 0; position: fixed; inset: 0;
width: 100%; height: 100%; width: 100%; height: 100%;
border: none; display: none; z-index: 10; 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 */ /* Verbindungsstatus-Punkt */
@ -210,12 +249,22 @@ const playerHTML = `<!DOCTYPE html>
<div id="splash"></div> <div id="splash"></div>
<div id="info-overlay"></div> <div id="info-overlay"></div>
<iframe id="frame" allow="autoplay; fullscreen" allowfullscreen></iframe> <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> <div id="dot"></div>
<script> <script>
var splash = document.getElementById('splash'); var splash = document.getElementById('splash');
var overlay = document.getElementById('info-overlay'); var overlay = document.getElementById('info-overlay');
var frame = document.getElementById('frame'); 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'); var dot = document.getElementById('dot');
// ── Splash-Orientierung ─────────────────────────────────────────── // ── Splash-Orientierung ───────────────────────────────────────────
@ -229,9 +278,30 @@ const playerHTML = `<!DOCTYPE html>
window.addEventListener('resize', updateSplash); window.addEventListener('resize', updateSplash);
// ── Sysinfo-Overlay ─────────────────────────────────────────────── // ── 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 = ''; overlay.innerHTML = '';
(items || []).forEach(function(item) { all.forEach(function(item) {
var el = document.createElement('div'); var el = document.createElement('div');
el.className = 'info-item'; el.className = 'info-item';
el.innerHTML = el.innerHTML =
@ -251,6 +321,11 @@ const playerHTML = `<!DOCTYPE html>
var currentIdx = 0; var currentIdx = 0;
var rotateTimer = null; 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. // Returns a fingerprint string for change detection.
function playlistKey(pl) { function playlistKey(pl) {
return (pl || []).map(function(i) { return i.src + ':' + i.duration_seconds; }).join('|'); 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; } if (rotateTimer) { clearTimeout(rotateTimer); rotateTimer = null; }
} }
function showItem(item) { // Versteckt alle Content-Elemente vor dem Anzeigen des richtigen Typs.
if (!item) { showSplash(); return; } // Blendet zunächst auf opacity:0 aus und entfernt display erst nach der
if (frame.src !== item.src) { frame.src = item.src; } // Transition (500ms), damit der Fade-Out sichtbar ist.
frame.style.display = ''; //
// Hide splash overlay while content is visible. // Race-Condition-Fix: Das setTimeout-Callback prüft vor dem display=none,
overlay.style.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(); clearRotation();
var ms = Math.max((item.duration_seconds || 20), 1) * 1000; var ms = Math.max((durationSeconds || 20), 1) * 1000;
rotateTimer = setTimeout(function() { rotateTimer = setTimeout(function() {
currentIdx = (currentIdx + 1) % items.length; currentIdx = (currentIdx + 1) % items.length;
showItem(items[currentIdx]); showItem(items[currentIdx]);
}, ms); }, 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() { function showSplash() {
clearRotation(); clearRotation();
frame.style.display = 'none'; hideAllContent();
showSplashDiv();
overlay.style.display = ''; overlay.style.display = '';
} }
var lastPlaylistKey = ''; var lastPlaylistKey = '';
function applyNowPlaying(data) { function applyNowPlaying(data) {
// Connectivity-Punkt und dynamische Overlay-Variable aktualisieren.
dot.className = data.connectivity || ''; dot.className = data.connectivity || '';
dynConnectivity = data.connectivity || '';
// Legacy single-URL fallback. // Legacy single-URL fallback.
if (data.url && (!data.playlist || data.playlist.length === 0)) { if (data.url && (!data.playlist || data.playlist.length === 0)) {
var key = data.url + ':legacy'; var key = data.url + ':legacy';
dynPlaylistLength = 1;
dynCurrentTitle = data.url;
renderSysInfo();
if (lastPlaylistKey !== key) { if (lastPlaylistKey !== key) {
lastPlaylistKey = key; lastPlaylistKey = key;
items = [{ src: data.url, type: 'web', duration_seconds: 30 }]; items = [{ src: data.url, type: 'web', duration_seconds: 30 }];
@ -299,13 +512,23 @@ const playerHTML = `<!DOCTYPE html>
} }
var playlist = data.playlist || []; var playlist = data.playlist || [];
dynPlaylistLength = playlist.length;
if (playlist.length === 0) { if (playlist.length === 0) {
dynCurrentTitle = '';
renderSysInfo();
showSplash(); showSplash();
lastPlaylistKey = ''; lastPlaylistKey = '';
items = []; items = [];
return; 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); var key = playlistKey(playlist);
if (key === lastPlaylistKey) { if (key === lastPlaylistKey) {
return; // unchanged — let current rotation continue return; // unchanged — let current rotation continue
@ -315,6 +538,8 @@ const playerHTML = `<!DOCTYPE html>
lastPlaylistKey = key; lastPlaylistKey = key;
items = playlist; items = playlist;
currentIdx = 0; currentIdx = 0;
dynCurrentTitle = items[0].title || items[0].src || '';
renderSysInfo();
showItem(items[0]); showItem(items[0]);
} }
@ -322,21 +547,79 @@ const playerHTML = `<!DOCTYPE html>
function pollSysInfo() { function pollSysInfo() {
fetch('/api/sysinfo') fetch('/api/sysinfo')
.then(function(r) { return r.json(); }) .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); }) .then(function(d) { renderSysInfo(d.items); })
.catch(function() {}); .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() { function pollNowPlaying() {
fetch('/api/now-playing') fetch('/api/now-playing')
.then(function(r) { return r.json(); }) .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'; }); .catch(function() { dot.className = 'offline'; });
} }
pollSysInfo(); pollSysInfo();
pollNowPlaying(); pollNowPlaying();
setInterval(pollSysInfo, 30000); // sysinfo alle 30s setInterval(pollSysInfo, 30000); // sysinfo weiterhin alle 30s
setInterval(pollNowPlaying, 30000); // playlist 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> </script>
</body> </body>
</html>` </html>`

View file

@ -3,10 +3,13 @@ module git.az-it.net/az/morz-infoboard/server/backend
go 1.25.0 go 1.25.0
require ( 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // 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/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect golang.org/x/text v0.29.0 // indirect
) )

View file

@ -1,4 +1,8 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=

View file

@ -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/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/db" "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/httpapi"
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store" "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
) )
type App struct { type App struct {
Config config.Config Config config.Config
server *http.Server server *http.Server
notifier *mqttnotifier.Notifier
} }
func New() (*App, error) { func New() (*App, error) {
@ -46,26 +48,34 @@ func New() (*App, error) {
media := store.NewMediaStore(pool.Pool) media := store.NewMediaStore(pool.Pool)
playlists := store.NewPlaylistStore(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{ handler := httpapi.NewRouter(httpapi.RouterDeps{
StatusStore: statusStore, StatusStore: statusStore,
TenantStore: tenants, TenantStore: tenants,
ScreenStore: screens, ScreenStore: screens,
MediaStore: media, MediaStore: media,
PlaylistStore: playlists, PlaylistStore: playlists,
Notifier: notifier,
UploadDir: cfg.UploadDir, UploadDir: cfg.UploadDir,
Logger: logger, Logger: logger,
}) })
return &App{ return &App{
Config: cfg, Config: cfg,
server: &http.Server{ server: &http.Server{Addr: cfg.HTTPAddress, Handler: handler},
Addr: cfg.HTTPAddress, notifier: notifier,
Handler: handler,
},
}, nil }, nil
} }
func (a *App) Run() error { func (a *App) Run() error {
defer a.notifier.Close()
err := a.server.ListenAndServe() err := a.server.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) { if errors.Is(err, http.ErrServerClosed) {
return nil return nil

View file

@ -7,6 +7,10 @@ type Config struct {
StatusStorePath string StatusStorePath string
DatabaseURL string DatabaseURL string
UploadDir string UploadDir string
// MQTT — optional. When MQTTBroker is empty, notifications are disabled.
MQTTBroker string
MQTTUsername string
MQTTPassword string
} }
func Load() Config { func Load() Config {
@ -15,6 +19,9 @@ func Load() Config {
StatusStorePath: os.Getenv("MORZ_INFOBOARD_STATUS_STORE_PATH"), 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"), 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"), 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"),
} }
} }

View file

@ -83,6 +83,9 @@ func HandleUploadMedia(tenants *store.TenantStore, media *store.MediaStore, uplo
defer file.Close() defer file.Close()
mimeType := header.Header.Get("Content-Type") mimeType := header.Header.Get("Content-Type")
if detectedType := mimeToAssetType(mimeType); detectedType != "" {
assetType = detectedType
}
if title == "" { if title == "" {
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)) 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 { func sanitize(s string) string {
var b strings.Builder var b strings.Builder
for _, r := range s { for _, r := range s {

View file

@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store" "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). // 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) { return func(w http.ResponseWriter, r *http.Request) {
playlistID := r.PathValue("playlistId") playlistID := r.PathValue("playlistId")
@ -98,6 +99,10 @@ func HandleAddItem(playlists *store.PlaylistStore, media *store.MediaStore) http
return return
} }
if slug, err := playlists.ScreenSlugByPlaylistID(r.Context(), playlistID); err == nil {
notifier.NotifyChanged(slug)
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(item) //nolint:errcheck 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. // 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) { return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("itemId") id := r.PathValue("itemId")
@ -136,24 +141,38 @@ func HandleUpdateItem(playlists *store.PlaylistStore) http.HandlerFunc {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }
if slug, err := playlists.ScreenSlugByItemID(r.Context(), id); err == nil {
notifier.NotifyChanged(slug)
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
} }
// HandleDeleteItem removes a playlist item. // 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) { return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("itemId") 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 { if err := playlists.DeleteItem(r.Context(), id); err != nil {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }
if slug != "" {
notifier.NotifyChanged(slug)
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
} }
// HandleReorder accepts an ordered list of item IDs and updates order_index. // 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) { return func(w http.ResponseWriter, r *http.Request) {
playlistID := r.PathValue("playlistId") playlistID := r.PathValue("playlistId")
@ -167,6 +186,11 @@ func HandleReorder(playlists *store.PlaylistStore) http.HandlerFunc {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }
if slug, err := playlists.ScreenSlugByPlaylistID(r.Context(), playlistID); err == nil {
notifier.NotifyChanged(slug)
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
} }

View 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
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,7 +6,7 @@ const provisionTmpl = `<!DOCTYPE html>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einrichten {{.Screen.Name}}</title> <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> <style>
body { background: #f5f5f5; } body { background: #f5f5f5; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 4px; pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 4px;
@ -62,7 +62,10 @@ ansible_user: {{.SSHUser}}
screen_id: {{.Screen.Slug}} screen_id: {{.Screen.Slug}}
screen_name: "{{.Screen.Name}}" screen_name: "{{.Screen.Name}}"
screen_orientation: {{.Orientation}}</pre> 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> <p class="help mt-2">Tipp: <code>mkdir -p ansible/host_vars/{{.Screen.Slug}}</code></p>
</div> </div>
</div> </div>
@ -115,15 +118,26 @@ ansible-playbook -i ansible/inventory.yml ansible/site.yml --limit {{.Screen.Slu
</section> </section>
<script> <script>
function copy(id) { function copy(id, btnId) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (!el) return;
navigator.clipboard.writeText(el.innerText).then(function() { 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; var orig = btn.textContent;
btn.textContent = ' Kopiert!'; btn.textContent = ' Kopiert!';
setTimeout(function() { btn.textContent = orig; }, 1500); 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> </script>
</body> </body>
</html>` </html>`
@ -134,30 +148,116 @@ const adminTmpl = `<!DOCTYPE html>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MORZ Infoboard Admin</title> <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> <style>
body { background: #f5f5f5; } body { background: #f5f5f5; }
.navbar { margin-bottom: 1.5rem; } .navbar { margin-bottom: 1.5rem; }
</style> </style>
</head> </head>
<body> <body>
<nav class="navbar is-dark" role="navigation"> <nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand"> <div class="navbar-brand">
<span class="navbar-item"><strong>📺 MORZ Infoboard</strong></span> <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> </div>
</nav> </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"> <section class="section pt-0">
<div class="container"> <div class="container">
<div class="box"> <div class="box">
<h2 class="title is-5">Bildschirme</h2> <h2 class="title is-5">Bildschirme</h2>
{{if .Screens}} {{if .Screens}}
<div style="overflow-x: auto">
<table class="table is-fullwidth is-hoverable is-striped"> <table class="table is-fullwidth is-hoverable is-striped">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Slug</th> <th>Slug</th>
<th>Format</th> <th>Format</th>
<th>Status</th>
<th>Aktionen</th> <th>Aktionen</th>
</tr> </tr>
</thead> </thead>
@ -167,18 +267,20 @@ const adminTmpl = `<!DOCTYPE html>
<td><strong>{{.Name}}</strong></td> <td><strong>{{.Name}}</strong></td>
<td><code>{{.Slug}}</code></td> <td><code>{{.Slug}}</code></td>
<td>{{orientationLabel .Orientation}}</td> <td>{{orientationLabel .Orientation}}</td>
<td id="status-{{.Slug}}"><span class="has-text-grey"></span></td>
<td> <td>
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a> <a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
&nbsp; &nbsp;
<form method="POST" action="/admin/screens/{{.ID}}/delete" style="display:inline" <button class="button is-small is-danger is-outlined"
onsubmit="return confirm('Bildschirm löschen?\n\nAlle Playlist-Einträge werden ebenfalls gelöscht.')"> type="button"
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button> aria-label="Bildschirm {{.Name}} löschen"
</form> onclick="openDeleteModal('/admin/screens/{{.ID}}/delete', '{{.Name}}')">Löschen</button>
</td> </td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div>
{{else}} {{else}}
<p class="has-text-grey">Noch keine Bildschirme angelegt.</p> <p class="has-text-grey">Noch keine Bildschirme angelegt.</p>
{{end}} {{end}}
@ -294,6 +396,24 @@ const adminTmpl = `<!DOCTYPE html>
</div> </div>
</section> </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> </body>
</html>` </html>`
@ -303,8 +423,8 @@ const manageTmpl = `<!DOCTYPE html>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playlist {{.Screen.Name}}</title> <title>Playlist {{.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">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"></script> <script src="/static/Sortable.min.js"></script>
<style> <style>
body { background: #f5f5f5; } body { background: #f5f5f5; }
.drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; } .drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; }
@ -319,7 +439,7 @@ const manageTmpl = `<!DOCTYPE html>
</head> </head>
<body> <body>
<nav class="navbar is-dark" role="navigation"> <nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand"> <div class="navbar-brand">
<a class="navbar-item" href="/admin"> Admin</a> <a class="navbar-item" href="/admin"> Admin</a>
<span class="navbar-item"> <span class="navbar-item">
@ -327,9 +447,79 @@ const manageTmpl = `<!DOCTYPE html>
&nbsp; &nbsp;
<span class="tag is-info is-light">{{orientationLabel .Screen.Orientation}}</span> <span class="tag is-info is-light">{{orientationLabel .Screen.Orientation}}</span>
</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> </div>
</nav> </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"> <section class="section pt-4">
<div class="container"> <div class="container">
@ -337,6 +527,7 @@ const manageTmpl = `<!DOCTYPE html>
<div class="box"> <div class="box">
<h2 class="title is-5 mb-3">Aktuelle Playlist</h2> <h2 class="title is-5 mb-3">Aktuelle Playlist</h2>
{{if .Items}} {{if .Items}}
<div style="overflow-x: auto">
<table class="table is-fullwidth" id="playlist-table"> <table class="table is-fullwidth" id="playlist-table">
<thead> <thead>
<tr> <tr>
@ -351,7 +542,7 @@ const manageTmpl = `<!DOCTYPE html>
<tbody id="sortable-items"> <tbody id="sortable-items">
{{range .Items}} {{range .Items}}
<tr id="item-{{.ID}}" class="{{if not .Enabled}}item-disabled{{end}}"> <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> <td>
<span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span> <span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span>
</td> </td>
@ -369,11 +560,11 @@ const manageTmpl = `<!DOCTYPE html>
</td> </td>
<td> <td>
<button class="button is-small is-info is-outlined" onclick="toggleEdit('{{.ID}}')">Bearbeiten</button> <button class="button is-small is-info is-outlined" onclick="toggleEdit('{{.ID}}')">Bearbeiten</button>
<form method="POST" action="/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete" <button class="button is-small is-danger is-outlined"
style="display:inline" type="button"
onsubmit="return confirm('Eintrag wirklich aus der Playlist entfernen?')"> aria-label="{{if .Title}}{{.Title}}{{else}}Eintrag{{end}} aus Playlist entfernen"
<button class="button is-small is-danger is-outlined" type="submit" title="Entfernen"></button> title="Entfernen"
</form> onclick="openManageDeleteModal('/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete', 'Eintrag entfernen?', 'Eintrag wirklich aus der Playlist entfernen?')"></button>
</td> </td>
</tr> </tr>
<tr id="edit-{{.ID}}" class="edit-row" style="display:none"> <tr id="edit-{{.ID}}" class="edit-row" style="display:none">
@ -402,11 +593,11 @@ const manageTmpl = `<!DOCTYPE html>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
<label class="label is-small">Aktiv</label> <label class="label is-small">Aktiv</label>
<div class="select is-small"> <div class="control" style="padding-top:0.4rem">
<select name="enabled"> <label class="checkbox">
<option value="true"{{if .Enabled}} selected{{end}}>Ja</option> <input type="checkbox" name="enabled" value="true" {{if .Enabled}}checked{{end}}>
<option value="false"{{if not .Enabled}} selected{{end}}>Nein</option> Aktiv
</select> </label>
</div> </div>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
@ -423,6 +614,7 @@ const manageTmpl = `<!DOCTYPE html>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div>
<p class="help has-text-grey mt-2">Einträge per Drag &amp; Drop in der Reihenfolge verschieben.</p> <p class="help has-text-grey mt-2">Einträge per Drag &amp; Drop in der Reihenfolge verschieben.</p>
{{else}} {{else}}
<div class="notification is-light"> <div class="notification is-light">
@ -435,6 +627,7 @@ const manageTmpl = `<!DOCTYPE html>
<div class="box"> <div class="box">
<h2 class="title is-5 mb-3">Medienbibliothek</h2> <h2 class="title is-5 mb-3">Medienbibliothek</h2>
{{if .Assets}} {{if .Assets}}
<div style="overflow-x: auto">
<table class="table is-fullwidth is-hoverable is-striped"> <table class="table is-fullwidth is-hoverable is-striped">
<thead> <thead>
<tr> <tr>
@ -464,16 +657,17 @@ const manageTmpl = `<!DOCTYPE html>
</form> </form>
&nbsp; &nbsp;
{{end}} {{end}}
<form method="POST" action="/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete" <button class="button is-small is-danger is-outlined"
style="display:inline" type="button"
onsubmit="return confirm('Medium wirklich aus der Bibliothek löschen?\n(Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.)')"> aria-label="{{.Title}} aus Bibliothek löschen"
<button class="button is-small is-danger is-outlined" type="submit" title="Aus Bibliothek löschen">🗑</button> title="Aus Bibliothek löschen"
</form> 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> </td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div>
{{else}} {{else}}
<p class="has-text-grey">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</p> <p class="has-text-grey">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</p>
{{end}} {{end}}
@ -491,7 +685,7 @@ const manageTmpl = `<!DOCTYPE html>
</div> </div>
<div id="panel-file" class="tab-panel is-active"> <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="columns is-vcentered">
<div class="column is-2"> <div class="column is-2">
<div class="field"> <div class="field">
@ -516,7 +710,7 @@ const manageTmpl = `<!DOCTYPE html>
<div class="field"> <div class="field">
<label class="label">Datei</label> <label class="label">Datei</label>
<div class="control"> <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"> accept="image/*,video/*,application/pdf">
</div> </div>
</div> </div>
@ -524,10 +718,14 @@ const manageTmpl = `<!DOCTYPE html>
<div class="column is-narrow"> <div class="column is-narrow">
<div class="field"> <div class="field">
<label class="label">&nbsp;</label> <label class="label">&nbsp;</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>
</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> </form>
</div> </div>
@ -563,6 +761,18 @@ const manageTmpl = `<!DOCTYPE html>
</section> </section>
<script> <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) { function toggleEdit(id) {
var row = document.getElementById('edit-' + id); var row = document.getElementById('edit-' + id);
if (row) { if (row) {
@ -575,6 +785,7 @@ function switchTab(tab) {
panels.forEach(function(p) { panels.forEach(function(p) {
var panel = document.getElementById('panel-' + p); var panel = document.getElementById('panel-' + p);
var tabEl = document.getElementById('tab-' + p); var tabEl = document.getElementById('tab-' + p);
if (!panel || !tabEl) return;
if (p === tab) { if (p === tab) {
panel.classList.add('is-active'); panel.classList.add('is-active');
tabEl.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> </script>
</body> </body>

View file

@ -12,6 +12,7 @@ import (
"strings" "strings"
"time" "time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store" "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) http.Error(w, "Fehler: "+err.Error(), http.StatusInternalServerError)
return 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) http.Error(w, "db error", http.StatusInternalServerError)
return 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) http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError)
return 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. // 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) { return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug") screenSlug := r.PathValue("screenSlug")
if err := r.ParseForm(); err != nil { 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) http.Error(w, "db error", http.StatusInternalServerError)
return 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. // 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) { return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug") screenSlug := r.PathValue("screenSlug")
itemID := r.PathValue("itemId") itemID := r.PathValue("itemId")
@ -366,12 +368,13 @@ func HandleDeleteItemUI(playlists *store.PlaylistStore) http.HandlerFunc {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return 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). // 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) { return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug") screenSlug := r.PathValue("screenSlug")
screen, err := screens.GetBySlug(r.Context(), 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) http.Error(w, "db error", http.StatusInternalServerError)
return return
} }
notifier.NotifyChanged(screenSlug)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
} }
// HandleUpdateItemUI handles form PATCH/POST to update a single item. // 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) { return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug") screenSlug := r.PathValue("screenSlug")
itemID := r.PathValue("itemId") 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 { if d, err := strconv.Atoi(strings.TrimSpace(r.FormValue("duration_seconds"))); err == nil && d > 0 {
durationSeconds = d durationSeconds = d
} }
enabled := r.FormValue("enabled") != "false" enabled := r.FormValue("enabled") == "true"
validFrom, _ := parseOptionalTime(r.FormValue("valid_from")) validFrom, _ := parseOptionalTime(r.FormValue("valid_from"))
validUntil, _ := parseOptionalTime(r.FormValue("valid_until")) validUntil, _ := parseOptionalTime(r.FormValue("valid_until"))
@ -419,12 +423,13 @@ func HandleUpdateItemUI(playlists *store.PlaylistStore) http.HandlerFunc {
http.Error(w, "db error", http.StatusInternalServerError) http.Error(w, "db error", http.StatusInternalServerError)
return 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. // 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) { return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug") screenSlug := r.PathValue("screenSlug")
mediaID := r.PathValue("mediaId") mediaID := r.PathValue("mediaId")
@ -435,6 +440,7 @@ func HandleDeleteMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
} }
media.Delete(r.Context(), mediaID) //nolint:errcheck 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)
} }
} }

View file

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage" "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" "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
) )
@ -15,6 +16,7 @@ type RouterDeps struct {
ScreenStore *store.ScreenStore ScreenStore *store.ScreenStore
MediaStore *store.MediaStore MediaStore *store.MediaStore
PlaylistStore *store.PlaylistStore PlaylistStore *store.PlaylistStore
Notifier *mqttnotifier.Notifier
UploadDir string UploadDir string
Logger *log.Logger Logger *log.Logger
} }
@ -73,9 +75,19 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
uploadDir = "/tmp/morz-uploads" 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. // Serve uploaded files.
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir)))) 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 ────────────────────────────────────────────────────────── // ── Admin UI ──────────────────────────────────────────────────────────
mux.HandleFunc("GET /admin", manage.HandleAdminUI(d.TenantStore, d.ScreenStore)) mux.HandleFunc("GET /admin", manage.HandleAdminUI(d.TenantStore, d.ScreenStore))
mux.HandleFunc("POST /admin/screens/provision", manage.HandleProvisionUI(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", mux.HandleFunc("POST /manage/{screenSlug}/upload",
manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir)) manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))
mux.HandleFunc("POST /manage/{screenSlug}/items", 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}", mux.HandleFunc("POST /manage/{screenSlug}/items/{itemId}",
manage.HandleUpdateItemUI(d.PlaylistStore)) manage.HandleUpdateItemUI(d.PlaylistStore, notifier))
mux.HandleFunc("POST /manage/{screenSlug}/items/{itemId}/delete", mux.HandleFunc("POST /manage/{screenSlug}/items/{itemId}/delete",
manage.HandleDeleteItemUI(d.PlaylistStore)) manage.HandleDeleteItemUI(d.PlaylistStore, notifier))
mux.HandleFunc("POST /manage/{screenSlug}/reorder", 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", 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 ──────────────────────────────────────────────── // ── JSON API — screens ────────────────────────────────────────────────
// Self-registration: called by agent on startup (must be before /{tenantSlug}/ routes) // 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}", mux.HandleFunc("GET /api/v1/playlists/{screenId}",
manage.HandleGetPlaylist(d.ScreenStore, d.PlaylistStore)) manage.HandleGetPlaylist(d.ScreenStore, d.PlaylistStore))
mux.HandleFunc("POST /api/v1/playlists/{playlistId}/items", 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}", mux.HandleFunc("PATCH /api/v1/items/{itemId}",
manage.HandleUpdateItem(d.PlaylistStore)) manage.HandleUpdateItem(d.PlaylistStore, notifier))
mux.HandleFunc("DELETE /api/v1/items/{itemId}", mux.HandleFunc("DELETE /api/v1/items/{itemId}",
manage.HandleDeleteItem(d.PlaylistStore)) manage.HandleDeleteItem(d.PlaylistStore, notifier))
mux.HandleFunc("PUT /api/v1/playlists/{playlistId}/order", 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", mux.HandleFunc("PATCH /api/v1/playlists/{playlistId}/duration",
manage.HandleUpdatePlaylistDuration(d.PlaylistStore)) manage.HandleUpdatePlaylistDuration(d.PlaylistStore))
} }

View file

@ -239,9 +239,9 @@ func TestRouterScreenDetailPageRoute(t *testing.T) {
"tcp://127.0.0.1:1883", "tcp://127.0.0.1:1883",
"2026-03-22T16:09:30Z", "2026-03-22T16:09:30Z",
"/api/v1/screens/info01-dev/status", "/api/v1/screens/info01-dev/status",
"← All screens", "← Alle Bildschirme",
"Timing", "Zeitstempel",
"Endpoints", "Verbindungen",
"<meta http-equiv=\"refresh\" content=\"15\">", "<meta http-equiv=\"refresh\" content=\"15\">",
} { } {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
@ -264,7 +264,7 @@ func TestRouterScreenDetailPageNotFound(t *testing.T) {
t.Fatalf("Content-Type = %q, want text/html", got) 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") t.Fatal("body missing back link")
} }
} }
@ -321,13 +321,13 @@ func TestRouterStatusPageRoute(t *testing.T) {
body := w.Body.String() body := w.Body.String()
for _, want := range []string{ for _, want := range []string{
"Screen Status", "Bildschirmstatus",
"2 screens", "2 Bildschirme",
"<meta http-equiv=\"refresh\" content=\"15\">", "<meta http-equiv=\"refresh\" content=\"15\">",
"Connectivity offline", "Konnektivität: Offline",
"Connectivity degraded", "Konnektivität: Eingeschränkt",
"Stale reports", "Veraltete Meldungen",
"Fresh reports", "Aktuelle Meldungen",
"updated_since=2026-03-22T15%3A55%3A00Z", "updated_since=2026-03-22T15%3A55%3A00Z",
"screen-offline", "screen-offline",
"offline", "offline",

View file

@ -432,15 +432,16 @@ var statusTemplateFuncs = template.FuncMap{
"screenDetailHTMLPath": screenDetailHTMLPath, "screenDetailHTMLPath": screenDetailHTMLPath,
"statusClass": statusClass, "statusClass": statusClass,
"timestampLabel": timestampLabel, "timestampLabel": timestampLabel,
"stateLabel": stateLabel,
} }
var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html> var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="{{.RefreshSeconds}}"> <meta http-equiv="refresh" content="{{.RefreshSeconds}}">
<title>Screen Status</title> <title>Bildschirmstatus</title>
` + statusPageCSSBlock + ` ` + statusPageCSSBlock + `
</head> </head>
<body> <body>
@ -448,19 +449,20 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
<section class="hero"> <section class="hero">
<div class="hero-top"> <div class="hero-top">
<div> <div>
<h1>Screen Status</h1> <h1>Bildschirmstatus</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> <p class="lead">Kompakte Übersicht der zuletzt gemeldeten Bildschirmzustände. Offline- und eingeschränkte Bildschirme erscheinen oben für schnelle Diagnose.</p>
</div> </div>
<div class="meta"> <div class="meta">
<div>{{.Overview.Summary.Total}} screens</div> <div>{{.Overview.Summary.Total}} Bildschirme</div>
<div>Updated {{.GeneratedAt}}</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> </div>
<div class="summary-grid"> <div class="summary-grid">
<article class="summary-card"> <article class="summary-card">
<strong>{{.Overview.Summary.Total}}</strong> <strong>{{.Overview.Summary.Total}}</strong>
<span>Total known screens</span> <span>Bildschirme gesamt</span>
</article> </article>
<article class="summary-card offline"> <article class="summary-card offline">
<strong>{{.Overview.Summary.Offline}}</strong> <strong>{{.Overview.Summary.Offline}}</strong>
@ -468,7 +470,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
</article> </article>
<article class="summary-card degraded"> <article class="summary-card degraded">
<strong>{{.Overview.Summary.Degraded}}</strong> <strong>{{.Overview.Summary.Degraded}}</strong>
<span>Degraded</span> <span>Eingeschränkt</span>
</article> </article>
<article class="summary-card online"> <article class="summary-card online">
<strong>{{.Overview.Summary.Online}}</strong> <strong>{{.Overview.Summary.Online}}</strong>
@ -476,7 +478,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
</article> </article>
<article class="summary-card"> <article class="summary-card">
<strong>{{.Overview.Summary.Stale}}</strong> <strong>{{.Overview.Summary.Stale}}</strong>
<span>Stale reports</span> <span>Veraltete Meldungen</span>
</article> </article>
</div> </div>
</section> </section>
@ -484,15 +486,15 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<div> <div>
<h2>Filters and refresh</h2> <h2>Filter und Aktualisierung</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> <p class="panel-copy">Diese Seite aktualisiert sich alle {{.RefreshSeconds}} Sekunden. Verwende die Schnellfilter oder das Formular, um die Ansicht einzugrenzen.</p>
</div> </div>
<a class="meta-chip" href="{{.StatusAPIPath}}">JSON overview</a> <a class="meta-chip" href="{{.StatusAPIPath}}">JSON-Übersicht</a>
</div> </div>
<div class="controls-grid"> <div class="controls-grid">
<div> <div>
<h2>Quick views</h2> <h2>Schnellansichten</h2>
<div class="quick-filters"> <div class="quick-filters">
{{range .QuickFilters}} {{range .QuickFilters}}
<a class="filter-link {{.Class}} {{if .Active}}active{{end}}" href="{{.Href}}">{{.Label}}</a> <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}}"> <form class="filter-form" method="get" action="{{.StatusPagePath}}">
<div class="field full"> <div class="field full">
<label for="q">Screen ID contains</label> <label for="q">Screen-ID enthält</label>
<input id="q" name="q" type="text" placeholder="e.g. info01" value="{{.Filters.ScreenIDFilter}}"> <input id="q" name="q" type="text" placeholder="z.B. info01" value="{{.Filters.ScreenIDFilter}}">
</div> </div>
<div class="field"> <div class="field">
<label for="server_connectivity">Server connectivity</label> <label for="server_connectivity">Serverkonnektivität</label>
<select id="server_connectivity" name="server_connectivity"> <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="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="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> </select>
</div> </div>
<div class="field"> <div class="field">
<label for="stale">Freshness</label> <label for="stale">Aktualität</label>
<select id="stale" name="stale"> <select id="stale" name="stale">
<option value="" {{if eq .Filters.Stale ""}}selected{{end}}>Any</option> <option value="" {{if eq .Filters.Stale ""}}selected{{end}}>Alle</option>
<option value="true" {{if eq .Filters.Stale "true"}}selected{{end}}>Stale only</option> <option value="true" {{if eq .Filters.Stale "true"}}selected{{end}}>Nur veraltet</option>
<option value="false" {{if eq .Filters.Stale "false"}}selected{{end}}>Fresh only</option> <option value="false" {{if eq .Filters.Stale "false"}}selected{{end}}>Nur aktuell</option>
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label for="derived_state">Derived state</label> <label for="derived_state">Abgeleiteter Status</label>
<select id="derived_state" name="derived_state"> <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="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> <option value="offline" {{if eq .Filters.DerivedState "offline"}}selected{{end}}>Offline</option>
</select> </select>
</div> </div>
<div class="field full"> <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}}"> <input id="updated_since" name="updated_since" type="text" placeholder="2026-03-22T16:05:00Z" value="{{.Filters.UpdatedSince}}">
</div> </div>
@ -547,8 +549,8 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit">Apply filters</button> <button type="submit">Filter anwenden</button>
<a class="text-link" href="{{.StatusPagePath}}">Clear</a> <a class="text-link" href="{{.StatusPagePath}}">Zurücksetzen</a>
</div> </div>
</form> </form>
</div> </div>
@ -557,23 +559,23 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<div> <div>
<h2>Latest reports</h2> <h2>Aktuelle Meldungen</h2>
<p class="panel-copy">Each row links to the HTML detail view and the raw JSON endpoint for a quick drill-down.</p> <p class="panel-copy">Jede Zeile verlinkt auf die HTML-Detailansicht und den JSON-Endpunkt.</p>
</div> </div>
</div> </div>
<div class="table-actions"> <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> </div>
{{if .Overview.Screens}} {{if .Overview.Screens}}
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Screen</th> <th>Bildschirm</th>
<th>Derived state</th> <th>Status</th>
<th>Player status</th> <th>Player-Status</th>
<th>Server link</th> <th>Server</th>
<th>Received</th> <th>Empfangen</th>
<th>Heartbeat</th> <th>Heartbeat</th>
</tr> </tr>
</thead> </thead>
@ -582,28 +584,28 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
<tr> <tr>
<td> <td>
<div class="screen">{{.ScreenID}}</div> <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"> <div class="screen-links">
<a class="filter-link" href="{{screenDetailHTMLPath .ScreenID}}">Details</a> <a class="filter-link" href="{{screenDetailHTMLPath .ScreenID}}">Details</a>
<a class="json-link" href="{{screenDetailPath .ScreenID}}">JSON</a> <a class="json-link" href="{{screenDetailPath .ScreenID}}">JSON</a>
</div> </div>
</td> </td>
<td><span class="pill {{statusClass .DerivedState}}">{{.DerivedState}}</span></td> <td><span class="pill {{statusClass .DerivedState}}">{{stateLabel .DerivedState}}</span></td>
<td> <td>
<div>{{.Status}}</div> <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>
<td> <td>
<span class="state {{statusClass .ServerConnectivity}}">{{connectivityLabel .ServerConnectivity}}</span> <span class="state {{statusClass .ServerConnectivity}}">{{connectivityLabel .ServerConnectivity}}</span>
{{if .ServerURL}}<small>{{.ServerURL}}</small>{{end}} {{if .ServerURL}}<small>{{.ServerURL}}</small>{{end}}
</td> </td>
<td> <td>
<div>{{timestampLabel .ReceivedAt}}</div> <div><time class="reltime" datetime="{{.ReceivedAt}}">{{timestampLabel .ReceivedAt}}</time></div>
{{if .LastHeartbeatAt}}<small>Heartbeat {{timestampLabel .LastHeartbeatAt}}</small>{{end}} {{if .LastHeartbeatAt}}<small>Heartbeat <time class="reltime" datetime="{{.LastHeartbeatAt}}">{{timestampLabel .LastHeartbeatAt}}</time></small>{{end}}
</td> </td>
<td> <td>
{{if gt .HeartbeatEverySeconds 0}}{{.HeartbeatEverySeconds}}s{{else}}-{{end}} {{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> </td>
</tr> </tr>
{{end}} {{end}}
@ -611,21 +613,48 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
</table> </table>
</div> </div>
{{else}} {{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}} {{end}}
</section> </section>
</main> </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> </body>
</html> </html>
`)) `))
var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html> var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="{{.RefreshSeconds}}"> <meta http-equiv="refresh" content="{{.RefreshSeconds}}">
<title>{{.Record.ScreenID}} Screen Status</title> <title>{{.Record.ScreenID}} Bildschirmstatus</title>
` + statusPageCSSBlock + ` ` + statusPageCSSBlock + `
</head> </head>
<body> <body>
@ -634,12 +663,13 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
<div class="hero-top"> <div class="hero-top">
<div> <div>
<h1>{{.Record.ScreenID}}</h1> <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>
<div class="meta"> <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;"> <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> <a class="meta-chip" href="{{screenDetailPath .Record.ScreenID}}">JSON</a>
</div> </div>
</div> </div>
@ -647,20 +677,20 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
<div class="summary-grid"> <div class="summary-grid">
<article class="summary-card {{statusClass .Record.DerivedState}}"> <article class="summary-card {{statusClass .Record.DerivedState}}">
<strong><span class="state {{statusClass .Record.DerivedState}}">{{.Record.DerivedState}}</span></strong> <strong><span class="state {{statusClass .Record.DerivedState}}">{{stateLabel .Record.DerivedState}}</span></strong>
<span>Derived state</span> <span>Abgeleiteter Status</span>
</article> </article>
<article class="summary-card"> <article class="summary-card">
<strong>{{.Record.Status}}</strong> <strong>{{.Record.Status}}</strong>
<span>Player status</span> <span>Player-Status</span>
</article> </article>
<article class="summary-card {{statusClass .Record.ServerConnectivity}}"> <article class="summary-card {{statusClass .Record.ServerConnectivity}}">
<strong>{{connectivityLabel .Record.ServerConnectivity}}</strong> <strong>{{connectivityLabel .Record.ServerConnectivity}}</strong>
<span>Server connectivity</span> <span>Serverkonnektivität</span>
</article> </article>
<article class="summary-card"> <article class="summary-card">
<strong>{{if .Record.Stale}}stale{{else}}fresh{{end}}</strong> <strong>{{if .Record.Stale}}Veraltet{{else}}Aktuell{{end}}</strong>
<span>Freshness</span> <span>Aktualität</span>
</article> </article>
</div> </div>
</section> </section>
@ -668,30 +698,30 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<div> <div>
<h2>Timing</h2> <h2>Zeitstempel</h2>
<p class="panel-copy">Timestamps reported by the player and annotated by the server at receive time.</p> <p class="panel-copy">Vom Player gemeldete und vom Server beim Empfang ergänzte Zeitstempel.</p>
</div> </div>
</div> </div>
<table class="detail-table"> <table class="detail-table">
<tbody> <tbody>
<tr> <tr>
<th>Received at (server)</th> <th>Empfangen (Server)</th>
<td>{{timestampLabel .Record.ReceivedAt}}</td> <td><time class="reltime" datetime="{{.Record.ReceivedAt}}">{{timestampLabel .Record.ReceivedAt}}</time></td>
</tr> </tr>
<tr> <tr>
<th>Player timestamp</th> <th>Player-Zeitstempel</th>
<td>{{timestampLabel .Record.Timestamp}}</td> <td><time class="reltime" datetime="{{.Record.Timestamp}}">{{timestampLabel .Record.Timestamp}}</time></td>
</tr> </tr>
<tr> <tr>
<th>Started at</th> <th>Gestartet</th>
<td>{{timestampLabel .Record.StartedAt}}</td> <td><time class="reltime" datetime="{{.Record.StartedAt}}">{{timestampLabel .Record.StartedAt}}</time></td>
</tr> </tr>
<tr> <tr>
<th>Last heartbeat at</th> <th>Letzter Heartbeat</th>
<td>{{timestampLabel .Record.LastHeartbeatAt}}</td> <td><time class="reltime" datetime="{{.Record.LastHeartbeatAt}}">{{timestampLabel .Record.LastHeartbeatAt}}</time></td>
</tr> </tr>
<tr> <tr>
<th>Heartbeat interval</th> <th>Heartbeat-Intervall</th>
<td>{{if gt .Record.HeartbeatEverySeconds 0}}{{.Record.HeartbeatEverySeconds}}s{{else}}-{{end}}</td> <td>{{if gt .Record.HeartbeatEverySeconds 0}}{{.Record.HeartbeatEverySeconds}}s{{else}}-{{end}}</td>
</tr> </tr>
</tbody> </tbody>
@ -701,34 +731,61 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<div> <div>
<h2>Endpoints</h2> <h2>Verbindungen</h2>
<p class="panel-copy">Connection details reported by the player in the last accepted status.</p> <p class="panel-copy">Verbindungsdetails aus dem zuletzt akzeptierten Status-Report.</p>
</div> </div>
</div> </div>
<table class="detail-table"> <table class="detail-table">
<tbody> <tbody>
<tr> <tr>
<th>Server URL</th> <th>Server-URL</th>
<td>{{if .Record.ServerURL}}{{.Record.ServerURL}}{{else}}-{{end}}</td> <td>{{if .Record.ServerURL}}{{.Record.ServerURL}}{{else}}-{{end}}</td>
</tr> </tr>
<tr> <tr>
<th>MQTT broker</th> <th>MQTT-Broker</th>
<td>{{if .Record.MQTTBroker}}{{.Record.MQTTBroker}}{{else}}-{{end}}</td> <td>{{if .Record.MQTTBroker}}{{.Record.MQTTBroker}}{{else}}-{{end}}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</section> </section>
</main> </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> </body>
</html> </html>
`)) `))
var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html> var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invalid filter Screen Status</title> <title>Ungültiger Filter Bildschirmstatus</title>
` + statusPageCSSBlock + ` ` + statusPageCSSBlock + `
</head> </head>
<body> <body>
@ -736,11 +793,11 @@ var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(s
<section class="hero"> <section class="hero">
<div class="hero-top"> <div class="hero-top">
<div> <div>
<h1>Invalid filter</h1> <h1>Ungültiger Filter</h1>
<p class="lead">{{.Message}}</p> <p class="lead">{{.Message}}</p>
</div> </div>
</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> </section>
</main> </main>
</body> </body>
@ -773,7 +830,7 @@ func handleScreenDetailPage(store playerStatusStore) http.HandlerFunc {
record, ok := store.Get(screenID) record, ok := store.Get(screenID)
if !ok { if !ok {
data := statusPageErrorData{ data := statusPageErrorData{
Message: "Fuer diesen Screen liegt noch kein Status vor.", Message: "Für diesen Screen liegt noch kein Status vor.",
StatusPagePath: "/status", StatusPagePath: "/status",
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") 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} base := statusPageFilters{ScreenIDFilter: filters.ScreenIDFilter, Limit: filters.Limit, UpdatedSince: filters.UpdatedSince}
return []statusFilterLink{ return []statusFilterLink{
{ {
Label: "All screens", Label: "Alle Bildschirme",
Href: buildStatusPageHref(base), Href: buildStatusPageHref(base),
Class: "", Class: "",
Active: filters.ServerConnectivity == "" && filters.Stale == "", 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}), Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, ServerConnectivity: "offline", Limit: base.Limit, UpdatedSince: base.UpdatedSince}),
Class: "offline", Class: "offline",
Active: filters.ServerConnectivity == "offline" && filters.Stale == "", 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}), Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, ServerConnectivity: "degraded", Limit: base.Limit, UpdatedSince: base.UpdatedSince}),
Class: "degraded", Class: "degraded",
Active: filters.ServerConnectivity == "degraded" && filters.Stale == "", 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}), Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, Stale: "true", Limit: base.Limit, UpdatedSince: base.UpdatedSince}),
Class: "", Class: "",
Active: filters.ServerConnectivity == "" && filters.Stale == "true", 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}), Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, Stale: "false", Limit: base.Limit, UpdatedSince: base.UpdatedSince}),
Class: "online", Class: "online",
Active: filters.ServerConnectivity == "" && filters.Stale == "false", Active: filters.ServerConnectivity == "" && filters.Stale == "false",
@ -935,3 +992,22 @@ func timestampLabel(value string) string {
} }
return trimmed 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
}
}

View 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)
}

View file

@ -407,6 +407,29 @@ func (s *PlaylistStore) DeleteItem(ctx context.Context, id string) error {
return err 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. // 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 { func (s *PlaylistStore) Reorder(ctx context.Context, playlistID string, itemIDs []string) error {
tx, err := s.pool.Begin(ctx) tx, err := s.pool.Begin(ctx)