Ergänzung Active Button
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ media/
|
||||
*.mp4
|
||||
*.mov
|
||||
agents.md
|
||||
opencode.md
|
||||
54
AGENTS.md
54
AGENTS.md
@@ -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 {} \;
|
||||
```
|
||||
|
||||
17
README.md
17
README.md
@@ -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
30
app.py
@@ -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",
|
||||
|
||||
32
history.json
32
history.json
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
8704
session-ses_1e96.md
8704
session-ses_1e96.md
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user