Files
signage/AGENTS.md
Erik Thiele a25566aab4 fix: Logo nicht komplett wegradieren, wenn es dem Hintergrund aehnelt
Ein helles Logo auf hellem Grund liegt innerhalb der Farbtoleranz des
Flood Fill. Der Fuellvorgang lief vom Rand in das Logo hinein und
loeschte es mit - das Ergebnis war ein vollstaendig transparentes PNG,
auf der Willkommensseite also gar kein Logo. Ohne jede Rueckmeldung:
die bisherige Pruefung schlug nur an, wenn *nichts* entfernt wurde, nicht
wenn alles entfernt wurde.

remove_logo_background() legt jetzt vor dem Eingriff eine Kopie an und
misst danach ueber das Alpha-Histogramm, wie viel sichtbar geblieben
ist. Unter 2% gilt der Versuch als misslungen: das unveraenderte Bild
wird zurueckgegeben und die Oberflaeche nennt den Grund samt Auswegen
(transparentes PNG hochladen oder "Hintergrund behalten" ankreuzen).

Getestet mit Logos von dunkelrot bis reinweiss auf weissem Grund sowie
einem dunklen Logo auf schwarzem Grund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:27:06 +02:00

23 KiB
Raw Blame History

CANCOM Simple Signage — Agent Guide

Start

pip install -r requirements.txt
python app.py
docker compose up -d

python app.py (via .venv/bin/python app.py) serves on http://localhost:5005. app.py runs Flask with debug=True, host="0.0.0.0", and port=5005.

Source Of Truth

  • Single Flask app in app.py; there is no database.
  • Persistent state is config.json plus files under media/<site>/<screen>/.
  • User data in users.json (hashed passwords via werkzeug.security scrypt).
  • Port 5005; README.md hat den korrekten Port.
  • Existing repo instructions in this file are the main local guidance; there is no opencode.json or workflow config in this repo.

Multi-Standort-URL-Struktur

Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.

Route Beschreibung
GET /player/<site>/<screen> Player für Screen an einem Standort
GET /admin Redirect zum ersten zugänglichen Standort
GET /admin/<site> Admin-Dashboard für einen Standort (@site_access_required)
GET /admin/<site>/priority Priority-Playlist als separate Seite
GET /media/<site>/<screen>/<file> Medien-Datei ausliefern
GET /media/priority/<file> Priority-Medien (global)
GET /media/<site>/background/<filename> Hintergrundbild der Willkommensseite
GET /playlist/<site>/<screen>/hash Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status)
GET /willkommen?site=<site> Willkommensseite für Standort generieren
POST /admin/<site>/upload-background Hintergrundbild für Willkommensseite hochladen
POST /admin/<site>/delete-background Hintergrundbild zurücksetzen auf Standard
POST /api/customer API-Endpunkt (JSON mit "site"-Feld)
GET /admin/<site>/add-screen?name=<name> Neuen Screen anlegen
POST /admin/<site>/delete-screen/<screen> Screen + Medien löschen
GET /add-site?name=<name> Neuen Standort anlegen (nur Admin)
POST /admin/<site>/update-actions/<screen> Aktionen-Einstellungen speichern (Custom-URL + Position)
POST /admin/<site>/update-voice/<screen> Voice-Agent-Einstellungen speichern (Enabled, Label, Target, Position)
POST /admin/<site>/delete-site Standort + alle Screens/Medien löschen (nur Admin)
GET /change-password Passwort ändern-Seite
GET /admin/users User-Liste (nur Admin)
GET /admin/users/create User anlegen (nur Admin)
GET /admin/users/edit/<email> User bearbeiten (nur Admin)

User-Verwaltung (seit v5.6.0)

  • Login via E-Mail + Passwort (scrypt-gehasht in users.json)
  • Drei Rollen:
    • Admin: Zugriff auf alle Standorte + Userverwaltung (anlegen/bearbeiten/löschen/Reset)
    • Superuser: Zugriff auf alle Standorte, keine Userverwaltung
    • User: Zugriff nur auf zugewiesene Standorte
  • Erster Login erfordert Passwort-Änderung (must_change_password=True)
  • Admin-Reset generiert temporäres Passwort (wird im UI angezeigt)
  • Fehlender/leerer password_hash → Login ohne Prüfung, direkt zu /change-password
  • Letzter Admin kann nicht gelöscht werden
  • users.json wird beim ersten Start aus config.json.admin befüllt
  • Notfall: users.json löschen → Server-Neustart erzeugt neuen Admin

