Merge branch 'worktree-agent-a872d2361a1cdaa60' into wartung/helfer-nutzung-dienste

This commit is contained in:
Hitonabi
2026-09-25 13:31:32 +02:00
16 changed files with 1314 additions and 201 deletions
+6 -76
View File
@@ -6,18 +6,12 @@ zurückgegeben statt zu crashen.
"""
import logging
import os
import subprocess
import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_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, waechter
from services.agent import agent_status
from services.gateway import gateway_reachable
from services.llamaswap import engine_reachable, list_models
from services import dienste, maintenance
from services.llamaswap import list_models
from services.pricing import compute_savings
from services.system import system_status
from services.token_stats import get_stats
@@ -31,76 +25,12 @@ def status() -> dict:
return system_status()
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": "Box-Wart (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 (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_reachable(), "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": _user_unit_active("lucy-stimme"), "scope": "user"},
]
# 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.
for row in rows:
row["schlaeft"] = (not row["ok"] and row["scope"] == "user"
and waechter.schlaeft(waechter.dienst_zustand(row["unit"])))
return {
"services": rows,
"links": {
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
"gateway": gw_url,
"hermes_ui": a["hermes_ui_url"],
},
}
"""Alle Dienste der Box mit Erreichbarkeit, Zustand und Kennzahlen (Speicher, CPU, Laufzeit,
Neustarts) — die EINE Quelle für die Seite „Dienste und Protokolle“ und die MCP-Werkzeuge.
Logik in services/dienste.py; `scope`: user|system (Restart-Weg), `unit`: echter systemd-Name."""
return dienste.liste()
@router.post("/system/backup")
+286
View File
@@ -0,0 +1,286 @@
"""
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}
-5
View File
@@ -196,11 +196,6 @@ def _update_laeuft() -> bool:
# --- Prüfungen ------------------------------------------------------------------
def dienst_zustand(name: str, system: bool = False) -> dict[str, str]:
"""systemd-Zustand einer Unit für andere Module (Dienste-Liste der Oberfläche)."""
return _systemctl_show(name, system)
def schlaeft(zustand: dict[str, str]) -> bool:
"""Abgeschaltet und nicht mehr für den Autostart eingetragen = bewusst schlafen gelegt
(24.09.2026: Konsole, Spracherkennung). Das ist kein Fehler; geweckt wird per Knopf."""
+295
View File
@@ -0,0 +1,295 @@
"""Tests für die Dienste-Liste mit Kennzahlen (services/dienste.py) — mit echter `systemctl show`-Ausgabe der Box
vom 25.09.2026 (/proc/uptime stand bei 80147,36 s)."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from services import dienste
JETZT = 80147.36
# systemctl --user show mc2-gateway mission-control-2 mc2-steward hermes-gateway hermes-builtin-ui voice-service
# lucy-stimme -p Id,LoadState,ActiveState,UnitFileState,MemoryCurrent,CPUUsageNSec,ActiveEnterTimestampMonotonic,
# InactiveEnterTimestampMonotonic,NRestarts
USER_AUSGABE = """\
Id=mc2-gateway.service
LoadState=loaded
ActiveState=active
UnitFileState=enabled
ActiveEnterTimestampMonotonic=79813970006
InactiveEnterTimestampMonotonic=79813968344
NRestarts=0
MemoryCurrent=45731840
CPUUsageNSec=1568208000
Id=mission-control-2.service
LoadState=loaded
ActiveState=active
UnitFileState=enabled
ActiveEnterTimestampMonotonic=79816961421
InactiveEnterTimestampMonotonic=79816959875
NRestarts=0
MemoryCurrent=198352896
CPUUsageNSec=5660312000
Id=mc2-steward.service
LoadState=loaded
ActiveState=active
UnitFileState=enabled
ActiveEnterTimestampMonotonic=79813795101
InactiveEnterTimestampMonotonic=79813794078
NRestarts=0
MemoryCurrent=56500224
CPUUsageNSec=1002999000
Id=hermes-gateway.service
LoadState=loaded
ActiveState=active
UnitFileState=enabled
ActiveEnterTimestampMonotonic=76113569939
InactiveEnterTimestampMonotonic=76113568536
NRestarts=0
MemoryCurrent=421068800
CPUUsageNSec=45597050000
Id=hermes-builtin-ui.service
LoadState=loaded
ActiveState=active
UnitFileState=enabled
ActiveEnterTimestampMonotonic=76110768028
InactiveEnterTimestampMonotonic=76110766393
NRestarts=0
MemoryCurrent=162058240
CPUUsageNSec=20446657000
Id=voice-service.service
LoadState=loaded
ActiveState=inactive
UnitFileState=disabled
ActiveEnterTimestampMonotonic=0
InactiveEnterTimestampMonotonic=0
NRestarts=0
MemoryCurrent=[not set]
CPUUsageNSec=[not set]
Id=lucy-stimme.service
LoadState=loaded
ActiveState=active
UnitFileState=enabled
ActiveEnterTimestampMonotonic=10066410
InactiveEnterTimestampMonotonic=0
NRestarts=0
MemoryCurrent=7500505088
CPUUsageNSec=502624087000
"""
# systemctl show llama-swap -p … (System-Dienst)
SYSTEM_AUSGABE = """\
Id=llama-swap.service
LoadState=loaded
ActiveState=active
UnitFileState=enabled
ActiveEnterTimestampMonotonic=74032986745
InactiveEnterTimestampMonotonic=74032938183
NRestarts=0
MemoryCurrent=3722850304
CPUUsageNSec=31617381000
"""
USER_UNITS = ["mc2-gateway", "mission-control-2", "mc2-steward", "hermes-gateway", "hermes-builtin-ui",
"voice-service", "lucy-stimme"]
# Ein abgestürzter Dienst, wie systemd ihn nach Restart=always mit zu vielen Fehlversuchen stehen lässt.
GESCHEITERT = {
"Id": "hermes-gateway.service", "LoadState": "loaded", "ActiveState": "failed", "UnitFileState": "enabled",
"ActiveEnterTimestampMonotonic": "76113569939", "InactiveEnterTimestampMonotonic": "80000000000",
"NRestarts": "5", "MemoryCurrent": "[not set]", "CPUUsageNSec": "[not set]",
}
# --- systemctl show lesen ------------------------------------------------------------------------------------------
def test_liest_die_bloecke_der_echten_ausgabe_in_der_reihenfolge_der_units():
stand = dienste.zuordnen(dienste.lies_show(USER_AUSGABE), USER_UNITS)
assert list(stand) == USER_UNITS
assert stand["lucy-stimme"]["MemoryCurrent"] == "7500505088"
assert stand["voice-service"]["UnitFileState"] == "disabled"
def test_ordnet_nach_id_zu_wenn_ein_block_fehlt():
bloecke = dienste.lies_show(SYSTEM_AUSGABE)
assert dienste.zuordnen(bloecke, ["llama-swap", "gibt-es-nicht"]) == {"llama-swap": bloecke[0]}
@pytest.mark.parametrize(("wert", "erwartet"), [
("198352896", 198352896), ("0", 0), ("[not set]", None), ("infinity", None), ("", None), (None, None),
("18446744073709551615", None), # altes systemd: UINT64_MAX heißt „nicht gezählt“
])
def test_zahl(wert, erwartet):
assert dienste.zahl(wert) == erwartet
# --- Auswertung ----------------------------------------------------------------------------------------------------
def test_kennzahlen_eines_laufenden_dienstes():
mc2 = dienste.zuordnen(dienste.lies_show(USER_AUSGABE), USER_UNITS)["mission-control-2"]
assert dienste.kennzahlen(mc2, JETZT) == {
"speicher_bytes": 198352896, "laeuft_seit_s": 330, "aus_seit_s": None, "neustarts": 0,
}
def test_kennzahlen_eines_schlafenden_und_eines_gescheiterten_dienstes():
voice = dienste.zuordnen(dienste.lies_show(USER_AUSGABE), USER_UNITS)["voice-service"]
assert dienste.kennzahlen(voice, JETZT) == {
"speicher_bytes": None, "laeuft_seit_s": None, "aus_seit_s": None, "neustarts": 0,
}
k = dienste.kennzahlen(GESCHEITERT, JETZT)
assert k["laeuft_seit_s"] is None and k["aus_seit_s"] == 147 and k["neustarts"] == 5
def test_zustand_in_worten():
laeuft = {"LoadState": "loaded", "ActiveState": "active"}
assert dienste.zustand(laeuft, ok=True, schlaeft=False) == "laeuft"
assert dienste.zustand(laeuft, ok=False, schlaeft=False) == "antwortet_nicht"
assert dienste.zustand(GESCHEITERT, ok=False, schlaeft=False) == "gescheitert"
assert dienste.zustand({**laeuft, "ActiveState": "activating"}, ok=False, schlaeft=False) == "startet"
assert dienste.zustand({**laeuft, "ActiveState": "deactivating"}, ok=False, schlaeft=False) == "stoppt"
assert dienste.zustand({**laeuft, "ActiveState": "inactive"}, ok=False, schlaeft=False) == "gestoppt"
assert dienste.zustand({**laeuft, "ActiveState": "inactive"}, ok=False, schlaeft=True) == "schlaeft"
def test_zustand_ohne_systemd_nur_nach_erreichbarkeit():
"""Windows (Entwicklung) oder eine Unit, die systemd nicht kennt: wie vor dem 25.09. nur „antwortet (nicht)“."""
assert dienste.zustand({}, ok=True, schlaeft=False) == "laeuft"
assert dienste.zustand({}, ok=False, schlaeft=False) == "antwortet_nicht"
assert dienste.zustand({"LoadState": "not-found", "ActiveState": "inactive"}, ok=True, schlaeft=False) == "laeuft"
def test_cpu_anteil_bezogen_auf_alle_kerne():
assert dienste.cpu_anteil((0.0, 0), (10.0, 3_200_000_000), kerne=32) == 1.0 # 0,32 Kerne von 32
assert dienste.cpu_anteil((0.0, 0), (1.0, 64_000_000_000), kerne=32) == 100.0 # mehr geht nicht
assert dienste.cpu_anteil((5.0, 900), (5.0, 1000), kerne=32) is None # keine Zeit vergangen
assert dienste.cpu_anteil((0.0, 5_000), (10.0, 1_000), kerne=32) is None # Zähler neu (Neustart)
def test_cpu_messung_haelt_den_letzten_wert_bei_zu_kurzem_abstand():
m = dienste.CpuMessung(kerne=32)
m.probe("hermes-gateway", 100.0, 0)
assert m.wert("hermes-gateway") is None # erste Probe: noch nichts zu messen
m.probe("hermes-gateway", 100.5, 160_000_000)
assert m.wert("hermes-gateway") == 1.0 # 0,16 s CPU in 0,5 s auf 32 Kernen
m.probe("hermes-gateway", 101.0, 1_000_000_000)
assert m.wert("hermes-gateway") == 1.0 # nur 0,5 s später: Wert bleibt, Probe auch
m.probe("hermes-gateway", 110.5, 160_000_000 + 32_000_000_000)
assert m.wert("hermes-gateway") == 10.0 # 32 s CPU in 10 s = 3,2 Kerne
m.probe("hermes-gateway", 120.5, 5_000)
assert m.wert("hermes-gateway") is None # neu gestartet: Zähler fing von vorn an
m.probe("hermes-gateway", 121.0, 25_000)
assert m.wert("hermes-gateway") == 0.0
m.probe("hermes-gateway", 121.0 + dienste.CPU_MAX_S + 1, 50_000)
assert m.wert("hermes-gateway") is None # zu lange niemand geschaut: neu ansetzen
m.probe("hermes-gateway", 400.0, None)
assert m.wert("hermes-gateway") is None # gestoppt
# --- Die ganze Liste -----------------------------------------------------------------------------------------------
@pytest.fixture
def box(monkeypatch):
"""Die Box vom 25.09.: alle Dienste laufen, die Spracherkennung schläft. Die erste Abfrage liest bei JETZT und —
kurze zweite Probe — eine halbe Sekunde später, die nächste zehn Sekunden danach."""
uhr = iter([JETZT, JETZT, JETZT + 0.5, JETZT + 0.5, JETZT + 10.5, JETZT + 10.5])
monkeypatch.setattr(dienste, "_uhr", lambda: next(uhr))
monkeypatch.setattr(dienste, "_cpu", dienste.CpuMessung(kerne=32))
monkeypatch.setattr(dienste, "CPU_KURZ_S", 0)
monkeypatch.setattr(dienste, "V1_UPSTREAM", "http://127.0.0.1:9010")
monkeypatch.setattr(dienste, "agent_status", lambda: {
"gateway_reachable": True, "hermes_ui_reachable": True, "hermes_ui_url": "/hermes-ui/"})
monkeypatch.setattr(dienste, "engine_reachable", lambda: True)
monkeypatch.setattr(dienste, "gateway_reachable", lambda: True)
monkeypatch.setattr(dienste, "_voice_antwortet", lambda: False)
monkeypatch.setattr(dienste.system, "_gpu_sysfs", lambda: {"busy_percent": 3, "gtt_used": 27611533312})
monkeypatch.setattr(dienste, "_speicher_gesamt", lambda: 131738611712)
lesungen = {"user": 0, "system": 0}
def show(units, system_dienst):
art = "system" if system_dienst else "user"
text = SYSTEM_AUSGABE if system_dienst else USER_AUSGABE
stand = dienste.zuordnen(dienste.lies_show(text), units)
# Die Engine rechnet 0,32 s CPU je Lesen, alle anderen nichts.
if system_dienst:
ns = int(stand["llama-swap"]["CPUUsageNSec"]) + lesungen[art] * 320_000_000
stand["llama-swap"] = {**stand["llama-swap"], "CPUUsageNSec": str(ns)}
lesungen[art] += 1
return stand
monkeypatch.setattr(dienste, "_systemctl_show", show)
return lesungen
def test_liste_mit_zustand_und_kennzahlen(box):
daten = dienste.liste()
zeilen = {z["unit"]: z for z in daten["services"]}
assert [z["unit"] for z in daten["services"]] == [
"llama-swap", "mc2-gateway", "mission-control-2", "mc2-steward", "hermes-gateway", "hermes-builtin-ui",
"voice-service", "lucy-stimme"]
assert daten["speicher_gesamt"] == 131738611712 and daten["kerne"] == 32
# Was die MCP-Werkzeuge lesen, ist geblieben.
assert {"name", "unit", "url", "ok", "scope", "schlaeft"} <= set(zeilen["llama-swap"])
assert daten["links"]["engine_ui"].endswith("/ui")
engine = zeilen["llama-swap"]
assert engine["zustand"] == "laeuft" and engine["scope"] == "system"
assert engine["speicher_bytes"] == 3722850304 and engine["grafik_bytes"] == 27611533312
assert engine["laeuft_seit_s"] == int(JETZT + 0.5 - 74032.986745)
assert engine["cpu_prozent"] == 2.0 # erste Abfrage: 0,32 s CPU in der halben Sekunde bis zur zweiten Probe
assert zeilen["hermes-gateway"]["cpu_prozent"] == 0.0 and zeilen["hermes-gateway"]["grafik_bytes"] is None
# Ohne HTTP-Port entscheidet systemd, ob der Dienst läuft.
assert zeilen["mc2-steward"]["ok"] is True and zeilen["lucy-stimme"]["speicher_bytes"] == 7500505088
voice = zeilen["voice-service"]
assert voice["schlaeft"] is True and voice["zustand"] == "schlaeft" and voice["ok"] is False
assert voice["speicher_bytes"] is None and voice["cpu_prozent"] is None
def test_zweite_abfrage_misst_ueber_den_abstand_seit_der_ersten(box):
dienste.liste()
assert box == {"user": 2, "system": 2} # erste Abfrage: kurze zweite Probe
engine = next(z for z in dienste.liste()["services"] if z["unit"] == "llama-swap")
assert box == {"user": 3, "system": 3} # jetzt ohne zweite Probe
assert engine["cpu_prozent"] == 0.1 # 0,32 s CPU in 10 s auf 32 Kernen
def test_liste_ohne_systemd_wie_bisher(box, monkeypatch):
"""Am PC gibt es kein systemd: die Liste steht, der Zustand kommt allein aus der Erreichbarkeit."""
monkeypatch.setattr(dienste, "_systemctl_show", lambda units, system_dienst: {})
zeilen = {z["unit"]: z for z in dienste.liste()["services"]}
assert zeilen["llama-swap"]["zustand"] == "laeuft" and zeilen["llama-swap"]["grafik_bytes"] is None
assert zeilen["voice-service"]["zustand"] == "antwortet_nicht" and zeilen["voice-service"]["schlaeft"] is False
assert zeilen["mc2-steward"]["ok"] is False
assert all(z["speicher_bytes"] is None and z["cpu_prozent"] is None for z in zeilen.values())
def test_gescheiterter_dienst(box, monkeypatch):
def show(units, system_dienst):
stand = dienste.zuordnen(dienste.lies_show(SYSTEM_AUSGABE if system_dienst else USER_AUSGABE), units)
if not system_dienst:
stand["hermes-gateway"] = GESCHEITERT
return stand
monkeypatch.setattr(dienste, "_systemctl_show", show)
monkeypatch.setattr(dienste, "agent_status", lambda: {
"gateway_reachable": False, "hermes_ui_reachable": True, "hermes_ui_url": "/hermes-ui/"})
hermes = next(z for z in dienste.liste()["services"] if z["unit"] == "hermes-gateway")
assert hermes["zustand"] == "gescheitert" and hermes["schlaeft"] is False
assert hermes["neustarts"] == 5 and hermes["aus_seit_s"] is not None and hermes["cpu_prozent"] is None
def test_schnittstelle_reicht_die_liste_durch(monkeypatch):
from routers import system as system_router
monkeypatch.setattr(dienste, "liste", lambda: {"services": [{"unit": "llama-swap", "zustand": "laeuft"}]})
app = FastAPI()
app.include_router(system_router.router)
assert TestClient(app).get("/api/system/services").json()["services"][0]["zustand"] == "laeuft"