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"
+1 -1
View File
@@ -201,7 +201,7 @@ Partner-Instanz. In der Rolle `homelab` gibt es nur `/api/health` und die Partne
| Radar (`routers/radar.py`) | `GET /radar`, `POST /radar/suche`, `POST /radar/{id}/uebernehmen`, `POST /radar/{id}/verwerfen` |
| Modelle (`routers/models.py`) | `GET /models`, `GET /discover`, `POST /models/register`, `GET /hf/search`, `GET /hf/quants`, `POST /models/install`, `GET /jobs`, `POST /jobs/{id}/cancel`, `POST /models/{id}/role`, `POST /models/unload`, `POST /models/{id}/unload`, `POST /models/{id}/load`, `DELETE /models/{id}` |
| Wartung (`routers/maintenance.py`) | `GET /maintenance/updates`, `GET /maintenance/update-details`, `POST` `check-updates`, `os-update`, `engine-update`, `swap-update`, `hermes-update`, `update-all`, `restart`; `GET /maintenance/logs`; `GET`/`POST /maintenance/geheimnisse` |
| System (`routers/system.py`) | `GET /system/status`, `GET /system/services`, `POST /system/backup`, `GET /system/backups`, `POST /system/restart`, `GET /system/token-stats` |
| System (`routers/system.py`) | `GET /system/status`, `GET /system/services` (Dienste mit Zustand, Speicher, CPU, Laufzeit und Neustarts aus einem `systemctl show` je Bereich, seit 25.09.; `services/dienste.py`), `POST /system/backup`, `GET /system/backups`, `POST /system/restart`, `GET /system/token-stats` |
| Messwerte (`routers/messwerte.py`, seit 24.09.) | `GET /messwerte?zeitraum=1h\|24h\|7d` |
| Lucy und Sprache (`routers/voice.py`) | `POST /alarm`, `POST /voice/announce`, `GET /voice/announcements`, `POST /voice/stt`, `POST /voice/turn`, `POST /voice/chat`, `GET /lucy/stimme/health`, `POST /lucy/stimme/tts`, `POST /lucy/stimme/tts/stream` |
| Sonstiges | `GET /health`, `GET /stream` (Live-Strom, SSE), `GET /routing`, `GET`/`POST /reminders`, `DELETE /reminders/{id}`, `GET /zeitmaschine`, `POST /zeitmaschine/restore` |
+18 -6
View File
@@ -16,7 +16,7 @@ Im Heimnetz im Browser `http://192.168.178.151:9001` öffnen (oder den Homelab-T
hinter dem Menü-Knopf oben links:
- **Übersicht** — die Startseite, alles auf einen Blick.
- **KI-Box** (Indigo): **Cockpit**, **Updates**, **Modelle**, **Monitoring**, dazu **Dienste und Protokolle** und
- **KI-Box** (Indigo): **Cockpit**, **Updates**, **Modelle**, **Monitoring**, **Dienste und Protokolle**, dazu
das Hermes-Dashboard.
- **Homelab** (Violett): **Cockpit**, **Updates**, **Monitoring**, **Protokoll**.
- Ganz unten die **Einstellungen**.
@@ -148,12 +148,24 @@ Update-Läufe, Hinweise des Wächters, gesendete Meldungen und die nächtliche P
„Berichte zeigen“ blendet auch die Berichte ein, die alle zehn Minuten kommen. „Ausgabe zeigen“ klappt die Ausgabe
eines Auftrags auf.
## Dienste und Protokolle
## KI-Box · Dienste und Protokolle
Die Liste aller Dienste der Box: grün = läuft, rot = antwortet nicht, grau = schläft (bewusst abgeschaltet).
„Protokoll" zeigt, was der Dienst zuletzt geschrieben hat. „Neu starten" startet ihn neu; „Wecken" weckt einen
schlafenden Dienst bis zum nächsten Neustart der Box. Die Spracherkennung und die Konsole schlafen seit 24.09. mit
Absicht; die Android-App wird sie später wieder brauchen.
Seit 25.09. eine eigene Seite (vorher eine Schublade), gebaut wie die Geräte im Homelab-Cockpit: eine Zeile je Dienst,
Probleme oben. Je Dienst stehen da
- der **Zustand**: läuft, schläft (mit Absicht abgeschaltet, kein Fehler), gestoppt, gescheitert (abgestürzt) oder
antwortet nicht (läuft laut systemd, gibt aber keine Antwort). Der Punkt vor dem Namen ist grün, grau oder rot.
- **CPU**: Anteil an allen 32 Kernen der Box, gemessen zwischen zwei Abfragen (die Seite fragt alle zehn Sekunden).
- **Speicher**: was systemd dem Dienst zurechnet, der Balken zeigt den Anteil am Arbeitsspeicher der Box. Bei der
Engine zählen die geladenen Modelle mit („davon … Modelle“) — sie liegen im Grafikspeicher, den systemd keinem
Dienst zurechnet.
- **Läuft seit** mit der Uhrzeit des letzten Starts, und **Neustarts**: wie oft systemd den Dienst nach einem Absturz
von selbst wieder gestartet hat (am Handy nur, wenn es welche gab).
„Protokoll" klappt unter der Zeile auf, was der Dienst zuletzt geschrieben hat, das Neueste unten. „Neu starten"
startet ihn neu, „Starten" einen gestoppten oder gescheiterten Dienst; „Wecken" weckt einen schlafenden bis zum
nächsten Neustart der Box. Jeder dieser Knöpfe fragt vorher nach. Die Spracherkennung und die Konsole schlafen seit
24.09. mit Absicht; die Android-App wird sie später wieder brauchen.
## Einstellungen
+2 -5
View File
@@ -5,10 +5,7 @@ import { Marke, Seitenleiste } from "./Seitenleiste"
* Handy und Tablet: dieselbe Menüführung wie links am PC, als Schublade hinter dem Menü-Knopf.
* Eigene Datei, damit sie (mit der Dialog-Kette von Radix) erst beim Öffnen lädt.
*/
export function MenueSchublade({ onDienste, onSchliessen }: {
onDienste: () => void
onSchliessen: () => void
}) {
export function MenueSchublade({ onSchliessen }: { onSchliessen: () => void }) {
return (
<Sheet open onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent
@@ -23,7 +20,7 @@ export function MenueSchublade({ onDienste, onSchliessen }: {
</SheetTitle>
<SheetDescription className="sr-only">Bereiche und Seiten</SheetDescription>
</SheetHeader>
<Seitenleiste onDienste={onDienste} onGewaehlt={onSchliessen} />
<Seitenleiste onGewaehlt={onSchliessen} />
</SheetContent>
</Sheet>
)
+3 -12
View File
@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router"
import { ExternalLink, ScrollText } from "lucide-react"
import { ExternalLink } from "lucide-react"
import { cn } from "cn"
import { useInstanz } from "@/lib/abfragen"
import { BEREICHE, type Ort, SEITEN, type Seite, useBereichStand, type Verbindung } from "@/lib/navigation"
@@ -67,8 +67,7 @@ function SeitenLink({ s, zahl, onGewaehlt }: { s: Seite; zahl?: number; onGewaeh
* Die Menüführung: oben die Übersicht, dann je Bereich ein farbiger Block mit Verbindungsanzeige und seinen
* Seiten, unten die Einstellungen. Am PC steht sie fest links, am Handy in der Schublade hinter dem Menü-Knopf.
*/
export function Seitenleiste({ onDienste, onGewaehlt }: {
onDienste: () => void
export function Seitenleiste({ onGewaehlt }: {
/** Nach jedem Klick — die Handy-Schublade schließt sich dann. */
onGewaehlt?: () => void
}) {
@@ -97,20 +96,12 @@ export function Seitenleiste({ onDienste, onGewaehlt }: {
{SEITEN.filter((s) => s.ort === b.id).map((s) => (
<SeitenLink key={s.to} s={s} zahl={s.zaehlt ? stand[b.id].updates : 0} onGewaehlt={onGewaehlt} />
))}
{b.id === "box" && (
<>
<button type="button" onClick={() => { onGewaehlt?.(); onDienste() }} className={cn(EINTRAG, "text-left")}>
<ScrollText className="size-[18px] shrink-0 text-text-3" aria-hidden />
Dienste und Protokolle
</button>
{hermes && (
{b.id === "box" && hermes && (
<a href="/hermes-ui/" target="_blank" rel="noopener" className={EINTRAG}>
<ExternalLink className="size-[18px] shrink-0 text-text-3" aria-hidden />
Hermes-Dashboard
</a>
)}
</>
)}
</section>
)
})}
+6 -12
View File
@@ -7,9 +7,9 @@ import { useStrom, useStromLage } from "@/lib/strom"
import { Meldungen } from "./Meldungen"
import { Marke, Seitenleiste } from "./Seitenleiste"
// Schubladen werden erst beim Öffnen geladen — sie gehören nicht zum Normalfall, und die
// Die Menü-Schublade wird erst beim Öffnen geladen — sie gehört nicht zum Normalfall, und die
// Radix-Dialog-Kette (Fokus-Falle, Scroll-Sperre) sprengte sonst das 140-KB-Budget.
const Dienste = lazy(() => import("@/views/Dienste").then((m) => ({ default: m.Dienste })))
// („Dienste und Protokolle“ war bis 25.09.2026 die zweite Schublade; jetzt ist es eine Seite.)
const MenueSchublade = lazy(() => import("./MenueSchublade").then((m) => ({ default: m.MenueSchublade })))
const uhrzeit = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })
@@ -46,8 +46,6 @@ function Verbindung() {
)
}
type Schublade = "menue" | "dienste" | null
// Die Zeile über dem Seitentitel trägt die Farbe des Orts: Indigo für die KI-Box, Violett fürs Homelab.
const ORT_FARBE: Record<Ort, string> = {
uebersicht: "text-bernstein",
@@ -58,8 +56,7 @@ const ORT_FARBE: Record<Ort, string> = {
export function Shell() {
useStrom()
const [schublade, setSchublade] = useState<Schublade>(null)
const zu = () => setSchublade(null)
const [menue, setMenue] = useState(false)
const seite = useAktuelleSeite()
return (
@@ -67,7 +64,7 @@ export function Shell() {
{/* Ab 1024 px steht die Menüführung fest links, darunter in der Schublade hinter dem Menü-Knopf. */}
<aside className="sticky top-0 hidden h-dvh flex-col gap-8 overflow-y-auto border-r border-linie bg-[#0c0f12] px-4 py-7 lg:flex">
<Marke />
<Seitenleiste onDienste={() => setSchublade("dienste")} />
<Seitenleiste />
</aside>
<div className="mx-auto flex w-full max-w-[1320px] min-w-0 flex-col gap-5 px-4 pt-4 pb-10 sm:px-6 md:gap-6 md:px-10 md:pt-8">
@@ -75,7 +72,7 @@ export function Shell() {
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={() => setSchublade("menue")}
onClick={() => setMenue(true)}
className="-ml-2 flex size-11 shrink-0 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground lg:hidden"
aria-label="Menü öffnen"
>
@@ -100,10 +97,7 @@ export function Shell() {
</div>
<Suspense fallback={null}>
{schublade === "menue" && (
<MenueSchublade onDienste={() => setSchublade("dienste")} onSchliessen={zu} />
)}
{schublade === "dienste" && <Dienste offen onSchliessen={zu} />}
{menue && <MenueSchublade onSchliessen={() => setMenue(false)} />}
</Suspense>
<Meldungen />
</div>
+5 -1
View File
@@ -31,6 +31,10 @@ const boxMonitoring = createRoute({
getParentRoute: () => wurzel, path: "/box/monitoring",
component: lazyRouteComponent(() => import("@/views/Monitoring"), "BoxMonitoringSeite"),
})
const boxDienste = createRoute({
getParentRoute: () => wurzel, path: "/box/dienste",
component: lazyRouteComponent(() => import("@/views/Dienste"), "DiensteSeite"),
})
// Bereich Homelab
const homelab = createRoute({
@@ -73,7 +77,7 @@ const altModelle = createRoute({
export const router = createRouter({
routeTree: wurzel.addChildren([
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, homelab, homelabUpdates, homelabMonitoring,
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, boxDienste, homelab, homelabUpdates, homelabMonitoring,
homelabProtokoll, einstellungen, altUpdates, altModelle,
]),
defaultPreload: "intent",
+29 -1
View File
@@ -113,6 +113,9 @@ export const useProtokoll = (dienst: string | null) =>
enabled: !!dienst,
})
/** Zustand eines Dienstes laut systemd (services/dienste.py). */
export type DienstZustand = "laeuft" | "antwortet_nicht" | "startet" | "stoppt" | "gestoppt" | "gescheitert" | "schlaeft"
export interface Dienst {
name: string
unit: string
@@ -120,10 +123,35 @@ export interface Dienst {
scope: string
/** Bewusst abgeschaltet (systemctl disable) — kein Fehler, nur geweckt wird per Knopf. */
schlaeft?: boolean
// Seit 25.09.2026: Zustand und Kennzahlen laut systemd — null, wo die Box sie nicht kennt (fehlt bei älteren Ständen).
zustand?: DienstZustand
/** Was systemd dem Dienst an Arbeitsspeicher zurechnet (MemoryCurrent). */
speicher_bytes?: number | null
/** Nur die Engine: belegter Grafikspeicher der Box — die geladenen Modelle, zusätzlich zu speicher_bytes. */
grafik_bytes?: number | null
/** Anteil an allen Kernen der Box in Prozent, gemessen seit der letzten Abfrage. */
cpu_prozent?: number | null
laeuft_seit_s?: number | null
/** Seit wann ein gestoppter oder gescheiterter Dienst aus ist. */
aus_seit_s?: number | null
/** Wie oft systemd ihn nach einem Absturz von selbst neu gestartet hat. */
neustarts?: number | null
}
export interface DiensteStand {
services: Dienst[]
/** Arbeitsspeicher der Box in Bytes — der Bezug des Speicher-Balkens. */
speicher_gesamt?: number | null
kerne?: number | null
}
/** Die Dienste-Seite misst die CPU zwischen zwei Abfragen — deshalb ein fester, kurzer Takt, solange sie offen ist. */
export const useDienste = () =>
useQuery({ queryKey: ["dienste"], queryFn: () => api<{ services: Dienst[] }>("/api/system/services") })
useQuery({
queryKey: ["dienste"],
queryFn: () => api<DiensteStand>("/api/system/services"),
refetchInterval: 10_000,
})
// --- Aktionen ----------------------------------------------------------------
+98
View File
@@ -0,0 +1,98 @@
import type { Dienst } from "./abfragen"
import {
dauer, erfolgText, groesse, knopfText, neustartFrage, prozent, sortiert, speicherAnteil, zeitpunkt, zustandVon,
zustandZusatz,
} from "./dienste"
const hermes: Dienst = { name: "Hermes-Gateway (Agent & Gedächtnis)", unit: "hermes-gateway", ok: true, scope: "user" }
// 25.09.2026, 13:10 Uhr in Berlin (Sommerzeit)
const JETZT = Date.UTC(2026, 8, 25, 11, 10)
describe("zustandVon", () => {
it("nimmt den Zustand der Box — bei einem älteren Stand wie bisher Erreichbarkeit und Schlaf", () => {
expect(zustandVon({ ...hermes, zustand: "gescheitert" })).toBe("gescheitert")
expect(zustandVon(hermes)).toBe("laeuft")
expect(zustandVon({ ...hermes, ok: false, schlaeft: true })).toBe("schlaeft")
expect(zustandVon({ ...hermes, ok: false })).toBe("antwortet_nicht")
})
})
describe("sortiert", () => {
it("stellt Probleme nach oben und schlafende Dienste nach unten, sonst bleibt die Reihenfolge der Box", () => {
const d = (unit: string, extra: Partial<Dienst>): Dienst => ({ ...hermes, name: unit, unit, ...extra })
const liste = [
d("a", { zustand: "laeuft" }), d("b", { zustand: "schlaeft" }), d("c", { zustand: "laeuft", neustarts: 2 }),
d("d", { zustand: "gestoppt" }), d("e", { zustand: "startet" }), d("f", { zustand: "laeuft" }),
d("g", { zustand: "gescheitert" }), d("h", { zustand: "antwortet_nicht" }),
]
expect(sortiert(liste).map((x) => x.unit)).toEqual(["d", "g", "h", "e", "c", "a", "f", "b"])
})
})
describe("Zahlen", () => {
it("schreibt Speicher, Prozent und Laufzeit deutsch", () => {
expect(groesse(56_500_224)).toBe("54 MB")
expect(groesse(7_500_505_088)).toBe("7,0 GB")
expect(groesse(null)).toBe("–")
expect(prozent(0.4)).toBe("0,4 %")
expect(prozent(2)).toBe("2 %")
expect(prozent(0)).toBe("0 %")
expect(prozent(0.01)).toBe("< 0,1 %")
expect(prozent(23.79)).toBe("24 %")
expect(prozent(null)).toBe("–")
expect(dauer(30)).toBe("< 1 min")
expect(dauer(6114)).toBe("1 h 41 min")
expect(dauer(3_430_000)).toBe("39 T 16 h")
expect(dauer(null)).toBe("–")
})
it("zählt bei der Engine die Modelle im Grafikspeicher zum Anteil an der Box", () => {
const engine: Dienst = {
...hermes, unit: "llama-swap", speicher_bytes: 3_722_850_304, grafik_bytes: 27_611_533_312,
}
expect(speicherAnteil(engine, 131_738_611_712)).toBeCloseTo(23.79, 1)
expect(speicherAnteil({ ...engine, grafik_bytes: null }, 131_738_611_712)).toBeCloseTo(2.83, 1)
expect(speicherAnteil({ ...engine, speicher_bytes: null }, 131_738_611_712)).toBeNull()
expect(speicherAnteil(engine, null)).toBeNull()
})
it("nennt Zeitpunkte in Box-Zeit", () => {
expect(zeitpunkt(330, JETZT)).toBe("heute 13:04")
expect(zeitpunkt(86_400, JETZT)).toBe("gestern 13:10")
expect(zeitpunkt(10 * 86_400, JETZT)).toBe("15.09., 13:10")
})
})
describe("Zustand und Knopf", () => {
it("sagt, seit wann ein Dienst aus ist, und dass ein schlafender mit Absicht aus ist", () => {
expect(zustandZusatz({ ...hermes, zustand: "gescheitert", aus_seit_s: 147 }, JETZT)).toBe("aus seit heute 13:07")
expect(zustandZusatz({ ...hermes, zustand: "schlaeft" }, JETZT)).toBe("mit Absicht aus")
expect(zustandZusatz({ ...hermes, zustand: "laeuft", laeuft_seit_s: 20 }, JETZT)).toBeNull()
})
it("weckt, startet oder startet neu — und fragt mit den Folgen für den Dienst", () => {
const schlaeft: Dienst = { ...hermes, name: "Spracherkennung", unit: "voice-service", zustand: "schlaeft" }
expect(knopfText(schlaeft)).toBe("Wecken")
expect(neustartFrage(schlaeft)).toEqual({
titel: "Spracherkennung wecken?",
text: "Der Dienst startet und läuft bis zum nächsten Neustart der Box. Danach schläft er wieder.",
knopf: "Wecken",
})
expect(erfolgText(schlaeft)).toBe("Spracherkennung wird geweckt.")
const gescheitert: Dienst = { ...hermes, zustand: "gescheitert" }
expect(neustartFrage(gescheitert).text).toBe("Der Dienst wird gestartet. Scheitert er wieder, steht der Grund im Protokoll.")
expect(erfolgText(gescheitert)).toBe("Hermes-Gateway (Agent & Gedächtnis) startet.")
expect(neustartFrage({ ...hermes, zustand: "laeuft" }).text)
.toBe("Der Dienst wird neu gestartet und ist dabei kurz nicht erreichbar. Lucy antwortet in der Zeit nicht.")
expect(erfolgText({ ...hermes, zustand: "laeuft" })).toBe("Hermes-Gateway (Agent & Gedächtnis) startet neu.")
const engine: Dienst = { ...hermes, name: "Engine (llama-swap)", unit: "llama-swap", scope: "system", zustand: "laeuft" }
expect(neustartFrage(engine)).toEqual({
titel: "Engine (llama-swap) neu starten?",
text: "Der Dienst wird neu gestartet und ist dabei kurz nicht erreichbar. Laufende Antworten brechen ab; die "
+ "Modelle werden danach neu geladen.",
knopf: "Neu starten",
})
})
})
+149
View File
@@ -0,0 +1,149 @@
import type { Dienst, DienstZustand } from "./abfragen"
import { laufzeit } from "./messung"
// Anzeige der Seite „Dienste und Protokolle“: Zustand in Worten, Reihenfolge, Zahlen und Rückfragen — ohne DOM,
// damit sie sich testen lässt. Gemessen wird auf der Box (backend/services/dienste.py).
export interface ZustandsAnzeige {
wort: string
/** Farbe des Punkts vor dem Namen. */
punkt: string
/** Farbe des Worts in der Spalte „Zustand“ — ruhig, solange nichts ist. */
text: string
}
export const ZUSTAND: Record<DienstZustand, ZustandsAnzeige> = {
laeuft: { wort: "läuft", punkt: "bg-gruen", text: "text-text-3" },
startet: { wort: "startet", punkt: "bg-bernstein", text: "text-bernstein" },
stoppt: { wort: "stoppt", punkt: "bg-bernstein", text: "text-bernstein" },
antwortet_nicht: { wort: "antwortet nicht", punkt: "bg-rot", text: "text-rot-text" },
gestoppt: { wort: "gestoppt", punkt: "bg-rot", text: "text-rot-text" },
gescheitert: { wort: "gescheitert", punkt: "bg-rot", text: "text-rot-text" },
schlaeft: { wort: "schläft", punkt: "bg-text-3", text: "text-text-3" },
}
/** Der Zustand — bei einer älteren Box ohne systemd-Angaben wie bisher aus „antwortet“ und „schläft“. */
export function zustandVon(d: Dienst): DienstZustand {
if (d.zustand) return d.zustand
return d.ok ? "laeuft" : d.schlaeft ? "schlaeft" : "antwortet_nicht"
}
const RANG: Record<DienstZustand, number> = {
gescheitert: 0, gestoppt: 0, antwortet_nicht: 0, startet: 1, stoppt: 1, laeuft: 3, schlaeft: 4,
}
/** Reihenfolge der Tabelle: 0 = Problem (gescheitert, gestoppt, antwortet nicht), 1 = startet oder stoppt gerade,
* 2 = läuft, stürzte aber zwischendurch ab, 3 = läuft, 4 = schläft mit Absicht. */
export function rang(d: Dienst): number {
const z = zustandVon(d)
if (z === "laeuft" && (d.neustarts ?? 0) > 0) return 2
return RANG[z]
}
export const istProblem = (d: Dienst) => rang(d) === 0
/** Probleme oben, schlafende Dienste unten; sonst bleibt die Reihenfolge der Box. */
export const sortiert = (dienste: Dienst[]) => [...dienste].sort((a, b) => rang(a) - rang(b))
const ganz = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 })
const eineStelle = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/** Speicher lesbar: „54 MB“, „7,0 GB“ (1 GB = 1024 MB, wie im Cockpit). */
export function groesse(bytes: number | null | undefined): string {
if (bytes == null) return "–"
const mb = bytes / 1024 ** 2
return mb < 1000 ? `${ganz.format(mb)} MB` : `${eineStelle.format(mb / 1024)} GB`
}
/** Anteil am Speicher der Box in Prozent — beim Dienst samt Grafikspeicher (die Modelle der Engine). */
export function speicherAnteil(d: Dienst, gesamt: number | null | undefined): number | null {
if (d.speicher_bytes == null || !gesamt) return null
return ((d.speicher_bytes + (d.grafik_bytes ?? 0)) / gesamt) * 100
}
/** Prozent kurz: unter 10 % mit einer Stelle („0,4 %“), sonst ganz („24 %“). */
export function prozent(p: number | null | undefined): string {
if (p == null) return "–"
if (p > 0 && p < 0.05) return "< 0,1 %"
return p < 10 ? `${eineStelle.format(p).replace(/,0$/, "")} %` : `${ganz.format(p)} %`
}
/** Laufzeit wie bei den Geräten im Homelab („3 T 4 h“, „5 h 12 min“), unter einer Minute „< 1 min“. */
export function dauer(s: number | null | undefined): string {
if (s == null || s < 0) return "–"
return s < 60 ? "< 1 min" : laufzeit(s)
}
const ZONE = "Europe/Berlin"
const uhr = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: ZONE })
const datum = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", timeZone: ZONE })
const tag = new Intl.DateTimeFormat("en-CA", { timeZone: ZONE })
/** Wann etwas war, das `s` Sekunden zurückliegt: „heute 11:05“, „gestern 23:10“, sonst „07.09., 11:03“. */
export function zeitpunkt(s: number, jetzt = Date.now()): string {
const d = new Date(jetzt - s * 1000)
const t = tag.format(d)
if (t === tag.format(new Date(jetzt))) return `heute ${uhr.format(d)}`
if (t === tag.format(new Date(jetzt - 86_400_000))) return `gestern ${uhr.format(d)}`
return `${datum.format(d)}, ${uhr.format(d)}`
}
/** Die Zeile unter dem Zustand: seit wann ein Dienst aus ist, oder dass er mit Absicht schläft. */
export function zustandZusatz(d: Dienst, jetzt = Date.now()): string | null {
const z = zustandVon(d)
if (z === "schlaeft") return "mit Absicht aus"
if ((z === "gestoppt" || z === "gescheitert") && d.aus_seit_s != null) return `aus seit ${zeitpunkt(d.aus_seit_s, jetzt)}`
return null
}
/** Beschriftung des Knopfs: ein schlafender Dienst wird geweckt, ein gestoppter gestartet, sonst neu gestartet. */
export function knopfText(d: Dienst): "Wecken" | "Starten" | "Neu starten" {
const z = zustandVon(d)
if (z === "schlaeft") return "Wecken"
if (z === "gestoppt" || z === "gescheitert") return "Starten"
return "Neu starten"
}
// Was ein Neustart eines laufenden Dienstes über ihn hinaus bedeutet.
const FOLGEN: Record<string, string> = {
"llama-swap": "Laufende Antworten brechen ab; die Modelle werden danach neu geladen.",
"mission-control-2": "Das ist diese Oberfläche selbst: Sie ist ein paar Sekunden nicht erreichbar.",
"hermes-gateway": "Lucy antwortet in der Zeit nicht.",
}
export interface Rueckfrage {
titel: string
text: string
knopf: string
}
/** Die Rückfrage vor dem Knopf — jede Aktion hier startet einen Dienst der Box. */
export function neustartFrage(d: Dienst): Rueckfrage {
const knopf = knopfText(d)
if (knopf === "Wecken") {
return {
titel: `${d.name} wecken?`,
text: "Der Dienst startet und läuft bis zum nächsten Neustart der Box. Danach schläft er wieder.",
knopf,
}
}
if (knopf === "Starten") {
return {
titel: `${d.name} starten?`,
text: "Der Dienst wird gestartet. Scheitert er wieder, steht der Grund im Protokoll.",
knopf,
}
}
const folge = FOLGEN[d.unit] ? ` ${FOLGEN[d.unit]}` : ""
return {
titel: `${d.name} neu starten?`,
text: `Der Dienst wird neu gestartet und ist dabei kurz nicht erreichbar.${folge}`,
knopf,
}
}
/** Die Meldung nach dem Klick. */
export function erfolgText(d: Dienst): string {
const knopf = knopfText(d)
return `${d.name} ${knopf === "Wecken" ? "wird geweckt" : knopf === "Starten" ? "startet" : "startet neu"}.`
}
+3 -1
View File
@@ -18,7 +18,7 @@ export const BEREICHE: { id: Bereich; name: string }[] = [
]
export type SeitenPfad =
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring"
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring" | "/box/dienste"
| "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen"
export interface Seite {
@@ -36,6 +36,8 @@ export const SEITEN: Seite[] = [
{ to: "/box/updates", label: "Updates", Icon: RefreshCw, ort: "box", zaehlt: true },
{ to: "/box/modelle", label: "Modelle", Icon: Cpu, ort: "box" },
{ to: "/box/monitoring", label: "Monitoring", Icon: Activity, ort: "box" },
// Bis 25.09.2026 eine Schublade; jetzt eine Seite mit Tabelle wie die Geräte im Homelab.
{ to: "/box/dienste", label: "Dienste und Protokolle", Icon: ScrollText, ort: "box" },
{ to: "/homelab", label: "Cockpit", Icon: Gauge, ort: "homelab" },
{ to: "/homelab/updates", label: "Updates", Icon: RefreshCw, ort: "homelab", zaehlt: true },
{ to: "/homelab/monitoring", label: "Monitoring", Icon: Activity, ort: "homelab" },
+138
View File
@@ -0,0 +1,138 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"
import type { DiensteStand } from "@/lib/abfragen"
import { api, post } from "@/lib/api"
import { DiensteSeite } from "./Dienste"
vi.mock("@/lib/api", async (original) => ({
...(await original<typeof import("@/lib/api")>()),
api: vi.fn(),
post: vi.fn(),
}))
// Zahlen der Box vom 25.09.2026; Hermes ist hier abgestürzt.
const STAND: DiensteStand = {
speicher_gesamt: 131_738_611_712,
kerne: 32,
services: [
{
name: "Engine (llama-swap)", unit: "llama-swap", ok: true, scope: "system", schlaeft: false, zustand: "laeuft",
speicher_bytes: 3_722_850_304, grafik_bytes: 27_611_533_312, cpu_prozent: 2, laeuft_seit_s: 6114,
aus_seit_s: null, neustarts: 0,
},
{
name: "Hermes-Gateway (Agent & Gedächtnis)", unit: "hermes-gateway", ok: false, scope: "user", schlaeft: false,
zustand: "gescheitert", speicher_bytes: null, grafik_bytes: null, cpu_prozent: null, laeuft_seit_s: null,
aus_seit_s: 147, neustarts: 5,
},
{
name: "Spracherkennung (Desktop-Lucy)", unit: "voice-service", ok: false, scope: "user", schlaeft: true,
zustand: "schlaeft", speicher_bytes: null, grafik_bytes: null, cpu_prozent: null, laeuft_seit_s: null,
aus_seit_s: null, neustarts: 0,
},
{
name: "Lucys Stimme", unit: "lucy-stimme", ok: true, scope: "user", schlaeft: false, zustand: "laeuft",
speicher_bytes: 7_500_505_088, grafik_bytes: null, cpu_prozent: 0.4, laeuft_seit_s: 3_430_000,
aus_seit_s: null, neustarts: 0,
},
],
}
function antworten() {
vi.mocked(api).mockImplementation((async (pfad: string) => {
if (pfad === "/api/system/services") return STAND
if (pfad === "/api/maintenance/logs?service=hermes-gateway&lines=200") {
return { ok: true, text: "Sep 25 13:07:30 box hermes[812]: Traceback (most recent call last):\nSep 25 13:07:33 box systemd[1]: hermes-gateway.service: Failed with result 'exit-code'." }
}
throw new Error(`unerwartete Abfrage ${pfad}`)
}) as typeof api)
}
function zeigen() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={qc}>
<DiensteSeite />
</QueryClientProvider>,
)
}
describe("Dienste und Protokolle", () => {
it("zeigt Probleme zuerst und je Dienst Zustand, CPU, Speicher, Laufzeit und Neustarts", async () => {
antworten()
zeigen()
const tabelle = await screen.findByRole("table", { name: "Dienste der KI-Box" })
const zeilen = within(tabelle).getAllByRole("row").slice(1)
expect(zeilen.map((z) => within(z).getByRole("rowheader").textContent)).toEqual([
"Hermes-Gateway (Agent & Gedächtnis)hermes-gateway", "Engine (llama-swap)llama-swap · Systemdienst",
"Lucys Stimmelucy-stimme", "Spracherkennung (Desktop-Lucy)voice-service",
])
expect(screen.getByText("1 Problem")).toBeInTheDocument()
const [hermes, engine, stimme, sprache] = zeilen
expect(hermes).toHaveTextContent("gescheitert")
expect(hermes).toHaveTextContent(/aus seit (heute|gestern) \d\d:\d\d/)
expect(within(hermes).getByText("5")).toHaveClass("text-bernstein")
// Die Engine samt Modellen im Grafikspeicher: 3,5 GB Dienst + 25,7 GB Modelle von 123 GB.
expect(engine).toHaveTextContent("29,2 GB · 24 %")
expect(engine).toHaveTextContent("davon 25,7 GB Modelle")
expect(engine).toHaveTextContent("2 %")
expect(engine).toHaveTextContent("1 h 41 min")
expect(stimme).toHaveTextContent("7,0 GB · 5,7 %")
expect(stimme).toHaveTextContent("0,4 %")
expect(stimme).toHaveTextContent("39 T 16 h")
expect(sprache).toHaveTextContent("schläft")
expect(sprache).toHaveTextContent("mit Absicht aus")
expect(within(sprache).getByRole("button", { name: "Spracherkennung (Desktop-Lucy): Wecken" })).toBeEnabled()
})
it("startet einen Dienst erst nach der Rückfrage", async () => {
antworten()
vi.mocked(post).mockResolvedValue({ ok: true })
zeigen()
fireEvent.click(await screen.findByRole("button", { name: "Hermes-Gateway (Agent & Gedächtnis): Starten" }))
const dialog = await screen.findByRole("dialog", { name: "Hermes-Gateway (Agent & Gedächtnis) starten?" })
expect(dialog).toHaveTextContent("Scheitert er wieder, steht der Grund im Protokoll.")
expect(post).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole("button", { name: "Starten" }))
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/maintenance/restart", { service: "hermes-gateway" }))
})
it("kommt mit einer Box ohne Kennzahlen zurecht (älterer Stand, etwa hinter dem Homelab-Teil)", async () => {
vi.mocked(api).mockResolvedValue({
services: [
{ name: "Engine (llama-swap)", unit: "llama-swap", ok: true, scope: "system", schlaeft: false },
{ name: "Lucys Stimme", unit: "lucy-stimme", ok: false, scope: "user", schlaeft: false },
{ name: "Spracherkennung (Desktop-Lucy)", unit: "voice-service", ok: false, scope: "user", schlaeft: true },
],
})
zeigen()
const tabelle = await screen.findByRole("table", { name: "Dienste der KI-Box" })
const [stimme, engine, sprache] = within(tabelle).getAllByRole("row").slice(1)
expect(stimme).toHaveTextContent("antwortet nicht")
expect(within(stimme).getByRole("button", { name: "Lucys Stimme: Neu starten" })).toBeInTheDocument()
expect(engine).toHaveTextContent("läuft")
expect(sprache).toHaveTextContent("schläft")
expect(screen.getByText(/Anteil an allen Kernen der Box/)).toBeInTheDocument()
})
it("klappt das Protokoll unter der Zeile auf und wieder zu", async () => {
antworten()
zeigen()
const knopf = await screen.findByRole("button", { name: "Protokoll von Hermes-Gateway (Agent & Gedächtnis)" })
fireEvent.click(knopf)
expect(knopf).toHaveAttribute("aria-expanded", "true")
const protokoll = await screen.findByLabelText("Letzte Protokollzeilen von Hermes-Gateway (Agent & Gedächtnis)")
await waitFor(() => expect(protokoll).toHaveTextContent("Traceback (most recent call last)"))
// Es steht in der Zeile des Dienstes, nicht irgendwo unten.
expect(knopf.closest("[role=row]")).toContainElement(protokoll)
fireEvent.click(knopf)
expect(knopf).toHaveAttribute("aria-expanded", "false")
expect(screen.queryByLabelText("Letzte Protokollzeilen von Hermes-Gateway (Agent & Gedächtnis)")).not.toBeInTheDocument()
})
})
+278 -84
View File
@@ -1,117 +1,311 @@
import { useState } from "react"
import { type ReactNode, useEffect, useRef, useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { type Dienst, useDienste, useProtokoll } from "@/lib/abfragen"
import { type Dienst, type DiensteStand, useDienste, useProtokoll } from "@/lib/abfragen"
import { fehlerText } from "@/lib/anzeige"
import { post } from "@/lib/api"
import { ApiFehler, post } from "@/lib/api"
import {
dauer, erfolgText, groesse, istProblem, knopfText, neustartFrage, prozent, rang, sortiert, speicherAnteil, ZUSTAND,
zeitpunkt, zustandVon, zustandZusatz,
} from "@/lib/dienste"
import { melden } from "@/lib/meldungen"
import type { AktionsErgebnis } from "@/lib/typen"
import { gbText } from "@/lib/zeit"
// Die Dienste der KI-Box als Tabelle wie die Geräte im Homelab-Cockpit (seit 25.09.2026 eine eigene Seite statt der
// Schublade): Zustand, CPU, Speicher, Laufzeit und Neustarts je Dienst, das Protokoll klappt unter der Zeile auf.
// Unter xl ist jede Zeile eine kleine Karte (zwei, ab sm vier Spalten); ab xl eine Tabelle. Die Knöpfe stehen erst
// ab 1400 px nebeneinander — bei 1280 px neben der Seitenleiste reichte der Platz sonst nicht für die Zahlen. Jede
// Zeile ist ein eigenes Raster: darum feste Breiten statt „auto“, sonst verschöbe ein kürzerer Knopf die Spalten.
const SPALTEN =
"grid-cols-2 gap-x-4 sm:grid-cols-4 xl:gap-x-3 " +
"xl:grid-cols-[minmax(0,1.7fr)_7rem_minmax(0,0.7fr)_minmax(0,1.2fr)_7.5rem_5rem_9.5rem] " +
"min-[87.5rem]:grid-cols-[minmax(0,1.7fr)_7rem_minmax(0,0.7fr)_minmax(0,1.2fr)_7.5rem_5rem_17.5rem]"
const uhrzeit = new Intl.DateTimeFormat("de-DE", {
hour: "2-digit", minute: "2-digit", second: "2-digit", timeZone: "Europe/Berlin",
})
/** Beschriftung der Zahl am Handy — in der Tabelle steht sie im Kopf. */
function Beschriftung({ children }: { children: ReactNode }) {
return <span className="block text-xs text-text-3 xl:hidden">{children}</span>
}
/** Wert mit Balken darunter wie im Homelab-Cockpit, aber ruhig in Blau: Ein Dienst hat keine Grenze, ab der er
* „zu viel“ hätte — was zählt, zeigt der Zustand. */
function Balken({ anteil, children }: { anteil: number | null; children: ReactNode }) {
return (
<span className="flex min-w-0 flex-col gap-1">
<span className="ziffern text-sm text-text-2">{children}</span>
{anteil !== null && (
<span className="h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-erhaben" aria-hidden>
<span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, anteil))}%`, background: "var(--reihe-1)" }} />
</span>
)}
</span>
)
}
/** Das Protokoll eines Dienstes, direkt unter seiner Zeile — am Handy sonst weit unten außer Sicht. */
function Protokoll({ unit }: { unit: string }) {
const protokoll = useProtokoll(unit)
function Protokoll({ d, id }: { d: Dienst; id: string }) {
const protokoll = useProtokoll(d.unit)
const feld = useRef<HTMLPreElement>(null)
let text: string
if (protokoll.isPending) text = "Wird gelesen …"
else if (protokoll.isError) text = `Das Protokoll lässt sich gerade nicht lesen: ${protokoll.error.message}`
else if (protokoll.data.ok === false) text = protokoll.data.err || protokoll.data.text || "Das Protokoll lässt sich nicht lesen."
else text = protokoll.data.text?.trim() || "Noch keine Einträge."
// Das Neueste steht unten — dorthin springen, sobald der Text da ist.
useEffect(() => {
if (feld.current) feld.current.scrollTop = feld.current.scrollHeight
}, [text])
return (
<pre className="ziffern m-0 max-h-[50vh] w-full basis-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-all text-text-2">
<pre
id={id}
ref={feld}
tabIndex={0}
aria-label={`Letzte Protokollzeilen von ${d.name}`}
className="ziffern m-0 max-h-[50vh] w-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-words text-text-2"
>
{text}
</pre>
)
}
/** Dienste der Box mit Protokoll und Neustart — die „Log-Ansicht der Dienste“. */
export function Dienste({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
const qc = useQueryClient()
const dienste = useDienste()
const [gewaehlt, setGewaehlt] = useState<string | null>(null)
const [bestaetigen, setBestaetigen] = useState<string | null>(null)
const [startet, setStartet] = useState<string | null>(null)
async function neustarten(d: Dienst) {
if (startet) return
setBestaetigen(null)
setStartet(d.unit)
try {
const e = await post<AktionsErgebnis>("/api/maintenance/restart", { service: d.unit })
if (e.ok) melden("erfolg", `${d.name} ${d.schlaeft ? "wird geweckt" : "startet neu"}.`)
else melden("fehler", `${d.name} ließ sich nicht starten: ${fehlerText(e)}`)
qc.invalidateQueries({ queryKey: ["dienste"] })
} catch (f) {
melden("fehler", (f as Error).message)
} finally {
setStartet(null)
}
}
let inhalt
if (dienste.isPending) {
inhalt = <p role="status" className="px-4 text-[15px] text-text-2">Dienste werden gelesen …</p>
} else if (dienste.isError && !dienste.data) {
inhalt = <p role="alert" className="px-4 text-[15px] text-rot-text">Die Dienste lassen sich gerade nicht lesen: {dienste.error.message}</p>
} else if (!dienste.data?.services.length) {
inhalt = <p className="px-4 text-[15px] text-text-2">Keine Dienste gefunden.</p>
} else {
inhalt = (
<ul className="m-0 flex list-none flex-col px-4 pb-4">
{dienste.data.services.map((d) => (
<li key={d.unit} className="flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5">
<span className="flex min-w-0 items-center gap-2.5">
function Zeile({ d, gesamt, offen, startet, onProtokoll, onFrage }: {
d: Dienst
gesamt: number | null | undefined
offen: boolean
startet: string | null
onProtokoll: () => void
onFrage: () => void
}) {
const z = ZUSTAND[zustandVon(d)]
const zusatz = zustandZusatz(d)
const anteil = speicherAnteil(d, gesamt)
const neustarts = d.neustarts ?? 0
const protokollId = `protokoll-${d.unit}`
return (
<div role="row" className={cn("grid gap-y-2.5 border-t border-linie py-3 xl:items-center", SPALTEN)}>
<div role="rowheader" className="col-span-2 flex min-w-0 items-start gap-2.5 xl:col-span-1">
<span aria-hidden className={cn("mt-1.5 size-2.5 shrink-0 rounded-full", z.punkt)} />
<span className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{d.name}</span>
<span className="ziffern text-xs break-words text-text-3">
{d.unit}
{d.scope === "system" ? " · Systemdienst" : ""}
</span>
</span>
</div>
<span role="cell" className="flex min-w-0 flex-col gap-0.5">
<Beschriftung>Zustand</Beschriftung>
<span className={cn("schild text-xs", z.text)}>{z.wort}</span>
{zusatz && <span className="text-xs text-text-3">{zusatz}</span>}
{/* Ohne Tabelle stehen die Neustarts hier — und nur, wenn es welche gab. */}
{neustarts > 0 && (
<span className="text-xs text-bernstein xl:hidden">{neustarts === 1 ? "1 Neustart" : `${neustarts} Neustarts`}</span>
)}
</span>
<span role="cell" className="min-w-0">
<Beschriftung>CPU</Beschriftung>
{d.cpu_prozent == null
? <span className="text-sm text-text-3">–</span>
: <Balken anteil={d.cpu_prozent}>{prozent(d.cpu_prozent)}</Balken>}
</span>
<span role="cell" className="min-w-0">
<Beschriftung>Speicher</Beschriftung>
{d.speicher_bytes == null ? (
<span className="text-sm text-text-3">–</span>
) : (
<Balken anteil={anteil}>
{groesse(d.speicher_bytes + (d.grafik_bytes ?? 0))}
{anteil !== null && <span className="font-sans text-xs text-text-3"> · {prozent(anteil)}</span>}
</Balken>
)}
{d.speicher_bytes != null && !!d.grafik_bytes && (
<span className="mt-1 block text-xs text-text-3">davon {groesse(d.grafik_bytes)} Modelle</span>
)}
</span>
<span role="cell" className="flex min-w-0 flex-col gap-0.5">
<Beschriftung>Läuft seit</Beschriftung>
<span className="ziffern text-sm text-text-2">{dauer(d.laeuft_seit_s)}</span>
{d.laeuft_seit_s != null && <span className="text-xs text-text-3">gestartet {zeitpunkt(d.laeuft_seit_s)}</span>}
</span>
<span role="cell" className="hidden min-w-0 xl:block">
<span className={cn("ziffern text-sm", neustarts > 0 ? "text-bernstein" : "text-text-3")}>
{d.neustarts ?? "–"}
</span>
</span>
{/* Am Handy unter den Zahlen; ab sm rechts neben dem Namen, ab xl in der letzten Spalte. */}
<span
className={cn("size-2.5 shrink-0 rounded-full", d.ok ? "bg-gruen" : d.schlaeft ? "bg-text-3" : "bg-rot")}
aria-hidden
/>
<span className="min-w-0">
<span className="block text-[15px]">{d.name}</span>
<span className="ziffern text-xs text-text-3">
{d.unit} · {d.ok ? "läuft" : d.schlaeft ? "schläft (abgeschaltet)" : "antwortet nicht"}
</span>
</span>
</span>
<span className="flex flex-wrap gap-2">
role="cell"
className={
"col-span-2 flex flex-wrap gap-2 sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-start " +
"xl:col-span-1 xl:col-start-auto xl:row-start-auto xl:flex-col xl:items-stretch xl:self-center " +
"min-[87.5rem]:flex-row min-[87.5rem]:justify-end"
}
>
<Button
variant={gewaehlt === d.unit ? "info" : "ghost"}
variant={offen ? "info" : "ghost"}
size="sm"
aria-expanded={gewaehlt === d.unit}
onClick={() => setGewaehlt(gewaehlt === d.unit ? null : d.unit)}
aria-expanded={offen}
aria-controls={offen ? protokollId : undefined}
aria-label={`Protokoll von ${d.name}`}
onClick={onProtokoll}
>
Protokoll
</Button>
{bestaetigen === d.unit ? (
<>
<Button variant="gefahr" size="sm" onClick={() => neustarten(d)}>
{d.schlaeft ? "Wirklich wecken?" : "Wirklich neu starten?"}
<Button
variant={istProblem(d) ? "default" : "outline"}
size="sm"
disabled={startet !== null}
aria-label={`${d.name}: ${startet === d.unit ? "startet …" : knopfText(d)}`}
onClick={onFrage}
>
{startet === d.unit ? "Startet …" : knopfText(d)}
</Button>
<Button variant="ghost" size="sm" onClick={() => setBestaetigen(null)}>
Nein
</Button>
</>
) : (
<Button variant="outline" size="sm" disabled={startet !== null} onClick={() => setBestaetigen(d.unit)}>
{startet === d.unit ? "Startet …" : d.schlaeft ? "Wecken" : "Neu starten"}
</Button>
)}
</span>
{gewaehlt === d.unit && <Protokoll unit={d.unit} />}
</li>
))}
</ul>
{offen && (
<div role="cell" className="col-span-full">
<Protokoll d={d} id={protokollId} />
</div>
)}
</div>
)
}
function Tabelle({ stand, gewaehlt, startet, onProtokoll, onFrage }: {
stand: DiensteStand
gewaehlt: string | null
startet: string | null
onProtokoll: (unit: string) => void
onFrage: (d: Dienst) => void
}) {
return (
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent side="right" className="gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl">
<SheetHeader>
<SheetTitle className="schild text-lg">Dienste und Protokolle</SheetTitle>
<SheetDescription className="text-text-2">Welcher Dienst läuft, was er zuletzt geschrieben hat.</SheetDescription>
</SheetHeader>
{inhalt}
</SheetContent>
</Sheet>
<div role="table" aria-label="Dienste der KI-Box" className="flex flex-col">
<div role="row" className={cn("schild hidden pb-2 text-xs font-semibold text-text-3 xl:grid", SPALTEN)}>
<span role="columnheader">Dienst</span>
<span role="columnheader">Zustand</span>
<span role="columnheader">CPU</span>
<span role="columnheader">Speicher</span>
<span role="columnheader">Läuft seit</span>
<span role="columnheader">Neustarts</span>
<span role="columnheader" className="sr-only">Aktionen</span>
</div>
{sortiert(stand.services).map((d) => (
<Zeile
key={d.unit}
d={d}
gesamt={stand.speicher_gesamt}
offen={gewaehlt === d.unit}
startet={startet}
onProtokoll={() => onProtokoll(d.unit)}
onFrage={() => onFrage(d)}
/>
))}
</div>
)
}
/** Oben rechts: wann zuletzt gemessen wurde und ob etwas nicht stimmt. */
function Lage({ dienste, stand }: { dienste: Dienst[]; stand: number }) {
const probleme = dienste.filter(istProblem).length
const wechsel = dienste.filter((d) => rang(d) === 1)
return (
<span className="flex flex-wrap items-center gap-3">
{stand > 0 && <span className="ziffern text-sm text-text-3">Stand {uhrzeit.format(stand)}</span>}
{probleme > 0 ? (
<Chip art="rot">{probleme === 1 ? "1 Problem" : `${probleme} Probleme`}</Chip>
) : wechsel.length > 0 ? (
<Chip art="bernstein">{wechsel.some((d) => zustandVon(d) === "startet") ? "Startet gerade" : "Stoppt gerade"}</Chip>
) : (
<Chip art="gruen">Alles in Ordnung</Chip>
)}
</span>
)
}
/** KI-Box · Dienste und Protokolle: welcher Dienst läuft, was er braucht und was er zuletzt geschrieben hat. */
export function DiensteSeite() {
const qc = useQueryClient()
const dienste = useDienste()
const [gewaehlt, setGewaehlt] = useState<string | null>(null)
// Die Rückfrage behält ihren Dienst, bis die nächste kommt — sonst stünde sie beim Ausblenden leer da.
const [frage, setFrage] = useState<Dienst | null>(null)
const [fragt, setFragt] = useState(false)
const [startet, setStartet] = useState<string | null>(null)
async function starten(d: Dienst) {
if (startet) return
setStartet(d.unit)
try {
const e = await post<AktionsErgebnis>("/api/maintenance/restart", { service: d.unit })
if (e.ok) melden("erfolg", erfolgText(d))
else melden("fehler", `${d.name} ließ sich nicht starten: ${fehlerText(e)}`)
} catch (f) {
// Startet der Box-Wart sich selbst neu, reißt seine Antwort ab — das ist dann kein Fehler.
if (d.unit === "mission-control-2" && f instanceof ApiFehler) melden("info", "Der Box-Wart startet neu. Die Seite ist gleich wieder da.")
else melden("fehler", (f as Error).message)
} finally {
setStartet(null)
qc.invalidateQueries({ queryKey: ["dienste"] })
qc.invalidateQueries({ queryKey: ["protokoll", d.unit] })
}
}
if (dienste.isPending) return <Zustandsfeld text="Die Dienste werden gelesen …" />
// Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen.
if (!dienste.data) {
return <Zustandsfeld fehler text={`Die Dienste lassen sich gerade nicht lesen: ${dienste.error?.message ?? "keine Daten"}`} />
}
const stand = dienste.data
const rueckfrage = frage ? neustartFrage(frage) : null
const speicher = stand.speicher_gesamt ? `den ${gbText(stand.speicher_gesamt)}` : "dem Arbeitsspeicher"
const kerne = stand.kerne ? `allen ${stand.kerne} Kernen` : "allen Kernen"
return (
<Panel titel="Dienste" rechts={<Lage dienste={stand.services} stand={dienste.dataUpdatedAt} />}>
<p className="-mt-2 text-[15px] text-text-2">
Die Dienste der KI-Box: ob sie laufen, was sie gerade an CPU und Speicher brauchen, seit wann sie laufen und was
sie zuletzt ins Protokoll geschrieben haben. Probleme stehen oben.
</p>
{stand.services.length === 0 ? (
<p className="text-[15px] text-text-2">Keine Dienste gefunden.</p>
) : (
<Tabelle
stand={stand}
gewaehlt={gewaehlt}
startet={startet}
onProtokoll={(unit) => setGewaehlt(gewaehlt === unit ? null : unit)}
onFrage={(d) => {
setFrage(d)
setFragt(true)
}}
/>
)}
<p className="m-0 text-xs leading-relaxed text-text-3">
CPU: Anteil an {kerne} der Box, gemessen zwischen zwei Abfragen (alle zehn Sekunden). Speicher: was systemd dem
Dienst zurechnet; der Balken zeigt den Anteil an {speicher} der Box. Bei der Engine zählen die geladenen Modelle
mit — sie liegen im Grafikspeicher, den systemd keinem Dienst zurechnet. Neustarts: wie oft systemd den Dienst
nach einem Absturz von selbst wieder gestartet hat. „Schläft“ heißt: mit Absicht abgeschaltet, kein Fehler.
</p>
<Bestaetigen
offen={fragt && !!rueckfrage}
titel={rueckfrage?.titel ?? ""}
text={rueckfrage?.text ?? ""}
knopf={rueckfrage?.knopf ?? ""}
gefahr={rueckfrage?.knopf === "Neu starten"}
gesperrt={startet !== null}
onJa={() => {
setFragt(false)
if (frage) void starten(frage)
}}
onNein={() => setFragt(false)}
/>
</Panel>
)
}