diff --git a/ansible/group_vars/signage_players/vars.yml b/ansible/group_vars/signage_players/vars.yml index 13c7a5d..57551e7 100644 --- a/ansible/group_vars/signage_players/vars.yml +++ b/ansible/group_vars/signage_players/vars.yml @@ -1,5 +1,5 @@ --- -morz_server_base_url: "http://10.0.0.70:8080" +morz_server_base_url: "http://192.168.64.1:8080" morz_mqtt_broker: "tcp://dockerbox.morz.de:1883" morz_heartbeat_every_seconds: 30 morz_status_report_every_seconds: 60 diff --git a/ansible/inventory.yml b/ansible/inventory.yml index 36cbd38..bc68a7f 100644 --- a/ansible/inventory.yml +++ b/ansible/inventory.yml @@ -5,6 +5,9 @@ all: hosts: info10: info01-dev: + info11-dev: + info12-dev: + debi: signage_servers: hosts: dockerbox: diff --git a/ansible/roles/signage_base/defaults/main.yml b/ansible/roles/signage_base/defaults/main.yml index 6bd7fd6..cc60fb4 100644 --- a/ansible/roles/signage_base/defaults/main.yml +++ b/ansible/roles/signage_base/defaults/main.yml @@ -5,8 +5,8 @@ signage_timezone: "Europe/Berlin" signage_base_packages: - curl - ca-certificates - - rsync - htop - vim-tiny - bash-completion - - ntp + - rsync + - chrony diff --git a/ansible/roles/signage_base/handlers/main.yml b/ansible/roles/signage_base/handlers/main.yml index 2af4d77..ea9579f 100644 --- a/ansible/roles/signage_base/handlers/main.yml +++ b/ansible/roles/signage_base/handlers/main.yml @@ -1,10 +1,4 @@ --- -- name: Restart cron - ansible.builtin.systemd: - name: cron - state: restarted - become: true - - name: Restart journald ansible.builtin.systemd: name: systemd-journald diff --git a/ansible/roles/signage_base/tasks/main.yml b/ansible/roles/signage_base/tasks/main.yml index c5af3a9..98dcd6e 100644 --- a/ansible/roles/signage_base/tasks/main.yml +++ b/ansible/roles/signage_base/tasks/main.yml @@ -1,9 +1,12 @@ --- -- name: Update apt cache and upgrade installed packages +- name: Update apt cache ansible.builtin.apt: update_cache: true + become: true + +- name: Upgrade installed packages + ansible.builtin.apt: upgrade: dist - cache_valid_time: 3600 become: true - name: Install base packages @@ -16,11 +19,18 @@ community.general.timezone: name: "{{ signage_timezone }}" become: true - notify: Restart cron -- name: Ensure NTP service is enabled and running +- name: Disable systemd-timesyncd if present (chrony replaces it) ansible.builtin.systemd: - name: ntp + name: systemd-timesyncd + enabled: false + state: stopped + become: true + failed_when: false + +- name: Ensure chrony NTP service is enabled and running + ansible.builtin.systemd: + name: chrony enabled: true state: started become: true diff --git a/ansible/roles/signage_player/defaults/main.yml b/ansible/roles/signage_player/defaults/main.yml index 8d0f3be..3d93ad5 100644 --- a/ansible/roles/signage_player/defaults/main.yml +++ b/ansible/roles/signage_player/defaults/main.yml @@ -3,7 +3,7 @@ signage_user: morz signage_config_dir: /etc/signage signage_binary_dest: /usr/local/bin/morz-agent -morz_server_base_url: "http://10.0.0.70:8080" +morz_server_base_url: "http://192.168.64.1:8080" morz_mqtt_broker: "" morz_mqtt_username: "" morz_mqtt_password: "" diff --git a/ansible/roles/signage_provision/defaults/main.yml b/ansible/roles/signage_provision/defaults/main.yml index e3ea820..bdcc2c8 100644 --- a/ansible/roles/signage_provision/defaults/main.yml +++ b/ansible/roles/signage_provision/defaults/main.yml @@ -4,7 +4,7 @@ signage_admin_token: "" # Server base URL reachable from the Ansible controller -signage_server_base_url: "http://10.0.0.70:8080" +signage_server_base_url: "http://192.168.64.1:8080" # SSH public key to deploy to the signage user signage_ssh_public_key: "" diff --git a/compose/server-stack.yml b/compose/server-stack.yml index d8a6f41..d5d2d55 100644 --- a/compose/server-stack.yml +++ b/compose/server-stack.yml @@ -35,6 +35,7 @@ services: MORZ_INFOBOARD_MQTT_BROKER: "tcp://mosquitto:1883" MORZ_INFOBOARD_ADMIN_PASSWORD: "${MORZ_INFOBOARD_ADMIN_PASSWORD}" MORZ_INFOBOARD_DEV_MODE: "${MORZ_INFOBOARD_DEV_MODE:-false}" + TZ: "Europe/Berlin" MORZ_INFOBOARD_DEFAULT_TENANT: "${MORZ_INFOBOARD_DEFAULT_TENANT:-morz}" volumes: - uploads:/uploads diff --git a/docs/SERVER-KONZEPT.md b/docs/SERVER-KONZEPT.md index c459a45..e061f7f 100644 --- a/docs/SERVER-KONZEPT.md +++ b/docs/SERVER-KONZEPT.md @@ -91,6 +91,16 @@ Aufgaben: - Events - Kommandos und ACKs +### MQTT-Topics (implementiert) + +| Topic | Publisher | Subscriber | Beschreibung | +|----------------------------------------------|------------|---------------|---------------------------------------------------| +| `signage/screen/{slug}/playlist-changed` | Backend | Player-Agent | Benachrichtigung bei Playlist-Aenderung; Backend debounced 2 s | +| `signage/screen/{slug}/screenshot-request` | Backend | Player-Agent | Fordert sofortigen On-Demand-Screenshot an | + +Der Backend-`Notifier` (`internal/mqttnotifier/notifier.go`) veroeffentlicht beide Topics. +Der Player-`Subscriber` (`player/agent/internal/mqttsubscriber/subscriber.go`) abonniert beide Topics fuer den eigenen Screen-Slug. Auf ein `screenshot-request`-Signal ruft der Agent `Screenshotter.TakeAndSendOnce(ctx)` auf und laedt das Bild direkt per `POST /api/v1/player/screenshot` hoch. + ### Dateispeicher Aufgaben: @@ -210,9 +220,16 @@ Der Server speichert: - letzten bekannten Heartbeat - letzten Status -- letzten Screenshot +- letzten Screenshot (In-Memory, nicht persistiert) - aktuelle Inhaltsquelle pro Screen +### Screenshot-Flow + +1. Der Player-Agent sendet periodisch (Intervall: `MORZ_INFOBOARD_SCREENSHOT_EVERY`) einen Screenshot per `POST /api/v1/player/screenshot` (Multipart, kein Auth). +2. Alternativ kann das Backend per MQTT-Topic `signage/screen/{slug}/screenshot-request` einen On-Demand-Screenshot anfordern (`Notifier.RequestScreenshot(slug)`). Der Player-Agent empfaengt das Signal und ruft `Screenshotter.TakeAndSendOnce(ctx)` auf. +3. Das Backend speichert den Screenshot im `ScreenshotStore` (In-Memory, keyed by `screen_id`). Pro Screen wird nur der jeweils neueste Screenshot gehalten. +4. Eingeloggte Benutzer koennen den Screenshot unter `GET /api/v1/screens/{screenId}/screenshot` abrufen (`authOnly`). Der Response-Header enthaelt den vom Player gemeldeten MIME-Typ sowie `Cache-Control: no-store`. + Die Admin-UI soll damit erkennen: - online/offline diff --git a/player/agent/internal/app/app.go b/player/agent/internal/app/app.go index 4a5a661..28be01c 100644 --- a/player/agent/internal/app/app.go +++ b/player/agent/internal/app/app.go @@ -199,7 +199,10 @@ func (a *App) Run(ctx context.Context) error { // Self-register this screen in the backend (best-effort, non-blocking). go a.registerScreen(ctx) - // Subscribe to playlist-changed MQTT notifications (optional; fallback = polling). + // Screenshot-Instanz immer anlegen (für periodische und On-Demand-Screenshots). + ss := screenshot.New(a.Config.ScreenID, a.Config.ServerBaseURL, a.Config.ScreenshotEvery, a.logger) + + // Subscribe to playlist-changed and screenshot-request MQTT notifications (optional; fallback = polling). sub := mqttsubscriber.New( a.Config.MQTTBroker, a.Config.ScreenID, @@ -213,10 +216,15 @@ func (a *App) Run(ctx context.Context) error { } a.logger.Printf("event=mqtt_playlist_notification screen_id=%s", a.Config.ScreenID) }, + func() { + a.logger.Printf("event=mqtt_screenshot_request screen_id=%s", a.Config.ScreenID) + go ss.TakeAndSendOnce(ctx) + }, ) 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)) + a.logger.Printf("event=mqtt_subscriber_enabled broker=%s screen_id=%s topic=%s screenshot_topic=%s", + a.Config.MQTTBroker, a.Config.ScreenID, mqttsubscriber.Topic(a.Config.ScreenID), + mqttsubscriber.ScreenshotRequestTopic(a.Config.ScreenID)) defer sub.Close() } @@ -225,7 +233,6 @@ func (a *App) Run(ctx context.Context) error { // Phase 6: Periodische Screenshot-Erzeugung, wenn konfiguriert. if a.Config.ScreenshotEvery > 0 { - ss := screenshot.New(a.Config.ScreenID, a.Config.ServerBaseURL, a.Config.ScreenshotEvery, a.logger) go ss.Run(ctx) a.logger.Printf("event=screenshot_enabled screen_id=%s interval_seconds=%d", a.Config.ScreenID, a.Config.ScreenshotEvery) diff --git a/player/agent/internal/mqttsubscriber/subscriber.go b/player/agent/internal/mqttsubscriber/subscriber.go index 5adc026..2298cc0 100644 --- a/player/agent/internal/mqttsubscriber/subscriber.go +++ b/player/agent/internal/mqttsubscriber/subscriber.go @@ -17,21 +17,29 @@ const ( // playlistChangedTopicTemplate is the topic the backend publishes to. playlistChangedTopic = "signage/screen/%s/playlist-changed" + + // screenshotRequestTopicTemplate is the topic the backend publishes to for on-demand screenshots. + screenshotRequestTopicTemplate = "signage/screen/%s/screenshot-request" ) // PlaylistChangedFunc is called when a debounced playlist-changed notification arrives. type PlaylistChangedFunc func() +// ScreenshotRequestFunc is called when a screenshot-request notification arrives. +type ScreenshotRequestFunc 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 + client mqtt.Client + timer *time.Timer + onChange PlaylistChangedFunc + onScreenshotRequest ScreenshotRequestFunc // timerC serializes timer resets through a dedicated goroutine. - resetC chan struct{} - stopC chan struct{} + resetC chan struct{} + screenshotReqC chan struct{} + stopC chan struct{} } // Topic returns the MQTT topic for a given screenSlug. @@ -39,23 +47,32 @@ func Topic(screenSlug string) string { return "signage/screen/" + screenSlug + "/playlist-changed" } +// ScreenshotRequestTopic returns the MQTT topic for on-demand screenshot requests for a given screenSlug. +func ScreenshotRequestTopic(screenSlug string) string { + return "signage/screen/" + screenSlug + "/screenshot-request" +} + // 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. +// onScreenshotRequest is called (in its own goroutine) when a screenshot-request message arrives. // // Returns nil when broker is empty — callers must handle nil. -func New(broker, screenSlug, username, password string, onChange PlaylistChangedFunc) *Subscriber { +func New(broker, screenSlug, username, password string, onChange PlaylistChangedFunc, onScreenshotRequest ScreenshotRequestFunc) *Subscriber { if broker == "" { return nil } s := &Subscriber{ - onChange: onChange, - resetC: make(chan struct{}, 16), - stopC: make(chan struct{}), + onChange: onChange, + onScreenshotRequest: onScreenshotRequest, + resetC: make(chan struct{}, 16), + screenshotReqC: make(chan struct{}, 16), + stopC: make(chan struct{}), } topic := Topic(screenSlug) + screenshotTopic := ScreenshotRequestTopic(screenSlug) opts := mqtt.NewClientOptions(). AddBroker(broker). @@ -72,6 +89,12 @@ func New(broker, screenSlug, username, password string, onChange PlaylistChanged default: // channel full — debounce timer will fire anyway } }) + c.Subscribe(screenshotTopic, 0, func(_ mqtt.Client, _ mqtt.Message) { //nolint:errcheck + select { + case s.screenshotReqC <- struct{}{}: + default: // channel full — request already pending + } + }) }) if username != "" { @@ -104,6 +127,10 @@ func (s *Subscriber) run() { timer = time.AfterFunc(debounceDuration, func() { go s.onChange() }) + case <-s.screenshotReqC: + if s.onScreenshotRequest != nil { + go s.onScreenshotRequest() + } } } } diff --git a/player/agent/internal/screenshot/screenshot.go b/player/agent/internal/screenshot/screenshot.go index 1b14a83..501c827 100644 --- a/player/agent/internal/screenshot/screenshot.go +++ b/player/agent/internal/screenshot/screenshot.go @@ -78,6 +78,12 @@ func (s *Screenshotter) Run(ctx context.Context) { } } +// TakeAndSendOnce macht genau einen Screenshot und lädt ihn hoch. +// Nicht-blockierend gegenüber dem periodischen Loop. +func (s *Screenshotter) TakeAndSendOnce(ctx context.Context) { + s.takeAndSend(ctx) +} + // takeAndSend erzeugt einen Screenshot und sendet ihn an den Server. func (s *Screenshotter) takeAndSend(ctx context.Context) { path, err := s.capture() diff --git a/server/backend/Dockerfile b/server/backend/Dockerfile index e9e46bd..46272c8 100644 --- a/server/backend/Dockerfile +++ b/server/backend/Dockerfile @@ -5,6 +5,7 @@ COPY . . RUN go build -o /out/backend ./cmd/api FROM alpine:3.22 +RUN apk add --no-cache tzdata WORKDIR /app COPY --from=build /out/backend /usr/local/bin/backend EXPOSE 8080 diff --git a/server/backend/internal/app/app.go b/server/backend/internal/app/app.go index 7333a96..9985a6c 100644 --- a/server/backend/internal/app/app.go +++ b/server/backend/internal/app/app.go @@ -76,6 +76,9 @@ func New() (*App, error) { // Non-fatal: server starts even if admin setup fails. } + // Screenshot store (in-memory). + ss := httpapi.NewScreenshotStore() + // MQTT notifier (no-op when broker not configured). notifier := mqttnotifier.New(cfg.MQTTBroker, cfg.MQTTUsername, cfg.MQTTPassword) if cfg.MQTTBroker != "" { @@ -85,16 +88,17 @@ func New() (*App, error) { } handler := httpapi.NewRouter(httpapi.RouterDeps{ - StatusStore: statusStore, - TenantStore: tenants, - ScreenStore: screens, - MediaStore: media, - PlaylistStore: playlists, - AuthStore: authStore, - Notifier: notifier, - Config: cfg, - UploadDir: cfg.UploadDir, - Logger: logger, + StatusStore: statusStore, + TenantStore: tenants, + ScreenStore: screens, + MediaStore: media, + PlaylistStore: playlists, + AuthStore: authStore, + Notifier: notifier, + ScreenshotStore: ss, + Config: cfg, + UploadDir: cfg.UploadDir, + Logger: logger, }) return &App{ diff --git a/server/backend/internal/httpapi/manage/playlist.go b/server/backend/internal/httpapi/manage/playlist.go index b5ff4de..b51f62d 100644 --- a/server/backend/internal/httpapi/manage/playlist.go +++ b/server/backend/internal/httpapi/manage/playlist.go @@ -379,9 +379,16 @@ func parseOptionalTime(s string) (*time.Time, error) { if s == "" { return nil, nil } - // Accept RFC3339 (API) and datetime-local HTML input format. - for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02T15:04:05"} { - if t, err := time.Parse(layout, s); err == nil { + // RFC3339 already carries timezone info — use as-is. + if t, err := time.Parse(time.RFC3339, s); err == nil { + return &t, nil + } + // datetime-local HTML inputs ("2006-01-02T15:04" / "2006-01-02T15:04:05") carry + // no timezone. Interpret them as local time so the value the user sees in their + // browser matches what PostgreSQL stores and what NOW() (also local on the DB + // server) is compared against. + for _, layout := range []string{"2006-01-02T15:04:05", "2006-01-02T15:04"} { + if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { return &t, nil } } diff --git a/server/backend/internal/httpapi/screenshot.go b/server/backend/internal/httpapi/screenshot.go new file mode 100644 index 0000000..8485290 --- /dev/null +++ b/server/backend/internal/httpapi/screenshot.go @@ -0,0 +1,59 @@ +package httpapi + +import ( + "io" + "net/http" +) + +const maxScreenshotSize = 3 << 20 // 3 MB + +func handlePlayerScreenshot(store *ScreenshotStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxScreenshotSize) + if err := r.ParseMultipartForm(maxScreenshotSize); err != nil { + http.Error(w, "bad multipart form", http.StatusBadRequest) + return + } + + screenID := r.FormValue("screen_id") + if screenID == "" { + http.Error(w, "screen_id required", http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("screenshot") + if err != nil { + http.Error(w, "screenshot file required", http.StatusBadRequest) + return + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + http.Error(w, "read error", http.StatusInternalServerError) + return + } + + mimeType := header.Header.Get("Content-Type") + if mimeType == "" { + mimeType = "image/png" + } + + store.Save(screenID, data, mimeType) + w.WriteHeader(http.StatusOK) + } +} + +func handleGetScreenshot(store *ScreenshotStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + screenID := r.PathValue("screenId") + data, mimeType, ok := store.Get(screenID) + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Cache-Control", "no-store") + w.Write(data) //nolint:errcheck + } +} diff --git a/server/backend/internal/httpapi/screenshot_store.go b/server/backend/internal/httpapi/screenshot_store.go new file mode 100644 index 0000000..8d6a25f --- /dev/null +++ b/server/backend/internal/httpapi/screenshot_store.go @@ -0,0 +1,33 @@ +package httpapi + +import "sync" + +type screenshotRecord struct { + Data []byte + MimeType string +} + +type ScreenshotStore struct { + mu sync.RWMutex + records map[string]screenshotRecord +} + +func NewScreenshotStore() *ScreenshotStore { + return &ScreenshotStore{records: make(map[string]screenshotRecord)} +} + +func (s *ScreenshotStore) Save(screenID string, data []byte, mimeType string) { + s.mu.Lock() + defer s.mu.Unlock() + s.records[screenID] = screenshotRecord{Data: data, MimeType: mimeType} +} + +func (s *ScreenshotStore) Get(screenID string) ([]byte, string, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + rec, ok := s.records[screenID] + if !ok { + return nil, "", false + } + return rec.Data, rec.MimeType, true +} diff --git a/server/backend/internal/httpapi/statuspage.go b/server/backend/internal/httpapi/statuspage.go index e38d1d4..c3c3997 100644 --- a/server/backend/internal/httpapi/statuspage.go +++ b/server/backend/internal/httpapi/statuspage.go @@ -436,12 +436,13 @@ var statusTemplateFuncs = template.FuncMap{ } var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusTemplateFuncs).Parse(` - +
+ -Detailansicht auf Basis des zuletzt akzeptierten Status-Reports.