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:
Hitonabi
2026-09-25 13:28:57 +02:00
co-authored by Claude Opus 5.5
parent 0df593c05a
commit cf01a5bb85
16 changed files with 1314 additions and 201 deletions
+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"