""" Die Dienste der KI-Box für die Seite „Dienste und Protokolle“ (seit 25.09.2026 mit Kennzahlen). Je Dienst steht hier, ob er antwortet (HTTP, wie bisher), in welchem Zustand systemd ihn sieht und was er gerade braucht: speicher_bytes MemoryCurrent — was systemd der Unit zurechnet (alle Prozesse, samt Datei-Zwischenspeicher) grafik_bytes nur die Engine: belegter Grafikspeicher (GTT) der Box — dort liegen die geladenen Modelle, und den rechnet systemd keinem Dienst zu (25.09.: Engine 4,3 GB laut systemd, Modelle 27,6 GB GTT) cpu_prozent Anteil an allen Kernen der Box aus CPUUsageNSec zwischen zwei Abfragen (100 % = alle Kerne voll) laeuft_seit_s seit dem letzten Start (ActiveEnterTimestampMonotonic) aus_seit_s seit wann ein gestoppter oder gescheiterter Dienst aus ist (InactiveEnterTimestampMonotonic) neustarts NRestarts — wie oft systemd ihn nach einem Absturz von selbst neu gestartet hat Die Zahlen kommen mit EINEM `systemctl show` je Bereich (User- und System-Dienste). Auf Windows (Entwicklung) oder ohne systemd fehlen sie einfach (None); die Liste steht trotzdem, der Zustand dann allein nach „antwortet“. Bis 25.09.2026 stand die Liste im Router (routers/system.py). Die Felder name, unit, url, ok, scope und schlaeft sind geblieben — die MCP-Werkzeuge (mcp/mcp_mc.py, mcp/mcp_voice.py) lesen sie. """ import os import subprocess import threading import time import httpx import psutil from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, V1_UPSTREAM, VOICE_SERVICE_URL from services import system, waechter from services.agent import agent_status from services.gateway import gateway_reachable from services.llamaswap import engine_reachable EIGENSCHAFTEN = ( "Id", "LoadState", "ActiveState", "UnitFileState", "MemoryCurrent", "CPUUsageNSec", "ActiveEnterTimestampMonotonic", "InactiveEnterTimestampMonotonic", "NRestarts", ) AKTIV = ("active", "reloading", "refreshing") # Nur die Engine rechnet auf der Grafik (Stand 25.09.2026: alle GTT-Belegung gehört den llama-server-Prozessen in # llama-swap.service). Deshalb bekommt allein sie den Grafikspeicher der Box zugeschrieben. GRAFIK_DIENST = "llama-swap" CPU_MIN_S = 2.0 # kürzere Abstände zweier Abfragen sind Rauschen: dann gilt die letzte Messung weiter CPU_MAX_S = 120.0 # eine ältere Probe sagt nichts mehr über „gerade“: neu ansetzen CPU_KURZ_S = 0.5 # erste Abfrage ohne brauchbare Probe: zweite Probe nach einer halben Sekunde LAUFEND = ("laeuft", "antwortet_nicht", "startet", "stoppt") # --- systemctl show lesen ------------------------------------------------------------------------------------------ def lies_show(text: str) -> list[dict[str, str]]: """Die Ausgabe von `systemctl show a b c -p …`: je Unit ein Block „Schlüssel=Wert“, Blöcke durch Leerzeilen getrennt, in der Reihenfolge der Units.""" bloecke: list[dict[str, str]] = [] block: dict[str, str] = {} for zeile in text.splitlines(): if not zeile.strip(): if block: bloecke.append(block) block = {} continue if "=" in zeile: schluessel, wert = zeile.split("=", 1) block[schluessel.strip()] = wert.strip() if block: bloecke.append(block) return bloecke def zuordnen(bloecke: list[dict[str, str]], units: list[str]) -> dict[str, dict[str, str]]: """Blöcke den Units zuordnen: nach Reihenfolge, wenn die Zahl stimmt (so kommen sie von systemctl), sonst nach Id.""" if len(bloecke) == len(units): return dict(zip(units, bloecke, strict=True)) return {b["Id"].removesuffix(".service"): b for b in bloecke if b.get("Id")} def _systemctl_show(units: list[str], system_dienst: bool) -> dict[str, dict[str, str]]: """Eigenschaften mehrerer Units mit einem Aufruf. Auf Windows (Entwicklung) oder ohne systemd: leer.""" if not units: return {} cmd = ["systemctl"] if system_dienst else ["systemctl", "--user"] cmd += ["show", *units, "-p", ",".join(EIGENSCHAFTEN)] try: out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout except Exception: return {} return zuordnen(lies_show(out), units) def zahl(wert: str | None) -> int | None: """Ganze Zahl aus systemd — „[not set]“, „infinity“ und das alte UINT64_MAX („nicht gezählt“) werden None.""" try: n = int(wert or "") except ValueError: return None return n if 0 <= n < 2**63 else None # --- Auswertung (rein, getestet) ----------------------------------------------------------------------------------- def zustand(e: dict[str, str], ok: bool, schlaeft: bool) -> str: """Der Zustand in einem Wort: laeuft | antwortet_nicht | startet | stoppt | gestoppt | gescheitert | schlaeft. Ohne systemd-Angaben (Windows, Unit unbekannt) entscheidet allein, ob der Dienst antwortet.""" if schlaeft: return "schlaeft" aktiv = e.get("ActiveState", "") if not aktiv or e.get("LoadState") in ("not-found", ""): return "laeuft" if ok else "antwortet_nicht" if aktiv == "failed": return "gescheitert" if aktiv == "activating": return "startet" if aktiv == "deactivating": return "stoppt" if aktiv == "inactive": return "gestoppt" return "laeuft" if ok else "antwortet_nicht" # active, reloading, refreshing def kennzahlen(e: dict[str, str], jetzt_s: float) -> dict: """Speicher, Laufzeit und Neustarts einer Unit. jetzt_s = time.monotonic() beim Lesen — systemd führt seine …Monotonic-Zeitstempel auf derselben Uhr (CLOCK_MONOTONIC), in Mikrosekunden.""" aktiv = e.get("ActiveState") an = zahl(e.get("ActiveEnterTimestampMonotonic")) aus = zahl(e.get("InactiveEnterTimestampMonotonic")) return { "speicher_bytes": zahl(e.get("MemoryCurrent")), "laeuft_seit_s": max(0, int(jetzt_s - an / 1e6)) if aktiv in AKTIV and an else None, "aus_seit_s": max(0, int(jetzt_s - aus / 1e6)) if aktiv in ("inactive", "failed") and aus else None, "neustarts": zahl(e.get("NRestarts")), } def cpu_anteil(vorher: tuple[float, int], jetzt: tuple[float, int], kerne: int) -> float | None: """Anteil an allen Kernen in Prozent zwischen zwei Proben (Zeitpunkt in s, CPU-Zeit der Unit in ns). None, wenn keine Zeit verging oder der Zähler kleiner wurde (die Unit ist dazwischen neu gestartet).""" dauer = jetzt[0] - vorher[0] if dauer <= 0 or jetzt[1] < vorher[1] or kerne < 1: return None return round(min(100.0, (jetzt[1] - vorher[1]) / 1e9 / dauer / kerne * 100), 1) class CpuMessung: """Merkt sich je Unit die letzte Probe von CPUUsageNSec und den daraus gemessenen Anteil. Fragt die Oberfläche alle paar Sekunden, ist das die Auslastung seit der letzten Abfrage — wie bei top, nur je Dienst.""" def __init__(self, kerne: int): self.kerne = max(1, kerne) self._proben: dict[str, tuple[float, int]] = {} self._werte: dict[str, float] = {} self._sperre = threading.Lock() def probe(self, unit: str, t: float, ns: int | None) -> None: with self._sperre: if ns is None: # läuft nicht, oder systemd zählt ihre CPU-Zeit nicht self._proben.pop(unit, None) self._werte.pop(unit, None) return vorher = self._proben.get(unit) if vorher and unit in self._werte and t - vorher[0] < CPU_MIN_S: return # zu kurz nach der letzten Messung: deren Wert gilt weiter wert = cpu_anteil(vorher, (t, ns), self.kerne) if vorher and t - vorher[0] <= CPU_MAX_S else None self._proben[unit] = (t, ns) if wert is None: self._werte.pop(unit, None) else: self._werte[unit] = wert def wert(self, unit: str) -> float | None: with self._sperre: return self._werte.get(unit) _cpu = CpuMessung(os.cpu_count() or 1) _uhr = time.monotonic # dieselbe Uhr wie systemds …Monotonic-Zeitstempel (Tests setzen eine eigene) # --- Die Liste ----------------------------------------------------------------------------------------------------- def _voice_antwortet() -> bool: try: return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200 except Exception: return False def _dienste() -> tuple[list[dict], dict]: """Die Dienste in ihrer Reihenfolge mit HTTP-Erreichbarkeit (ok) — None heißt: systemd entscheidet (Dienste ohne eigenen HTTP-Port). `scope`: user|system (Weg für Neustart und Protokoll), `unit`: echter systemd-Name.""" a = agent_status() gw_url = f"{GATEWAY_URL}/v1" zeilen: list[dict] = [ {"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: zeilen.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. zeilen.append({"name": "Box-Wart (MC2)", "unit": "mission-control-2", "url": gw_url, "ok": True, "scope": "user"}) else: zeilen.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url, "ok": gateway_reachable(), "scope": "user"}) zeilen += [ {"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "", "ok": None, "scope": "user"}, {"name": "Hermes-Gateway (Agent & Gedächtnis)", "unit": "hermes-gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"], "scope": "user"}, {"name": "Hermes-Dashboard", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"], "ok": a["hermes_ui_reachable"], "scope": "user"}, {"name": "Spracherkennung (Desktop-Lucy)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_antwortet(), "scope": "user"}, # Lucys Stimme (pocket-tts) spricht Telegram und Lucy-Desktop — bis 09/2026 fehlte # sie hier; die Box-Konsole ist mit dem Box-Wart-Umbau aus MC2 raus. {"name": "Lucys Stimme", "unit": "lucy-stimme", "url": "", "ok": None, "scope": "user"}, ] links = {"engine_ui": f"{LLAMA_SWAP_URL}/ui", "gateway": gw_url, "hermes_ui": a["hermes_ui_url"]} return zeilen, links def _lesen(user: list[str], system_units: list[str]) -> dict[str, tuple[float, dict[str, str]]]: """systemd-Stand je Unit samt dem Zeitpunkt, zu dem er gelesen wurde.""" stand: dict[str, tuple[float, dict[str, str]]] = {} for units, system_dienst in ((user, False), (system_units, True)): gelesen = _systemctl_show(units, system_dienst) t = _uhr() for unit, e in gelesen.items(): stand[unit] = (t, e) return stand def _messen(user: list[str], system_units: list[str]) -> dict[str, tuple[float, dict[str, str]]]: """Einmal lesen und die CPU-Proben nachführen. Fehlt einem laufenden Dienst noch ein Wert (erste Abfrage nach dem Start von MC2, Dienst neu gestartet, lange niemand geschaut), nach einer halben Sekunde ein zweites Mal.""" def nachfuehren(stand: dict[str, tuple[float, dict[str, str]]]) -> bool: fehlt = False for unit, (t, e) in stand.items(): # Gestoppte Units behalten den Zähler ihres letzten Laufs (25.09.: projekte-sync, mc2-backup) — der # sagt nichts über „gerade“. ns = zahl(e.get("CPUUsageNSec")) if e.get("ActiveState") in (*AKTIV, "activating", "deactivating") else None _cpu.probe(unit, t, ns) fehlt = fehlt or (ns is not None and _cpu.wert(unit) is None) return fehlt stand = _lesen(user, system_units) if nachfuehren(stand): time.sleep(CPU_KURZ_S) stand = _lesen(user, system_units) nachfuehren(stand) return stand def _speicher_gesamt() -> int | None: try: return psutil.virtual_memory().total except Exception: return None def liste() -> dict: """Alle Dienste der Box mit Zustand und Kennzahlen — die EINE Quelle für die Seite „Dienste und Protokolle“ und die MCP-Werkzeuge. speicher_gesamt (Bytes) und kerne sind der Bezug für die Balken der Oberfläche.""" zeilen, links = _dienste() stand = _messen([z["unit"] for z in zeilen if z["scope"] == "user"], [z["unit"] for z in zeilen if z["scope"] == "system"]) for z in zeilen: t, e = stand.get(z["unit"], (0.0, {})) if z["ok"] is None: z["ok"] = e.get("ActiveState") == "active" # Bewusst abgeschaltete Dienste (24.09.2026: Spracherkennung bis zur Android-App) sind kein # Fehler — die Oberfläche zeigt sie als „schläft“ mit Knopf zum Wecken statt rot. z["schlaeft"] = not z["ok"] and z["scope"] == "user" and waechter.schlaeft(e) z["zustand"] = zustand(e, z["ok"], z["schlaeft"]) z.update(kennzahlen(e, t)) z["cpu_prozent"] = _cpu.wert(z["unit"]) if z["zustand"] in LAUFEND else None z["grafik_bytes"] = None if z["unit"] == GRAFIK_DIENST and z["speicher_bytes"] is not None: z["grafik_bytes"] = (system._gpu_sysfs() or {}).get("gtt_used") return {"services": zeilen, "links": links, "speicher_gesamt": _speicher_gesamt(), "kerne": _cpu.kerne}