feat: dashboard screenshot upload & retention (last 20 per client)

- Listener: subscribe to dashboard topic, forward screenshots to API
- API: store latest + last 20 timestamped screenshots per client, auto-delete older files
- Docs: updated README, TECH-CHANGELOG, and copilot-instructions for screenshot upload and retention policy
This commit is contained in:
RobbStarkAustria
2025-11-30 13:38:07 +00:00
parent df9f29bc6a
commit c193209326
7 changed files with 273 additions and 14 deletions

View File

@@ -33,17 +33,22 @@ Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/pro
- `dashboard/src/settings.tsx` — settings UI (nested tabs; system defaults for presentations and videos) - `dashboard/src/settings.tsx` — settings UI (nested tabs; system defaults for presentations and videos)
## Big picture ## Big picture
- Multi-service app orchestrated by Docker Compose. - Multi-service app orchestrated by Docker Compose.
- API: Flask + SQLAlchemy (MariaDB), in `server/` exposed on :8000 (health: `/health`). - API: Flask + SQLAlchemy (MariaDB), in `server/` exposed on :8000 (health: `/health`).
- Dashboard: React + Vite in `dashboard/`, dev on :5173, served via Nginx in prod. - Dashboard: React + Vite in `dashboard/`, dev on :5173, served via Nginx in prod.
- MQTT broker: Eclipse Mosquitto, config in `mosquitto/config/mosquitto.conf`. - MQTT broker: Eclipse Mosquitto, config in `mosquitto/config/mosquitto.conf`.
- Listener: MQTT consumer handling discovery + heartbeats in `listener/listener.py`. - Listener: MQTT consumer handling discovery, heartbeats, and dashboard screenshot uploads in `listener/listener.py`.
- Scheduler: Publishes only currently active events (per group, at "now") to MQTT retained topics in `scheduler/scheduler.py`. It queries a future window (default: 7 days) to expand recurring events using RFC 5545 rules and applies event exceptions, but only publishes events that are active at the current time. When a group has no active events, the scheduler clears its retained topic by publishing an empty list. All time comparisons are UTC; any naive timestamps are normalized. Logging is concise; conversion lookups are cached and logged only once per media. - Scheduler: Publishes only currently active events (per group, at "now") to MQTT retained topics in `scheduler/scheduler.py`. It queries a future window (default: 7 days) to expand recurring events using RFC 5545 rules and applies event exceptions, but only publishes events that are active at the current time. When a group has no active events, the scheduler clears its retained topic by publishing an empty list. All time comparisons are UTC; any naive timestamps are normalized. Logging is concise; conversion lookups are cached and logged only once per media.
- Nginx: Reverse proxy routes `/api/*` and `/screenshots/*` to API; everything else to dashboard (`nginx.conf`). - Nginx: Reverse proxy routes `/api/*` and `/screenshots/*` to API; everything else to dashboard (`nginx.conf`).
- Dev Container (hygiene): UI-only `Dev Containers` extension runs on host UI via `remote.extensionKind`; do not install it in-container. Dashboard installs use `npm ci`; shell aliases in `postStartCommand` are appended idempotently. - Dev Container (hygiene): UI-only `Dev Containers` extension runs on host UI via `remote.extensionKind`; do not install it in-container. Dashboard installs use `npm ci`; shell aliases in `postStartCommand` are appended idempotently.
### Screenshot retention
- Screenshots sent via dashboard MQTT are stored in `server/screenshots/`.
- For each client, only the latest and last 20 timestamped screenshots are kept; older files are deleted automatically on each upload.
## Recent changes since last commit ## Recent changes since last commit
### Latest (November 2025) ### Latest (November 2025)

View File

@@ -339,6 +339,8 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
- `POST /api/conversions/{media_id}/pdf` - Request conversion - `POST /api/conversions/{media_id}/pdf` - Request conversion
- `GET /api/conversions/{media_id}/status` - Check conversion status - `GET /api/conversions/{media_id}/status` - Check conversion status
- `GET /api/eventmedia/stream/<media_id>/<filename>` - Stream media with byte-range support (206) for seeking - `GET /api/eventmedia/stream/<media_id>/<filename>` - Stream media with byte-range support (206) for seeking
- `POST /api/clients/{uuid}/screenshot` - Upload screenshot for client (base64 JPEG)
- **Screenshot retention:** Only the latest and last 20 timestamped screenshots per client are stored on the server. Older screenshots are automatically deleted.
### System Settings ### System Settings
- `GET /api/system-settings` - List all system settings (admin+) - `GET /api/system-settings` - List all system settings (admin+)

View File

