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>
23 KiB
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.jsonplus files undermedia/<site>/<screen>/. - User data in
users.json(hashed passwords via werkzeug.security scrypt). - Port 5005;
README.mdhat den korrekten Port. - Existing repo instructions in this file are the main local guidance; there is no
opencode.jsonor 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.jsonwird beim ersten Start ausconfig.json.adminbefüllt- Notfall:
users.jsonlö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 viaqrcode[pil](nur in.venvinstalliert) - Der TOTP-Secret wird sofort beim ersten Aufruf von
/mfa/setup(GET) inusers.jsonpersistiert, 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/disablemit 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_trustmit Token, gespeichert inusers.json[email].trusted_devices). Beim Logout oder Deaktivieren von MFA wird der Cookie gelöscht.
MFA-Login-Flow
- POST
/loginmit Passwort → bei aktivem MFA: Sessionmfa_pendingsetzen → redirect/mfa/verify - GET/POST
/mfa/verify→ TOTP-Code prüfen →login_user()aufrufen → redirect/admin/<site> - Recovery-Code: POST
/mfa/verifymitrecovery=true→ Code prüfen →mfa_enabled=False→ Login erlauben, aber User muss MFA neu einrichten - Trusted Device: POST
/mfa/verifymitremember(Tage) → Token generieren → inusers.json[email].trusted_devicesspeichern → Cookiemfa_trustsetzen. 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 /adminrequires 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_playlistcopies existing dicts, so extra keys likeschedulesurvive reordering. -
.htmlitems inmedia/are rendered inline as content, not in an iframe. -
config.priority.enabledmakes the priority playlist show on every screen. -
POST /api/customergenerateswelcome.htmland inserts it at the front of the playlist of every target screen for the specified site. Target screens are those withwelcome_enabled; if no screen of the site has the key at all, the old behaviour applies (lobbyonly). Optionalvalid_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 toconfig.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_namesare preserved in form fields after POST (viavalue-Attribute). -
Admin-UI nutzt keyadmin-Design:
brand-surface(#2b2f36),nav-surface(rot #DA002D), Dark Mode perlocalStorage("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üheradd_customer_to_lobby_playlist) entferntwelcome.htmlals 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, damitenabledundschedulemitgeführt werden können. -
welcome_target_screens(config, site)fällt nur dann auflobbyzurück, wenn kein Screen des Standorts den Schlüsselwelcome_enabledbesitzt. Wurde er überall bewusst aufFalsegesetzt, 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 vorsearch_customer_logo. Ablage alsmedia/<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 ueberget_screen_config()in der Konfiguration - ein Ordnerlogoserschien dadurch als Phantom-Screen, bekam einewelcome.htmlund liess sich ueber die Admin-UI bespielen und loeschen. Deshalb liegen Logos wie das Hintergrundbild als Dateien direkt im Standort-Verzeichnis. Der Namelogosist 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-logoskaliert mitobject-fit: containauf 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_datain 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_enabledwirkt sofort:update_screenruft bei einer Aenderungsync_welcome_for_screen(), das die vorhandenewelcome.htmlin 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_pagelegt zusaetzlich eine Standort-Kopie untermedia/<site>/welcome.htmlan, 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.htmlaus abgewaehlten Screens auch als Datei entfernt, nicht nur aus der Playlist: bei leerer Playlist spieltplayer()sonst noch nicht verzeichnete Dateien aus dem Ordner ab. -
Weder
save_welcome_pagenochadd_customer_to_playlistslegen Screens an. Das früherescreens.setdefault("lobby", {})erzeugte an Standorten ohne Lobby stillschweigend einen Screen. -
add_screenlegt ein Verzeichnis untermedia/<site>/<screen>/an und einen Config-Eintrag. -
delete_screenentfernt 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_urlwird 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 inconfig.jsongesetzt. -
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 überGET /add-site?name=<name>angelegt werden (nur Admins). -
delete_siteentfernt 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=acmehängt?customer=acmenur 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 überappendParams()inplayer.html+player_paramsim Flask-Route. -
Zeitsteuerung: Optionales
schedule-Objekt pro Playlist-Eintrag (URL und Datei, Screen-Playlist und Priority). Ausgewertet ausschliesslich serverseitig inis_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_customerund/customererfordern jetzt Login (@login_required) mit Site-Zugriffsprüfung. -
/adminredirectet 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.jsonis 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 aufstatic/wallpaper.pngwenn 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 optionalecontacts-,site- undbackground_url-Parameter.welcome.htmlwird inmedia/<site>/lobby/gespeichert.search_customer_logoingenerate_welcome_page.pynutzt OpenAI GPT-4 + Brandfetch CDN.admin_priorityrendertpriority.htmlmitsite_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
oncevergleicht vollständige Zeitstempel.daily/weeklywerten nur die Uhrzeit aus; ein Datum wird dort ausschliesslich übervalid_from/valid_untilgesetzt (beide optional, beide inklusiv).- Bei
dailygilt jeder Wochentag; ein vorhandenesdayswird in diesem Modus nicht ausgewertet (bleibt aber erhalten, damit ein Moduswechsel die Auswahl nicht vernichtet). start>endbedeutet 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 einfachnow.date().- Unlesbare oder unvollständige Zeitangaben geben
Truezurü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 beidaily/weeklyeinen 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()inapp.py- Jinja-Filter
schedule_text()für den Tooltip am "Zeit"-Badge add_url()liest beidaily/weeklydie Felderschedule_start_time/schedule_end_time, beionceschedule_start/schedule_endupdate_playlist_item()behandeltnew_url: nullals Datei-Eintrag und schreibt{"name": ...};schedule: {}entfernt die Zeitsteuerung, ein fehlender Key behält sie- UI in
admin.htmlundpriority.html: zwei Feldpaare (datetime-localfüronce,timefürdaily/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-Parameterw(Breite) undh(Höhe) +remote_addr+User-Agentund übergibt sie anrecord_heartbeat()admin()-Route sammeltclient_info[screen] = get_client_info(...)für jeden Screen- Admin-Template (
admin.html) zeigt den Online-Button mittitle-Tooltip (Browser, Auflösung, IP, letztes Update) und den Info-Tab mit Client-Info-Tabelle - Player (
player.html) sendetscreen.widthundscreen.heightals?w=...&h=...imcheckForUpdates-Fetch - Die Lösch-Buttons (
btn-outline-danger btn-sm) haben gleiche Größe durch gleiche btn-sm-Klasse, Form-Wrapper hatm-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 {} \;