From cf01a5bb857c34c13cc827d9abf46084d2c51e50 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 25 Sep 2026 13:28:57 +0200 Subject: [PATCH] =?UTF-8?q?dienste:=20eigene=20Seite=20/box/dienste=20mit?= =?UTF-8?q?=20Tabelle=20wie=20die=20Ger=C3=A4te=20im=20Homelab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/routers/system.py | 82 +------ backend/services/dienste.py | 286 +++++++++++++++++++++++ backend/services/waechter.py | 5 - backend/tests/test_dienste.py | 295 +++++++++++++++++++++++ docs/ARCHITEKTUR.md | 2 +- docs/BEDIENUNG.md | 24 +- frontend/src/app/MenueSchublade.tsx | 7 +- frontend/src/app/Seitenleiste.tsx | 23 +- frontend/src/app/Shell.tsx | 18 +- frontend/src/app/router.tsx | 6 +- frontend/src/lib/abfragen.ts | 30 ++- frontend/src/lib/dienste.test.ts | 98 ++++++++ frontend/src/lib/dienste.ts | 149 ++++++++++++ frontend/src/lib/navigation.ts | 4 +- frontend/src/views/Dienste.test.tsx | 138 +++++++++++ frontend/src/views/Dienste.tsx | 348 ++++++++++++++++++++++------ 16 files changed, 1314 insertions(+), 201 deletions(-) create mode 100644 backend/services/dienste.py create mode 100644 backend/tests/test_dienste.py create mode 100644 frontend/src/lib/dienste.test.ts create mode 100644 frontend/src/lib/dienste.ts create mode 100644 frontend/src/views/Dienste.test.tsx diff --git a/backend/routers/system.py b/backend/routers/system.py index 6d8358c..12552bf 100644 --- a/backend/routers/system.py +++ b/backend/routers/system.py @@ -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") diff --git a/backend/services/dienste.py b/backend/services/dienste.py new file mode 100644 index 0000000..2b70f95 --- /dev/null +++ b/backend/services/dienste.py @@ -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} diff --git a/backend/services/waechter.py b/backend/services/waechter.py index 5d6177b..e6b65c4 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -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.""" diff --git a/backend/tests/test_dienste.py b/backend/tests/test_dienste.py new file mode 100644 index 0000000..f377cbb --- /dev/null +++ b/backend/tests/test_dienste.py @@ -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" diff --git a/docs/ARCHITEKTUR.md b/docs/ARCHITEKTUR.md index 1708ea6..321737b 100644 --- a/docs/ARCHITEKTUR.md +++ b/docs/ARCHITEKTUR.md @@ -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` | diff --git a/docs/BEDIENUNG.md b/docs/BEDIENUNG.md index 40c7b53..a52c251 100644 --- a/docs/BEDIENUNG.md +++ b/docs/BEDIENUNG.md @@ -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 diff --git a/frontend/src/app/MenueSchublade.tsx b/frontend/src/app/MenueSchublade.tsx index 409568a..11fa360 100644 --- a/frontend/src/app/MenueSchublade.tsx +++ b/frontend/src/app/MenueSchublade.tsx @@ -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 ( !o && onSchliessen()}> Bereiche und Seiten - + ) diff --git a/frontend/src/app/Seitenleiste.tsx b/frontend/src/app/Seitenleiste.tsx index c4136d8..4a6d802 100644 --- a/frontend/src/app/Seitenleiste.tsx +++ b/frontend/src/app/Seitenleiste.tsx @@ -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,19 +96,11 @@ export function Seitenleiste({ onDienste, onGewaehlt }: { {SEITEN.filter((s) => s.ort === b.id).map((s) => ( ))} - {b.id === "box" && ( - <> - - {hermes && ( - - - Hermes-Dashboard - - )} - + {b.id === "box" && hermes && ( + + + Hermes-Dashboard + )} ) diff --git a/frontend/src/app/Shell.tsx b/frontend/src/app/Shell.tsx index 0f960d9..e3c0b34 100644 --- a/frontend/src/app/Shell.tsx +++ b/frontend/src/app/Shell.tsx @@ -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 = { uebersicht: "text-bernstein", @@ -58,8 +56,7 @@ const ORT_FARBE: Record = { export function Shell() { useStrom() - const [schublade, setSchublade] = useState(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. */}
@@ -75,7 +72,7 @@ export function Shell() {
- {schublade === "menue" && ( - setSchublade("dienste")} onSchliessen={zu} /> - )} - {schublade === "dienste" && } + {menue && setMenue(false)} />}
diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index fab85fb..5aa23af 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -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", diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index 5b8c44d..a974974 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -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("/api/system/services"), + refetchInterval: 10_000, + }) // --- Aktionen ---------------------------------------------------------------- diff --git a/frontend/src/lib/dienste.test.ts b/frontend/src/lib/dienste.test.ts new file mode 100644 index 0000000..bea31ba --- /dev/null +++ b/frontend/src/lib/dienste.test.ts @@ -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 => ({ ...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", + }) + }) +}) diff --git a/frontend/src/lib/dienste.ts b/frontend/src/lib/dienste.ts new file mode 100644 index 0000000..5cdbc73 --- /dev/null +++ b/frontend/src/lib/dienste.ts @@ -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 = { + 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 = { + 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 = { + "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"}.` +} diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index 774b74e..7d48cea 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -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" }, diff --git a/frontend/src/views/Dienste.test.tsx b/frontend/src/views/Dienste.test.tsx new file mode 100644 index 0000000..3206659 --- /dev/null +++ b/frontend/src/views/Dienste.test.tsx @@ -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()), + 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( + + + , + ) +} + +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() + }) +}) diff --git a/frontend/src/views/Dienste.tsx b/frontend/src/views/Dienste.tsx index 378c8de..c1100a5 100644 --- a/frontend/src/views/Dienste.tsx +++ b/frontend/src/views/Dienste.tsx @@ -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 {children} +} + +/** 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 ( + + {children} + {anteil !== null && ( + + + + )} + + ) +} /** 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(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 ( -
+    
       {text}
     
) } -/** Dienste der Box mit Protokoll und Neustart — die „Log-Ansicht der Dienste“. */ -export function Dienste({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) { +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 ( +
+
+ + + {d.name} + + {d.unit} + {d.scope === "system" ? " · Systemdienst" : ""} + + +
+ + Zustand + {z.wort} + {zusatz && {zusatz}} + {/* Ohne Tabelle stehen die Neustarts hier — und nur, wenn es welche gab. */} + {neustarts > 0 && ( + {neustarts === 1 ? "1 Neustart" : `${neustarts} Neustarts`} + )} + + + CPU + {d.cpu_prozent == null + ? – + : {prozent(d.cpu_prozent)}} + + + Speicher + {d.speicher_bytes == null ? ( + – + ) : ( + + {groesse(d.speicher_bytes + (d.grafik_bytes ?? 0))} + {anteil !== null && · {prozent(anteil)}} + + )} + {d.speicher_bytes != null && !!d.grafik_bytes && ( + davon {groesse(d.grafik_bytes)} Modelle + )} + + + Läuft seit + {dauer(d.laeuft_seit_s)} + {d.laeuft_seit_s != null && gestartet {zeitpunkt(d.laeuft_seit_s)}} + + + 0 ? "text-bernstein" : "text-text-3")}> + {d.neustarts ?? "–"} + + + {/* Am Handy unter den Zahlen; ab sm rechts neben dem Namen, ab xl in der letzten Spalte. */} + + + + + {offen && ( +
+ +
+ )} +
+ ) +} + +function Tabelle({ stand, gewaehlt, startet, onProtokoll, onFrage }: { + stand: DiensteStand + gewaehlt: string | null + startet: string | null + onProtokoll: (unit: string) => void + onFrage: (d: Dienst) => void +}) { + return ( +
+ + {sortiert(stand.services).map((d) => ( + onProtokoll(d.unit)} + onFrage={() => onFrage(d)} + /> + ))} +
+ ) +} + +/** 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 ( + + {stand > 0 && Stand {uhrzeit.format(stand)}} + {probleme > 0 ? ( + {probleme === 1 ? "1 Problem" : `${probleme} Probleme`} + ) : wechsel.length > 0 ? ( + {wechsel.some((d) => zustandVon(d) === "startet") ? "Startet gerade" : "Stoppt gerade"} + ) : ( + Alles in Ordnung + )} + + ) +} + +/** 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(null) - const [bestaetigen, setBestaetigen] = useState(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(null) + const [fragt, setFragt] = useState(false) const [startet, setStartet] = useState(null) - async function neustarten(d: Dienst) { + async function starten(d: Dienst) { if (startet) return - setBestaetigen(null) setStartet(d.unit) try { const e = await post("/api/maintenance/restart", { service: d.unit }) - if (e.ok) melden("erfolg", `${d.name} ${d.schlaeft ? "wird geweckt" : "startet neu"}.`) + if (e.ok) melden("erfolg", erfolgText(d)) else melden("fehler", `${d.name} ließ sich nicht starten: ${fehlerText(e)}`) - qc.invalidateQueries({ queryKey: ["dienste"] }) } catch (f) { - melden("fehler", (f as Error).message) + // 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] }) } } - let inhalt - if (dienste.isPending) { - inhalt =

Dienste werden gelesen …

- } else if (dienste.isError && !dienste.data) { - inhalt =

Die Dienste lassen sich gerade nicht lesen: {dienste.error.message}

- } else if (!dienste.data?.services.length) { - inhalt =

Keine Dienste gefunden.

- } else { - inhalt = ( -
    - {dienste.data.services.map((d) => ( -
  • - - - - {d.name} - - {d.unit} · {d.ok ? "läuft" : d.schlaeft ? "schläft (abgeschaltet)" : "antwortet nicht"} - - - - - - {bestaetigen === d.unit ? ( - <> - - - - ) : ( - - )} - - {gewaehlt === d.unit && } -
  • - ))} -
- ) + if (dienste.isPending) return + // Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen. + if (!dienste.data) { + return } + 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 ( - !o && onSchliessen()}> - - - Dienste und Protokolle - Welcher Dienst läuft, was er zuletzt geschrieben hat. - - {inhalt} - - + }> +

+ 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. +

+ {stand.services.length === 0 ? ( +

Keine Dienste gefunden.

+ ) : ( + setGewaehlt(gewaehlt === unit ? null : unit)} + onFrage={(d) => { + setFrage(d) + setFragt(true) + }} + /> + )} +

+ 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. +

+ { + setFragt(false) + if (frage) void starten(frage) + }} + onNein={() => setFragt(false)} + /> +
) }