Phase 5: Dashboard-Datengrundlage (Kunde + Admin)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-11 11:21:33 +02:00
1 parent 4706870774
commit 2aae6348a6
1 file changed
+194
+194
View File
@@ -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,