User-Routen

Route Beschreibung
GET /admin/users User-Liste (Admin, mit MFA-Spalte)
GET /admin/users/create User anlegen (Formular)
POST /admin/users/create User anlegen
GET /admin/users/edit/<email> User bearbeiten (Formular)
POST /admin/users/edit/<email> User bearbeiten speichern
POST /admin/users/delete/<email> User löschen (letzter Admin geschützt)
POST /admin/users/reset-password/<email> Passwort-Reset (temporäres Passwort)
GET /mfa/setup MFA einrichten (QR-Code + Verifikation)
POST /mfa/setup TOTP-Code verifizieren → MFA aktivieren
GET /mfa/verify TOTP-Code nach Login eingeben
POST /mfa/verify Code prüfen oder Recovery-Code verwenden
POST /mfa/disable MFA deaktivieren (mit Passwort)

MFA (Multi-Faktor-Authentifizierung)

  • TOTP via pyotp, QR-Code via qrcode[pil] (nur in .venv installiert)
  • Der TOTP-Secret wird sofort beim ersten Aufruf von /mfa/setup (GET) in users.json persistiert, sodass die nachfolgende POST-Verifikation denselben Secret verwendet
  • Nach erfolgreicher Aktivierung: 8 Recovery-Codes (hex, 7 Stellen), einmalig im UI angezeigt, jeder nur einmal verwendbar
  • Recovery-Code-Einsatz erzwingt erneute MFA-Einrichtung
  • Deaktivierung via /mfa/disable mit Passwort-Bestätigung
  • MFA-Status in der Userliste (/admin/users) als grünes "Aktiv"-Badge oder "–"
  • Trusted Device: Nach erfolgreichem TOTP-Code kann das Gerät für 1–30 Tage gespeichert werden. Bei erneuter Anmeldung wird der MFA-Code dann übersprungen (Cookie mfa_trust mit Token, gespeichert in users.json[email].trusted_devices). Beim Logout oder Deaktivieren von MFA wird der Cookie gelöscht.

MFA-Login-Flow

  1. POST /login mit Passwort → bei aktivem MFA: Session mfa_pending setzen → redirect /mfa/verify
  2. GET/POST /mfa/verify → TOTP-Code prüfen → login_user() aufrufen → redirect /admin/<site>
  3. Recovery-Code: POST /mfa/verify mit recovery=true → Code prüfen → mfa_enabled=False → Login erlauben, aber User muss MFA neu einrichten
  4. Trusted Device: POST /mfa/verify mit remember (Tage) → Token generieren → in users.json[email].trusted_devices speichern → Cookie mfa_trust setzen. Nächstes POST /login: Cookie erkannt → MFA überspringen → direkt einloggen

Zugriffs-Dekoratoren

Dekorator Wirkung
@login_required User muss eingeloggt sein
@admin_required Nur Admins
@site_access_required Admin/Superuser → alle Sites, User → nur freigegebene