@@ -0,0 +1,94 @@
# Screenshot Transmission Implementation
## Overview
Clients send screenshots via MQTT during heartbeat intervals. The listener service receives these screenshots and forwards them to the server API for storage.
## Architecture
### MQTT Topic
- **Topic**: `infoscreen/{uuid}/screenshot`
- **Payload Format**:
- Raw binary image data (JPEG/PNG), OR
- JSON with base64-encoded image: `{"image": "<base64-string>"}`
### Components
#### 1. Listener Service (`listener/listener.py`)
- **Subscribes to**: `infoscreen/+/screenshot`
- **Function**: `handle_screenshot(uuid, payload)`
- Detects payload format (binary or JSON)
- Converts binary to base64 if needed
- Forwards to API via HTTP POST
#### 2. Server API (`server/routes/clients.py`)
- **Endpoint**: `POST /api/clients/<uuid>/screenshot`
- **Authentication**: No authentication required (internal service call)
- **Accepts**:
- JSON: `{"image": "<base64-encoded-image>"}`
- Binary: raw image data
- **Storage**:
- Saves to `server/screenshots/{uuid}_{timestamp}.jpg` (with timestamp)
- Saves to `server/screenshots/{uuid}.jpg` (latest, for quick retrieval)
#### 3. Retrieval (`server/wsgi.py`)
- **Endpoint**: `GET /screenshots/<uuid>`
- **Returns**: Latest screenshot for the given client UUID
- **Nginx**: Exposes `/screenshots/{uuid}.jpg` in production
## Unified Identification Method
Screenshots are identified by **client UUID**:
- Each client has a unique UUID stored in the `clients` table
- Screenshots are stored as `{uuid}.jpg` (latest) and `{uuid}_{timestamp}.jpg` (historical)
- The API endpoint requires UUID validation against the database
- Retrieval is done via `GET /screenshots/<uuid>` which returns the latest screenshot
## Data Flow
```
Client → MQTT (infoscreen/{uuid}/screenshot)
Listener Service
↓ (validates client exists)
↓ (converts binary → base64 if needed)
API POST /api/clients/{uuid}/screenshot
↓ (validates client UUID)
↓ (decodes base64 → binary)
Filesystem: server/screenshots/{uuid}.jpg
Dashboard/Nginx: GET /screenshots/{uuid}
```
## Configuration
### Environment Variables
- **Listener**: `API_BASE_URL` (default: `http://server:8000`)
- **Server**: Screenshots stored in `server/screenshots/` directory
### Dependencies
- Listener: Added `requests>=2.31.0` to `listener/requirements.txt`
- Server: Uses built-in Flask and base64 libraries
## Error Handling
- **Client Not Found**: Returns 404 if UUID doesn't exist in database
- **Invalid Payload**: Returns 400 if image data is missing or invalid
- **API Timeout**: Listener logs error and continues (timeout: 10s)
- **Network Errors**: Listener logs and continues operation
## Security Considerations
- Screenshot endpoint does not require authentication (internal service-to-service)
- Client UUID must exist in database before screenshot is accepted
- Base64 encoding prevents binary data issues in JSON transport
- File size is tracked and logged for monitoring
## Future Enhancements
- Add screenshot retention policy (auto-delete old timestamped files)
- Add compression before transmission
- Add screenshot quality settings
- Add authentication between listener and API
- Add screenshot history API endpoint

View File

@@ -5,9 +5,8 @@
This changelog documents technical and developer-relevant changes included in public releases. For development workspace changes, see DEV-CHANGELOG.md. Not all changes here are reflected in the user-facing changelog (`program-info.json`), and not all UI/feature changes are repeated here. Some changes (e.g., backend refactoring, API adjustments, infrastructure, developer tooling, or internal logic) may only appear in TECH-CHANGELOG.md. For UI/feature changes, see `dashboard/public/program-info.json`. This changelog documents technical and developer-relevant changes included in public releases. For development workspace changes, see DEV-CHANGELOG.md. Not all changes here are reflected in the user-facing changelog (`program-info.json`), and not all UI/feature changes are repeated here. Some changes (e.g., backend refactoring, API adjustments, infrastructure, developer tooling, or internal logic) may only appear in TECH-CHANGELOG.md. For UI/feature changes, see `dashboard/public/program-info.json`.
---
## 2025.1.0-alpha.12 (2025-11-27)
Backend rework (post-release notes; no version bump): Backend rework (post-release notes; no version bump):
- 🧩 Dev Container hygiene: Remote Containers runs on UI (`remote.extensionKind`), removed in-container install to prevent reappearance loops; switched `postCreateCommand` to `npm ci` for reproducible dashboard installs; `postStartCommand` aliases made idempotent. - 🧩 Dev Container hygiene: Remote Containers runs on UI (`remote.extensionKind`), removed in-container install to prevent reappearance loops; switched `postCreateCommand` to `npm ci` for reproducible dashboard installs; `postStartCommand` aliases made idempotent.
- 🔄 Serialization: Consolidated snake_case→camelCase via `server/serializers.py` for all JSON outputs; ensured enums/UTC datetimes serialize consistently across routes. - 🔄 Serialization: Consolidated snake_case→camelCase via `server/serializers.py` for all JSON outputs; ensured enums/UTC datetimes serialize consistently across routes.

