From 2e9f22f5cce6e3499da44470aa1f6249c4d01fc7 Mon Sep 17 00:00:00 2001 From: olaf Date: Fri, 18 Jul 2025 14:49:53 +0000 Subject: [PATCH] test communication scheduler<->simclient --- dashboard/package-lock.json | 20 ++- dashboard/package.json | 1 + dashboard/src/App.tsx | 14 +-- dashboard/src/apiClients.ts | 11 +- dashboard/src/clients.tsx | 243 ++++++++++++++++++++++++++++++------ listener/listener.py | 20 +-- scheduler/scheduler.py | 78 ++++++++---- server/database.py | 5 +- server/routes/clients.py | 38 ++++++ simclient/requirements.txt | 1 + simclient/simclient.py | 46 ++++++- 11 files changed, 389 insertions(+), 88 deletions(-) diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 9999521..caf28b1 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -15,6 +15,7 @@ "@syncfusion/ej2-react-grids": "^30.1.37", "@syncfusion/ej2-react-inputs": "^30.1.38", "@syncfusion/ej2-react-kanban": "^30.1.37", + "@syncfusion/ej2-react-layouts": "^30.1.40", "@syncfusion/ej2-react-notifications": "^30.1.37", "@syncfusion/ej2-react-popups": "^30.1.37", "@syncfusion/ej2-react-schedule": "^30.1.37", @@ -1165,12 +1166,12 @@ } }, "node_modules/@syncfusion/ej2-layouts": { - "version": "30.1.37", - "resolved": "https://registry.npmjs.org/@syncfusion/ej2-layouts/-/ej2-layouts-30.1.37.tgz", - "integrity": "sha512-EPI00OSBMuzxp8od6jTOK2LRlv5IWtN4WmpIklUe34vev0qG9HLo8yT2BKCUIo5TRm4xEUXx0mQM7ZSKw2iHig==", + "version": "30.1.40", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-layouts/-/ej2-layouts-30.1.40.tgz", + "integrity": "sha512-PPv+brJOOkaMp+HZ7IDq7Tc8aYMYrpP7i0cp/b2W8fFTwcXoI06l4oRp65NAy1THnU82m2pRnbTETyrSDMu+TA==", "license": "SEE LICENSE IN license", "dependencies": { - "@syncfusion/ej2-base": "~30.1.37" + "@syncfusion/ej2-base": "~30.1.38" } }, "node_modules/@syncfusion/ej2-lists": { @@ -1315,6 +1316,17 @@ "@syncfusion/ej2-react-base": "~30.1.37" } }, + "node_modules/@syncfusion/ej2-react-layouts": { + "version": "30.1.40", + "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-layouts/-/ej2-react-layouts-30.1.40.tgz", + "integrity": "sha512-gOIo3DOEy+m0QF8vuj+dEB/PdnEnv4n+8pvCIPfiCcatY46rPSv4Lzh8q4PixGfFwauLfljAIdm+3gquxvfJIA==", + "license": "SEE LICENSE IN license", + "dependencies": { + "@syncfusion/ej2-base": "~30.1.38", + "@syncfusion/ej2-layouts": "30.1.40", + "@syncfusion/ej2-react-base": "~30.1.37" + } + }, "node_modules/@syncfusion/ej2-react-notifications": { "version": "30.1.37", "resolved": "https://registry.npmjs.org/@syncfusion/ej2-react-notifications/-/ej2-react-notifications-30.1.37.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 69a02f3..36d1ba6 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -17,6 +17,7 @@ "@syncfusion/ej2-react-grids": "^30.1.37", "@syncfusion/ej2-react-inputs": "^30.1.38", "@syncfusion/ej2-react-kanban": "^30.1.37", + "@syncfusion/ej2-react-layouts": "^30.1.40", "@syncfusion/ej2-react-notifications": "^30.1.37", "@syncfusion/ej2-react-popups": "^30.1.37", "@syncfusion/ej2-react-schedule": "^30.1.37", diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index f1a3e38..af25ec6 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -32,7 +32,6 @@ const sidebarItems = [ import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { fetchClientsWithoutDescription } from './apiClients'; // ENV aus .env holen (Platzhalter, im echten Projekt über process.env oder API) const ENV = import.meta.env.VITE_ENV || 'development'; @@ -128,7 +127,6 @@ const Layout: React.FC = () => { ); }; - function useLoginCheck() { const [isLoggedIn, setIsLoggedIn] = useState(false); @@ -150,17 +148,7 @@ const App: React.FC = () => { const navigate = useNavigate(); const isLoggedIn = useLoginCheck(); - useEffect(() => { - if (!isLoggedIn) return; - fetchClientsWithoutDescription().then(list => { - if (list.length > 0) { - console.log('[Navigation] Weiterleitung zu /clients wegen fehlender Beschreibung'); - navigate('/clients'); - } else { - console.log('[Navigation] Dashboard wird angezeigt, alle Clients haben Beschreibung'); - } - }); - }, [isLoggedIn, navigate]); + // Automatische Navigation zu /clients bei leerer Beschreibung entfernt return ( diff --git a/dashboard/src/apiClients.ts b/dashboard/src/apiClients.ts index c369748..119e6e8 100644 --- a/dashboard/src/apiClients.ts +++ b/dashboard/src/apiClients.ts @@ -1,4 +1,3 @@ - export interface Client { uuid: string; hardware_token?: string; @@ -51,3 +50,13 @@ export async function updateClientGroup(clientIds: string[], groupName: string) if (!res.ok) throw new Error('Fehler beim Aktualisieren der Clients'); return await res.json(); } + +export async function updateClient(uuid: string, data: { description?: string; model?: string }) { + const res = await fetch(`/api/clients/${uuid}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Fehler beim Aktualisieren des Clients'); + return await res.json(); +} diff --git a/dashboard/src/clients.tsx b/dashboard/src/clients.tsx index 2356c60..7e461b3 100644 --- a/dashboard/src/clients.tsx +++ b/dashboard/src/clients.tsx @@ -1,17 +1,108 @@ import SetupModeButton from './components/SetupModeButton'; import React, { useEffect, useState } from 'react'; -import { fetchClients, fetchClientsWithoutDescription, setClientDescription } from './apiClients'; +// ...ButtonComponent entfernt... +// Card-Komponente wird als eigenes Layout umgesetzt +import { fetchClients, updateClient } from './apiClients'; import type { Client } from './apiClients'; +import { + GridComponent, + ColumnsDirective, + ColumnDirective, + Page, + Inject, + Toolbar, + Search, + Sort, + Edit, +} from '@syncfusion/ej2-react-grids'; -// Dummy Modalbox (ersetzbar durch SyncFusion Dialog) -function ModalBox({ open, onClose }) { - if (!open) return null; +// Raumgruppen werden dynamisch aus der API geladen + +interface DetailsModalProps { + open: boolean; + client: Client | null; + groupIdToName: Record; + onClose: () => void; +} + +function DetailsModal({ open, client, groupIdToName, onClose }: DetailsModalProps) { + if (!open || !client) return null; return ( -
-
-

Neue Clients ohne Beschreibung!

-

Bitte ergänzen Sie die Beschreibung für neue Clients.

- +
+
+
+

Client-Details

+ + + {Object.entries(client) + .filter( + ([key]) => + ![ + 'index', + 'is_active', + 'type', + 'column', + 'group_name', + 'foreignKeyData', + 'hardware_token', + ].includes(key) + ) + .map(([key, value]) => ( + + + + + ))} + +
+ {key === 'group_id' + ? 'Raumgruppe' + : key === 'ip' + ? 'IP-Adresse' + : key === 'registration_time' + ? 'Registriert am' + : key.charAt(0).toUpperCase() + key.slice(1)} + : + + {key === 'group_id' + ? value !== undefined + ? groupIdToName[value as string | number] || value + : '' + : key === 'registration_time' && value + ? new Date( + (value as string).endsWith('Z') ? (value as string) : value + 'Z' + ).toLocaleString() + : key === 'last_alive' && value + ? new Date( + (value as string).endsWith('Z') ? (value as string) : value + 'Z' + ).toLocaleString() + : String(value)} +
+
+ +
+
); @@ -19,42 +110,122 @@ function ModalBox({ open, onClose }) { const Clients: React.FC = () => { const [clients, setClients] = useState([]); - const [showModal, setShowModal] = useState(false); + const [groups, setGroups] = useState<{ id: number; name: string }[]>([]); + const [selectedClient, setSelectedClient] = useState(null); useEffect(() => { fetchClients().then(setClients); - fetchClientsWithoutDescription().then(list => { - if (list.length > 0) setShowModal(true); - }); + // Gruppen auslesen + import('./apiGroups').then(mod => mod.fetchGroups()).then(setGroups); }, []); + // Map group_id zu group_name + const groupIdToName: Record = {}; + groups.forEach(g => { + groupIdToName[g.id] = g.name; + }); + + // DataGrid data mit korrektem Gruppennamen und formatierten Zeitangaben + const gridData = clients.map(c => ({ + ...c, + group_name: c.group_id !== undefined ? groupIdToName[c.group_id] || String(c.group_id) : '', + last_alive: c.last_alive + ? new Date( + (c.last_alive as string).endsWith('Z') ? (c.last_alive as string) : c.last_alive + 'Z' + ).toLocaleString() + : '', + })); + + // DataGrid row template für Details-Button + const detailsButtonTemplate = (props: Client) => ( + + ); + return (
-

Client-Übersicht

- - - - - - - - - - - {clients.map(c => ( - - - - - - - ))} - -
UUIDHostnameBeschreibungGruppe
{c.uuid}{c.hostname}{c.description}{c.group_id}
-
+
+

Client-Übersicht

- setShowModal(false)} /> + {groups.length > 0 ? ( + <> + { + if (args.requestType === 'save') { + const { uuid, description, model } = args.data; + // API-Aufruf zum Speichern + await updateClient(uuid, { description, model }); + // Nach dem Speichern neu laden + fetchClients().then(setClients); + } + }} + > + + + + + + + + + + + + setSelectedClient(null)} + /> + + ) : ( +
Raumgruppen werden geladen ...
+ )}
); }; diff --git a/listener/listener.py b/listener/listener.py index ed7f6b3..848d329 100644 --- a/listener/listener.py +++ b/listener/listener.py @@ -2,8 +2,12 @@ import os import json import logging +import threading +import time +# ...requests entfernt... +import datetime import paho.mqtt.client as mqtt -from sqlalchemy import create_engine, func +from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from models.models import Client from dotenv import load_dotenv @@ -17,19 +21,21 @@ DB_URL = os.environ.get( "DB_CONN", "mysql+pymysql://user:password@db/infoscreen") # Logging -logging.basicConfig(level=getattr(logging, LOG_LEVEL.upper(), logging.INFO), +logging.basicConfig(level=logging.DEBUG, format='%(asctime)s [%(levelname)s] %(message)s') # DB-Konfiguration engine = create_engine(DB_URL) Session = sessionmaker(bind=engine) +# ...externe Zeitsynchronisation entfernt... # 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"): @@ -37,11 +43,10 @@ def on_message(client, userdata, msg): session = Session() client_obj = session.query(Client).filter_by(uuid=uuid).first() if client_obj: - from sqlalchemy import func - client_obj.last_alive = func.current_timestamp() - session.commit() - logging.info( - f"Heartbeat von {uuid} empfangen, last_alive aktualisiert.") + client_obj.last_alive = datetime.datetime.now(datetime.UTC) + session.commit() + logging.info( + f"Heartbeat von {uuid} empfangen, last_alive (UTC) aktualisiert.") session.close() return @@ -62,6 +67,7 @@ def on_message(client, userdata, msg): 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() diff --git a/scheduler/scheduler.py b/scheduler/scheduler.py index f46f8aa..b0350d9 100644 --- a/scheduler/scheduler.py +++ b/scheduler/scheduler.py @@ -1,40 +1,74 @@ # scheduler/scheduler.py + +import os +import logging from scheduler.db_utils import get_active_events import paho.mqtt.client as mqtt import json import datetime import time +# Logging-Konfiguration +ENV = os.getenv("ENV", "development") +LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG" if ENV == "development" else "INFO") +LOG_PATH = os.path.join(os.path.dirname(__file__), "scheduler.log") +os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) +log_handlers = [] +if ENV == "production": + from logging.handlers import RotatingFileHandler + log_handlers.append(RotatingFileHandler( + LOG_PATH, maxBytes=2*1024*1024, backupCount=5, encoding="utf-8")) +else: + log_handlers.append(logging.FileHandler(LOG_PATH, encoding="utf-8")) +if os.getenv("DEBUG_MODE", "1" if ENV == "development" else "0") in ("1", "true", "True"): + log_handlers.append(logging.StreamHandler()) +logging.basicConfig( + level=getattr(logging, LOG_LEVEL.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=log_handlers +) + + def main(): # Fix für die veraltete API - explizit callback_api_version setzen client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) client.connect("mqtt", 1883) - POLL_INTERVAL = 10 # Sekunden - last_sent_event_ids = set() - + POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen + last_payloads = {} # group_id -> payload while True: - now = datetime.datetime.now() - # Hole alle Events, die jetzt aktiv sind (start < now < end) + now = datetime.datetime.now(datetime.timezone.utc) + # Hole alle aktiven Events (Vergleich mit UTC) events = get_active_events(now, now) - current_event_ids = set(event.id for event in events) - - # Sende nur neue Events (die noch nicht gesendet wurden) - new_event_ids = current_event_ids - last_sent_event_ids + # Gruppiere Events nach group_id + groups = {} for event in events: - if event.id in new_event_ids: - # Beispiel: Sende Event-Daten als JSON auf Topic "infoscreen/events" - payload = json.dumps({ - "id": event.id, - "title": getattr(event, "title", ""), - "start": str(getattr(event, "start", "")), - "end": str(getattr(event, "end", "")), - "group_id": getattr(event, "group_id", None), - }) - client.publish("infoscreen/events", payload) - print(f"Event {event.id} gesendet: {payload}") - - last_sent_event_ids = current_event_ids + gid = getattr(event, "group_id", None) + if gid not in groups: + groups[gid] = [] + groups[gid].append({ + "id": event.id, + "title": getattr(event, "title", ""), + "start": str(getattr(event, "start", "")), + "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(): + topic = f"infoscreen/events/{gid}" + payload = json.dumps(event_list) + if last_payloads.get(gid) != payload: + client.publish(topic, payload, retain=True) + 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) + for gid in list(last_payloads.keys()): + if gid not in groups: + topic = f"infoscreen/events/{gid}" + client.publish(topic, payload="[]", retain=True) + logging.info( + f"Events für Gruppe {gid} entfernt (leere retained Message gesendet)") + del last_payloads[gid] time.sleep(POLL_INTERVAL) diff --git a/server/database.py b/server/database.py index 3b4718a..5689527 100644 --- a/server/database.py +++ b/server/database.py @@ -3,11 +3,12 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker # Umgebungsvariablen -DB_USER = os.getenv("DB_USER", "infoscreen_admin") +DB_USER = os.getenv("DB_USER", "infoscreen_admin") DB_PASSWORD = os.getenv("DB_PASSWORD", "KqtpM7wmNd&mFKs") DB_HOST = os.getenv("DB_HOST", "db") DB_NAME = os.getenv("DB_NAME", "infoscreen_by_taa") DB_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}" +print(f"Using DB_URL: {DB_URL}") # Debug-Ausgabe engine = create_engine(DB_URL, echo=False) -Session = sessionmaker(bind=engine) \ No newline at end of file +Session = sessionmaker(bind=engine) diff --git a/server/routes/clients.py b/server/routes/clients.py index 75ff64c..9b8ccc3 100644 --- a/server/routes/clients.py +++ b/server/routes/clients.py @@ -6,6 +6,7 @@ sys.path.append('/workspace') clients_bp = Blueprint("clients", __name__, url_prefix="/api/clients") + @clients_bp.route("/without_description", methods=["GET"]) def get_clients_without_description(): session = Session() @@ -50,6 +51,7 @@ def set_client_description(uuid): session.close() return jsonify({"success": True}) + @clients_bp.route("", methods=["GET"]) def get_clients(): session = Session() @@ -93,3 +95,39 @@ def update_clients_group(): session.commit() session.close() return jsonify({"success": True}) + + +@clients_bp.route("/", methods=["PATCH"]) +def update_client(uuid): + data = request.get_json() + session = Session() + client = session.query(Client).filter_by(uuid=uuid).first() + if not client: + session.close() + return jsonify({"error": "Client nicht gefunden"}), 404 + allowed_fields = ["description", "model"] + updated = False + for field in allowed_fields: + if field in data: + setattr(client, field, data[field]) + updated = True + if updated: + session.commit() + result = {"success": True} + else: + result = {"error": "Keine gültigen Felder zum Aktualisieren übergeben"} + session.close() + return jsonify(result) + + +# Neue Route: Liefert die aktuelle group_id für einen Client +@clients_bp.route("//group", methods=["GET"]) +def get_client_group(uuid): + session = Session() + client = session.query(Client).filter_by(uuid=uuid).first() + if not client: + session.close() + return jsonify({"error": "Client nicht gefunden"}), 404 + group_id = client.group_id + session.close() + return jsonify({"group_id": group_id}) diff --git a/simclient/requirements.txt b/simclient/requirements.txt index 3eca880..02a54e4 100644 --- a/simclient/requirements.txt +++ b/simclient/requirements.txt @@ -1,2 +1,3 @@ paho-mqtt dotenv +requests diff --git a/simclient/simclient.py b/simclient/simclient.py index a82fe6d..14e6bf2 100644 --- a/simclient/simclient.py +++ b/simclient/simclient.py @@ -1,5 +1,6 @@ # simclient/simclient.py +from logging.handlers import RotatingFileHandler import time import uuid import json @@ -11,6 +12,7 @@ import re import platform import logging from dotenv import load_dotenv +import requests # ENV laden load_dotenv("/workspace/simclient/.env") @@ -26,8 +28,11 @@ DEBUG_MODE = os.getenv("DEBUG_MODE", "1" if ENV == "development" else "0") in ("1", "true", "True") # Logging-Konfiguration -LOG_PATH = "/tmp/simclient.log" -log_handlers = [logging.FileHandler(LOG_PATH, encoding="utf-8")] +LOG_PATH = os.path.join(os.path.dirname(__file__), "simclient.log") +os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) +log_handlers = [] +log_handlers.append(RotatingFileHandler( + LOG_PATH, maxBytes=2*1024*1024, backupCount=5, encoding="utf-8")) if DEBUG_MODE: log_handlers.append(logging.StreamHandler()) logging.basicConfig( @@ -43,6 +48,10 @@ discovered = False def on_message(client, userdata, msg): global discovered logging.info(f"Empfangen: {msg.topic} {msg.payload.decode()}") + # Event-Messages vom Scheduler explizit loggen + if msg.topic.startswith("infoscreen/events/"): + logging.info( + f"Event-Message vom Scheduler empfangen: {msg.payload.decode()}") # ACK-Quittung empfangen? if msg.topic.endswith("/discovery_ack"): discovered = True @@ -168,14 +177,45 @@ def main(): client.subscribe(ack_topic) client.subscribe(f"infoscreen/{client_id}/config") + # Hilfsfunktion: Hole group_id per API + def get_group_id(client_id): + api_url = f"http://server:8000/api/clients/{client_id}/group" + try: + resp = requests.get(api_url, timeout=5) + logging.debug( + f"API-Request: {api_url} - Status: {resp.status_code} - Response: {resp.text}") + if resp.ok: + group_id = resp.json().get("group_id") + logging.info( + f"Ermittelte group_id für Client {client_id}: {group_id}") + return group_id + except Exception as e: + logging.error(f"Fehler beim API-Request für group_id: {e}") + return None + # Discovery-Phase: Sende Discovery bis ACK empfangen while not discovered: send_discovery(client, client_id, hardware_token, ip_addr) client.loop(timeout=1.0) time.sleep(HEARTBEAT_INTERVAL) - # Heartbeat-Phase + # Event-Topic abonnieren (und bei Änderung wechseln) + current_group_id = None + event_topic = None while True: + group_id = get_group_id(client_id) + logging.debug( + f"Aktuelle group_id: {group_id}, vorherige group_id: {current_group_id}") + if group_id != current_group_id: + # Topic wechseln + if event_topic: + client.unsubscribe(event_topic) + logging.info(f"Event-Topic abbestellt: {event_topic}") + event_topic = f"infoscreen/events/{group_id}" + client.subscribe(event_topic) + current_group_id = group_id + logging.info( + f"Abonniere Event-Topic: {event_topic} für group_id: {group_id}") client.publish(f"infoscreen/{client_id}/heartbeat", "alive") logging.debug("Heartbeat gesendet.") client.loop(timeout=1.0)