From 2aae6348a60ed7f901654b9d7439c96aa64d4458 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Fri, 11 Sep 2026 11:21:33 +0200 Subject: [PATCH] Phase 5: Dashboard-Datengrundlage (Kunde + Admin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue fetch_customer_dashboard()/fetch_admin_dashboard() + Endpunkte GET /api/v1/organizations/{id}/dashboard und GET /api/v1/dashboard/admin. V1 bewusst nur aus bereits vorhandenen Daten (siehe Plan-Klärungsrunde 11.09.2026) - der Agent meldet Platten-/RAM-Auslastung schon länger bei jedem Check-in (health_disk_used_percent/health_ram_used_percent), das wird hier erstmals aggregiert statt nur pro Gerät angezeigt: - "Nächste Schritte": eigener Workspace vorhanden, Installationsmedium erstellt (das eigentliche iso_build_id lebt in kundenplattforms eigener DB, dort ergänzt), erstes Gerät aktiv, Hostnamenkonvention eingerichtet. - Flotten-Gesundheit: Geräte ohne Check-in seit 7 Tagen, Platten-/RAM- Auslastung ab 85% - gemeinsame Auswertung _dashboard_flotten_gesundheit() für Kunde (org-gefiltert) und Admin (alle Organisationen, mit Namens- Präfix je Zeile). - Letzte Aktionen: jüngste device_merkmal_events (applied/apply_failed). - Admin zusätzlich: Organisationstabelle mit Geräteanzahl/letzter Aktivität. Festplattenfehler-/SMART-Daten bleiben bewusst außen vor - der Agent erfasst das aktuell nicht, wäre ein eigener Erfassungsmechanismus (siehe Rückfrage-Runde im Plan). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9 --- app.py | 194 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/app.py b/app.py index 44329c8..8f37be5 100644 --- a/app.py +++ b/app.py @@ -2121,6 +2121,184 @@ def fetch_all_devices(): ) in cur.fetchall() ] + +# Phase 5 des GUI-Ausbaus (11.09.2026, siehe +# project_kundenplattform_gui_umbau_2026-09-11_dashboard_workspace-Memory): +# Dashboard v1, ausschliesslich aus bereits vorhandenen Daten (kein neuer +# Agent-Datenerfassungsmechanismus - der Agent meldet schon länger Platten-/ +# RAM-Auslastung + Load-Average bei jedem Check-in, siehe +# GERAETE_HEALTH_SPALTEN_SQL/agent_checkin() weiter oben). Fehlende +# Festplattenfehler-/SMART-Daten bleiben eine bekannte, bewusst hier NICHT +# geschlossene Lücke (Nutzer-Rückfrage-Runde, siehe Plan). +DASHBOARD_KEIN_CHECKIN_TAGE = 7 +DASHBOARD_DISK_KRITISCH_PROZENT = 85 +DASHBOARD_RAM_KRITISCH_PROZENT = 85 +DASHBOARD_LETZTE_AKTIONEN_LIMIT = 10 + + +def _dashboard_flotten_gesundheit(geraete_rows) -> dict: + """ + Gemeinsame Auswertung fuer Kunden- und Admin-Dashboard: geraete_rows ist + eine Liste von (hostname, agent_last_checkin, disk_prozent, ram_prozent) + -Tupeln, optional mit vorangestelltem organisationsnamen (siehe Aufrufer). + """ + + schwelle = datetime.now(timezone.utc) - timedelta(days=DASHBOARD_KEIN_CHECKIN_TAGE) + + ohne_checkin = [] + disk_kritisch = [] + ram_kritisch = [] + + for row in geraete_rows: + *praefix, hostname, last_checkin, disk, ram = row + anzeige_name = praefix[0] + " – " + (hostname or "unbenannt") if praefix else (hostname or "unbenannt") + + if last_checkin is None or last_checkin < schwelle: + ohne_checkin.append({ + "name": anzeige_name, + "agent_last_checkin": last_checkin.isoformat() if last_checkin else None, + }) + if disk is not None and disk >= DASHBOARD_DISK_KRITISCH_PROZENT: + disk_kritisch.append({"name": anzeige_name, "wert": round(disk)}) + if ram is not None and ram >= DASHBOARD_RAM_KRITISCH_PROZENT: + ram_kritisch.append({"name": anzeige_name, "wert": round(ram)}) + + return { + "geraete_gesamt": len(geraete_rows), + "geraete_ohne_checkin": sorted(ohne_checkin, key=lambda e: e["agent_last_checkin"] or ""), + "geraete_disk_kritisch": sorted(disk_kritisch, key=lambda e: -e["wert"]), + "geraete_ram_kritisch": sorted(ram_kritisch, key=lambda e: -e["wert"]), + } + + +def fetch_customer_dashboard(organization_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT hostname_praefix FROM organizations WHERE id = %s", (organization_id,)) + row = cur.fetchone() + hostname_praefix = row[0] if row else None + + cur.execute("SELECT COUNT(*) FROM workspaces WHERE organization_id = %s", (organization_id,)) + (anzahl_eigene_workspaces,) = cur.fetchone() + + cur.execute( + """ + SELECT hostname, agent_last_checkin, health_disk_used_percent, health_ram_used_percent + FROM devices + WHERE organization_id = %s AND archived_at IS NULL + """, + (organization_id,), + ) + geraete_rows = cur.fetchall() + + cur.execute( + """ + SELECT COUNT(*) FROM devices + WHERE organization_id = %s AND archived_at IS NULL AND agent_last_checkin IS NOT NULL + """, + (organization_id,), + ) + (anzahl_aktive_geraete,) = cur.fetchone() + + cur.execute( + """ + SELECT e.occurred_at, d.hostname, m.name, e.event_type + FROM device_merkmal_events e + JOIN devices d ON d.id = e.device_id + JOIN merkmale m ON m.id = e.merkmal_id + WHERE d.organization_id = %s AND e.event_type IN ('applied', 'apply_failed') + ORDER BY e.occurred_at DESC + LIMIT %s + """, + (organization_id, DASHBOARD_LETZTE_AKTIONEN_LIMIT), + ) + aktionen = cur.fetchall() + + return { + "naechste_schritte": { + "hat_workspace": anzahl_eigene_workspaces > 0, + "hat_aktives_geraet": anzahl_aktive_geraete > 0, + "hostname_konvention_aktiv": bool(hostname_praefix), + }, + "flotten_gesundheit": _dashboard_flotten_gesundheit(geraete_rows), + "letzte_aktionen": [ + { + "occurred_at": occurred_at.isoformat(), + "hostname": hostname or "unbenannt", + "merkmal_name": merkmal_name, + "event_type": event_type, + } + for occurred_at, hostname, merkmal_name, event_type in aktionen + ], + } + + +def fetch_admin_dashboard(): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT o.name, d.hostname, d.agent_last_checkin, + d.health_disk_used_percent, d.health_ram_used_percent + FROM devices d + JOIN organizations o ON o.id = d.organization_id + WHERE d.archived_at IS NULL + """ + ) + geraete_rows = cur.fetchall() + + cur.execute( + """ + SELECT o.id, o.name, + COUNT(d.id) FILTER (WHERE d.archived_at IS NULL) AS geraete, + MAX(d.agent_last_checkin) AS letzte_aktivitaet + FROM organizations o + LEFT JOIN devices d ON d.organization_id = o.id + GROUP BY o.id, o.name + ORDER BY o.name + """ + ) + organisationen = cur.fetchall() + + cur.execute( + """ + SELECT e.occurred_at, o.name, d.hostname, m.name, e.event_type + FROM device_merkmal_events e + JOIN devices d ON d.id = e.device_id + JOIN organizations o ON o.id = d.organization_id + JOIN merkmale m ON m.id = e.merkmal_id + WHERE e.event_type IN ('applied', 'apply_failed') + ORDER BY e.occurred_at DESC + LIMIT %s + """, + (DASHBOARD_LETZTE_AKTIONEN_LIMIT,), + ) + aktionen = cur.fetchall() + + return { + "flotten_gesundheit": _dashboard_flotten_gesundheit(geraete_rows), + "organisationen": [ + { + "id": str(org_id), + "name": name, + "geraete_anzahl": geraete_anzahl, + "letzte_aktivitaet": letzte_aktivitaet.isoformat() if letzte_aktivitaet else None, + } + for org_id, name, geraete_anzahl, letzte_aktivitaet in organisationen + ], + "letzte_aktionen": [ + { + "occurred_at": occurred_at.isoformat(), + "organization_name": organization_name, + "hostname": hostname or "unbenannt", + "merkmal_name": merkmal_name, + "event_type": event_type, + } + for occurred_at, organization_name, hostname, merkmal_name, event_type in aktionen + ], + } + + def fetch_latest_hardware_snapshot(device_id: str): """ Für die Kundenportal-Aktion "Hardware-Informationen": neuester @@ -4142,6 +4320,22 @@ def get_organization_endpoint(organization_id: str, authorization: str | None = return {"success": True, "organization": organization} +@app.get("/api/v1/organizations/{organization_id}/dashboard") +def get_customer_dashboard_endpoint(organization_id: str, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + return {"success": True, "dashboard": fetch_customer_dashboard(organization_id)} + + +@app.get("/api/v1/dashboard/admin") +def get_admin_dashboard_endpoint(authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + return {"success": True, "dashboard": fetch_admin_dashboard()} + + @app.patch("/api/v1/organizations/{organization_id}") def update_organization_endpoint( organization_id: str,