View File

@@ -2,29 +2,73 @@ import os
import json import json
import logging import logging
import datetime import datetime
import base64
import requests
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from models.models import Client from models.models import Client
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s [%(levelname)s] %(message)s')
# Load .env in development
if os.getenv("ENV", "development") == "development": if os.getenv("ENV", "development") == "development":
try:
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv(".env") load_dotenv(".env")
except Exception:
pass
# ENV-abhängige Konfiguration # ENV-dependent configuration
ENV = os.getenv("ENV", "development") ENV = os.getenv("ENV", "development")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO" if ENV == "production" else "DEBUG") LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO" if ENV == "production" else "DEBUG")
DB_URL = os.environ.get( DB_URL = os.environ.get("DB_CONN", "mysql+pymysql://user:password@db/infoscreen")
"DB_CONN", "mysql+pymysql://user:password@db/infoscreen")
# Logging
logging.basicConfig(level=logging.DEBUG,
format='%(asctime)s [%(levelname)s] %(message)s')
# DB-Konfiguration # DB-Konfiguration
engine = create_engine(DB_URL) engine = create_engine(DB_URL)
Session = sessionmaker(bind=engine) Session = sessionmaker(bind=engine)
# API configuration
API_BASE_URL = os.getenv("API_BASE_URL", "http://server:8000")
def handle_screenshot(uuid, payload):
"""
Handle screenshot data received via MQTT and forward to API.
Payload can be either raw binary image data or JSON with base64-encoded image.
"""
try:
# Try to parse as JSON first
try:
data = json.loads(payload.decode())
if "image" in data:
# Payload is JSON with base64 image
api_payload = {"image": data["image"]}
headers = {"Content-Type": "application/json"}
logging.debug(f"Forwarding base64 screenshot from {uuid} to API")
else:
logging.warning(f"Screenshot JSON from {uuid} missing 'image' field")
return
except (json.JSONDecodeError, UnicodeDecodeError):
# Payload is raw binary image data - encode to base64 for API
image_b64 = base64.b64encode(payload).decode('utf-8')
api_payload = {"image": image_b64}
headers = {"Content-Type": "application/json"}
logging.debug(f"Forwarding binary screenshot from {uuid} to API (encoded as base64)")
# Forward to API endpoint
api_url = f"{API_BASE_URL}/api/clients/{uuid}/screenshot"
response = requests.post(api_url, json=api_payload, headers=headers, timeout=10)
if response.status_code == 200:
logging.info(f"Screenshot von {uuid} erfolgreich an API weitergeleitet")
else:
logging.error(f"API returned status {response.status_code} for screenshot from {uuid}: {response.text}")
except requests.exceptions.RequestException as e:
logging.error(f"Failed to forward screenshot from {uuid} to API: {e}")
except Exception as e:
logging.error(f"Error handling screenshot from {uuid}: {e}")
def on_connect(client, userdata, flags, reasonCode, properties): def on_connect(client, userdata, flags, reasonCode, properties):
"""Callback for when client connects or reconnects (API v2).""" """Callback for when client connects or reconnects (API v2)."""
@@ -32,7 +76,9 @@ def on_connect(client, userdata, flags, reasonCode, properties):
# Subscribe on every (re)connect so we don't miss heartbeats after broker restarts # Subscribe on every (re)connect so we don't miss heartbeats after broker restarts
client.subscribe("infoscreen/discovery") client.subscribe("infoscreen/discovery")
client.subscribe("infoscreen/+/heartbeat") client.subscribe("infoscreen/+/heartbeat")
logging.info(f"MQTT connected (reasonCode: {reasonCode}); (re)subscribed to discovery and heartbeats") client.subscribe("infoscreen/+/screenshot")
client.subscribe("infoscreen/+/dashboard")
logging.info(f"MQTT connected (reasonCode: {reasonCode}); (re)subscribed to discovery, heartbeats, screenshots, and dashboards")
except Exception as e: except Exception as e:
logging.error(f"Subscribe failed on connect: {e}") logging.error(f"Subscribe failed on connect: {e}")
@@ -42,6 +88,39 @@ def on_message(client, userdata, msg):
logging.debug(f"Empfangene Nachricht auf Topic: {topic}") logging.debug(f"Empfangene Nachricht auf Topic: {topic}")
try: try:
# Dashboard-Handling (nested screenshot payload)
if topic.startswith("infoscreen/") and topic.endswith("/dashboard"):
uuid = topic.split("/")[1]
try:
payload_text = msg.payload.decode()
data = json.loads(payload_text)
shot = data.get("screenshot")
if isinstance(shot, dict):
# Prefer 'data' field (base64) inside screenshot object
image_b64 = shot.get("data")
if image_b64:
logging.debug(f"Dashboard enthält Screenshot für {uuid}; Weiterleitung an API")
# Build a lightweight JSON with image field for API handler
api_payload = json.dumps({"image": image_b64}).encode("utf-8")
handle_screenshot(uuid, api_payload)
# Update last_alive if status present
if data.get("status") == "alive":
session = Session()
client_obj = session.query(Client).filter_by(uuid=uuid).first()
if client_obj:
client_obj.last_alive = datetime.datetime.now(datetime.UTC)
session.commit()
session.close()
except Exception as e:
logging.error(f"Fehler beim Verarbeiten des Dashboard-Payloads von {uuid}: {e}")
return
# Screenshot-Handling
if topic.startswith("infoscreen/") and topic.endswith("/screenshot"):
uuid = topic.split("/")[1]
handle_screenshot(uuid, msg.payload)
return
# Heartbeat-Handling # Heartbeat-Handling
if topic.startswith("infoscreen/") and topic.endswith("/heartbeat"): if topic.startswith("infoscreen/") and topic.endswith("/heartbeat"):
uuid = topic.split("/")[1] uuid = topic.split("/")[1]

