diff --git a/dashboard/public/program-info.json b/dashboard/public/program-info.json index 5ef396b..e4eebb6 100644 --- a/dashboard/public/program-info.json +++ b/dashboard/public/program-info.json @@ -1,21 +1,20 @@ { "appName": "Infoscreen-Management", - "version": "2025.1.0-alpha.4", + "version": "2025.1.0-alpha.5", "copyright": "© 2025 Third-Age-Applications", "supportContact": "support@third-age-applications.com", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", "techStack": { - "frontend": "React, Vite, TypeScript, Tailwind CSS", - "backend": "Python (Flask), SQLAlchemy", - "database": "MariaDB", - "realtime": "Mosquitto (MQTT)", - "containerization": "Docker" + "Frontend": "React, Vite, TypeScript", + "Backend": "Python (Flask), SQLAlchemy", + "Database": "MariaDB", + "Realtime": "Mosquitto (MQTT)", + "Containerization": "Docker" }, "openSourceComponents": { "frontend": [ { "name": "React", "license": "MIT" }, { "name": "Vite", "license": "MIT" }, - { "name": "Tailwind CSS", "license": "MIT" }, { "name": "Lucide Icons", "license": "ISC" }, { "name": "Syncfusion UI Components", "license": "Kommerziell / Community" } ], @@ -31,6 +30,13 @@ "commitId": "a1b2c3d4e5f6" }, "changelog": [ + { + "version": "2025.1.0-alpha.5", + "date": "2025-09-14", + "changes": [ + "Komplettes Redesign des Backend-Handlings der Gruppenzuordnungen von neuen Clients und der Schritte bei Änderung der Gruppenzuordnung." + ] + }, { "version": "2025.1.0-alpha.4", "date": "2025-09-01", diff --git a/dashboard/src/apiClients.ts b/dashboard/src/apiClients.ts index e9c7455..11095b2 100644 --- a/dashboard/src/apiClients.ts +++ b/dashboard/src/apiClients.ts @@ -59,11 +59,11 @@ export async function setClientDescription(uuid: string, description: string) { return await res.json(); } -export async function updateClientGroup(clientIds: string[], groupName: string) { +export async function updateClientGroup(clientIds: string[], groupId: number) { const res = await fetch('/api/clients/group', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ client_ids: clientIds, group_name: groupName }), + body: JSON.stringify({ client_ids: clientIds, group_id: groupId }), }); if (!res.ok) throw new Error('Fehler beim Aktualisieren der Clients'); return await res.json(); diff --git a/dashboard/src/infoscreen_groups.tsx b/dashboard/src/infoscreen_groups.tsx index caf5411..eceeaf2 100644 --- a/dashboard/src/infoscreen_groups.tsx +++ b/dashboard/src/infoscreen_groups.tsx @@ -72,7 +72,7 @@ L10n.load({ const Infoscreen_groups: React.FC = () => { const toast = useToast(); const [clients, setClients] = useState([]); - const [groups, setGroups] = useState<{ keyField: string; headerText: string }[]>([]); + const [groups, setGroups] = useState<{ keyField: string; headerText: string; id?: number }[]>([]); const [showDialog, setShowDialog] = useState(false); const [newGroupName, setNewGroupName] = useState(''); const [draggedCard, setDraggedCard] = useState<{ id: string; fromColumn: string } | null>(null); @@ -130,7 +130,10 @@ const Infoscreen_groups: React.FC = () => { timeOut: 5000, showCloseButton: false, }); - setGroups([...groups, { keyField: newGroup.name, headerText: newGroup.name }]); + setGroups([ + ...groups, + { keyField: newGroup.name, headerText: newGroup.name, id: newGroup.id }, + ]); setNewGroupName(''); setShowDialog(false); } catch (err) { @@ -149,9 +152,12 @@ const Infoscreen_groups: React.FC = () => { // Clients der Gruppe in "Nicht zugeordnet" verschieben const groupClients = clients.filter(c => c.Status === groupName); if (groupClients.length > 0) { + // Ermittle die ID der Zielgruppe "Nicht zugeordnet" + const target = groups.find(g => g.headerText === 'Nicht zugeordnet'); + if (!target || !target.id) throw new Error('Zielgruppe "Nicht zugeordnet" nicht gefunden'); await updateClientGroup( groupClients.map(c => c.Id), - 'Nicht zugeordnet' + target.id ); } await deleteGroup(groupName); @@ -271,7 +277,10 @@ const Infoscreen_groups: React.FC = () => { const clientIds = dropped.map((card: KanbanClient) => card.Id); try { - await updateClientGroup(clientIds, targetGroupName); + // Ermittle Zielgruppen-ID anhand des Namens + const target = groups.find(g => g.headerText === targetGroupName); + if (!target || !target.id) throw new Error('Zielgruppe nicht gefunden'); + await updateClientGroup(clientIds, target.id); fetchGroups().then((groupData: Group[]) => { const groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name])); setGroups( diff --git a/dashboard/src/types/json.d.ts b/dashboard/src/types/json.d.ts index f3f5966..d282b60 100644 --- a/dashboard/src/types/json.d.ts +++ b/dashboard/src/types/json.d.ts @@ -1,4 +1,4 @@ -declare module "*.json" { - const value: any; +declare module '*.json' { + const value: unknown; export default value; } diff --git a/listener/Dockerfile b/listener/Dockerfile index 3ebc38c..a9333fc 100644 --- a/listener/Dockerfile +++ b/listener/Dockerfile @@ -6,6 +6,9 @@ WORKDIR /app COPY listener/requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt +# Mosquitto-Tools für MQTT-Tests installieren +RUN apt-get update && apt-get install -y --no-install-recommends mosquitto-clients && rm -rf /var/lib/apt/lists/* + COPY listener/ ./listener COPY models/ ./models diff --git a/listener/listener.py b/listener/listener.py index d0d87b6..ce104b1 100644 --- a/listener/listener.py +++ b/listener/listener.py @@ -1,8 +1,6 @@ import os import json import logging -import threading -import time import datetime import paho.mqtt.client as mqtt from sqlalchemy import create_engine @@ -27,12 +25,11 @@ logging.basicConfig(level=logging.DEBUG, engine = create_engine(DB_URL) Session = sessionmaker(bind=engine) -# MQTT-Callback - def on_message(client, userdata, msg): topic = msg.topic logging.debug(f"Empfangene Nachricht auf Topic: {topic}") + try: # Heartbeat-Handling if topic.startswith("infoscreen/") and topic.endswith("/heartbeat"): @@ -41,57 +38,53 @@ def on_message(client, userdata, msg): client_obj = session.query(Client).filter_by(uuid=uuid).first() if client_obj: client_obj.last_alive = datetime.datetime.now(datetime.UTC) - session.commit() - logging.info( - f"Heartbeat von {uuid} empfangen, last_alive (UTC) aktualisiert.") + session.commit() + logging.info( + f"Heartbeat von {uuid} empfangen, last_alive (UTC) aktualisiert.") session.close() return # Discovery-Handling - payload = json.loads(msg.payload.decode()) - logging.info(f"Discovery empfangen: {payload}") - if "uuid" in payload: - uuid = payload["uuid"] - session = Session() - existing = session.query(Client).filter_by(uuid=uuid).first() - if not existing: - new_client = Client( - uuid=uuid, - hardware_token=payload.get("hardware_token"), - ip=payload.get("ip"), - type=payload.get("type"), - hostname=payload.get("hostname"), - os_version=payload.get("os_version"), - software_version=payload.get("software_version"), - macs=",".join(payload.get("macs", [])), - model=payload.get("model"), - registration_time=datetime.datetime.now(datetime.UTC), - ) - session.add(new_client) - session.commit() - logging.info(f"Neuer Client registriert: {uuid}") + if topic == "infoscreen/discovery": + payload = json.loads(msg.payload.decode()) + logging.info(f"Discovery empfangen: {payload}") + + if "uuid" in payload: + uuid = payload["uuid"] + session = Session() + existing = session.query(Client).filter_by(uuid=uuid).first() + + if not existing: + new_client = Client( + uuid=uuid, + hardware_token=payload.get("hardware_token"), + ip=payload.get("ip"), + type=payload.get("type"), + hostname=payload.get("hostname"), + os_version=payload.get("os_version"), + software_version=payload.get("software_version"), + macs=",".join(payload.get("macs", [])), + model=payload.get("model"), + registration_time=datetime.datetime.now(datetime.UTC), + ) + session.add(new_client) + session.commit() + logging.info(f"Neuer Client registriert: {uuid}") + else: + logging.info(f"Client bereits bekannt: {uuid}") + + session.close() + + # Discovery-ACK senden + ack_topic = f"infoscreen/{uuid}/discovery_ack" + client.publish(ack_topic, json.dumps({"status": "ok"})) + logging.info(f"Discovery-ACK gesendet an {ack_topic}") else: - logging.info(f"Client bereits bekannt: {uuid}") - session.close() - # Discovery-ACK senden - ack_topic = f"infoscreen/{uuid}/discovery_ack" - client.publish(ack_topic, json.dumps({"status": "ok"})) - logging.info(f"Discovery-ACK gesendet an {ack_topic}") - else: - logging.warning("Discovery ohne UUID empfangen, ignoriert.") + logging.warning("Discovery ohne UUID empfangen, ignoriert.") + except Exception as e: logging.error(f"Fehler bei Verarbeitung: {e}") - topic_parts = msg.topic.split('/') - if len(topic_parts) == 3 and topic_parts[0] == "infoscreen" and topic_parts[1] == "request_group_id": - client_id = topic_parts[2] - session = Session() - client_obj = session.query(Client).filter_by(uuid=client_id).first() - group_id = client_obj.group_id if client_obj else None - session.close() - response_topic = f"infoscreen/response_group_id/{client_id}" - client.publish(response_topic, json.dumps({"group_id": group_id})) - def main(): mqtt_client = mqtt.Client(protocol=mqtt.MQTTv311, callback_api_version=2) @@ -99,9 +92,9 @@ def main(): mqtt_client.connect("mqtt", 1883) mqtt_client.subscribe("infoscreen/discovery") mqtt_client.subscribe("infoscreen/+/heartbeat") - mqtt_client.subscribe("infoscreen/request_group_id/#") + logging.info( - "Listener gestartet und abonniert auf infoscreen/discovery, infoscreen/+/heartbeat und infoscreen/request_group_id/#") + "Listener gestartet und abonniert auf infoscreen/discovery und infoscreen/+/heartbeat") mqtt_client.loop_forever() diff --git a/scheduler/scheduler.py b/scheduler/scheduler.py index 6f6a37e..302bb11 100644 --- a/scheduler/scheduler.py +++ b/scheduler/scheduler.py @@ -32,15 +32,32 @@ logging.basicConfig( def main(): # Fix für die veraltete API - explizit callback_api_version setzen client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) + client.reconnect_delay_set(min_delay=1, max_delay=30) + + POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen + # 0 = aus; z.B. 600 für alle 10 Min + REFRESH_SECONDS = int(os.getenv("REFRESH_SECONDS", "0")) + last_payloads = {} # group_id -> payload + last_published_at = {} # group_id -> epoch seconds + + # Beim (Re-)Connect alle bekannten retained Payloads erneut senden + def on_connect(client, userdata, flags, reasonCode, properties=None): + logging.info( + f"MQTT connected (reasonCode={reasonCode}) - republishing {len(last_payloads)} groups") + for gid, payload in last_payloads.items(): + topic = f"infoscreen/events/{gid}" + client.publish(topic, payload, retain=True) + + client.on_connect = on_connect + client.connect("mqtt", 1883) client.loop_start() - POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen - last_payloads = {} # group_id -> payload while True: now = datetime.datetime.now(datetime.timezone.utc) # Hole alle aktiven Events (Vergleich mit UTC) events = get_active_events(now, now) + # Gruppiere Events nach group_id groups = {} for event in events: @@ -54,20 +71,32 @@ def main(): "end": str(getattr(event, "end", "")), "group_id": gid, }) + # Sende pro Gruppe die Eventliste als retained Message, nur bei Änderung for gid, event_list in groups.items(): + # stabile Reihenfolge, um unnötige Publishes zu vermeiden + event_list.sort(key=lambda e: (e.get("start"), e.get("id"))) + payload = json.dumps( + event_list, sort_keys=True, separators=(",", ":")) topic = f"infoscreen/events/{gid}" - payload = json.dumps(event_list) - if last_payloads.get(gid) != payload: + + should_send = (last_payloads.get(gid) != payload) + if not should_send and REFRESH_SECONDS: + last_ts = last_published_at.get(gid, 0) + if time.time() - last_ts >= REFRESH_SECONDS: + should_send = True + + if should_send: result = client.publish(topic, payload, retain=True) if result.rc != mqtt.MQTT_ERR_SUCCESS: logging.error( f"Fehler beim Publish für Gruppe {gid}: {mqtt.error_string(result.rc)}") else: - logging.info( - f"Events für Gruppe {gid} gesendet: {payload}") - last_payloads[gid] = payload - # Entferne Gruppen, die nicht mehr existieren (optional: retained Message löschen) + logging.info(f"Events für Gruppe {gid} gesendet") + last_payloads[gid] = payload + last_published_at[gid] = time.time() + + # Entferne Gruppen, die nicht mehr existieren (leere retained Message senden) for gid in list(last_payloads.keys()): if gid not in groups: topic = f"infoscreen/events/{gid}" @@ -78,7 +107,9 @@ def main(): else: logging.info( f"Events für Gruppe {gid} entfernt (leere retained Message gesendet)") - del last_payloads[gid] + del last_payloads[gid] + last_published_at.pop(gid, None) + time.sleep(POLL_INTERVAL) diff --git a/server/mqtt_helper.py b/server/mqtt_helper.py new file mode 100644 index 0000000..fb79617 --- /dev/null +++ b/server/mqtt_helper.py @@ -0,0 +1,142 @@ +""" +Einfache MQTT-Hilfsfunktion für Client-Gruppenzuordnungen +""" +import os +import json +import logging +import paho.mqtt.client as mqtt + +logger = logging.getLogger(__name__) + + +def publish_client_group(client_uuid: str, group_id: int) -> bool: + """ + Publiziert die Gruppenzuordnung eines Clients als retained message + + Args: + client_uuid: UUID des Clients + group_id: ID der Gruppe + + Returns: + bool: True bei Erfolg, False bei Fehler + """ + try: + # MQTT-Konfiguration aus .env + broker_host = os.getenv("MQTT_BROKER_HOST", "mqtt") + broker_port = int(os.getenv("MQTT_BROKER_PORT", 1883)) + username = os.getenv("MQTT_USER") + password = os.getenv("MQTT_PASSWORD") + + # Topic und Payload + topic = f"infoscreen/{client_uuid}/group_id" + payload = json.dumps({ + "group_id": group_id, + "client_uuid": client_uuid + }) + + # MQTT-Client erstellen und verbinden + client = mqtt.Client() + if username and password: + client.username_pw_set(username, password) + + client.connect(broker_host, broker_port, 60) + + # Retained message publizieren + result = client.publish(topic, payload, qos=1, retain=True) + result.wait_for_publish(timeout=5.0) + + client.disconnect() + + logger.info( + f"Group assignment published for client {client_uuid}: group_id={group_id}") + return True + + except Exception as e: + logger.error( + f"Error publishing group assignment for client {client_uuid}: {e}") + return False + + +def publish_multiple_client_groups(client_group_mappings: dict) -> tuple[int, int]: + """ + Publiziert Gruppenzuordnungen für mehrere Clients in einer Verbindung + + Args: + client_group_mappings: Dict mit {client_uuid: group_id} + + Returns: + tuple: (success_count, failed_count) + """ + try: + broker_host = os.getenv("MQTT_BROKER_HOST", "mqtt") + broker_port = int(os.getenv("MQTT_BROKER_PORT", 1883)) + username = os.getenv("MQTT_USER") + password = os.getenv("MQTT_PASSWORD") + + client = mqtt.Client() + if username and password: + client.username_pw_set(username, password) + + client.connect(broker_host, broker_port, 60) + + success_count = 0 + failed_count = 0 + + for client_uuid, group_id in client_group_mappings.items(): + try: + topic = f"infoscreen/{client_uuid}/group_id" + payload = json.dumps({ + "group_id": group_id, + "client_uuid": client_uuid + }) + + result = client.publish(topic, payload, qos=1, retain=True) + result.wait_for_publish(timeout=5.0) + success_count += 1 + + except Exception as e: + logger.error(f"Failed to publish for {client_uuid}: {e}") + failed_count += 1 + + client.disconnect() + + logger.info( + f"Bulk publish completed: {success_count} success, {failed_count} failed") + return success_count, failed_count + + except Exception as e: + logger.error(f"Error in bulk publish: {e}") + return 0, len(client_group_mappings) + + +def delete_client_group_message(client_uuid: str) -> bool: + """ + Löscht die retained message für einen Client (bei Client-Löschung) + """ + try: + broker_host = os.getenv("MQTT_BROKER_HOST", "mqtt") + broker_port = int(os.getenv("MQTT_BROKER_PORT", 1883)) + username = os.getenv("MQTT_USER") + password = os.getenv("MQTT_PASSWORD") + + topic = f"infoscreen/{client_uuid}/group_id" + + client = mqtt.Client() + if username and password: + client.username_pw_set(username, password) + + client.connect(broker_host, broker_port, 60) + + # Leere retained message löscht die vorherige + result = client.publish(topic, "", qos=1, retain=True) + result.wait_for_publish(timeout=5.0) + + client.disconnect() + + logger.info(f"Deleted retained group message for client {client_uuid}") + return True + + except Exception as e: + logger.error( + f"Error deleting group message for client {client_uuid}: {e}") + return False diff --git a/server/routes/clients.py b/server/routes/clients.py index 5bd53d7..f393a0e 100644 --- a/server/routes/clients.py +++ b/server/routes/clients.py @@ -1,12 +1,49 @@ from server.database import Session from models.models import Client, ClientGroup from flask import Blueprint, request, jsonify +from server.mqtt_helper import publish_client_group, delete_client_group_message, publish_multiple_client_groups import sys sys.path.append('/workspace') clients_bp = Blueprint("clients", __name__, url_prefix="/api/clients") +@clients_bp.route("/sync-all-groups", methods=["POST"]) +def sync_all_client_groups(): + """ + Administrative Route: Synchronisiert alle bestehenden Client-Gruppenzuordnungen mit MQTT + Nützlich für die einmalige Migration bestehender Clients + """ + session = Session() + try: + # Alle aktiven Clients abrufen + clients = session.query(Client).filter(Client.is_active == True).all() + + if not clients: + session.close() + return jsonify({"message": "Keine aktiven Clients gefunden", "synced": 0}) + + # Alle Clients synchronisieren + client_group_mappings = { + client.uuid: client.group_id for client in clients} + success_count, failed_count = publish_multiple_client_groups( + client_group_mappings) + + session.close() + + return jsonify({ + "success": True, + "message": f"Synchronisation abgeschlossen", + "synced": success_count, + "failed": failed_count, + "total": len(clients) + }) + + except Exception as e: + session.close() + return jsonify({"error": f"Fehler bei der Synchronisation: {str(e)}"}), 500 + + @clients_bp.route("/without_description", methods=["GET"]) def get_clients_without_description(): session = Session() @@ -46,10 +83,20 @@ def set_client_description(uuid): if not client: session.close() return jsonify({"error": "Client nicht gefunden"}), 404 + client.description = description session.commit() + + # MQTT: Gruppenzuordnung publizieren (wichtig für neue Clients aus SetupMode) + mqtt_success = publish_client_group(client.uuid, client.group_id) + session.close() - return jsonify({"success": True}) + + response = {"success": True} + if not mqtt_success: + response["warning"] = "Beschreibung gespeichert, aber MQTT-Publishing fehlgeschlagen" + + return jsonify(response) @clients_bp.route("", methods=["GET"]) @@ -83,18 +130,51 @@ def get_clients(): def update_clients_group(): data = request.get_json() client_ids = data.get("client_ids", []) + group_id = data.get("group_id") group_name = data.get("group_name") + + if not isinstance(client_ids, list) or len(client_ids) == 0: + return jsonify({"error": "client_ids muss eine nicht-leere Liste sein"}), 400 + session = Session() - group = session.query(ClientGroup).filter_by(name=group_name).first() - if not group: + + # Bestimme Ziel-Gruppe: Priorität hat group_id, ansonsten group_name + group = None + if group_id is not None: + group = session.query(ClientGroup).filter_by(id=group_id).first() + if not group: + session.close() + return jsonify({"error": f"Gruppe mit id={group_id} nicht gefunden"}), 404 + elif group_name: + group = session.query(ClientGroup).filter_by(name=group_name).first() + if not group: + session.close() + return jsonify({"error": f"Gruppe '{group_name}' nicht gefunden"}), 404 + else: session.close() - return jsonify({"error": "Gruppe nicht gefunden"}), 404 + return jsonify({"error": "Entweder group_id oder group_name ist erforderlich"}), 400 + + # WICHTIG: group.id vor dem Schließen puffern, um DetachedInstanceError zu vermeiden + target_group_id = group.id + session.query(Client).filter(Client.uuid.in_(client_ids)).update( - {Client.group_id: group.id}, synchronize_session=False + {Client.group_id: target_group_id}, synchronize_session=False ) session.commit() session.close() - return jsonify({"success": True}) + + # MQTT: Gruppenzuordnungen für alle betroffenen Clients publizieren (nutzt gecachten target_group_id) + client_group_mappings = { + client_id: target_group_id for client_id in client_ids} + success_count, failed_count = publish_multiple_client_groups( + client_group_mappings) + + response = {"success": True} + if failed_count > 0: + response[ + "warning"] = f"Gruppenzuordnung gespeichert, aber {failed_count} MQTT-Publishing(s) fehlgeschlagen" + + return jsonify(response) @clients_bp.route("/", methods=["PATCH"]) @@ -194,7 +274,16 @@ def delete_client(uuid): if not client: session.close() return jsonify({"error": "Client nicht gefunden"}), 404 + session.delete(client) session.commit() session.close() - return jsonify({"success": True}) + + # MQTT: Retained message für gelöschten Client entfernen + mqtt_success = delete_client_group_message(uuid) + + response = {"success": True} + if not mqtt_success: + response["warning"] = "Client gelöscht, aber MQTT-Message-Löschung fehlgeschlagen" + + return jsonify(response) diff --git a/server/sync_existing_clients.py b/server/sync_existing_clients.py new file mode 100644 index 0000000..e835a13 --- /dev/null +++ b/server/sync_existing_clients.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +Einmaliges Skript zur Synchronisation aller bestehenden Client-Gruppenzuordnungen +Verwendung: python sync_existing_clients.py +""" +from server.mqtt_helper import publish_multiple_client_groups +from models.models import Client +from server.database import Session +import sys +import os +sys.path.append('/workspace') + + +def main(): + print("Synchronisiere bestehende Client-Gruppenzuordnungen mit MQTT...") + + session = Session() + try: + # Alle aktiven Clients abrufen + clients = session.query(Client).filter(Client.is_active == True).all() + + if not clients: + print("Keine aktiven Clients gefunden.") + return + + print(f"Gefunden: {len(clients)} aktive Clients") + + # Mapping erstellen + client_group_mappings = { + client.uuid: client.group_id for client in clients} + + # Alle auf einmal publizieren + success_count, failed_count = publish_multiple_client_groups( + client_group_mappings) + + print(f"Synchronisation abgeschlossen:") + print(f" Erfolgreich: {success_count}") + print(f" Fehlgeschlagen: {failed_count}") + print(f" Gesamt: {len(clients)}") + + if failed_count == 0: + print("✅ Alle Clients erfolgreich synchronisiert!") + else: + print( + f"⚠️ {failed_count} Clients konnten nicht synchronisiert werden.") + + except Exception as e: + print(f"Fehler: {e}") + finally: + session.close() + + +if __name__ == "__main__": + main()