Ampel / ampel (push) Failing after 20s
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>
223 lines
8.7 KiB
Python
223 lines
8.7 KiB
Python
"""
|
||
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}
|