Files
mission-control-v2/backend/services/dienste.py
T
HitonabiandClaude Opus 5.5 cf01a5bb85 dienste: eigene Seite /box/dienste mit Tabelle wie die Geräte im Homelab
Die Schublade „Dienste und Protokolle" war eine schlichte Liste (Punkt, Name, „läuft",
zwei Knöpfe). Jetzt eine Seite im Bereich KI-Box, gebaut wie die Geräte-Tabelle im
Homelab-Cockpit: je Dienst Zustand in Worten (läuft, schläft, gestoppt, gescheitert,
antwortet nicht), CPU, Speicher, „Läuft seit" und Neustarts; Probleme oben. „Protokoll"
klappt unter der Zeile auf (das Neueste unten im Blick), Neu starten / Starten / Wecken
fragen vorher nach.

Entscheidungen:
- Seite statt Schublade: die Tabelle braucht die Breite, und der Eintrag steht jetzt wie
  die anderen Seiten in der Seitenleiste. Schublade samt onDienste-Weg entfernt.
- Speicher = MemoryCurrent als Zahl mit Einheit; der Balken zeigt den echten Anteil am
  Arbeitsspeicher der Box, keine erfundenen Prozente. Bei der Engine zählt der
  Grafikspeicher (GTT) mit — dort liegen die Modelle, und systemd rechnet ihn keinem
  Dienst zu (25.09.: 4,3 GB laut systemd, 27,6 GB Modelle).
- CPU = Anteil an allen Kernen aus CPUUsageNSec zwischen zwei Abfragen (die Seite fragt
  alle 10 s). Fehlt eine brauchbare Probe, misst die Box einmal 0,5 s nach. Ruhiges Blau
  ohne Warnfarben: ein Dienst hat keine Grenze, was zählt, zeigt der Zustand.
- Neustarts = NRestarts (automatische Neustarts nach Absturz), am Handy nur, wenn es
  welche gab; mehr als 0 rückt den Dienst nach oben.
- Backend: Liste und Kennzahlen in services/dienste.py (ein systemctl show je Bereich,
  auf Windows harmlos leer), Router dünn. Die Felder der MCP-Werkzeuge bleiben;
  waechter.dienst_zustand war nur für die alte Liste da und ist weg.
- Tabelle ab xl, Knöpfe ab 1400 px nebeneinander, darunter Karten mit zwei bzw. vier
  Spalten; bis 320 px keine waagrechte Scrollleiste.

Tests: backend/tests/test_dienste.py (echte systemctl-show-Ausgabe der Box),
frontend lib/dienste.test.ts und views/Dienste.test.tsx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:28:57 +02:00

287 lines
14 KiB
Python

"""
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}