Kopplung Agent↔Backend: Selbstregistrierung + Playlist-Rotation
Backend:
- ScreenStore.Upsert(): idempotentes INSERT ON CONFLICT für Self-Registration
- POST /api/v1/screens/register: Agent registriert sich beim Start (upsert)
- manage/register.go: neuer Handler, immer unter Tenant "morz"
Agent:
- config: screen_name + screen_orientation (mit Fallback auf screen_id / landscape)
- app.go: registerScreen() — POST /api/v1/screens/register beim Start (Retry 30s)
- app.go: pollPlaylist() — GET /api/v1/screens/{slug}/playlist alle 60s
- app.go: nowFn liefert Playlist statt statischer URL; PlayerContentURL als Fallback
- playerserver: PlaylistItem-Struct in NowPlaying; JS rotiert Items per duration_seconds
- JS: Playlist-Fingerprint verhindert Reset laufender Rotation bei unverändertem Stand
Ansible:
- config.json.j2: screen_name + screen_orientation ergänzt
- host_vars/info10: screen_name + screen_orientation
- host_vars/info01-dev: screen_name + screen_orientation
Kopplung per Konvention: screen_id (config.json) = slug (DB)
Beim ersten Neustart der Agents erscheinen die Bildschirme automatisch im Admin-UI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d395804612
commit
e03948f25d
9 changed files with 325 additions and 44 deletions
|
|
@ -2,3 +2,5 @@
|
||||||
ansible_host: 192.168.64.11
|
ansible_host: 192.168.64.11
|
||||||
ansible_user: admin
|
ansible_user: admin
|
||||||
screen_id: info01-dev
|
screen_id: info01-dev
|
||||||
|
screen_name: "Info01 Entwicklung"
|
||||||
|
screen_orientation: landscape
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,5 @@
|
||||||
ansible_host: 10.0.0.200
|
ansible_host: 10.0.0.200
|
||||||
ansible_user: morz
|
ansible_user: morz
|
||||||
screen_id: info10
|
screen_id: info10
|
||||||
|
screen_name: "Info10 Schule"
|
||||||
|
screen_orientation: landscape
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
{
|
{
|
||||||
"screen_id": "{{ screen_id }}",
|
"screen_id": "{{ screen_id }}",
|
||||||
|
"screen_name": "{{ screen_name | default(screen_id) }}",
|
||||||
|
"screen_orientation": "{{ screen_orientation | default('landscape') }}",
|
||||||
"server_base_url": "{{ morz_server_base_url }}",
|
"server_base_url": "{{ morz_server_base_url }}",
|
||||||
"mqtt_broker": "{{ morz_mqtt_broker }}",
|
"mqtt_broker": "{{ morz_mqtt_broker }}",
|
||||||
"mqtt_username": "{{ morz_mqtt_username }}",
|
"mqtt_username": "{{ morz_mqtt_username }}",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -55,6 +58,10 @@ type App struct {
|
||||||
consecutiveReportFailures int
|
consecutiveReportFailures int
|
||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
lastHeartbeatAt time.Time
|
lastHeartbeatAt time.Time
|
||||||
|
|
||||||
|
// Playlist fetched from the backend (protected by playlistMu).
|
||||||
|
playlistMu sync.RWMutex
|
||||||
|
playlist []playerserver.PlaylistItem
|
||||||
}
|
}
|
||||||
|
|
||||||
type statusSender interface {
|
type statusSender interface {
|
||||||
|
|
@ -140,21 +147,39 @@ func (a *App) Run(ctx context.Context) error {
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
a.logger.Printf(
|
a.logger.Printf(
|
||||||
"event=agent_configured screen_id=%s server_url=%s mqtt_broker=%s heartbeat_every_seconds=%d player_addr=%s",
|
"event=agent_configured screen_id=%s screen_name=%q orientation=%s server_url=%s mqtt_broker=%s heartbeat_every_seconds=%d player_addr=%s",
|
||||||
a.Config.ScreenID,
|
a.Config.ScreenID,
|
||||||
|
a.Config.ScreenName,
|
||||||
|
a.Config.ScreenOrientation,
|
||||||
a.Config.ServerBaseURL,
|
a.Config.ServerBaseURL,
|
||||||
a.Config.MQTTBroker,
|
a.Config.MQTTBroker,
|
||||||
a.Config.HeartbeatEvery,
|
a.Config.HeartbeatEvery,
|
||||||
a.Config.PlayerListenAddr,
|
a.Config.PlayerListenAddr,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Start the player HTTP server (serves Chromium UI).
|
||||||
ps := playerserver.New(a.Config.PlayerListenAddr, func() playerserver.NowPlaying {
|
ps := playerserver.New(a.Config.PlayerListenAddr, func() playerserver.NowPlaying {
|
||||||
snap := a.Snapshot()
|
snap := a.Snapshot()
|
||||||
return playerserver.NowPlaying{
|
a.playlistMu.RLock()
|
||||||
URL: a.Config.PlayerContentURL,
|
items := a.playlist
|
||||||
|
a.playlistMu.RUnlock()
|
||||||
|
|
||||||
|
np := playerserver.NowPlaying{
|
||||||
Status: string(snap.Status),
|
Status: string(snap.Status),
|
||||||
Connectivity: string(snap.ServerConnectivity),
|
Connectivity: string(snap.ServerConnectivity),
|
||||||
}
|
}
|
||||||
|
if len(items) > 0 {
|
||||||
|
np.Playlist = items
|
||||||
|
} else if a.Config.PlayerContentURL != "" {
|
||||||
|
// Fallback: single static URL when no playlist is available yet.
|
||||||
|
np.Playlist = []playerserver.PlaylistItem{{
|
||||||
|
Src: a.Config.PlayerContentURL,
|
||||||
|
Type: "web",
|
||||||
|
Title: "",
|
||||||
|
DurationSeconds: 30,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return np
|
||||||
})
|
})
|
||||||
go func() {
|
go func() {
|
||||||
if err := ps.Run(ctx); err != nil {
|
if err := ps.Run(ctx); err != nil {
|
||||||
|
|
@ -162,6 +187,12 @@ func (a *App) Run(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Self-register this screen in the backend (best-effort, non-blocking).
|
||||||
|
go a.registerScreen(ctx)
|
||||||
|
|
||||||
|
// Start polling the backend for playlist updates.
|
||||||
|
go a.pollPlaylist(ctx)
|
||||||
|
|
||||||
a.emitHeartbeat()
|
a.emitHeartbeat()
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.status = StatusRunning
|
a.status = StatusRunning
|
||||||
|
|
@ -193,6 +224,102 @@ func (a *App) Run(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registerScreen upserts this screen in the backend so it appears in the admin UI.
|
||||||
|
// Called once at startup in a goroutine — retries every 30s until successful.
|
||||||
|
func (a *App) registerScreen(ctx context.Context) {
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"slug": a.Config.ScreenID,
|
||||||
|
"name": a.Config.ScreenName,
|
||||||
|
"orientation": a.Config.ScreenOrientation,
|
||||||
|
})
|
||||||
|
|
||||||
|
for attempt := 1; ; attempt++ {
|
||||||
|
req, err := http.NewRequestWithContext(ctx,
|
||||||
|
http.MethodPost,
|
||||||
|
a.Config.ServerBaseURL+"/api/v1/screens/register",
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
a.logger.Printf("event=screen_registered screen_id=%s attempt=%d", a.Config.ScreenID, attempt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logger.Printf("event=screen_register_failed screen_id=%s status=%d attempt=%d",
|
||||||
|
a.Config.ScreenID, resp.StatusCode, attempt)
|
||||||
|
} else {
|
||||||
|
a.logger.Printf("event=screen_register_failed screen_id=%s error=%v attempt=%d",
|
||||||
|
a.Config.ScreenID, err, attempt)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollPlaylist fetches the active playlist from the backend periodically.
|
||||||
|
func (a *App) pollPlaylist(ctx context.Context) {
|
||||||
|
// Fetch immediately on startup, then every 60s.
|
||||||
|
a.fetchPlaylist(ctx)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(60 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
a.fetchPlaylist(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remotePlaylistResponse is the JSON shape of GET /api/v1/screens/{slug}/playlist.
|
||||||
|
type remotePlaylistResponse struct {
|
||||||
|
Items []playerserver.PlaylistItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) fetchPlaylist(ctx context.Context) {
|
||||||
|
url := a.Config.ServerBaseURL + "/api/v1/screens/" + a.Config.ScreenID + "/playlist"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Printf("event=playlist_fetch_failed screen_id=%s error=%v", a.Config.ScreenID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
a.logger.Printf("event=playlist_fetch_failed screen_id=%s status=%d", a.Config.ScreenID, resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var pr remotePlaylistResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
|
||||||
|
a.logger.Printf("event=playlist_decode_failed screen_id=%s error=%v", a.Config.ScreenID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.playlistMu.Lock()
|
||||||
|
a.playlist = pr.Items
|
||||||
|
a.playlistMu.Unlock()
|
||||||
|
|
||||||
|
a.logger.Printf("event=playlist_fetched screen_id=%s items=%d", a.Config.ScreenID, len(pr.Items))
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) emitHeartbeat() {
|
func (a *App) emitHeartbeat() {
|
||||||
now := a.now()
|
now := a.now()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
// ScreenID is the unique slug for this screen — used for self-registration and playlist fetching.
|
||||||
ScreenID string `json:"screen_id"`
|
ScreenID string `json:"screen_id"`
|
||||||
|
// ScreenName is the human-readable display name shown in the admin UI. Falls back to ScreenID.
|
||||||
|
ScreenName string `json:"screen_name"`
|
||||||
|
// ScreenOrientation is "landscape" (default) or "portrait".
|
||||||
|
ScreenOrientation string `json:"screen_orientation"`
|
||||||
|
|
||||||
ServerBaseURL string `json:"server_base_url"`
|
ServerBaseURL string `json:"server_base_url"`
|
||||||
MQTTBroker string `json:"mqtt_broker"`
|
MQTTBroker string `json:"mqtt_broker"`
|
||||||
MQTTUsername string `json:"mqtt_username"`
|
MQTTUsername string `json:"mqtt_username"`
|
||||||
|
|
@ -15,6 +21,7 @@ type Config struct {
|
||||||
HeartbeatEvery int `json:"heartbeat_every_seconds"`
|
HeartbeatEvery int `json:"heartbeat_every_seconds"`
|
||||||
StatusReportEvery int `json:"status_report_every_seconds"`
|
StatusReportEvery int `json:"status_report_every_seconds"`
|
||||||
PlayerListenAddr string `json:"player_listen_addr"`
|
PlayerListenAddr string `json:"player_listen_addr"`
|
||||||
|
// PlayerContentURL is a fallback URL shown when no playlist is available from the server.
|
||||||
PlayerContentURL string `json:"player_content_url"`
|
PlayerContentURL string `json:"player_content_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +43,10 @@ func Load() (Config, error) {
|
||||||
return Config{}, fmt.Errorf("screen id is required")
|
return Config{}, fmt.Errorf("screen id is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.ScreenName == "" {
|
||||||
|
cfg.ScreenName = cfg.ScreenID
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.HeartbeatEvery <= 0 {
|
if cfg.HeartbeatEvery <= 0 {
|
||||||
cfg.HeartbeatEvery = defaultConfig().HeartbeatEvery
|
cfg.HeartbeatEvery = defaultConfig().HeartbeatEvery
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +61,7 @@ func Load() (Config, error) {
|
||||||
func defaultConfig() Config {
|
func defaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
ScreenID: "unset-screen",
|
ScreenID: "unset-screen",
|
||||||
|
ScreenOrientation: "landscape",
|
||||||
ServerBaseURL: "http://127.0.0.1:8080",
|
ServerBaseURL: "http://127.0.0.1:8080",
|
||||||
MQTTBroker: "",
|
MQTTBroker: "",
|
||||||
HeartbeatEvery: 30,
|
HeartbeatEvery: 30,
|
||||||
|
|
@ -75,6 +87,8 @@ func overrideFromEnv(cfg *Config) {
|
||||||
cfg.MQTTUsername = getenv("MORZ_INFOBOARD_MQTT_USERNAME", cfg.MQTTUsername)
|
cfg.MQTTUsername = getenv("MORZ_INFOBOARD_MQTT_USERNAME", cfg.MQTTUsername)
|
||||||
cfg.MQTTPassword = getenv("MORZ_INFOBOARD_MQTT_PASSWORD", cfg.MQTTPassword)
|
cfg.MQTTPassword = getenv("MORZ_INFOBOARD_MQTT_PASSWORD", cfg.MQTTPassword)
|
||||||
cfg.PlayerListenAddr = getenv("MORZ_INFOBOARD_PLAYER_ADDR", cfg.PlayerListenAddr)
|
cfg.PlayerListenAddr = getenv("MORZ_INFOBOARD_PLAYER_ADDR", cfg.PlayerListenAddr)
|
||||||
|
cfg.ScreenName = getenv("MORZ_INFOBOARD_SCREEN_NAME", cfg.ScreenName)
|
||||||
|
cfg.ScreenOrientation = getenv("MORZ_INFOBOARD_SCREEN_ORIENTATION", cfg.ScreenOrientation)
|
||||||
cfg.PlayerContentURL = getenv("MORZ_INFOBOARD_PLAYER_CONTENT_URL", cfg.PlayerContentURL)
|
cfg.PlayerContentURL = getenv("MORZ_INFOBOARD_PLAYER_CONTENT_URL", cfg.PlayerContentURL)
|
||||||
if value := getenv("MORZ_INFOBOARD_STATUS_REPORT_EVERY", ""); value != "" {
|
if value := getenv("MORZ_INFOBOARD_STATUS_REPORT_EVERY", ""); value != "" {
|
||||||
var parsed int
|
var parsed int
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,25 @@ import (
|
||||||
//go:embed assets
|
//go:embed assets
|
||||||
var assetsFS embed.FS
|
var assetsFS embed.FS
|
||||||
|
|
||||||
// NowPlaying describes what the player should currently display.
|
// PlaylistItem is a single displayable content item fetched from the backend.
|
||||||
|
type PlaylistItem struct {
|
||||||
|
Src string `json:"src"`
|
||||||
|
Type string `json:"type"` // web | image | video | pdf
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
DurationSeconds int `json:"duration_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NowPlaying describes the current playback state returned to the browser.
|
||||||
type NowPlaying struct {
|
type NowPlaying struct {
|
||||||
|
// Playlist holds the ordered items to display (primary).
|
||||||
|
Playlist []PlaylistItem `json:"playlist,omitempty"`
|
||||||
|
// URL is a legacy single-URL fallback (kept for backwards compatibility).
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Connectivity string `json:"connectivity"`
|
Connectivity string `json:"connectivity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InfoItem is a single entry shown in the lower-third sysinfo overlay.
|
// InfoItem is a single entry shown in the lower-third sysinfo overlay.
|
||||||
// Add new items in collectSysInfo to extend the overlay.
|
|
||||||
type InfoItem struct {
|
type InfoItem struct {
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
|
|
@ -142,7 +152,7 @@ const playerHTML = `<!DOCTYPE html>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
html, body { width: 100%; height: 100%; background: #000; overflow: hidden; }
|
html, body { width: 100%; height: 100%; background: #000; overflow: hidden; }
|
||||||
|
|
||||||
/* Splash-Hintergrund – Bild wird per JS orientierungsabhängig gesetzt */
|
/* Splash-Hintergrund */
|
||||||
#splash {
|
#splash {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
|
|
@ -166,41 +176,29 @@ const playerHTML = `<!DOCTYPE html>
|
||||||
gap: 3rem;
|
gap: 3rem;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.info-item {
|
.info-item { display: flex; flex-direction: column; }
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.info-item .label {
|
.info-item .label {
|
||||||
font-family: sans-serif;
|
font-family: sans-serif; font-size: 0.7rem;
|
||||||
font-size: 0.7rem;
|
letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
letter-spacing: 0.12em;
|
color: rgba(255,255,255,0.5); margin-bottom: 0.25em;
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(255,255,255,0.5);
|
|
||||||
margin-bottom: 0.25em;
|
|
||||||
}
|
}
|
||||||
.info-item .value {
|
.info-item .value {
|
||||||
font-family: monospace;
|
font-family: monospace; font-size: 1.1rem;
|
||||||
font-size: 1.1rem;
|
font-weight: 500; letter-spacing: 0.03em; color: #fff;
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inhalts-iframe – liegt über dem Splash, wenn eine URL gesetzt ist */
|
/* Inhalts-iframe */
|
||||||
#frame {
|
#frame {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
width: 100%; height: 100%;
|
width: 100%; height: 100%;
|
||||||
border: none;
|
border: none; display: none; z-index: 10;
|
||||||
display: none;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Verbindungsstatus-Punkt */
|
/* Verbindungsstatus-Punkt */
|
||||||
#dot {
|
#dot {
|
||||||
position: fixed; bottom: 10px; right: 10px;
|
position: fixed; bottom: 10px; right: 10px;
|
||||||
width: 10px; height: 10px; border-radius: 50%;
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
background: #444; opacity: 0.6;
|
background: #444; opacity: 0.6; z-index: 9999;
|
||||||
z-index: 9999;
|
|
||||||
transition: background 0.5s;
|
transition: background 0.5s;
|
||||||
}
|
}
|
||||||
#dot.online { background: #4caf50; }
|
#dot.online { background: #4caf50; }
|
||||||
|
|
@ -220,7 +218,7 @@ const playerHTML = `<!DOCTYPE html>
|
||||||
var frame = document.getElementById('frame');
|
var frame = document.getElementById('frame');
|
||||||
var dot = document.getElementById('dot');
|
var dot = document.getElementById('dot');
|
||||||
|
|
||||||
// Orientierungsgerechtes Splash-Bild wählen
|
// ── Splash-Orientierung ───────────────────────────────────────────
|
||||||
function updateSplash() {
|
function updateSplash() {
|
||||||
var portrait = window.innerHeight > window.innerWidth;
|
var portrait = window.innerHeight > window.innerWidth;
|
||||||
splash.style.backgroundImage = portrait
|
splash.style.backgroundImage = portrait
|
||||||
|
|
@ -230,7 +228,7 @@ const playerHTML = `<!DOCTYPE html>
|
||||||
updateSplash();
|
updateSplash();
|
||||||
window.addEventListener('resize', updateSplash);
|
window.addEventListener('resize', updateSplash);
|
||||||
|
|
||||||
// Sysinfo-Overlay rendern
|
// ── Sysinfo-Overlay ───────────────────────────────────────────────
|
||||||
function renderSysInfo(items) {
|
function renderSysInfo(items) {
|
||||||
overlay.innerHTML = '';
|
overlay.innerHTML = '';
|
||||||
(items || []).forEach(function(item) {
|
(items || []).forEach(function(item) {
|
||||||
|
|
@ -245,22 +243,82 @@ const playerHTML = `<!DOCTYPE html>
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now-Playing anwenden: iframe zeigen wenn URL gesetzt, sonst Splash
|
// ── Playlist-Rotation ─────────────────────────────────────────────
|
||||||
|
var items = []; // current playlist
|
||||||
|
var currentIdx = 0;
|
||||||
|
var rotateTimer = null;
|
||||||
|
|
||||||
|
// Returns a fingerprint string for change detection.
|
||||||
|
function playlistKey(pl) {
|
||||||
|
return (pl || []).map(function(i) { return i.src + ':' + i.duration_seconds; }).join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRotation() {
|
||||||
|
if (rotateTimer) { clearTimeout(rotateTimer); rotateTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showItem(item) {
|
||||||
|
if (!item) { showSplash(); return; }
|
||||||
|
if (frame.src !== item.src) { frame.src = item.src; }
|
||||||
|
frame.style.display = '';
|
||||||
|
// Hide splash overlay while content is visible.
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
|
||||||
|
clearRotation();
|
||||||
|
var ms = Math.max((item.duration_seconds || 20), 1) * 1000;
|
||||||
|
rotateTimer = setTimeout(function() {
|
||||||
|
currentIdx = (currentIdx + 1) % items.length;
|
||||||
|
showItem(items[currentIdx]);
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSplash() {
|
||||||
|
clearRotation();
|
||||||
|
frame.style.display = 'none';
|
||||||
|
overlay.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastPlaylistKey = '';
|
||||||
|
|
||||||
function applyNowPlaying(data) {
|
function applyNowPlaying(data) {
|
||||||
dot.className = data.connectivity || '';
|
dot.className = data.connectivity || '';
|
||||||
if (data.url) {
|
|
||||||
if (frame.src !== data.url) { frame.src = data.url; }
|
// Legacy single-URL fallback.
|
||||||
frame.style.display = '';
|
if (data.url && (!data.playlist || data.playlist.length === 0)) {
|
||||||
} else {
|
var key = data.url + ':legacy';
|
||||||
frame.style.display = 'none';
|
if (lastPlaylistKey !== key) {
|
||||||
|
lastPlaylistKey = key;
|
||||||
|
items = [{ src: data.url, type: 'web', duration_seconds: 30 }];
|
||||||
|
currentIdx = 0;
|
||||||
|
showItem(items[0]);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var playlist = data.playlist || [];
|
||||||
|
if (playlist.length === 0) {
|
||||||
|
showSplash();
|
||||||
|
lastPlaylistKey = '';
|
||||||
|
items = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = playlistKey(playlist);
|
||||||
|
if (key === lastPlaylistKey) {
|
||||||
|
return; // unchanged — let current rotation continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist changed: start from beginning.
|
||||||
|
lastPlaylistKey = key;
|
||||||
|
items = playlist;
|
||||||
|
currentIdx = 0;
|
||||||
|
showItem(items[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Polling ───────────────────────────────────────────────────────
|
||||||
function pollSysInfo() {
|
function pollSysInfo() {
|
||||||
fetch('/api/sysinfo')
|
fetch('/api/sysinfo')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
|
|
@ -277,8 +335,8 @@ const playerHTML = `<!DOCTYPE html>
|
||||||
|
|
||||||
pollSysInfo();
|
pollSysInfo();
|
||||||
pollNowPlaying();
|
pollNowPlaying();
|
||||||
setInterval(pollSysInfo, 30000);
|
setInterval(pollSysInfo, 30000); // sysinfo alle 30s
|
||||||
setInterval(pollNowPlaying, 15000);
|
setInterval(pollNowPlaying, 30000); // playlist alle 30s
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|
|
||||||
59
server/backend/internal/httpapi/manage/register.go
Normal file
59
server/backend/internal/httpapi/manage/register.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package manage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleRegisterScreen is called by the player agent on startup.
|
||||||
|
// It upserts the screen in the default tenant (morz) so that all
|
||||||
|
// deployed screens appear automatically in the admin UI.
|
||||||
|
//
|
||||||
|
// POST /api/v1/screens/register
|
||||||
|
// Body: {"slug":"info10","name":"Info10 Bildschirm","orientation":"landscape"}
|
||||||
|
func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Orientation string `json:"orientation"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body.Slug = strings.TrimSpace(body.Slug)
|
||||||
|
body.Name = strings.TrimSpace(body.Name)
|
||||||
|
if body.Slug == "" {
|
||||||
|
http.Error(w, "slug required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Name == "" {
|
||||||
|
body.Name = body.Slug
|
||||||
|
}
|
||||||
|
if body.Orientation == "" {
|
||||||
|
body.Orientation = "landscape"
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1: single tenant — always register under "morz".
|
||||||
|
tenant, err := tenants.Get(r.Context(), "morz")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "default tenant not found", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
screen, err := screens.Upsert(r.Context(), tenant.ID, body.Slug, body.Name, body.Orientation)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK) // 200 whether created or updated
|
||||||
|
json.NewEncoder(w).Encode(screen) //nolint:errcheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -98,6 +98,9 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
||||||
manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir))
|
manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir))
|
||||||
|
|
||||||
// ── JSON API — screens ────────────────────────────────────────────────
|
// ── JSON API — screens ────────────────────────────────────────────────
|
||||||
|
// Self-registration: called by agent on startup (must be before /{tenantSlug}/ routes)
|
||||||
|
mux.HandleFunc("POST /api/v1/screens/register",
|
||||||
|
manage.HandleRegisterScreen(d.TenantStore, d.ScreenStore))
|
||||||
mux.HandleFunc("GET /api/v1/tenants/{tenantSlug}/screens",
|
mux.HandleFunc("GET /api/v1/tenants/{tenantSlug}/screens",
|
||||||
manage.HandleListScreens(d.TenantStore, d.ScreenStore))
|
manage.HandleListScreens(d.TenantStore, d.ScreenStore))
|
||||||
mux.HandleFunc("POST /api/v1/tenants/{tenantSlug}/screens",
|
mux.HandleFunc("POST /api/v1/tenants/{tenantSlug}/screens",
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,20 @@ func (s *ScreenStore) Create(ctx context.Context, tenantID, slug, name, orientat
|
||||||
return scanScreen(row)
|
return scanScreen(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upsert creates or updates a screen by slug (idempotent).
|
||||||
|
// Used by agents on startup to self-register.
|
||||||
|
func (s *ScreenStore) Upsert(ctx context.Context, tenantID, slug, name, orientation string) (*Screen, error) {
|
||||||
|
row := s.pool.QueryRow(ctx,
|
||||||
|
`insert into screens(tenant_id, slug, name, orientation)
|
||||||
|
values($1,$2,$3,$4)
|
||||||
|
on conflict (slug) do update
|
||||||
|
set name = excluded.name,
|
||||||
|
orientation = excluded.orientation
|
||||||
|
returning id, tenant_id, slug, name, orientation, created_at`,
|
||||||
|
tenantID, slug, name, orientation)
|
||||||
|
return scanScreen(row)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ScreenStore) Update(ctx context.Context, id, name, orientation string) (*Screen, error) {
|
func (s *ScreenStore) Update(ctx context.Context, id, name, orientation string) (*Screen, error) {
|
||||||
row := s.pool.QueryRow(ctx,
|
row := s.pool.QueryRow(ctx,
|
||||||
`update screens set name=$2, orientation=$3 where id=$1
|
`update screens set name=$2, orientation=$3 where id=$1
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue