Ergänzung Active Button

This commit is contained in:
Erik Thiele
2026-06-26 15:10:38 +02:00
parent 22ed03c8fa
commit 1243910d4b
7 changed files with 6292 additions and 2546 deletions

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ media/
*.mp4
*.mov
agents.md
opencode.md

View File

@@ -31,7 +31,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
| `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 |
| `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 |
@@ -120,7 +120,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- `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()`, `playlist_item_enabled()`, `load_priority_files()`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`.
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `playlist_item_enabled()`, `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()`.
- 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.
@@ -129,3 +129,53 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- `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).
## 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" |
### Helper-Funktionen in `app.py`
```python
HEARTBEAT_DIR = Path("/tmp/signage-heartbeat")
HEARTBEAT_TIMEOUT = 60 # Sekunden
def record_heartbeat(site, screen):
path = HEARTBEAT_DIR / site / screen
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
def screen_is_active(site, screen):
path = HEARTBEAT_DIR / site / screen
if not path.exists():
return False
return (time.time() - path.stat().st_mtime) < HEARTBEAT_TIMEOUT
```
### Integration
- `playlist_hash()`-Route ruft `record_heartbeat()` direkt am Anfang auf
- `admin()`-Route setzt `screen_status["online"/"offline"]` via `screen_is_active()` für jeden Screen
- Admin-Template (`admin.html`) zeigt beide Buttons als `<button class="btn btn-sm btn-success/btn-danger" onclick="return false;">`
- 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 {} \;
```

View File

@@ -17,6 +17,7 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
- **Custom-URL-Button**: pro Screen konfigurierbarer Aktions-Button mit frei wählbarer Position (9 Positionen) im Player (öffnet URL in iframe-Overlay mit Zurück-Button oder per Direkt-Weiterleitung)
- **Digital Voice Agent**: global konfigurierbarer Voice-Agent-Button pro Screen (9 Positionen, iframe-Overlay mit positionsgetreuem Zurück-Button, Typewriter-Tagline mit mehrsprachigen Wechseltexten, optionales Bild über dem Button)
- **Stay-on-First**: Screen kann auf erstem Playlist-Element stehen bleiben (kein automatischer Refresh)
- **Online/Offline-Status**: Player-Status wird via dateibasiertem Heartbeat ermittelt grün (online) bei aktivem Player, rot (offline) nach 60s ohne Poll
- **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort
- **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash
- **Admin Dashboard**: `/admin/dashboard` Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf
@@ -41,6 +42,7 @@ Flask App (Server)
├── config.json
├── users.json
├── history.json
├── /tmp/signage-heartbeat/ (Heartbeat-Dateien für Online-Status)
├── media/
│ ├── <site>/
│ │ ├── <screen>/
@@ -185,7 +187,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
| `POST /admin/<site>/upload-background` | Hintergrundbild für Willkommensseite hochladen |
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen |
| `GET /player/<site>/<screen>` | Player-Ansicht |
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) |
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) + Heartbeat (Online-Status) |
| `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) |
| `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) |
| `GET /change-password` | Passwort ändern (First-Login / nach Admin-Reset) |
@@ -200,6 +202,17 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
| `GET /media/priority/<file>` | Priority-Medien (global) |
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
### Heartbeat / Online-Status
Der Player ruft alle 5s `/playlist/<site>/<screen>/hash` auf. Der Server schreibt dabei einen Timestamp nach `/tmp/signage-heartbeat/<site>/<screen>`. Im Admin-Dashboard wird geprüft, ob dieser Timestamp < 60s alt ist → grüner "Online"- oder roter "Offline"-Button pro Screen.
```bash
# Heartbeat-Dateien anzeigen
ls -la /tmp/signage-heartbeat/*/
```
Nach einem Server-Neustart werden alle Screens kurz rot, bis die Player wieder pollen (max. 60s).
### Admin Portal
```
@@ -357,6 +370,8 @@ Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `
- `load_users()` / `save_users()` / `get_user()` User aus `users.json`
- `init_user_db()` initialen Admin aus `config.json` anlegen
- `get_accessible_sites(cfg, user)` Standorte nach User-Berechtigung filtern
- `record_heartbeat(site, screen)` Heartbeat-Datei schreiben (wird vom Player-Poll getriggert)
- `screen_is_active(site, screen)` Prüft ob Heartbeat < 60s alt ist
- `admin_required` / `site_access_required` Zugriffs-Dekoratoren
---

30
app.py
View File

@@ -12,10 +12,12 @@ import os
import json
import hashlib
import re
import time
import generate_welcome_page # Logo-Suche und Willkommensseite-Generierung
from functools import wraps
from datetime import datetime
from pathlib import Path
from flask import (
Flask, jsonify, render_template,
@@ -40,7 +42,7 @@ CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration
USERS_FILE = os.path.join(BASE_DIR, "users.json") # User-Datenbank
HISTORY_FILE = os.path.join(BASE_DIR, "history.json") # Aktivitätsverlauf
APP_VERSION = "6.0.2"
APP_VERSION = "6.0.3"
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
app = Flask(__name__)
@@ -68,7 +70,25 @@ def save_config(cfg):
"""Schreibt das Config-Dict zurück in die config.json."""
with open(CONFIG_FILE, "w") as f:
json.dump(cfg, f, indent=2)
f.flush()
HEARTBEAT_DIR = Path("/tmp/signage-heartbeat")
HEARTBEAT_TIMEOUT = 60 # Sekunden
def record_heartbeat(site, screen):
"""Aktualisiert den Heartbeat-Timestamp für einen Screen."""
path = HEARTBEAT_DIR / site / screen
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
def screen_is_active(site, screen):
"""Prüft, ob ein Screen zuletzt innerhalb des Timeouts registriert wurde."""
path = HEARTBEAT_DIR / site / screen
if not path.exists():
return False
return (time.time() - path.stat().st_mtime) < HEARTBEAT_TIMEOUT
def get_site_list(cfg):
@@ -737,7 +757,9 @@ def player(site, screen):
@app.route("/playlist/<site>/<screen>/hash")
def playlist_hash(site, screen):
"""Berechnet einen MD5-Hash über die aktuelle Konfiguration (Screen + Priority).
Gibt JSON mit Hash und aktuellem stay_on_first-Wert zurück."""
Gibt JSON mit Hash und aktuellem stay_on_first-Wert zurück.
Zeichnet gleichzeitig den Heartbeat auf (Player aktiv)."""
record_heartbeat(site, screen)
config = load_config()
relevant = {
@@ -921,7 +943,7 @@ def admin(site):
screens[screen_name] = screen_cfg
media_files[screen_name] = files
screen_status[screen_name] = "active" if files else "empty"
screen_status[screen_name] = "online" if screen_is_active(current_site, screen_name) else "offline"
return render_template(
"admin.html",

View File

@@ -1,4 +1,10 @@
[
{
"timestamp": "2026-06-26 14:49:09",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-20 16:38:54",
"action": "superuser_deleted",
@@ -8,13 +14,13 @@
{
"timestamp": "2026-06-20 16:38:54",
"action": "user_updated",
"detail": "User 'test1@test.de' bearbeitet (Rolle: superuser user)",
"detail": "User 'test1@test.de' bearbeitet (Rolle: superuser \u2192 user)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-20 16:38:19",
"action": "site_deleted",
"detail": "Standort 'wdqwsqws' gelöscht",
"detail": "Standort 'wdqwsqws' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
@@ -38,7 +44,7 @@
{
"timestamp": "2026-06-20 16:29:59",
"action": "site_deleted",
"detail": "Standort 'qwsqwsqs' gelöscht",
"detail": "Standort 'qwsqwsqs' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
@@ -56,7 +62,7 @@
{
"timestamp": "2026-06-20 16:29:33",
"action": "site_deleted",
"detail": "Standort 'wsqwdsqws' gelöscht",
"detail": "Standort 'wsqwdsqws' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
@@ -68,7 +74,7 @@
{
"timestamp": "2026-06-20 16:27:58",
"action": "site_deleted",
"detail": "Standort 'sdwqdwddd' gelöscht",
"detail": "Standort 'sdwqdwddd' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
@@ -80,31 +86,31 @@
{
"timestamp": "2026-06-20 16:26:50",
"action": "site_deleted",
"detail": "Standort 'test1' gelöscht",
"detail": "Standort 'test1' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-20 16:26:43",
"action": "site_deleted",
"detail": "Standort 'test2' gelöscht",
"detail": "Standort 'test2' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-20 16:26:40",
"action": "screen_deleted",
"detail": "Screen 'dasdsfs' an Standort 'test2' gelöscht",
"detail": "Screen 'dasdsfs' an Standort 'test2' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-20 16:26:36",
"action": "screen_deleted",
"detail": "Screen 'asdwedwed' an Standort 'test2' gelöscht",
"detail": "Screen 'asdwedwed' an Standort 'test2' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-06-20 16:22:12",
"action": "site_deleted",
"detail": "Standort 'dwedwed' gelöscht",
"detail": "Standort 'dwedwed' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
@@ -134,7 +140,7 @@
{
"timestamp": "2026-06-20 16:19:27",
"action": "screen_deleted",
"detail": "Screen 'test2' an Standort 'test2' gelöscht",
"detail": "Screen 'test2' an Standort 'test2' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
@@ -158,7 +164,7 @@
{
"timestamp": "2026-06-20 16:16:32",
"action": "password_changed",
"detail": "Passwort für User 'erik.thiele@cancom.de' geändert",
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' ge\u00e4ndert",
"user": "erik.thiele@cancom.de"
},
{
@@ -170,7 +176,7 @@
{
"timestamp": "2026-06-20 16:14:03",
"action": "user_updated",
"detail": "User 'test1@test.de' bearbeitet (Rolle: user superuser)",
"detail": "User 'test1@test.de' bearbeitet (Rolle: user \u2192 superuser)",
"user": "admin"
},
{

File diff suppressed because one or more lines are too long

View File

@@ -175,13 +175,13 @@
<div class="page-subtitle"><a href="{{ server_url }}/player/{{ current_site }}/{{ screen }}">{{ server_url }}/player/{{ current_site }}/{{ screen }}</a></div>
</span>
<div class="d-flex align-items-center gap-2">
{% if screen_status[screen] == "active" %}
<span class="badge bg-green text-green-fg">Aktiv</span>
{% if screen_status[screen] == "online" %}
<button type="button" class="btn btn-sm btn-success" onclick="return false;">Online</button>
{% else %}
<span class="badge bg-warning text-dark">Leer</span>
<button type="button" class="btn btn-sm btn-danger" onclick="return false;">Offline</button>
{% endif %}
<form action="/admin/{{ current_site }}/delete-screen/{{ screen }}"
method="post" class="d-inline"
method="post" class="d-inline m-0 p-0"
onsubmit="return confirm('Screen »{{ screen }}« wirklich löschen? Alle Medien werden gelöscht.')">
<button type="submit" class="btn btn-outline-danger btn-sm">Löschen</button>
</form>