"""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 import httpx from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL from fastapi import APIRouter from pydantic import BaseModel 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: 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: 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)