dienste: eigene Seite /box/dienste mit Tabelle wie die Geräte im Homelab
Die Schublade „Dienste und Protokolle" war eine schlichte Liste (Punkt, Name, „läuft", zwei Knöpfe). Jetzt eine Seite im Bereich KI-Box, gebaut wie die Geräte-Tabelle im Homelab-Cockpit: je Dienst Zustand in Worten (läuft, schläft, gestoppt, gescheitert, antwortet nicht), CPU, Speicher, „Läuft seit" und Neustarts; Probleme oben. „Protokoll" klappt unter der Zeile auf (das Neueste unten im Blick), Neu starten / Starten / Wecken fragen vorher nach. Entscheidungen: - Seite statt Schublade: die Tabelle braucht die Breite, und der Eintrag steht jetzt wie die anderen Seiten in der Seitenleiste. Schublade samt onDienste-Weg entfernt. - Speicher = MemoryCurrent als Zahl mit Einheit; der Balken zeigt den echten Anteil am Arbeitsspeicher der Box, keine erfundenen Prozente. Bei der Engine zählt der Grafikspeicher (GTT) mit — dort liegen die Modelle, und systemd rechnet ihn keinem Dienst zu (25.09.: 4,3 GB laut systemd, 27,6 GB Modelle). - CPU = Anteil an allen Kernen aus CPUUsageNSec zwischen zwei Abfragen (die Seite fragt alle 10 s). Fehlt eine brauchbare Probe, misst die Box einmal 0,5 s nach. Ruhiges Blau ohne Warnfarben: ein Dienst hat keine Grenze, was zählt, zeigt der Zustand. - Neustarts = NRestarts (automatische Neustarts nach Absturz), am Handy nur, wenn es welche gab; mehr als 0 rückt den Dienst nach oben. - Backend: Liste und Kennzahlen in services/dienste.py (ein systemctl show je Bereich, auf Windows harmlos leer), Router dünn. Die Felder der MCP-Werkzeuge bleiben; waechter.dienst_zustand war nur für die alte Liste da und ist weg. - Tabelle ab xl, Knöpfe ab 1400 px nebeneinander, darunter Karten mit zwei bzw. vier Spalten; bis 320 px keine waagrechte Scrollleiste. Tests: backend/tests/test_dienste.py (echte systemctl-show-Ausgabe der Box), frontend lib/dienste.test.ts und views/Dienste.test.tsx. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0df593c05a
commit
cf01a5bb85
@@ -6,18 +6,12 @@ zurückgegeben statt zu crashen.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import httpx
|
||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from services import backup as backup_svc
|
||||
from services import maintenance, waechter
|
||||
from services.agent import agent_status
|
||||
from services.gateway import gateway_reachable
|
||||
from services.llamaswap import engine_reachable, list_models
|
||||
from services import dienste, maintenance
|
||||
from services.llamaswap import list_models
|
||||
from services.pricing import compute_savings
|
||||
from services.system import system_status
|
||||
from services.token_stats import get_stats
|
||||
@@ -31,76 +25,12 @@ def status() -> dict:
|
||||
return system_status()
|
||||
|
||||
|
||||
def _voice_reachable() -> bool:
|
||||
try:
|
||||
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _user_unit_active(unit: str) -> bool:
|
||||
"""systemd-User-Unit ohne eigenen HTTP-Port (z. B. mc2-steward) — Zustand via is-active.
|
||||
Auf Windows/Dev schlägt das harmlos fehl (False)."""
|
||||
if os.name != "posix":
|
||||
return False
|
||||
try:
|
||||
r = subprocess.run(["systemctl", "--user", "is-active", unit],
|
||||
capture_output=True, text=True, timeout=3)
|
||||
return r.stdout.strip() == "active"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/system/services")
|
||||
def services() -> dict:
|
||||
"""Aggregierte Erreichbarkeit aller Stack-Dienste — die EINE Quelle für Cockpit-Kachel,
|
||||
Schubladen-Liste und Log-Auswahl (Frontend rendert diese Liste, keine UI-Kopie mehr).
|
||||
`scope`: user|system (Restart-Weg), `unit`: echter systemd-Name (Restart/Logs/Matching)."""
|
||||
a = agent_status()
|
||||
gw_url = f"{GATEWAY_URL}/v1"
|
||||
rows = [
|
||||
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL,
|
||||
"ok": engine_reachable(), "scope": "system"},
|
||||
]
|
||||
# Seit UMBAU v3 P1 ist der LLM-Gateway ein EIGENER Prozess — ohne diese Zeile war er
|
||||
# in der Dienste-Ampel unsichtbar (Kachel "6/6 grün" bei totem Denkpfad).
|
||||
if V1_UPSTREAM:
|
||||
rows.append({"name": "LLM-Gateway (Denkpfad)", "unit": "mc2-gateway", "url": V1_UPSTREAM,
|
||||
"ok": gateway_reachable(), "scope": "user"})
|
||||
# MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich;
|
||||
# die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel.
|
||||
rows.append({"name": "Box-Wart (MC2)", "unit": "mission-control-2", "url": gw_url,
|
||||
"ok": True, "scope": "user"})
|
||||
else:
|
||||
rows.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url,
|
||||
"ok": gateway_reachable(), "scope": "user"})
|
||||
rows += [
|
||||
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "",
|
||||
"ok": _user_unit_active("mc2-steward"), "scope": "user"},
|
||||
{"name": "Hermes-Gateway (Agent & Gedächtnis)", "unit": "hermes-gateway", "url": HERMES_API_URL,
|
||||
"ok": a["gateway_reachable"], "scope": "user"},
|
||||
{"name": "Hermes-Dashboard", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
|
||||
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
||||
{"name": "Spracherkennung (Desktop-Lucy)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
||||
"ok": _voice_reachable(), "scope": "user"},
|
||||
# Lucys Stimme (pocket-tts) spricht Telegram und Lucy-Desktop — bis 09/2026 fehlte
|
||||
# sie hier; die Box-Konsole ist mit dem Box-Wart-Umbau aus MC2 raus.
|
||||
{"name": "Lucys Stimme", "unit": "lucy-stimme", "url": "",
|
||||
"ok": _user_unit_active("lucy-stimme"), "scope": "user"},
|
||||
]
|
||||
# Bewusst abgeschaltete Dienste (24.09.2026: Spracherkennung bis zur Android-App) sind kein
|
||||
# Fehler — die Oberfläche zeigt sie als „schläft“ mit Knopf zum Wecken statt rot.
|
||||
for row in rows:
|
||||
row["schlaeft"] = (not row["ok"] and row["scope"] == "user"
|
||||
and waechter.schlaeft(waechter.dienst_zustand(row["unit"])))
|
||||
return {
|
||||
"services": rows,
|
||||
"links": {
|
||||
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
|
||||
"gateway": gw_url,
|
||||
"hermes_ui": a["hermes_ui_url"],
|
||||
},
|
||||
}
|
||||
"""Alle Dienste der Box mit Erreichbarkeit, Zustand und Kennzahlen (Speicher, CPU, Laufzeit,
|
||||
Neustarts) — die EINE Quelle für die Seite „Dienste und Protokolle“ und die MCP-Werkzeuge.
|
||||
Logik in services/dienste.py; `scope`: user|system (Restart-Weg), `unit`: echter systemd-Name."""
|
||||
return dienste.liste()
|
||||
|
||||
|
||||
@router.post("/system/backup")
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Die Dienste der KI-Box für die Seite „Dienste und Protokolle“ (seit 25.09.2026 mit Kennzahlen).
|
||||
|
||||
Je Dienst steht hier, ob er antwortet (HTTP, wie bisher), in welchem Zustand systemd ihn sieht und was er gerade
|
||||
braucht:
|
||||
|
||||
speicher_bytes MemoryCurrent — was systemd der Unit zurechnet (alle Prozesse, samt Datei-Zwischenspeicher)
|
||||
grafik_bytes nur die Engine: belegter Grafikspeicher (GTT) der Box — dort liegen die geladenen Modelle, und
|
||||
den rechnet systemd keinem Dienst zu (25.09.: Engine 4,3 GB laut systemd, Modelle 27,6 GB GTT)
|
||||
cpu_prozent Anteil an allen Kernen der Box aus CPUUsageNSec zwischen zwei Abfragen (100 % = alle Kerne voll)
|
||||
laeuft_seit_s seit dem letzten Start (ActiveEnterTimestampMonotonic)
|
||||
aus_seit_s seit wann ein gestoppter oder gescheiterter Dienst aus ist (InactiveEnterTimestampMonotonic)
|
||||
neustarts NRestarts — wie oft systemd ihn nach einem Absturz von selbst neu gestartet hat
|
||||
|
||||
Die Zahlen kommen mit EINEM `systemctl show` je Bereich (User- und System-Dienste). Auf Windows (Entwicklung) oder
|
||||
ohne systemd fehlen sie einfach (None); die Liste steht trotzdem, der Zustand dann allein nach „antwortet“.
|
||||
|
||||
Bis 25.09.2026 stand die Liste im Router (routers/system.py). Die Felder name, unit, url, ok, scope und schlaeft sind
|
||||
geblieben — die MCP-Werkzeuge (mcp/mcp_mc.py, mcp/mcp_voice.py) lesen sie.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
||||
|
||||
from services import system, waechter
|
||||
from services.agent import agent_status
|
||||
from services.gateway import gateway_reachable
|
||||
from services.llamaswap import engine_reachable
|
||||
|
||||
EIGENSCHAFTEN = (
|
||||
"Id", "LoadState", "ActiveState", "UnitFileState", "MemoryCurrent", "CPUUsageNSec",
|
||||
"ActiveEnterTimestampMonotonic", "InactiveEnterTimestampMonotonic", "NRestarts",
|
||||
)
|
||||
AKTIV = ("active", "reloading", "refreshing")
|
||||
|
||||
# Nur die Engine rechnet auf der Grafik (Stand 25.09.2026: alle GTT-Belegung gehört den llama-server-Prozessen in
|
||||
# llama-swap.service). Deshalb bekommt allein sie den Grafikspeicher der Box zugeschrieben.
|
||||
GRAFIK_DIENST = "llama-swap"
|
||||
|
||||
CPU_MIN_S = 2.0 # kürzere Abstände zweier Abfragen sind Rauschen: dann gilt die letzte Messung weiter
|
||||
CPU_MAX_S = 120.0 # eine ältere Probe sagt nichts mehr über „gerade“: neu ansetzen
|
||||
CPU_KURZ_S = 0.5 # erste Abfrage ohne brauchbare Probe: zweite Probe nach einer halben Sekunde
|
||||
|
||||
LAUFEND = ("laeuft", "antwortet_nicht", "startet", "stoppt")
|
||||
|
||||
|
||||
# --- systemctl show lesen ------------------------------------------------------------------------------------------
|
||||
|
||||
def lies_show(text: str) -> list[dict[str, str]]:
|
||||
"""Die Ausgabe von `systemctl show a b c -p …`: je Unit ein Block „Schlüssel=Wert“, Blöcke durch Leerzeilen
|
||||
getrennt, in der Reihenfolge der Units."""
|
||||
bloecke: list[dict[str, str]] = []
|
||||
block: dict[str, str] = {}
|
||||
for zeile in text.splitlines():
|
||||
if not zeile.strip():
|
||||
if block:
|
||||
bloecke.append(block)
|
||||
block = {}
|
||||
continue
|
||||
if "=" in zeile:
|
||||
schluessel, wert = zeile.split("=", 1)
|
||||
block[schluessel.strip()] = wert.strip()
|
||||
if block:
|
||||
bloecke.append(block)
|
||||
return bloecke
|
||||
|
||||
|
||||
def zuordnen(bloecke: list[dict[str, str]], units: list[str]) -> dict[str, dict[str, str]]:
|
||||
"""Blöcke den Units zuordnen: nach Reihenfolge, wenn die Zahl stimmt (so kommen sie von systemctl), sonst nach Id."""
|
||||
if len(bloecke) == len(units):
|
||||
return dict(zip(units, bloecke, strict=True))
|
||||
return {b["Id"].removesuffix(".service"): b for b in bloecke if b.get("Id")}
|
||||
|
||||
|
||||
def _systemctl_show(units: list[str], system_dienst: bool) -> dict[str, dict[str, str]]:
|
||||
"""Eigenschaften mehrerer Units mit einem Aufruf. Auf Windows (Entwicklung) oder ohne systemd: leer."""
|
||||
if not units:
|
||||
return {}
|
||||
cmd = ["systemctl"] if system_dienst else ["systemctl", "--user"]
|
||||
cmd += ["show", *units, "-p", ",".join(EIGENSCHAFTEN)]
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
|
||||
except Exception:
|
||||
return {}
|
||||
return zuordnen(lies_show(out), units)
|
||||
|
||||
|
||||
def zahl(wert: str | None) -> int | None:
|
||||
"""Ganze Zahl aus systemd — „[not set]“, „infinity“ und das alte UINT64_MAX („nicht gezählt“) werden None."""
|
||||
try:
|
||||
n = int(wert or "")
|
||||
except ValueError:
|
||||
return None
|
||||
return n if 0 <= n < 2**63 else None
|
||||
|
||||
|
||||
# --- Auswertung (rein, getestet) -----------------------------------------------------------------------------------
|
||||
|
||||
def zustand(e: dict[str, str], ok: bool, schlaeft: bool) -> str:
|
||||
"""Der Zustand in einem Wort: laeuft | antwortet_nicht | startet | stoppt | gestoppt | gescheitert | schlaeft.
|
||||
Ohne systemd-Angaben (Windows, Unit unbekannt) entscheidet allein, ob der Dienst antwortet."""
|
||||
if schlaeft:
|
||||
return "schlaeft"
|
||||
aktiv = e.get("ActiveState", "")
|
||||
if not aktiv or e.get("LoadState") in ("not-found", ""):
|
||||
return "laeuft" if ok else "antwortet_nicht"
|
||||
if aktiv == "failed":
|
||||
return "gescheitert"
|
||||
if aktiv == "activating":
|
||||
return "startet"
|
||||
if aktiv == "deactivating":
|
||||
return "stoppt"
|
||||
if aktiv == "inactive":
|
||||
return "gestoppt"
|
||||
return "laeuft" if ok else "antwortet_nicht" # active, reloading, refreshing
|
||||
|
||||
|
||||
def kennzahlen(e: dict[str, str], jetzt_s: float) -> dict:
|
||||
"""Speicher, Laufzeit und Neustarts einer Unit. jetzt_s = time.monotonic() beim Lesen — systemd führt seine
|
||||
…Monotonic-Zeitstempel auf derselben Uhr (CLOCK_MONOTONIC), in Mikrosekunden."""
|
||||
aktiv = e.get("ActiveState")
|
||||
an = zahl(e.get("ActiveEnterTimestampMonotonic"))
|
||||
aus = zahl(e.get("InactiveEnterTimestampMonotonic"))
|
||||
return {
|
||||
"speicher_bytes": zahl(e.get("MemoryCurrent")),
|
||||
"laeuft_seit_s": max(0, int(jetzt_s - an / 1e6)) if aktiv in AKTIV and an else None,
|
||||
"aus_seit_s": max(0, int(jetzt_s - aus / 1e6)) if aktiv in ("inactive", "failed") and aus else None,
|
||||
"neustarts": zahl(e.get("NRestarts")),
|
||||
}
|
||||
|
||||
|
||||
def cpu_anteil(vorher: tuple[float, int], jetzt: tuple[float, int], kerne: int) -> float | None:
|
||||
"""Anteil an allen Kernen in Prozent zwischen zwei Proben (Zeitpunkt in s, CPU-Zeit der Unit in ns). None, wenn
|
||||
keine Zeit verging oder der Zähler kleiner wurde (die Unit ist dazwischen neu gestartet)."""
|
||||
dauer = jetzt[0] - vorher[0]
|
||||
if dauer <= 0 or jetzt[1] < vorher[1] or kerne < 1:
|
||||
return None
|
||||
return round(min(100.0, (jetzt[1] - vorher[1]) / 1e9 / dauer / kerne * 100), 1)
|
||||
|
||||
|
||||
class CpuMessung:
|
||||
"""Merkt sich je Unit die letzte Probe von CPUUsageNSec und den daraus gemessenen Anteil. Fragt die Oberfläche alle
|
||||
paar Sekunden, ist das die Auslastung seit der letzten Abfrage — wie bei top, nur je Dienst."""
|
||||
|
||||
def __init__(self, kerne: int):
|
||||
self.kerne = max(1, kerne)
|
||||
self._proben: dict[str, tuple[float, int]] = {}
|
||||
self._werte: dict[str, float] = {}
|
||||
self._sperre = threading.Lock()
|
||||
|
||||
def probe(self, unit: str, t: float, ns: int | None) -> None:
|
||||
with self._sperre:
|
||||
if ns is None: # läuft nicht, oder systemd zählt ihre CPU-Zeit nicht
|
||||
self._proben.pop(unit, None)
|
||||
self._werte.pop(unit, None)
|
||||
return
|
||||
vorher = self._proben.get(unit)
|
||||
if vorher and unit in self._werte and t - vorher[0] < CPU_MIN_S:
|
||||
return # zu kurz nach der letzten Messung: deren Wert gilt weiter
|
||||
wert = cpu_anteil(vorher, (t, ns), self.kerne) if vorher and t - vorher[0] <= CPU_MAX_S else None
|
||||
self._proben[unit] = (t, ns)
|
||||
if wert is None:
|
||||
self._werte.pop(unit, None)
|
||||
else:
|
||||
self._werte[unit] = wert
|
||||
|
||||
def wert(self, unit: str) -> float | None:
|
||||
with self._sperre:
|
||||
return self._werte.get(unit)
|
||||
|
||||
|
||||
_cpu = CpuMessung(os.cpu_count() or 1)
|
||||
_uhr = time.monotonic # dieselbe Uhr wie systemds …Monotonic-Zeitstempel (Tests setzen eine eigene)
|
||||
|
||||
|
||||
# --- Die Liste -----------------------------------------------------------------------------------------------------
|
||||
|
||||
def _voice_antwortet() -> bool:
|
||||
try:
|
||||
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _dienste() -> tuple[list[dict], dict]:
|
||||
"""Die Dienste in ihrer Reihenfolge mit HTTP-Erreichbarkeit (ok) — None heißt: systemd entscheidet (Dienste ohne
|
||||
eigenen HTTP-Port). `scope`: user|system (Weg für Neustart und Protokoll), `unit`: echter systemd-Name."""
|
||||
a = agent_status()
|
||||
gw_url = f"{GATEWAY_URL}/v1"
|
||||
zeilen: list[dict] = [
|
||||
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL,
|
||||
"ok": engine_reachable(), "scope": "system"},
|
||||
]
|
||||
# Seit UMBAU v3 P1 ist der LLM-Gateway ein EIGENER Prozess — ohne diese Zeile war er
|
||||
# in der Dienste-Ampel unsichtbar (Kachel "6/6 grün" bei totem Denkpfad).
|
||||
if V1_UPSTREAM:
|
||||
zeilen.append({"name": "LLM-Gateway (Denkpfad)", "unit": "mc2-gateway", "url": V1_UPSTREAM,
|
||||
"ok": gateway_reachable(), "scope": "user"})
|
||||
# MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich;
|
||||
# die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel.
|
||||
zeilen.append({"name": "Box-Wart (MC2)", "unit": "mission-control-2", "url": gw_url,
|
||||
"ok": True, "scope": "user"})
|
||||
else:
|
||||
zeilen.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url,
|
||||
"ok": gateway_reachable(), "scope": "user"})
|
||||
zeilen += [
|
||||
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "", "ok": None, "scope": "user"},
|
||||
{"name": "Hermes-Gateway (Agent & Gedächtnis)", "unit": "hermes-gateway", "url": HERMES_API_URL,
|
||||
"ok": a["gateway_reachable"], "scope": "user"},
|
||||
{"name": "Hermes-Dashboard", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
|
||||
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
||||
{"name": "Spracherkennung (Desktop-Lucy)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
||||
"ok": _voice_antwortet(), "scope": "user"},
|
||||
# Lucys Stimme (pocket-tts) spricht Telegram und Lucy-Desktop — bis 09/2026 fehlte
|
||||
# sie hier; die Box-Konsole ist mit dem Box-Wart-Umbau aus MC2 raus.
|
||||
{"name": "Lucys Stimme", "unit": "lucy-stimme", "url": "", "ok": None, "scope": "user"},
|
||||
]
|
||||
links = {"engine_ui": f"{LLAMA_SWAP_URL}/ui", "gateway": gw_url, "hermes_ui": a["hermes_ui_url"]}
|
||||
return zeilen, links
|
||||
|
||||
|
||||
def _lesen(user: list[str], system_units: list[str]) -> dict[str, tuple[float, dict[str, str]]]:
|
||||
"""systemd-Stand je Unit samt dem Zeitpunkt, zu dem er gelesen wurde."""
|
||||
stand: dict[str, tuple[float, dict[str, str]]] = {}
|
||||
for units, system_dienst in ((user, False), (system_units, True)):
|
||||
gelesen = _systemctl_show(units, system_dienst)
|
||||
t = _uhr()
|
||||
for unit, e in gelesen.items():
|
||||
stand[unit] = (t, e)
|
||||
return stand
|
||||
|
||||
|
||||
def _messen(user: list[str], system_units: list[str]) -> dict[str, tuple[float, dict[str, str]]]:
|
||||
"""Einmal lesen und die CPU-Proben nachführen. Fehlt einem laufenden Dienst noch ein Wert (erste Abfrage nach dem
|
||||
Start von MC2, Dienst neu gestartet, lange niemand geschaut), nach einer halben Sekunde ein zweites Mal."""
|
||||
def nachfuehren(stand: dict[str, tuple[float, dict[str, str]]]) -> bool:
|
||||
fehlt = False
|
||||
for unit, (t, e) in stand.items():
|
||||
# Gestoppte Units behalten den Zähler ihres letzten Laufs (25.09.: projekte-sync, mc2-backup) — der
|
||||
# sagt nichts über „gerade“.
|
||||
ns = zahl(e.get("CPUUsageNSec")) if e.get("ActiveState") in (*AKTIV, "activating", "deactivating") else None
|
||||
_cpu.probe(unit, t, ns)
|
||||
fehlt = fehlt or (ns is not None and _cpu.wert(unit) is None)
|
||||
return fehlt
|
||||
|
||||
stand = _lesen(user, system_units)
|
||||
if nachfuehren(stand):
|
||||
time.sleep(CPU_KURZ_S)
|
||||
stand = _lesen(user, system_units)
|
||||
nachfuehren(stand)
|
||||
return stand
|
||||
|
||||
|
||||
def _speicher_gesamt() -> int | None:
|
||||
try:
|
||||
return psutil.virtual_memory().total
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def liste() -> dict:
|
||||
"""Alle Dienste der Box mit Zustand und Kennzahlen — die EINE Quelle für die Seite „Dienste und Protokolle“ und
|
||||
die MCP-Werkzeuge. speicher_gesamt (Bytes) und kerne sind der Bezug für die Balken der Oberfläche."""
|
||||
zeilen, links = _dienste()
|
||||
stand = _messen([z["unit"] for z in zeilen if z["scope"] == "user"],
|
||||
[z["unit"] for z in zeilen if z["scope"] == "system"])
|
||||
for z in zeilen:
|
||||
t, e = stand.get(z["unit"], (0.0, {}))
|
||||
if z["ok"] is None:
|
||||
z["ok"] = e.get("ActiveState") == "active"
|
||||
# Bewusst abgeschaltete Dienste (24.09.2026: Spracherkennung bis zur Android-App) sind kein
|
||||
# Fehler — die Oberfläche zeigt sie als „schläft“ mit Knopf zum Wecken statt rot.
|
||||
z["schlaeft"] = not z["ok"] and z["scope"] == "user" and waechter.schlaeft(e)
|
||||
z["zustand"] = zustand(e, z["ok"], z["schlaeft"])
|
||||
z.update(kennzahlen(e, t))
|
||||
z["cpu_prozent"] = _cpu.wert(z["unit"]) if z["zustand"] in LAUFEND else None
|
||||
z["grafik_bytes"] = None
|
||||
if z["unit"] == GRAFIK_DIENST and z["speicher_bytes"] is not None:
|
||||
z["grafik_bytes"] = (system._gpu_sysfs() or {}).get("gtt_used")
|
||||
return {"services": zeilen, "links": links, "speicher_gesamt": _speicher_gesamt(), "kerne": _cpu.kerne}
|
||||
@@ -196,11 +196,6 @@ def _update_laeuft() -> bool:
|
||||
|
||||
# --- Prüfungen ------------------------------------------------------------------
|
||||
|
||||
def dienst_zustand(name: str, system: bool = False) -> dict[str, str]:
|
||||
"""systemd-Zustand einer Unit für andere Module (Dienste-Liste der Oberfläche)."""
|
||||
return _systemctl_show(name, system)
|
||||
|
||||
|
||||
def schlaeft(zustand: dict[str, str]) -> bool:
|
||||
"""Abgeschaltet und nicht mehr für den Autostart eingetragen = bewusst schlafen gelegt
|
||||
(24.09.2026: Konsole, Spracherkennung). Das ist kein Fehler; geweckt wird per Knopf."""
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Tests für die Dienste-Liste mit Kennzahlen (services/dienste.py) — mit echter `systemctl show`-Ausgabe der Box
|
||||
vom 25.09.2026 (/proc/uptime stand bei 80147,36 s)."""
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from services import dienste
|
||||
|
||||
JETZT = 80147.36
|
||||
|
||||
# systemctl --user show mc2-gateway mission-control-2 mc2-steward hermes-gateway hermes-builtin-ui voice-service
|
||||
# lucy-stimme -p Id,LoadState,ActiveState,UnitFileState,MemoryCurrent,CPUUsageNSec,ActiveEnterTimestampMonotonic,
|
||||
# InactiveEnterTimestampMonotonic,NRestarts
|
||||
USER_AUSGABE = """\
|
||||
Id=mc2-gateway.service
|
||||
LoadState=loaded
|
||||
ActiveState=active
|
||||
UnitFileState=enabled
|
||||
ActiveEnterTimestampMonotonic=79813970006
|
||||
InactiveEnterTimestampMonotonic=79813968344
|
||||
NRestarts=0
|
||||
MemoryCurrent=45731840
|
||||
CPUUsageNSec=1568208000
|
||||
|
||||
Id=mission-control-2.service
|
||||
LoadState=loaded
|
||||
ActiveState=active
|
||||
UnitFileState=enabled
|
||||
ActiveEnterTimestampMonotonic=79816961421
|
||||
InactiveEnterTimestampMonotonic=79816959875
|
||||
NRestarts=0
|
||||
MemoryCurrent=198352896
|
||||
CPUUsageNSec=5660312000
|
||||
|
||||
Id=mc2-steward.service
|
||||
LoadState=loaded
|
||||
ActiveState=active
|
||||
UnitFileState=enabled
|
||||
ActiveEnterTimestampMonotonic=79813795101
|
||||
InactiveEnterTimestampMonotonic=79813794078
|
||||
NRestarts=0
|
||||
MemoryCurrent=56500224
|
||||
CPUUsageNSec=1002999000
|
||||
|
||||
Id=hermes-gateway.service
|
||||
LoadState=loaded
|
||||
ActiveState=active
|
||||
UnitFileState=enabled
|
||||
ActiveEnterTimestampMonotonic=76113569939
|
||||
InactiveEnterTimestampMonotonic=76113568536
|
||||
NRestarts=0
|
||||
MemoryCurrent=421068800
|
||||
CPUUsageNSec=45597050000
|
||||
|
||||
Id=hermes-builtin-ui.service
|
||||
LoadState=loaded
|
||||
ActiveState=active
|
||||
UnitFileState=enabled
|
||||
ActiveEnterTimestampMonotonic=76110768028
|
||||
InactiveEnterTimestampMonotonic=76110766393
|
||||
NRestarts=0
|
||||
MemoryCurrent=162058240
|
||||
CPUUsageNSec=20446657000
|
||||
|
||||
Id=voice-service.service
|
||||
LoadState=loaded
|
||||
ActiveState=inactive
|
||||
UnitFileState=disabled
|
||||
ActiveEnterTimestampMonotonic=0
|
||||
InactiveEnterTimestampMonotonic=0
|
||||
NRestarts=0
|
||||
MemoryCurrent=[not set]
|
||||
CPUUsageNSec=[not set]
|
||||
|
||||
Id=lucy-stimme.service
|
||||
LoadState=loaded
|
||||
ActiveState=active
|
||||
UnitFileState=enabled
|
||||
ActiveEnterTimestampMonotonic=10066410
|
||||
InactiveEnterTimestampMonotonic=0
|
||||
NRestarts=0
|
||||
MemoryCurrent=7500505088
|
||||
CPUUsageNSec=502624087000
|
||||
"""
|
||||
|
||||
# systemctl show llama-swap -p … (System-Dienst)
|
||||
SYSTEM_AUSGABE = """\
|
||||
Id=llama-swap.service
|
||||
LoadState=loaded
|
||||
ActiveState=active
|
||||
UnitFileState=enabled
|
||||
ActiveEnterTimestampMonotonic=74032986745
|
||||
InactiveEnterTimestampMonotonic=74032938183
|
||||
NRestarts=0
|
||||
MemoryCurrent=3722850304
|
||||
CPUUsageNSec=31617381000
|
||||
"""
|
||||
|
||||
USER_UNITS = ["mc2-gateway", "mission-control-2", "mc2-steward", "hermes-gateway", "hermes-builtin-ui",
|
||||
"voice-service", "lucy-stimme"]
|
||||
|
||||
# Ein abgestürzter Dienst, wie systemd ihn nach Restart=always mit zu vielen Fehlversuchen stehen lässt.
|
||||
GESCHEITERT = {
|
||||
"Id": "hermes-gateway.service", "LoadState": "loaded", "ActiveState": "failed", "UnitFileState": "enabled",
|
||||
"ActiveEnterTimestampMonotonic": "76113569939", "InactiveEnterTimestampMonotonic": "80000000000",
|
||||
"NRestarts": "5", "MemoryCurrent": "[not set]", "CPUUsageNSec": "[not set]",
|
||||
}
|
||||
|
||||
|
||||
# --- systemctl show lesen ------------------------------------------------------------------------------------------
|
||||
|
||||
def test_liest_die_bloecke_der_echten_ausgabe_in_der_reihenfolge_der_units():
|
||||
stand = dienste.zuordnen(dienste.lies_show(USER_AUSGABE), USER_UNITS)
|
||||
assert list(stand) == USER_UNITS
|
||||
assert stand["lucy-stimme"]["MemoryCurrent"] == "7500505088"
|
||||
assert stand["voice-service"]["UnitFileState"] == "disabled"
|
||||
|
||||
|
||||
def test_ordnet_nach_id_zu_wenn_ein_block_fehlt():
|
||||
bloecke = dienste.lies_show(SYSTEM_AUSGABE)
|
||||
assert dienste.zuordnen(bloecke, ["llama-swap", "gibt-es-nicht"]) == {"llama-swap": bloecke[0]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("wert", "erwartet"), [
|
||||
("198352896", 198352896), ("0", 0), ("[not set]", None), ("infinity", None), ("", None), (None, None),
|
||||
("18446744073709551615", None), # altes systemd: UINT64_MAX heißt „nicht gezählt“
|
||||
])
|
||||
def test_zahl(wert, erwartet):
|
||||
assert dienste.zahl(wert) == erwartet
|
||||
|
||||
|
||||
# --- Auswertung ----------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_kennzahlen_eines_laufenden_dienstes():
|
||||
mc2 = dienste.zuordnen(dienste.lies_show(USER_AUSGABE), USER_UNITS)["mission-control-2"]
|
||||
assert dienste.kennzahlen(mc2, JETZT) == {
|
||||
"speicher_bytes": 198352896, "laeuft_seit_s": 330, "aus_seit_s": None, "neustarts": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_kennzahlen_eines_schlafenden_und_eines_gescheiterten_dienstes():
|
||||
voice = dienste.zuordnen(dienste.lies_show(USER_AUSGABE), USER_UNITS)["voice-service"]
|
||||
assert dienste.kennzahlen(voice, JETZT) == {
|
||||
"speicher_bytes": None, "laeuft_seit_s": None, "aus_seit_s": None, "neustarts": 0,
|
||||
}
|
||||
k = dienste.kennzahlen(GESCHEITERT, JETZT)
|
||||
assert k["laeuft_seit_s"] is None and k["aus_seit_s"] == 147 and k["neustarts"] == 5
|
||||
|
||||
|
||||
def test_zustand_in_worten():
|
||||
laeuft = {"LoadState": "loaded", "ActiveState": "active"}
|
||||
assert dienste.zustand(laeuft, ok=True, schlaeft=False) == "laeuft"
|
||||
assert dienste.zustand(laeuft, ok=False, schlaeft=False) == "antwortet_nicht"
|
||||
assert dienste.zustand(GESCHEITERT, ok=False, schlaeft=False) == "gescheitert"
|
||||
assert dienste.zustand({**laeuft, "ActiveState": "activating"}, ok=False, schlaeft=False) == "startet"
|
||||
assert dienste.zustand({**laeuft, "ActiveState": "deactivating"}, ok=False, schlaeft=False) == "stoppt"
|
||||
assert dienste.zustand({**laeuft, "ActiveState": "inactive"}, ok=False, schlaeft=False) == "gestoppt"
|
||||
assert dienste.zustand({**laeuft, "ActiveState": "inactive"}, ok=False, schlaeft=True) == "schlaeft"
|
||||
|
||||
|
||||
def test_zustand_ohne_systemd_nur_nach_erreichbarkeit():
|
||||
"""Windows (Entwicklung) oder eine Unit, die systemd nicht kennt: wie vor dem 25.09. nur „antwortet (nicht)“."""
|
||||
assert dienste.zustand({}, ok=True, schlaeft=False) == "laeuft"
|
||||
assert dienste.zustand({}, ok=False, schlaeft=False) == "antwortet_nicht"
|
||||
assert dienste.zustand({"LoadState": "not-found", "ActiveState": "inactive"}, ok=True, schlaeft=False) == "laeuft"
|
||||
|
||||
|
||||
def test_cpu_anteil_bezogen_auf_alle_kerne():
|
||||
assert dienste.cpu_anteil((0.0, 0), (10.0, 3_200_000_000), kerne=32) == 1.0 # 0,32 Kerne von 32
|
||||
assert dienste.cpu_anteil((0.0, 0), (1.0, 64_000_000_000), kerne=32) == 100.0 # mehr geht nicht
|
||||
assert dienste.cpu_anteil((5.0, 900), (5.0, 1000), kerne=32) is None # keine Zeit vergangen
|
||||
assert dienste.cpu_anteil((0.0, 5_000), (10.0, 1_000), kerne=32) is None # Zähler neu (Neustart)
|
||||
|
||||
|
||||
def test_cpu_messung_haelt_den_letzten_wert_bei_zu_kurzem_abstand():
|
||||
m = dienste.CpuMessung(kerne=32)
|
||||
m.probe("hermes-gateway", 100.0, 0)
|
||||
assert m.wert("hermes-gateway") is None # erste Probe: noch nichts zu messen
|
||||
m.probe("hermes-gateway", 100.5, 160_000_000)
|
||||
assert m.wert("hermes-gateway") == 1.0 # 0,16 s CPU in 0,5 s auf 32 Kernen
|
||||
m.probe("hermes-gateway", 101.0, 1_000_000_000)
|
||||
assert m.wert("hermes-gateway") == 1.0 # nur 0,5 s später: Wert bleibt, Probe auch
|
||||
m.probe("hermes-gateway", 110.5, 160_000_000 + 32_000_000_000)
|
||||
assert m.wert("hermes-gateway") == 10.0 # 32 s CPU in 10 s = 3,2 Kerne
|
||||
m.probe("hermes-gateway", 120.5, 5_000)
|
||||
assert m.wert("hermes-gateway") is None # neu gestartet: Zähler fing von vorn an
|
||||
m.probe("hermes-gateway", 121.0, 25_000)
|
||||
assert m.wert("hermes-gateway") == 0.0
|
||||
m.probe("hermes-gateway", 121.0 + dienste.CPU_MAX_S + 1, 50_000)
|
||||
assert m.wert("hermes-gateway") is None # zu lange niemand geschaut: neu ansetzen
|
||||
m.probe("hermes-gateway", 400.0, None)
|
||||
assert m.wert("hermes-gateway") is None # gestoppt
|
||||
|
||||
|
||||
# --- Die ganze Liste -----------------------------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def box(monkeypatch):
|
||||
"""Die Box vom 25.09.: alle Dienste laufen, die Spracherkennung schläft. Die erste Abfrage liest bei JETZT und —
|
||||
kurze zweite Probe — eine halbe Sekunde später, die nächste zehn Sekunden danach."""
|
||||
uhr = iter([JETZT, JETZT, JETZT + 0.5, JETZT + 0.5, JETZT + 10.5, JETZT + 10.5])
|
||||
monkeypatch.setattr(dienste, "_uhr", lambda: next(uhr))
|
||||
monkeypatch.setattr(dienste, "_cpu", dienste.CpuMessung(kerne=32))
|
||||
monkeypatch.setattr(dienste, "CPU_KURZ_S", 0)
|
||||
monkeypatch.setattr(dienste, "V1_UPSTREAM", "http://127.0.0.1:9010")
|
||||
monkeypatch.setattr(dienste, "agent_status", lambda: {
|
||||
"gateway_reachable": True, "hermes_ui_reachable": True, "hermes_ui_url": "/hermes-ui/"})
|
||||
monkeypatch.setattr(dienste, "engine_reachable", lambda: True)
|
||||
monkeypatch.setattr(dienste, "gateway_reachable", lambda: True)
|
||||
monkeypatch.setattr(dienste, "_voice_antwortet", lambda: False)
|
||||
monkeypatch.setattr(dienste.system, "_gpu_sysfs", lambda: {"busy_percent": 3, "gtt_used": 27611533312})
|
||||
monkeypatch.setattr(dienste, "_speicher_gesamt", lambda: 131738611712)
|
||||
lesungen = {"user": 0, "system": 0}
|
||||
|
||||
def show(units, system_dienst):
|
||||
art = "system" if system_dienst else "user"
|
||||
text = SYSTEM_AUSGABE if system_dienst else USER_AUSGABE
|
||||
stand = dienste.zuordnen(dienste.lies_show(text), units)
|
||||
# Die Engine rechnet 0,32 s CPU je Lesen, alle anderen nichts.
|
||||
if system_dienst:
|
||||
ns = int(stand["llama-swap"]["CPUUsageNSec"]) + lesungen[art] * 320_000_000
|
||||
stand["llama-swap"] = {**stand["llama-swap"], "CPUUsageNSec": str(ns)}
|
||||
lesungen[art] += 1
|
||||
return stand
|
||||
|
||||
monkeypatch.setattr(dienste, "_systemctl_show", show)
|
||||
return lesungen
|
||||
|
||||
|
||||
def test_liste_mit_zustand_und_kennzahlen(box):
|
||||
daten = dienste.liste()
|
||||
zeilen = {z["unit"]: z for z in daten["services"]}
|
||||
assert [z["unit"] for z in daten["services"]] == [
|
||||
"llama-swap", "mc2-gateway", "mission-control-2", "mc2-steward", "hermes-gateway", "hermes-builtin-ui",
|
||||
"voice-service", "lucy-stimme"]
|
||||
assert daten["speicher_gesamt"] == 131738611712 and daten["kerne"] == 32
|
||||
# Was die MCP-Werkzeuge lesen, ist geblieben.
|
||||
assert {"name", "unit", "url", "ok", "scope", "schlaeft"} <= set(zeilen["llama-swap"])
|
||||
assert daten["links"]["engine_ui"].endswith("/ui")
|
||||
|
||||
engine = zeilen["llama-swap"]
|
||||
assert engine["zustand"] == "laeuft" and engine["scope"] == "system"
|
||||
assert engine["speicher_bytes"] == 3722850304 and engine["grafik_bytes"] == 27611533312
|
||||
assert engine["laeuft_seit_s"] == int(JETZT + 0.5 - 74032.986745)
|
||||
assert engine["cpu_prozent"] == 2.0 # erste Abfrage: 0,32 s CPU in der halben Sekunde bis zur zweiten Probe
|
||||
assert zeilen["hermes-gateway"]["cpu_prozent"] == 0.0 and zeilen["hermes-gateway"]["grafik_bytes"] is None
|
||||
|
||||
# Ohne HTTP-Port entscheidet systemd, ob der Dienst läuft.
|
||||
assert zeilen["mc2-steward"]["ok"] is True and zeilen["lucy-stimme"]["speicher_bytes"] == 7500505088
|
||||
|
||||
voice = zeilen["voice-service"]
|
||||
assert voice["schlaeft"] is True and voice["zustand"] == "schlaeft" and voice["ok"] is False
|
||||
assert voice["speicher_bytes"] is None and voice["cpu_prozent"] is None
|
||||
|
||||
|
||||
def test_zweite_abfrage_misst_ueber_den_abstand_seit_der_ersten(box):
|
||||
dienste.liste()
|
||||
assert box == {"user": 2, "system": 2} # erste Abfrage: kurze zweite Probe
|
||||
engine = next(z for z in dienste.liste()["services"] if z["unit"] == "llama-swap")
|
||||
assert box == {"user": 3, "system": 3} # jetzt ohne zweite Probe
|
||||
assert engine["cpu_prozent"] == 0.1 # 0,32 s CPU in 10 s auf 32 Kernen
|
||||
|
||||
|
||||
def test_liste_ohne_systemd_wie_bisher(box, monkeypatch):
|
||||
"""Am PC gibt es kein systemd: die Liste steht, der Zustand kommt allein aus der Erreichbarkeit."""
|
||||
monkeypatch.setattr(dienste, "_systemctl_show", lambda units, system_dienst: {})
|
||||
zeilen = {z["unit"]: z for z in dienste.liste()["services"]}
|
||||
assert zeilen["llama-swap"]["zustand"] == "laeuft" and zeilen["llama-swap"]["grafik_bytes"] is None
|
||||
assert zeilen["voice-service"]["zustand"] == "antwortet_nicht" and zeilen["voice-service"]["schlaeft"] is False
|
||||
assert zeilen["mc2-steward"]["ok"] is False
|
||||
assert all(z["speicher_bytes"] is None and z["cpu_prozent"] is None for z in zeilen.values())
|
||||
|
||||
|
||||
def test_gescheiterter_dienst(box, monkeypatch):
|
||||
def show(units, system_dienst):
|
||||
stand = dienste.zuordnen(dienste.lies_show(SYSTEM_AUSGABE if system_dienst else USER_AUSGABE), units)
|
||||
if not system_dienst:
|
||||
stand["hermes-gateway"] = GESCHEITERT
|
||||
return stand
|
||||
|
||||
monkeypatch.setattr(dienste, "_systemctl_show", show)
|
||||
monkeypatch.setattr(dienste, "agent_status", lambda: {
|
||||
"gateway_reachable": False, "hermes_ui_reachable": True, "hermes_ui_url": "/hermes-ui/"})
|
||||
hermes = next(z for z in dienste.liste()["services"] if z["unit"] == "hermes-gateway")
|
||||
assert hermes["zustand"] == "gescheitert" and hermes["schlaeft"] is False
|
||||
assert hermes["neustarts"] == 5 and hermes["aus_seit_s"] is not None and hermes["cpu_prozent"] is None
|
||||
|
||||
|
||||
def test_schnittstelle_reicht_die_liste_durch(monkeypatch):
|
||||
from routers import system as system_router
|
||||
|
||||
monkeypatch.setattr(dienste, "liste", lambda: {"services": [{"unit": "llama-swap", "zustand": "laeuft"}]})
|
||||
app = FastAPI()
|
||||
app.include_router(system_router.router)
|
||||
assert TestClient(app).get("/api/system/services").json()["services"][0]["zustand"] == "laeuft"
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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) => (
|
||||
<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 && (
|
||||
<a href="/hermes-ui/" target="_blank" rel="noopener" className={EINTRAG}>
|
||||
<ExternalLink className="size-[18px] shrink-0 text-text-3" aria-hidden />
|
||||
Hermes-Dashboard
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
{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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"}.`
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
+271
-77
@@ -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 }) {
|
||||
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
|
||||
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={offen ? "info" : "ghost"}
|
||||
size="sm"
|
||||
aria-expanded={offen}
|
||||
aria-controls={offen ? protokollId : undefined}
|
||||
aria-label={`Protokoll von ${d.name}`}
|
||||
onClick={onProtokoll}
|
||||
>
|
||||
Protokoll
|
||||
</Button>
|
||||
<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>
|
||||
</span>
|
||||
{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 (
|
||||
<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)
|
||||
const [bestaetigen, setBestaetigen] = 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 neustarten(d: Dienst) {
|
||||
async function starten(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"}.`)
|
||||
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 = <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">
|
||||
<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">
|
||||
<Button
|
||||
variant={gewaehlt === d.unit ? "info" : "ghost"}
|
||||
size="sm"
|
||||
aria-expanded={gewaehlt === d.unit}
|
||||
onClick={() => setGewaehlt(gewaehlt === d.unit ? null : d.unit)}
|
||||
>
|
||||
Protokoll
|
||||
</Button>
|
||||
{bestaetigen === d.unit ? (
|
||||
<>
|
||||
<Button variant="gefahr" size="sm" onClick={() => neustarten(d)}>
|
||||
{d.schlaeft ? "Wirklich wecken?" : "Wirklich neu starten?"}
|
||||
</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>
|
||||
)
|
||||
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 (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user