View File

@@ -2,3 +2,4 @@ paho-mqtt>=2.0
SQLAlchemy>=2.0 SQLAlchemy>=2.0
pymysql pymysql
python-dotenv python-dotenv
requests>=2.31.0

View File

@@ -273,6 +273,85 @@ def restart_client(uuid):
return jsonify({"error": f"Failed to send MQTT message: {str(e)}"}), 500 return jsonify({"error": f"Failed to send MQTT message: {str(e)}"}), 500
@clients_bp.route("/<uuid>/screenshot", methods=["POST"])
def upload_screenshot(uuid):
"""
Route to receive and store a screenshot from a client.
Expected payload: base64-encoded image data in JSON or binary image data.
Screenshots are stored as {uuid}.jpg in the screenshots folder.
Keeps last 20 screenshots per client (auto-cleanup).
"""
import os
import base64
import glob
from datetime import datetime
session = Session()
client = session.query(Client).filter_by(uuid=uuid).first()
if not client:
session.close()
return jsonify({"error": "Client nicht gefunden"}), 404
session.close()
try:
# Handle JSON payload with base64-encoded image
if request.is_json:
data = request.get_json()
if "image" not in data:
return jsonify({"error": "Missing 'image' field in JSON payload"}), 400
# Decode base64 image
image_data = base64.b64decode(data["image"])
else:
# Handle raw binary image data
image_data = request.get_data()
if not image_data:
return jsonify({"error": "No image data received"}), 400
# Ensure screenshots directory exists
screenshots_dir = os.path.join(os.path.dirname(__file__), "..", "screenshots")
os.makedirs(screenshots_dir, exist_ok=True)
# Store screenshot with timestamp to track latest
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{uuid}_{timestamp}.jpg"
filepath = os.path.join(screenshots_dir, filename)
with open(filepath, "wb") as f:
f.write(image_data)
# Also create/update a symlink or copy to {uuid}.jpg for easy retrieval
latest_filepath = os.path.join(screenshots_dir, f"{uuid}.jpg")
with open(latest_filepath, "wb") as f:
f.write(image_data)
# Cleanup: keep only last 20 timestamped screenshots per client
pattern = os.path.join(screenshots_dir, f"{uuid}_*.jpg")
existing_screenshots = sorted(glob.glob(pattern))
# Keep last 20, delete older ones
max_screenshots = 20
if len(existing_screenshots) > max_screenshots:
for old_file in existing_screenshots[:-max_screenshots]:
try:
os.remove(old_file)
except Exception as cleanup_error:
# Log but don't fail the request if cleanup fails
import logging
logging.warning(f"Failed to cleanup old screenshot {old_file}: {cleanup_error}")
return jsonify({
"success": True,
"message": f"Screenshot received for client {uuid}",
"filename": filename,
"size": len(image_data)
}), 200
except Exception as e:
return jsonify({"error": f"Failed to process screenshot: {str(e)}"}), 500
@clients_bp.route("/<uuid>", methods=["DELETE"]) @clients_bp.route("/<uuid>", methods=["DELETE"])
@admin_or_higher @admin_or_higher
def delete_client(uuid): def delete_client(uuid):