Behavior To Preserve

  • GET /player/<site>/<screen> renders the playlist and auto-reloads from /playlist/<site>/<screen>/hash.

  • GET /admin requires login; redirects to first accessible site for the user.

  • URL playlist items are stored as dicts like {"url": "https://...", "zoom": 0.8} and the zoom value must survive save/reorder flows. File items may also be dicts ({"name": "bild.jpg", ...}); save_playlist copies existing dicts, so extra keys like schedule survive reordering.

  • .html items in media/ are rendered inline as content, not in an iframe.

  • config.priority.enabled makes the priority playlist show on every screen.

  • POST /api/customer generates welcome.html and inserts it at the front of the playlist of every target screen for the specified site. Target screens are those with welcome_enabled; if no screen of the site has the key at all, the old behaviour applies (lobby only). Optional valid_until ("YYYY-MM-DD") attaches a schedule so the page disappears on its own.

  • New standorte can be added by creating media/<neuer-standort>/<screen>/ directories and optionally adding config to config.json["sites"][<neuer-standort>].

  • Priority playlist (config.priority) is global and affects all sites/screens.

  • Willkommensseite (customer.html) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column).

  • generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None) takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo.

  • Contact info supports Markdown: **bold**, *italic*, `code`, - list items, and line breaks (preserved as <br>).

  • customer_names are preserved in form fields after POST (via value-Attribute).

  • Admin-UI nutzt keyadmin-Design: brand-surface (#2b2f36), nav-surface (rot #DA002D), Dark Mode per localStorage("signage-theme").

  • Gemeinsame HTML-Bausteine: _header.html, _footer.html, _styles.html (CSS-Variablen --ccm-*, Dark Mode, Card-Border-Radius 1rem).

  • add_customer_to_playlists (früher add_customer_to_lobby_playlist) entfernt welcome.html als String und als Dict aus allen Screens des Standorts und fügt sie danach nur in den Zielscreens an Position 0 wieder ein – ein abgewählter Screen bleibt so nicht mit einer alten Begrüßung stehen. Eingefügt wird ein Dict, kein String, damit enabled und schedule mitgeführt werden können.

  • welcome_target_screens(config, site) fällt nur dann auf lobby zurück, wenn kein Screen des Standorts den Schlüssel welcome_enabled besitzt. Wurde er überall bewusst auf False gesetzt, ist die leere Liste das gewollte Ergebnis.

  • Logo-Upload: Pro Kunde kann im Willkommensseiten-Formular eine Bilddatei hochgeladen werden (logo_file_1..3); ein Upload hat Vorrang vor search_customer_logo. Ablage als media/<site>/logo_<slot>.png, Auslieferung über /media/<site>/logos/<datei> (die Route liest aus dem Standort-Verzeichnis).

  • Keine neuen Unterordner unter media/<site>/ anlegen. admin() behandelt jeden Unterordner dort als Screen und persistiert ihn ueber get_screen_config() in der Konfiguration - ein Ordner logos erschien dadurch als Phantom-Screen, bekam eine welcome.html und liess sich ueber die Admin-UI bespielen und loeschen. Deshalb liegen Logos wie das Hintergrundbild als Dateien direkt im Standort-Verzeichnis. Der Name logos ist in der Screen-Erkennung zusaetzlich ausgenommen, damit Altbestaende nicht wieder auftauchen. Die URL traegt einen ?v=<timestamp>, damit Player kein altes Logo aus dem Cache zeigen.

  • Nach dem Freistellen schneidet trim_transparent_border() die durchsichtigen Raender weg. Das ist kein Schoenheitsdetail: .customer-logo skaliert mit object-fit: contain auf 120px Hoehe, ein breiter Rand zaehlt dabei mit und laesst ein hochgeladenes Logo deutlich kleiner erscheinen als ein eng beschnittenes Brandfetch-Logo (gemessen: 30px statt 120px). Der Schnitt greift auch bei bereits transparenten PNGs, aber nicht bei abgewaehltem Freistellen - dort gibt es keinen transparenten Rand.

  • remove_logo_background() arbeitet mit Pillow (Flood Fill von den Raendern, weiche Kante). Bewusst kein ML-Modell wie rembg: der Container laeuft mit read-only Root-FS ohne Modell-Cache. Flood Fill statt globalem Farbvergleich ist wesentlich - sonst verschwindet auch Weiss innerhalb des Logos. Bereits transparente Bilder werden unveraendert durchgereicht; bei unruhigem Bildrand bleibt das Bild unangetastet und die Funktion gibt einen Hinweis zurueck. Ebenso, wenn nach dem Fuellen weniger als 2% sichtbar waeren: ein helles Logo auf hellem Grund liegt innerhalb der Toleranz, der Flood Fill frisst es mit auf und das Ergebnis waere ein leeres PNG - auf der Willkommensseite also gar kein Logo. In dem Fall wird die vor dem Eingriff angelegte Kopie zurueckgegeben.

  • welcome_data in der Site-Config wird feldweise gespeichert (Slot 1-3, auch leere), nicht nur die erfolgreichen Kunden - sonst verschiebt sich die Zuordnung von Name, Logo und Ansprechpartner beim naechsten Aufruf. Ein zuvor hochgeladenes Logo wird beim erneuten Generieren wiederverwendet, solange der Kundenname im selben Feld unveraendert ist.

  • Der Schalter welcome_enabled wirkt sofort: update_screen ruft bei einer Aenderung sync_welcome_for_screen(), das die vorhandene welcome.html in den Screen kopiert und vorne einfuegt bzw. beim Abschalten Eintrag und Datei entfernt. Kein erneutes Generieren noetig - die Seite ist je Standort identisch. Die Funktion aendert nur das uebergebene config-Dict, gespeichert wird vom Aufrufer.

  • save_welcome_page legt zusaetzlich eine Standort-Kopie unter media/<site>/welcome.html an, die nie geloescht wird. Ohne sie waere nach dem Abschalten aller Screens keine Quelle mehr da und der Schalter wuerde stillschweigend nichts tun.

  • Beim Generieren wird welcome.html aus abgewaehlten Screens auch als Datei entfernt, nicht nur aus der Playlist: bei leerer Playlist spielt player() sonst noch nicht verzeichnete Dateien aus dem Ordner ab.

  • Weder save_welcome_page noch add_customer_to_playlists legen Screens an. Das frühere screens.setdefault("lobby", {}) erzeugte an Standorten ohne Lobby stillschweigend einen Screen.

  • add_screen legt ein Verzeichnis unter media/<site>/<screen>/ an und einen Config-Eintrag.

  • delete_screen entfernt den Screen aus der Config und löscht das Verzeichnis rekursiv.

  • Custom-URL-Button: pro Screen konfigurierbar (custom_url + custom_url_label + custom_url_enabled + custom_url_target + custom_url_position) im Admin-Formular (Aktionen-Tab); wird im Player als Button an wählbarer Position (9 Positionen: oben/mitte/unten × links/mitte/rechts) angezeigt und öffnet die URL wahlweise in einem iframe-Overlay mit Zurück-Button (overlay) oder per Direkt-Weiterleitung (redirect); Player pausiert während das Overlay geöffnet ist. Die unteren Positionen weichen automatisch 16px über dem Newsticker-Balken aus.

  • Voice-Agent-Button: globale config.voice_agent_url wird pro Screen im Tab "Digital Voice Agent" konfiguriert; Einstellungen (voice_agent_enabled, voice_agent_label, voice_agent_target, voice_agent_position) funktionieren identisch zum Aktionen-Tab. Die URL ist read-only im Admin sichtbar und wird global in config.json gesetzt.

  • Voice-Agent-Typewriter: Tagline im Button zeigt wechselnde mehrsprachige Texte mit Buchstaben-für-Buchstaben-Effekt; nach 2,5s Pause fade-out über 0,8s, dann nächster Text.

  • Voice-Agent-Overlay: positioniert den Zurück-Button exakt an der Position des geklickten Buttons (identisches Positionssystem).

  • voice_agent_show_image (per Screen): optionales Bild (static/dva.png) über dem Button, per Admin-Toggle schaltbar (Beta).

  • Action-Button (custom_url) und Voice-Agent-Button haben einheitliches Styling: weißer Hintergrund, schwarzer Text, 20px Border-Radius, fette Schrift.

  • Neue Standorte können über den +-Button im Header oder über GET /add-site?name=<name> angelegt werden (nur Admins).

  • delete_site entfernt den Standort aus Config und löscht das Medienverzeichnis rekursiv (nur Admins).

  • config.json["server_url"] (z. B. http://signage.ccmake.de) wird in der Admin-Ansicht für die Player-URLs verwendet.

  • Query-Parameter-Passthrough: Query-Parameter an der Player-URL (/player/<site>/<screen>) können pro Playlist-URL im Edit-Modal (Checkbox) gesteuert werden. Standardmäßig aus – nur URLs mit aktivierter Checkbox erhalten die Parameter. Beispiel: /player/stuttgart/lobby?customer=acme hängt ?customer=acme nur an URLs mit Passthrough=an. In der Playlist-Zeile zeigt ein Link-Icon (ti-link, grau) den Status an: sichtbar = an, durchgestrichen (ti-link-off, hellgrau) = aus. Funktioniert über appendParams() in player.html + player_params im Flask-Route.

  • Zeitsteuerung: Optionales schedule-Objekt pro Playlist-Eintrag (URL und Datei, Screen-Playlist und Priority). Ausgewertet ausschliesslich serverseitig in is_item_active(); siehe Abschnitt "Zeitsteuerung" unten.

  • Screen-Card-Body hat Tabler-Tabs: Playlist (1, aktiv), Einstellungen (2), Aktionen (3), Digital Voice Agent (4), Medien (5), Info (6); Priority-Seite ebenfalls Tabs Playlist und Medien.

  • stay_on_first: Wenn aktiviert bleibt der Player auf dem ersten Playlist-Element stehen (kein Durchlauf).

  • Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien → Info.

  • Player-URL im Screen-Header ist ein klickbarer Link in grauer Farbe.

  • add_customer und /customer erfordern jetzt Login (@login_required) mit Site-Zugriffsprüfung.

  • /admin redirectet zum ersten für den User zugänglichen Standort (nicht mehr global ersten).

  • User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden.

  • Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen

Repo Quirks

  • media/ and media file extensions are gitignored.
  • users.json is NOT gitignored (tracked for initial admin setup, contains no secrets by default).
  • The app has no configured tests, lint, typecheck, formatter, or CI.
  • Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually.
  • Wichtige Helper-Funktionen in app.py: load_config(), save_config(), get_site_list(), get_screen_config(), is_url(), normalize_url(), playlist_item_name(), is_item_active(), _schedule_time(), _window_start_date(), _in_validity_range(), load_priority_files(), prio_redirect(), get_background_url(), load_users(), save_users(), get_user(), init_user_db(), get_accessible_sites(), record_heartbeat(), screen_is_active(), get_client_info(), add_history_entry().
  • Zugriffs-Dekoratoren in app.py: admin_required, site_access_required.
  • Hintergrundbild der Willkommensseite wird pro Standort unter media/<site>/background.* gespeichert; Fallback auf static/wallpaper.png wenn keine Datei existiert.
  • get_background_url(site) prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.
  • generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None) akzeptiert optionale contacts-, site- und background_url-Parameter.
  • welcome.html wird in media/<site>/lobby/ gespeichert.
  • search_customer_logo in generate_welcome_page.py nutzt OpenAI GPT-4 + Brandfetch CDN.
  • admin_priority rendert priority.html mit site_list, current_site, priority_files, server_url.
  • static/dva.png: Bild für den Voice-Agent-Button (optional, per Admin-Toggle ein-/ausblendbar).

Zeitsteuerung (seit v6.2.0)

Jeder Playlist-Eintrag kann ein optionales schedule-Objekt tragen — URL wie Datei, in Screen-Playlists wie in der Priority-Playlist.

{ "mode": "daily",  "start": "08:00", "end": "12:00",
  "valid_from": "2026-09-10", "valid_until": "2026-09-20" }   // valid_* optional
{ "mode": "weekly", "start": "09:00", "end": "17:00", "days": ["0", "2", "4"] }
{ "mode": "once",   "start": "2026-09-10T08:00", "end": "2026-09-20T12:00" }

Regeln

  • once vergleicht vollständige Zeitstempel. daily/weekly werten nur die Uhrzeit aus; ein Datum wird dort ausschliesslich über valid_from/valid_until gesetzt (beide optional, beide inklusiv).
  • Bei daily gilt jeder Wochentag; ein vorhandenes days wird in diesem Modus nicht ausgewertet (bleibt aber erhalten, damit ein Moduswechsel die Auswahl nicht vernichtet).
  • start > end bedeutet ein Fenster über Mitternacht. Ein solches Fenster gehört zum Wochentag und Gültigkeitstag seines Starts — deshalb liefert _window_start_date() das Datum des Fensterbeginns und nicht einfach now.date().
  • Unlesbare oder unvollständige Zeitangaben geben True zurück. Ein Konfigurationsfehler soll den Screen nicht stillschweigend leeren — beim Ändern dieser Logik unbedingt beibehalten.
  • _schedule_time() liest "HH:MM", "HH:MM:SS" und volle ISO-Zeitstempel. Letzteres ist nötig, weil ältere Versionen bei daily/weekly einen kompletten Zeitstempel gespeichert haben, dessen Datumsanteil ignoriert wurde.
  • Auswertung erfolgt nur serverseitig (datetime.now() des Servers, nicht des Players).

Reload-Kopplung

playlist_hash() hasht neben der Konfiguration auch [is_item_active(item) for item in ...] für Screen- und Priority-Playlist. Ohne das würde der Player ein ablaufendes Zeitfenster erst beim nächsten Reload aus anderem Grund bemerken, da sich die Konfiguration dabei nicht ändert.

Beteiligte Stellen

  • is_item_active(), _window_start_date(), _in_validity_range(), _schedule_time() in app.py
  • Jinja-Filter schedule_text() für den Tooltip am "Zeit"-Badge
  • add_url() liest bei daily/weekly die Felder schedule_start_time/schedule_end_time, bei once schedule_start/schedule_end
  • update_playlist_item() behandelt new_url: null als Datei-Eintrag und schreibt {"name": ...}; schedule: {} entfernt die Zeitsteuerung, ein fehlender Key behält sie
  • UI in admin.html und priority.html: zwei Feldpaare (datetime-local für once, time für daily/weekly), per Modus umgeschaltet

Heartbeat / Online-Status (seit v6.1.0)

Der Player-Status (Online/Offline) wird mittels dateibasiertem Heartbeat ermittelt.

Funktionsweise

Schritt Beschreibung
Player-pollt alle 5s /playlist/<site>/<screen>/hash (checkForUpdates) Bestehender Mechanismus, kein Extra-Request
Server zeichnet Heartbeat auf record_heartbeat(site, screen) erstellt/updated /tmp/signage-heartbeat/<site>/<screen> via path.touch()
Admin-UI fragt Status ab screen_is_active(site, screen) prüft ob mtime < 60s alt
Anzeige Grüner "Online"-Button (filled success) oder roter "Offline"-Button (filled danger) im Card-Header neben "Löschen"
Client-Info Beim Hovern über den Online-Button: Tooltip mit Browser, Auflösung, IP, letztem Seen-Zeitpunkt

Helper-Funktionen in app.py

HEARTBEAT_DIR = Path("/tmp/signage-heartbeat")
HEARTBEAT_TIMEOUT = 60  # Sekunden

def record_heartbeat(site, screen, ip="", ua="", resolution=""):
    """Schreibt JSON mit Client-Info (ip, ua, resolution, last_seen)."""
    path = HEARTBEAT_DIR / site / f"{screen}.json"
    path.parent.mkdir(parents=True, exist_ok=True)
    data = {"ip": ip, "ua": ua[:200], "resolution": resolution,
            "last_seen": datetime.now().strftime("%d.%m.%Y %H:%M:%S")}
    path.write_text(json.dumps(data))

def screen_is_active(site, screen):
    """Prüft, ob Heartbeat < 60s alt ist (anhand .json-mtime)."""
    path = HEARTBEAT_DIR / site / f"{screen}.json"
    if not path.exists():
        return False
    return (time.time() - path.stat().st_mtime) < 60

def get_client_info(site, screen):
    """Gibt das gespeicherte Client-Info-JSON zurück."""
    ...

Integration

  • playlist_hash()-Route liest Query-Parameter w (Breite) und h (Höhe) + remote_addr + User-Agent und übergibt sie an record_heartbeat()
  • admin()-Route sammelt client_info[screen] = get_client_info(...) für jeden Screen
  • Admin-Template (admin.html) zeigt den Online-Button mit title-Tooltip (Browser, Auflösung, IP, letztes Update) und den Info-Tab mit Client-Info-Tabelle
  • Player (player.html) sendet screen.width und screen.height als ?w=...&h=... im checkForUpdates-Fetch
  • Die Lösch-Buttons (btn-outline-danger btn-sm) haben gleiche Größe durch gleiche btn-sm-Klasse, Form-Wrapper hat m-0 p-0

Docker-Hinweis

Im Docker-Container liegt der Heartbeat-Pfad unter /tmp/signage-heartbeat/ im Container. Da die Docker-Umgebung keine Volumes mounted, sind Heartbeats container-lokal. Nach docker compose restart werden alle Screens kurz rot, bis die Player innerhalb von max. 60s wieder pollen.

Debugging

# Alle Heartbeat-Dateien anzeigen
ls -la /tmp/signage-heartbeat/*/
find /tmp/signage-heartbeat -type f -exec ls -la {} \;