Files
mission-control-v2/backend/routers/system.py
T

131 lines
4.9 KiB
Python

"""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, 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()
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:
"""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)