# CANCOM Simple Signage — Agent Guide ## Start ```bash 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///`. - 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//` | Player für Screen an einem Standort | | `GET /admin` | Redirect zum ersten zugänglichen Standort | | `GET /admin/` | Admin-Dashboard für einen Standort (`@site_access_required`) | | `GET /admin//priority` | Priority-Playlist als separate Seite | | `GET /media///` | Medien-Datei ausliefern | | `GET /media/priority/` | Priority-Medien (global) | | `GET /media//background/` | Hintergrundbild der Willkommensseite | | `GET /playlist///hash` | Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status) | | `GET /willkommen?site=` | Willkommensseite für Standort generieren | | `POST /admin//upload-background` | Hintergrundbild für Willkommensseite hochladen | | `POST /admin//delete-background` | Hintergrundbild zurücksetzen auf Standard | | `POST /api/customer` | API-Endpunkt (JSON mit "site"-Feld) | | `GET /admin//add-screen?name=` | Neuen Screen anlegen | | `POST /admin//delete-screen/` | Screen + Medien löschen | | `GET /add-site?name=` | Neuen Standort anlegen (nur Admin) | | `POST /admin//update-actions/` | Aktionen-Einstellungen speichern (Custom-URL + Position) | | `POST /admin//update-voice/` | Voice-Agent-Einstellungen speichern (Enabled, Label, Target, Position) | | `POST /admin//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/` | 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/` | User bearbeiten (Formular) | | `POST /admin/users/edit/` | User bearbeiten speichern | | `POST /admin/users/delete/` | User löschen (letzter Admin geschützt) | | `POST /admin/users/reset-password/` | 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/` 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//` renders the playlist and auto-reloads from `/playlist///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///` directories and optionally adding config to `config.json["sites"][]`. - 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 `
`). - `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//logo_.png`, Auslieferung über `/media//logos/` (die Route liest aus dem Standort-Verzeichnis). - **Keine neuen Unterordner unter `media//` 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=`, 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//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///` 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=` 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//`) 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//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//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. ```jsonc { "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///hash` (`checkForUpdates`) | Bestehender Mechanismus, kein Extra-Request | | **Server zeichnet Heartbeat auf** | `record_heartbeat(site, screen)` erstellt/updated `/tmp/signage-heartbeat//` 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` ```python 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 ```bash # Alle Heartbeat-Dateien anzeigen ls -la /tmp/signage-heartbeat/*/ find /tmp/signage-heartbeat -type f -exec ls -la {} \; ```