Files
mission-control-v2/backend/routers/boxwart.py
T
HitonabiandClaude Opus 5.5 4b226d5d22
Ampel / ampel (push) Failing after 20s
boxwart: Start wartet nie auf die Update-Suche im Netz, Verlauf am Handy kompakter
Nach einem MC2-Neustart hing /api/start, bis GitHub, apt und Hugging Face
geantwortet hatten. Jetzt kommt der letzte Stand sofort, frisch geholt wird im
Hintergrund; bis dahin steht "wird geprueft" da.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:04:50 +02:00

223 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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, "laeuft": False}
UPDATES_CACHE_S = 600
def _updates_holen() -> None:
try:
daten = maintenance.updates()
except Exception:
daten = None
with _updates_lock:
if daten is not None or _updates_cache["daten"] is None:
_updates_cache["daten"] = daten or {}
_updates_cache["ts"] = time.time()
_updates_cache["laeuft"] = False
def _updates_gecacht() -> dict:
"""maintenance.updates() fragt GitHub, apt und Hugging Face und braucht nach einem Neustart
gern zehn Sekunden und mehr. Der Start wartet darauf nie: Er bekommt den letzten Stand (oder
zunächst nichts), frisch geholt wird im Hintergrund."""
with _updates_lock:
veraltet = _updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S
if veraltet and not _updates_cache["laeuft"]:
_updates_cache["laeuft"] = True
threading.Thread(target=_updates_holen, name="updates-holen", daemon=True).start()
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"))
elif _updates_cache["daten"] is None:
lampen.append(lampe("updates", "Updates", "aus", "wird geprüft"))
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(),
"gelesen": _updates_cache["daten"] is not None},
"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}