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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0df593c05a
commit
cf01a5bb85
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user