"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box). Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern). Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler zurückgegeben statt zu crashen. """ import logging import os import subprocess from fastapi import APIRouter, HTTPException from pydantic import BaseModel from config import GATEWAY_URL, HERMES_API_URL, HERMES_WEBUI_URL, LLAMA_SWAP_URL from services import backup as backup_svc from services.agent import agent_status from services.gateway import gateway_reachable from services.llamaswap import engine_reachable, list_models from services.pricing import compute_savings from services.system import system_status from services.token_stats import get_stats log = logging.getLogger(__name__) router = APIRouter(prefix="/api") # Nur diese User-Dienste dürfen neugestartet werden. ALLOWED_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-webui"} # Quelle für Self-Update (auf der Box ~/mission-control-v2). SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")) @router.get("/system/status") def status() -> dict: return system_status() @router.get("/system/services") def services() -> dict: """Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige).""" a = agent_status() gw_url = f"{GATEWAY_URL}/v1" return { "services": [ {"name": "Engine (llama-swap)", "url": LLAMA_SWAP_URL, "ok": engine_reachable()}, {"name": "Gateway (integriert)", "url": gw_url, "ok": gateway_reachable()}, {"name": "Hermes-Gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]}, {"name": "Hermes-WebUI", "url": HERMES_WEBUI_URL, "ok": a["webui_reachable"]}, ], "links": { "engine_ui": f"{LLAMA_SWAP_URL}/ui", "gateway": gw_url, "hermes_webui": HERMES_WEBUI_URL, }, } @router.post("/system/backup") def backup() -> dict: return backup_svc.backup_now() @router.get("/system/backups") def backups() -> dict: return {"backups": backup_svc.list_backups()} def _run(cmd: list[str], cwd: str | None = None) -> dict: try: p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180) return {"ok": p.returncode == 0, "code": p.returncode, "out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]} except Exception as exc: # noqa: BLE001 return {"ok": False, "code": -1, "out": "", "err": str(exc)} class RestartReq(BaseModel): service: str @router.post("/system/restart") def restart(req: RestartReq) -> dict: if req.service not in ALLOWED_SERVICES: raise HTTPException(400, f"Dienst '{req.service}' nicht erlaubt.") return _run(["systemctl", "--user", "restart", req.service]) @router.post("/system/self-update") def self_update() -> dict: """git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler.""" pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR) reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR) restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"]) return {"pull": pull, "reset": reset, "restart": restart_res} @router.get("/system/token-stats") def token_stats() -> dict: """Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT).""" # Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen. role_map: dict[str, str | None] = {} try: for m in list_models(): role_map[m["name"].lower()] = m.get("role") for alias in m.get("aliases", []): role_map[alias.lower()] = m.get("role") except Exception: log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True) return compute_savings(get_stats(), role_map)