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
@@ -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}
|
||||
Reference in New Issue
Block a user