""" Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm braucht. GET /api/start Zustand, Hinweise, Warnlampen, Box-Werte, Updates, Flugplan GET /api/hinweise Hinweise + Verlauf des Wächters POST /api/hinweise/{id}/aktion/{aktion} Knopf eines Hinweises ausführen GET /api/modelle/nutzung Wer nutzt die Modelle (7 Tage, 24 h je Stunde) GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt GET /api/ziele Die KI-Box als Ziel mit Bausteinen (gemeinsames Modell, Phase 2) POST /api/modelle/aufraeumen/loeschen Einen Kandidaten löschen (nur auf Knopfdruck) Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py. """ import time from datetime import datetime import psutil from config import MODELS_DIR from fastapi import APIRouter, HTTPException from kern.zeit import LOCAL_TZ from pydantic import BaseModel from services import ( aufraeumen, backup, box_updates, llamaswap, modell_nutzung, system, update_verlauf, waechter, zeitplan, ) router = APIRouter(prefix="/api", tags=["boxwart"]) def _zeit_kurz(ts: float | None) -> str: if not ts: return "–" dt = datetime.fromtimestamp(ts, LOCAL_TZ) heute = datetime.now(LOCAL_TZ).date() if dt.date() == heute: return f"heute {dt:%H:%M}" if (heute - dt.date()).days == 1: return f"gestern {dt:%H:%M}" return f"{dt:%d.%m. %H:%M}" def _lampen(stand: dict, u: dict) -> list[dict]: ids = {h.get("id", "") for h in stand["hinweise"]} versionen = system.check_versions_cached() def lampe(lid: str, label: str, zustand: str, wert: str) -> dict: return {"id": lid, "label": label, "zustand": zustand, "wert": wert} engine_ok = llamaswap.engine_reachable() build = (versionen.get("engine") or {}).get("version_text", "") lampen = [lampe("motor", "Motor", "ok" if engine_ok else "fehler", build if engine_ok else "antwortet nicht")] hirn = llamaswap.brain_status() if engine_ok else {} lampen.append(lampe("hirn", "Hirn", "ok" if hirn.get("ready") else "fehler", "geladen" if hirn.get("ready") else "lädt nicht")) laufend = set(llamaswap.get_running_models()) if engine_ok else set() coder = next((m for m in llamaswap.list_models() if "coder" in (m.get("aliases") or [])), None) coder_an = bool(coder and coder.get("name") in laufend) lampen.append(lampe("coder", "Coder", "ok" if coder_an else "aus", "geladen" if coder_an else "auf Abruf")) hermes_weg = any(i in ids for i in ("kern:hermes", "dienst:hermes-gateway")) lampen.append(lampe("hermes", "Hermes", "fehler" if hermes_weg else "ok", "antwortet nicht" if hermes_weg else "läuft")) job_hinweise = [h for h in stand["hinweise"] if h.get("id", "").startswith(("job:", "timer:"))] rot = any(h.get("stufe") == "rot" for h in job_hinweise) lampen.append(lampe("jobs", "Jobs", "fehler" if rot else ("warn" if job_hinweise else "ok"), f"{len(job_hinweise)} Hinweis{'e' if len(job_hinweise) != 1 else ''}" if job_hinweise else "alles gelaufen")) try: sicherungen = backup.list_backups() except Exception: sicherungen = [] letzte = max((s.get("mtime") or s.get("ts") or 0 for s in sicherungen), default=0) zu_alt = not letzte or time.time() - letzte > 36 * 3600 lampen.append(lampe("sicherung", "Sicherung", "warn" if zu_alt else "ok", _zeit_kurz(letzte))) try: platte = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".").percent except Exception: platte = None zustand = "ok" if platte is None or platte < waechter.DISK_GELB_PCT else ( "warn" if platte < waechter.DISK_ROT_PCT else "fehler") lampen.append(lampe("platte", "Platte", zustand, f"{platte:.0f} %" if platte is not None else "–")) n = len(box_updates.bausteine(u)) fest = len(update_verlauf.festgehalten()) unklar = len(box_updates.unklar(u)) if fest: lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten")) elif not box_updates.gelesen(): lampen.append(lampe("updates", "Updates", "aus", "wird geprüft")) elif n: lampen.append(lampe("updates", "Updates", "info", f"{n} bereit")) elif unklar: lampen.append(lampe("updates", "Updates", "warn", "Prüfung unklar")) else: lampen.append(lampe("updates", "Updates", "ok", "aktuell")) return lampen def _box() -> dict: p = system.metrik_punkt() try: du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".") platte = {"used": du.used, "total": du.total, "percent": du.percent} except Exception: platte = None return {"ram_used": p.get("ram_used"), "ram_total": p.get("ram_total"), "temp_cpu": p.get("temp_cpu"), "temp_gpu": p.get("temp_gpu"), "platte": platte, "uptime_s": p.get("uptime_s")} @router.get("/start") def start() -> dict: stand = waechter.lese_stand() u = box_updates.gecacht() rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot") anzahl = len(stand["hinweise"]) wach = bool(stand["stand"]) and time.time() - float(stand["stand"] or 0) < 5 * 60 return { "zustand": { "stufe": "rot" if rot else ("gelb" if anzahl else "ok"), "anzahl": anzahl, "waechter_wach": wach, "stand": stand["stand"], "update_laeuft": stand["update_laeuft"], }, "hinweise": stand["hinweise"], "verlauf": stand["verlauf"][:20], "lampen": _lampen(stand, u), "box": _box(), "updates": {"bausteine": box_updates.bausteine(u), "unklar": box_updates.unklar(u), "festgehalten": update_verlauf.festgehalten(), "gelesen": box_updates.gelesen()}, "flugplan": zeitplan.flugplan(), } @router.get("/hinweise") def hinweise() -> dict: return waechter.lese_stand() @router.post("/hinweise/{hinweis_id}/aktion/{aktion_id}") def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict: ergebnis = waechter.fuehre_aktion_aus(hinweis_id, aktion_id) if ergebnis.get("detail") and not ergebnis.get("ok"): raise HTTPException(status_code=409, detail=ergebnis["detail"]) return ergebnis @router.get("/modelle/nutzung") def nutzung() -> dict: return modell_nutzung.nutzung() @router.get("/updates/verlauf") def updates_verlauf(anzahl: int = 8) -> dict: """Die letzten Update-Läufe mit dem Ergebnis je Baustein (aus dem Meldeprotokoll der Box).""" return {"laeufe": update_verlauf.laeufe(max(1, min(anzahl, 30)))} @router.post("/updates/festgehalten/{baustein}/freigeben") def festgehalten_freigeben(baustein: str) -> dict: if not update_verlauf.freigeben(baustein): raise HTTPException(status_code=404, detail="Dieser Baustein ist nicht festgehalten.") return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT} class LoeschAuftrag(BaseModel): art: str # "eintrag" | "ordner" name: str @router.get("/modelle/aufraeumen") def aufraeumen_liste() -> dict: return {"kandidaten": aufraeumen.kandidaten()} @router.post("/modelle/aufraeumen/loeschen") def aufraeumen_loeschen(auftrag: LoeschAuftrag) -> dict: ergebnis = aufraeumen.loeschen(auftrag.art, auftrag.name) if not ergebnis.get("ok"): raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.") return ergebnis @router.get("/ziele") def ziele() -> dict: """Die KI-Box im gemeinsamen Ziel-Modell (kern/ziele.py). Der Homelab-Teil liefert seine Ziele ab Phase 3 unter /api/homelab/ziele; die Oberfläche legt beide zu einer Update-Liste zusammen.""" return {"ziele": [box_updates.ki_box_ziel().als_dict()]}