"""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 from pydantic import BaseModel import httpx from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL from services import backup as backup_svc from services import maintenance 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") # 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/history") def history(minutes: int = 60) -> dict: """Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history.""" from services import metrics_history return metrics_history.history(minutes) 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 def _user_unit_active(unit: str) -> bool: """systemd-User-Unit ohne eigenen HTTP-Port (z. B. mc2-steward) — Zustand via is-active. Auf Windows/Dev schlägt das harmlos fehl (False).""" if os.name != "posix": return False try: r = subprocess.run(["systemctl", "--user", "is-active", unit], capture_output=True, text=True, timeout=3) return r.stdout.strip() == "active" except Exception: # noqa: BLE001 return False @router.get("/system/services") def services() -> dict: """Aggregierte Erreichbarkeit aller Stack-Dienste — die EINE Quelle für Cockpit-Kachel, Schubladen-Liste und Log-Auswahl (Frontend rendert diese Liste, keine UI-Kopie mehr). `scope`: user|system (Restart-Weg), `unit`: echter systemd-Name (Restart/Logs/Matching).""" a = agent_status() gw_url = f"{GATEWAY_URL}/v1" rows = [ {"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL, "ok": engine_reachable(), "scope": "system"}, ] # Seit UMBAU v3 P1 ist der LLM-Gateway ein EIGENER Prozess — ohne diese Zeile war er # in der Dienste-Ampel unsichtbar (Kachel "6/6 grün" bei totem Denkpfad). if V1_UPSTREAM: rows.append({"name": "LLM-Gateway (Denkpfad)", "unit": "mc2-gateway", "url": V1_UPSTREAM, "ok": gateway_reachable(), "scope": "user"}) # MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich; # die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel. rows.append({"name": "Steuerpult (MC2)", "unit": "mission-control-2", "url": gw_url, "ok": True, "scope": "user"}) else: rows.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url, "ok": gateway_reachable(), "scope": "user"}) rows += [ {"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "", "ok": _user_unit_active("mc2-steward"), "scope": "user"}, {"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"], "scope": "user"}, {"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"], "ok": a["hermes_ui_reachable"], "scope": "user"}, {"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable(), "scope": "user"}, {"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_reachable(), "scope": "user"}, # ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste, # das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf. {"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/", "ok": bool(a.get("box_console_reachable")), "scope": "user"}, ] return { "services": rows, "links": { "engine_ui": f"{LLAMA_SWAP_URL}/ui", "gateway": gw_url, "hermes_ui": a["hermes_ui_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: """Dienst neustarten. Delegiert an den Wartungs-Service (EINE Allowlist-Wahrheit): der kennt System-Dienste (llama-swap, via sudo -n) UND User-Dienste und wird auch von der UI (/api/maintenance/restart) genutzt. Vorher lag hier eine zweite, veraltete Liste (ohne llama-swap/hermes-terminal, mit Geist-Eintrag hermes-webui) → Engine-Neustart per Sprache/MCP schlug fehl.""" return maintenance.restart_service(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)