"""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 import httpx from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_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", "mem0-service", "voice-service"} # 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() def _mem0_reachable() -> bool: try: return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200 except Exception: return False def _voice_reachable() -> bool: try: return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200 except Exception: return False @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)", "unit": "llama-swap", "url": LLAMA_SWAP_URL, "ok": engine_reachable()}, {"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url, "ok": gateway_reachable()}, {"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]}, {"name": "Hermes-Terminal", "unit": "hermes-terminal", "url": a["terminal_url"], "ok": a["terminal_reachable"]}, {"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()}, {"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()}, ], "links": { "engine_ui": f"{LLAMA_SWAP_URL}/ui", "gateway": gw_url, "hermes_terminal": a["terminal_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)