""" 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/zeitplan Heute gelaufen / demnächst geplant GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py. """ import os import threading import time from datetime import datetime from zoneinfo import ZoneInfo import psutil from config import MODELS_DIR from fastapi import APIRouter, HTTPException from services import ( backup, llamaswap, maintenance, modell_nutzung, system, update_verlauf, waechter, zeitplan, ) router = APIRouter(prefix="/api", tags=["boxwart"]) LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) _updates_lock = threading.Lock() _updates_cache: dict = {"ts": 0.0, "daten": None} UPDATES_CACHE_S = 600 def _updates_gecacht() -> dict: """maintenance.updates() fragt GitHub, apt und Hugging Face — das darf den Start nicht bremsen.""" with _updates_lock: if _updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S: try: _updates_cache["daten"] = maintenance.updates() except Exception: _updates_cache["daten"] = _updates_cache["daten"] or {} _updates_cache["ts"] = time.time() return _updates_cache["daten"] or {} def _bausteine(u: dict) -> list[dict]: """Welche Bausteine haben Neues? In der Reihenfolge, in der auch das Update läuft.""" liste = [] if u.get("os"): liste.append({"id": "os", "name": "Betriebssystem", "neu": f"{u['os']} Pakete"}) if u.get("engine"): liste.append({"id": "engine", "name": "Motor", "neu": "neuer Build"}) if u.get("swap"): liste.append({"id": "swap", "name": "llama-swap", "neu": "neue Version"}) for c in u.get("components") or []: if c.get("key") == "hermes_agent" and c.get("update"): liste.append({"id": "hermes", "name": "Hermes", "neu": f"{c.get('behind', '?')} Änderungen"}) return liste 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(_bausteine(u)) fest = len(update_verlauf.festgehalten()) if fest: lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten")) else: lampen.append(lampe("updates", "Updates", "info" if n else "ok", f"{n} bereit" if n else "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 = _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": _bausteine(u), "modelle": u.get("model_list") or [], "festgehalten": update_verlauf.festgehalten()}, "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("/zeitplan") def zeitplan_route() -> dict: return zeitplan.flugplan() @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}