Merge branch 'worktree-agent-a872d2361a1cdaa60' into wartung/helfer-nutzung-dienste

This commit is contained in:
Hitonabi
2026-09-25 13:31:32 +02:00
16 changed files with 1314 additions and 201 deletions
+2 -5
View File
@@ -5,10 +5,7 @@ import { Marke, Seitenleiste } from "./Seitenleiste"
* Handy und Tablet: dieselbe Menüführung wie links am PC, als Schublade hinter dem Menü-Knopf.
* Eigene Datei, damit sie (mit der Dialog-Kette von Radix) erst beim Öffnen lädt.
*/
export function MenueSchublade({ onDienste, onSchliessen }: {
onDienste: () => void
onSchliessen: () => void
}) {
export function MenueSchublade({ onSchliessen }: { onSchliessen: () => void }) {
return (
<Sheet open onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent
@@ -23,7 +20,7 @@ export function MenueSchublade({ onDienste, onSchliessen }: {
</SheetTitle>
<SheetDescription className="sr-only">Bereiche und Seiten</SheetDescription>
</SheetHeader>
<Seitenleiste onDienste={onDienste} onGewaehlt={onSchliessen} />
<Seitenleiste onGewaehlt={onSchliessen} />
</SheetContent>
</Sheet>
)
+7 -16
View File
@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router"
import { ExternalLink, ScrollText } from "lucide-react"
import { ExternalLink } from "lucide-react"
import { cn } from "cn"
import { useInstanz } from "@/lib/abfragen"
import { BEREICHE, type Ort, SEITEN, type Seite, useBereichStand, type Verbindung } from "@/lib/navigation"
@@ -67,8 +67,7 @@ function SeitenLink({ s, zahl, onGewaehlt }: { s: Seite; zahl?: number; onGewaeh
* Die Menüführung: oben die Übersicht, dann je Bereich ein farbiger Block mit Verbindungsanzeige und seinen
* Seiten, unten die Einstellungen. Am PC steht sie fest links, am Handy in der Schublade hinter dem Menü-Knopf.
*/
export function Seitenleiste({ onDienste, onGewaehlt }: {
onDienste: () => void
export function Seitenleiste({ onGewaehlt }: {
/** Nach jedem Klick — die Handy-Schublade schließt sich dann. */
onGewaehlt?: () => void
}) {
@@ -97,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>
)
+6 -12
View File
@@ -7,9 +7,9 @@ import { useStrom, useStromLage } from "@/lib/strom"
import { Meldungen } from "./Meldungen"
import { Marke, Seitenleiste } from "./Seitenleiste"
// Schubladen werden erst beim Öffnen geladen — sie gehören nicht zum Normalfall, und die
// Die Menü-Schublade wird erst beim Öffnen geladen — sie gehört nicht zum Normalfall, und die
// Radix-Dialog-Kette (Fokus-Falle, Scroll-Sperre) sprengte sonst das 140-KB-Budget.
const Dienste = lazy(() => import("@/views/Dienste").then((m) => ({ default: m.Dienste })))
// („Dienste und Protokolle“ war bis 25.09.2026 die zweite Schublade; jetzt ist es eine Seite.)
const MenueSchublade = lazy(() => import("./MenueSchublade").then((m) => ({ default: m.MenueSchublade })))
const uhrzeit = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })
@@ -46,8 +46,6 @@ function Verbindung() {
)
}
type Schublade = "menue" | "dienste" | null
// Die Zeile über dem Seitentitel trägt die Farbe des Orts: Indigo für die KI-Box, Violett fürs Homelab.
const ORT_FARBE: Record<Ort, string> = {
uebersicht: "text-bernstein",
@@ -58,8 +56,7 @@ const ORT_FARBE: Record<Ort, string> = {
export function Shell() {
useStrom()
const [schublade, setSchublade] = useState<Schublade>(null)
const zu = () => setSchublade(null)
const [menue, setMenue] = useState(false)
const seite = useAktuelleSeite()
return (
@@ -67,7 +64,7 @@ export function Shell() {
{/* Ab 1024 px steht die Menüführung fest links, darunter in der Schublade hinter dem Menü-Knopf. */}
<aside className="sticky top-0 hidden h-dvh flex-col gap-8 overflow-y-auto border-r border-linie bg-[#0c0f12] px-4 py-7 lg:flex">
<Marke />
<Seitenleiste onDienste={() => setSchublade("dienste")} />
<Seitenleiste />
</aside>
<div className="mx-auto flex w-full max-w-[1320px] min-w-0 flex-col gap-5 px-4 pt-4 pb-10 sm:px-6 md:gap-6 md:px-10 md:pt-8">
@@ -75,7 +72,7 @@ export function Shell() {
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={() => setSchublade("menue")}
onClick={() => setMenue(true)}
className="-ml-2 flex size-11 shrink-0 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground lg:hidden"
aria-label="Menü öffnen"
>
@@ -100,10 +97,7 @@ export function Shell() {
</div>
<Suspense fallback={null}>
{schublade === "menue" && (
<MenueSchublade onDienste={() => setSchublade("dienste")} onSchliessen={zu} />
)}
{schublade === "dienste" && <Dienste offen onSchliessen={zu} />}
{menue && <MenueSchublade onSchliessen={() => setMenue(false)} />}
</Suspense>
<Meldungen />
</div>
+5 -1
View File
@@ -31,6 +31,10 @@ const boxMonitoring = createRoute({
getParentRoute: () => wurzel, path: "/box/monitoring",
component: lazyRouteComponent(() => import("@/views/Monitoring"), "BoxMonitoringSeite"),
})
const boxDienste = createRoute({
getParentRoute: () => wurzel, path: "/box/dienste",
component: lazyRouteComponent(() => import("@/views/Dienste"), "DiensteSeite"),
})
// Bereich Homelab
const homelab = createRoute({
@@ -73,7 +77,7 @@ const altModelle = createRoute({
export const router = createRouter({
routeTree: wurzel.addChildren([
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, homelab, homelabUpdates, homelabMonitoring,
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, boxDienste, homelab, homelabUpdates, homelabMonitoring,
homelabProtokoll, einstellungen, altUpdates, altModelle,
]),
defaultPreload: "intent",
+29 -1
View File
@@ -113,6 +113,9 @@ export const useProtokoll = (dienst: string | null) =>
enabled: !!dienst,
})
/** Zustand eines Dienstes laut systemd (services/dienste.py). */
export type DienstZustand = "laeuft" | "antwortet_nicht" | "startet" | "stoppt" | "gestoppt" | "gescheitert" | "schlaeft"
export interface Dienst {
name: string
unit: string
@@ -120,10 +123,35 @@ export interface Dienst {
scope: string
/** Bewusst abgeschaltet (systemctl disable) — kein Fehler, nur geweckt wird per Knopf. */
schlaeft?: boolean
// Seit 25.09.2026: Zustand und Kennzahlen laut systemd — null, wo die Box sie nicht kennt (fehlt bei älteren Ständen).
zustand?: DienstZustand
/** Was systemd dem Dienst an Arbeitsspeicher zurechnet (MemoryCurrent). */
speicher_bytes?: number | null
/** Nur die Engine: belegter Grafikspeicher der Box — die geladenen Modelle, zusätzlich zu speicher_bytes. */
grafik_bytes?: number | null
/** Anteil an allen Kernen der Box in Prozent, gemessen seit der letzten Abfrage. */
cpu_prozent?: number | null
laeuft_seit_s?: number | null
/** Seit wann ein gestoppter oder gescheiterter Dienst aus ist. */
aus_seit_s?: number | null
/** Wie oft systemd ihn nach einem Absturz von selbst neu gestartet hat. */
neustarts?: number | null
}
export interface DiensteStand {
services: Dienst[]
/** Arbeitsspeicher der Box in Bytes — der Bezug des Speicher-Balkens. */
speicher_gesamt?: number | null
kerne?: number | null
}
/** Die Dienste-Seite misst die CPU zwischen zwei Abfragen — deshalb ein fester, kurzer Takt, solange sie offen ist. */
export const useDienste = () =>
useQuery({ queryKey: ["dienste"], queryFn: () => api<{ services: Dienst[] }>("/api/system/services") })
useQuery({
queryKey: ["dienste"],
queryFn: () => api<DiensteStand>("/api/system/services"),
refetchInterval: 10_000,
})
// --- Aktionen ----------------------------------------------------------------
+98
View File
@@ -0,0 +1,98 @@
import type { Dienst } from "./abfragen"
import {
dauer, erfolgText, groesse, knopfText, neustartFrage, prozent, sortiert, speicherAnteil, zeitpunkt, zustandVon,
zustandZusatz,
} from "./dienste"
const hermes: Dienst = { name: "Hermes-Gateway (Agent & Gedächtnis)", unit: "hermes-gateway", ok: true, scope: "user" }
// 25.09.2026, 13:10 Uhr in Berlin (Sommerzeit)
const JETZT = Date.UTC(2026, 8, 25, 11, 10)
describe("zustandVon", () => {
it("nimmt den Zustand der Box — bei einem älteren Stand wie bisher Erreichbarkeit und Schlaf", () => {
expect(zustandVon({ ...hermes, zustand: "gescheitert" })).toBe("gescheitert")
expect(zustandVon(hermes)).toBe("laeuft")
expect(zustandVon({ ...hermes, ok: false, schlaeft: true })).toBe("schlaeft")
expect(zustandVon({ ...hermes, ok: false })).toBe("antwortet_nicht")
})
})
describe("sortiert", () => {
it("stellt Probleme nach oben und schlafende Dienste nach unten, sonst bleibt die Reihenfolge der Box", () => {
const d = (unit: string, extra: Partial<Dienst>): Dienst => ({ ...hermes, name: unit, unit, ...extra })
const liste = [
d("a", { zustand: "laeuft" }), d("b", { zustand: "schlaeft" }), d("c", { zustand: "laeuft", neustarts: 2 }),
d("d", { zustand: "gestoppt" }), d("e", { zustand: "startet" }), d("f", { zustand: "laeuft" }),
d("g", { zustand: "gescheitert" }), d("h", { zustand: "antwortet_nicht" }),
]
expect(sortiert(liste).map((x) => x.unit)).toEqual(["d", "g", "h", "e", "c", "a", "f", "b"])
})
})
describe("Zahlen", () => {
it("schreibt Speicher, Prozent und Laufzeit deutsch", () => {
expect(groesse(56_500_224)).toBe("54 MB")
expect(groesse(7_500_505_088)).toBe("7,0 GB")
expect(groesse(null)).toBe("–")
expect(prozent(0.4)).toBe("0,4 %")
expect(prozent(2)).toBe("2 %")
expect(prozent(0)).toBe("0 %")
expect(prozent(0.01)).toBe("< 0,1 %")
expect(prozent(23.79)).toBe("24 %")
expect(prozent(null)).toBe("–")
expect(dauer(30)).toBe("< 1 min")
expect(dauer(6114)).toBe("1 h 41 min")
expect(dauer(3_430_000)).toBe("39 T 16 h")
expect(dauer(null)).toBe("–")
})
it("zählt bei der Engine die Modelle im Grafikspeicher zum Anteil an der Box", () => {
const engine: Dienst = {
...hermes, unit: "llama-swap", speicher_bytes: 3_722_850_304, grafik_bytes: 27_611_533_312,
}
expect(speicherAnteil(engine, 131_738_611_712)).toBeCloseTo(23.79, 1)
expect(speicherAnteil({ ...engine, grafik_bytes: null }, 131_738_611_712)).toBeCloseTo(2.83, 1)
expect(speicherAnteil({ ...engine, speicher_bytes: null }, 131_738_611_712)).toBeNull()
expect(speicherAnteil(engine, null)).toBeNull()
})
it("nennt Zeitpunkte in Box-Zeit", () => {
expect(zeitpunkt(330, JETZT)).toBe("heute 13:04")
expect(zeitpunkt(86_400, JETZT)).toBe("gestern 13:10")
expect(zeitpunkt(10 * 86_400, JETZT)).toBe("15.09., 13:10")
})
})
describe("Zustand und Knopf", () => {
it("sagt, seit wann ein Dienst aus ist, und dass ein schlafender mit Absicht aus ist", () => {
expect(zustandZusatz({ ...hermes, zustand: "gescheitert", aus_seit_s: 147 }, JETZT)).toBe("aus seit heute 13:07")
expect(zustandZusatz({ ...hermes, zustand: "schlaeft" }, JETZT)).toBe("mit Absicht aus")
expect(zustandZusatz({ ...hermes, zustand: "laeuft", laeuft_seit_s: 20 }, JETZT)).toBeNull()
})
it("weckt, startet oder startet neu — und fragt mit den Folgen für den Dienst", () => {
const schlaeft: Dienst = { ...hermes, name: "Spracherkennung", unit: "voice-service", zustand: "schlaeft" }
expect(knopfText(schlaeft)).toBe("Wecken")
expect(neustartFrage(schlaeft)).toEqual({
titel: "Spracherkennung wecken?",
text: "Der Dienst startet und läuft bis zum nächsten Neustart der Box. Danach schläft er wieder.",
knopf: "Wecken",
})
expect(erfolgText(schlaeft)).toBe("Spracherkennung wird geweckt.")
const gescheitert: Dienst = { ...hermes, zustand: "gescheitert" }
expect(neustartFrage(gescheitert).text).toBe("Der Dienst wird gestartet. Scheitert er wieder, steht der Grund im Protokoll.")
expect(erfolgText(gescheitert)).toBe("Hermes-Gateway (Agent & Gedächtnis) startet.")
expect(neustartFrage({ ...hermes, zustand: "laeuft" }).text)
.toBe("Der Dienst wird neu gestartet und ist dabei kurz nicht erreichbar. Lucy antwortet in der Zeit nicht.")
expect(erfolgText({ ...hermes, zustand: "laeuft" })).toBe("Hermes-Gateway (Agent & Gedächtnis) startet neu.")
const engine: Dienst = { ...hermes, name: "Engine (llama-swap)", unit: "llama-swap", scope: "system", zustand: "laeuft" }
expect(neustartFrage(engine)).toEqual({
titel: "Engine (llama-swap) neu starten?",
text: "Der Dienst wird neu gestartet und ist dabei kurz nicht erreichbar. Laufende Antworten brechen ab; die "
+ "Modelle werden danach neu geladen.",
knopf: "Neu starten",
})
})
})
+149
View File
@@ -0,0 +1,149 @@
import type { Dienst, DienstZustand } from "./abfragen"
import { laufzeit } from "./messung"
// Anzeige der Seite „Dienste und Protokolle“: Zustand in Worten, Reihenfolge, Zahlen und Rückfragen — ohne DOM,
// damit sie sich testen lässt. Gemessen wird auf der Box (backend/services/dienste.py).
export interface ZustandsAnzeige {
wort: string
/** Farbe des Punkts vor dem Namen. */
punkt: string
/** Farbe des Worts in der Spalte „Zustand“ — ruhig, solange nichts ist. */
text: string
}
export const ZUSTAND: Record<DienstZustand, ZustandsAnzeige> = {
laeuft: { wort: "läuft", punkt: "bg-gruen", text: "text-text-3" },
startet: { wort: "startet", punkt: "bg-bernstein", text: "text-bernstein" },
stoppt: { wort: "stoppt", punkt: "bg-bernstein", text: "text-bernstein" },
antwortet_nicht: { wort: "antwortet nicht", punkt: "bg-rot", text: "text-rot-text" },
gestoppt: { wort: "gestoppt", punkt: "bg-rot", text: "text-rot-text" },
gescheitert: { wort: "gescheitert", punkt: "bg-rot", text: "text-rot-text" },
schlaeft: { wort: "schläft", punkt: "bg-text-3", text: "text-text-3" },
}
/** Der Zustand — bei einer älteren Box ohne systemd-Angaben wie bisher aus „antwortet“ und „schläft“. */
export function zustandVon(d: Dienst): DienstZustand {
if (d.zustand) return d.zustand
return d.ok ? "laeuft" : d.schlaeft ? "schlaeft" : "antwortet_nicht"
}
const RANG: Record<DienstZustand, number> = {
gescheitert: 0, gestoppt: 0, antwortet_nicht: 0, startet: 1, stoppt: 1, laeuft: 3, schlaeft: 4,
}
/** Reihenfolge der Tabelle: 0 = Problem (gescheitert, gestoppt, antwortet nicht), 1 = startet oder stoppt gerade,
* 2 = läuft, stürzte aber zwischendurch ab, 3 = läuft, 4 = schläft mit Absicht. */
export function rang(d: Dienst): number {
const z = zustandVon(d)
if (z === "laeuft" && (d.neustarts ?? 0) > 0) return 2
return RANG[z]
}
export const istProblem = (d: Dienst) => rang(d) === 0
/** Probleme oben, schlafende Dienste unten; sonst bleibt die Reihenfolge der Box. */
export const sortiert = (dienste: Dienst[]) => [...dienste].sort((a, b) => rang(a) - rang(b))
const ganz = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 })
const eineStelle = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 })
/** Speicher lesbar: „54 MB“, „7,0 GB“ (1 GB = 1024 MB, wie im Cockpit). */
export function groesse(bytes: number | null | undefined): string {
if (bytes == null) return "–"
const mb = bytes / 1024 ** 2
return mb < 1000 ? `${ganz.format(mb)} MB` : `${eineStelle.format(mb / 1024)} GB`
}
/** Anteil am Speicher der Box in Prozent — beim Dienst samt Grafikspeicher (die Modelle der Engine). */
export function speicherAnteil(d: Dienst, gesamt: number | null | undefined): number | null {
if (d.speicher_bytes == null || !gesamt) return null
return ((d.speicher_bytes + (d.grafik_bytes ?? 0)) / gesamt) * 100
}
/** Prozent kurz: unter 10 % mit einer Stelle („0,4 %“), sonst ganz („24 %“). */
export function prozent(p: number | null | undefined): string {
if (p == null) return "–"
if (p > 0 && p < 0.05) return "< 0,1 %"
return p < 10 ? `${eineStelle.format(p).replace(/,0$/, "")} %` : `${ganz.format(p)} %`
}
/** Laufzeit wie bei den Geräten im Homelab („3 T 4 h“, „5 h 12 min“), unter einer Minute „< 1 min“. */
export function dauer(s: number | null | undefined): string {
if (s == null || s < 0) return "–"
return s < 60 ? "< 1 min" : laufzeit(s)
}
const ZONE = "Europe/Berlin"
const uhr = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: ZONE })
const datum = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", timeZone: ZONE })
const tag = new Intl.DateTimeFormat("en-CA", { timeZone: ZONE })
/** Wann etwas war, das `s` Sekunden zurückliegt: „heute 11:05“, „gestern 23:10“, sonst „07.09., 11:03“. */
export function zeitpunkt(s: number, jetzt = Date.now()): string {
const d = new Date(jetzt - s * 1000)
const t = tag.format(d)
if (t === tag.format(new Date(jetzt))) return `heute ${uhr.format(d)}`
if (t === tag.format(new Date(jetzt - 86_400_000))) return `gestern ${uhr.format(d)}`
return `${datum.format(d)}, ${uhr.format(d)}`
}
/** Die Zeile unter dem Zustand: seit wann ein Dienst aus ist, oder dass er mit Absicht schläft. */
export function zustandZusatz(d: Dienst, jetzt = Date.now()): string | null {
const z = zustandVon(d)
if (z === "schlaeft") return "mit Absicht aus"
if ((z === "gestoppt" || z === "gescheitert") && d.aus_seit_s != null) return `aus seit ${zeitpunkt(d.aus_seit_s, jetzt)}`
return null
}
/** Beschriftung des Knopfs: ein schlafender Dienst wird geweckt, ein gestoppter gestartet, sonst neu gestartet. */
export function knopfText(d: Dienst): "Wecken" | "Starten" | "Neu starten" {
const z = zustandVon(d)
if (z === "schlaeft") return "Wecken"
if (z === "gestoppt" || z === "gescheitert") return "Starten"
return "Neu starten"
}
// Was ein Neustart eines laufenden Dienstes über ihn hinaus bedeutet.
const FOLGEN: Record<string, string> = {
"llama-swap": "Laufende Antworten brechen ab; die Modelle werden danach neu geladen.",
"mission-control-2": "Das ist diese Oberfläche selbst: Sie ist ein paar Sekunden nicht erreichbar.",
"hermes-gateway": "Lucy antwortet in der Zeit nicht.",
}
export interface Rueckfrage {
titel: string
text: string
knopf: string
}
/** Die Rückfrage vor dem Knopf — jede Aktion hier startet einen Dienst der Box. */
export function neustartFrage(d: Dienst): Rueckfrage {
const knopf = knopfText(d)
if (knopf === "Wecken") {
return {
titel: `${d.name} wecken?`,
text: "Der Dienst startet und läuft bis zum nächsten Neustart der Box. Danach schläft er wieder.",
knopf,
}
}
if (knopf === "Starten") {
return {
titel: `${d.name} starten?`,
text: "Der Dienst wird gestartet. Scheitert er wieder, steht der Grund im Protokoll.",
knopf,
}
}
const folge = FOLGEN[d.unit] ? ` ${FOLGEN[d.unit]}` : ""
return {
titel: `${d.name} neu starten?`,
text: `Der Dienst wird neu gestartet und ist dabei kurz nicht erreichbar.${folge}`,
knopf,
}
}
/** Die Meldung nach dem Klick. */
export function erfolgText(d: Dienst): string {
const knopf = knopfText(d)
return `${d.name} ${knopf === "Wecken" ? "wird geweckt" : knopf === "Starten" ? "startet" : "startet neu"}.`
}
+3 -1
View File
@@ -18,7 +18,7 @@ export const BEREICHE: { id: Bereich; name: string }[] = [
]
export type SeitenPfad =
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring"
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring" | "/box/dienste"
| "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen"
export interface Seite {
@@ -36,6 +36,8 @@ export const SEITEN: Seite[] = [
{ to: "/box/updates", label: "Updates", Icon: RefreshCw, ort: "box", zaehlt: true },
{ to: "/box/modelle", label: "Modelle", Icon: Cpu, ort: "box" },
{ to: "/box/monitoring", label: "Monitoring", Icon: Activity, ort: "box" },
// Bis 25.09.2026 eine Schublade; jetzt eine Seite mit Tabelle wie die Geräte im Homelab.
{ to: "/box/dienste", label: "Dienste und Protokolle", Icon: ScrollText, ort: "box" },
{ to: "/homelab", label: "Cockpit", Icon: Gauge, ort: "homelab" },
{ to: "/homelab/updates", label: "Updates", Icon: RefreshCw, ort: "homelab", zaehlt: true },
{ to: "/homelab/monitoring", label: "Monitoring", Icon: Activity, ort: "homelab" },
+138
View File
@@ -0,0 +1,138 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"
import type { DiensteStand } from "@/lib/abfragen"
import { api, post } from "@/lib/api"
import { DiensteSeite } from "./Dienste"
vi.mock("@/lib/api", async (original) => ({
...(await original<typeof import("@/lib/api")>()),
api: vi.fn(),
post: vi.fn(),
}))
// Zahlen der Box vom 25.09.2026; Hermes ist hier abgestürzt.
const STAND: DiensteStand = {
speicher_gesamt: 131_738_611_712,
kerne: 32,
services: [
{
name: "Engine (llama-swap)", unit: "llama-swap", ok: true, scope: "system", schlaeft: false, zustand: "laeuft",
speicher_bytes: 3_722_850_304, grafik_bytes: 27_611_533_312, cpu_prozent: 2, laeuft_seit_s: 6114,
aus_seit_s: null, neustarts: 0,
},
{
name: "Hermes-Gateway (Agent & Gedächtnis)", unit: "hermes-gateway", ok: false, scope: "user", schlaeft: false,
zustand: "gescheitert", speicher_bytes: null, grafik_bytes: null, cpu_prozent: null, laeuft_seit_s: null,
aus_seit_s: 147, neustarts: 5,
},
{
name: "Spracherkennung (Desktop-Lucy)", unit: "voice-service", ok: false, scope: "user", schlaeft: true,
zustand: "schlaeft", speicher_bytes: null, grafik_bytes: null, cpu_prozent: null, laeuft_seit_s: null,
aus_seit_s: null, neustarts: 0,
},
{
name: "Lucys Stimme", unit: "lucy-stimme", ok: true, scope: "user", schlaeft: false, zustand: "laeuft",
speicher_bytes: 7_500_505_088, grafik_bytes: null, cpu_prozent: 0.4, laeuft_seit_s: 3_430_000,
aus_seit_s: null, neustarts: 0,
},
],
}
function antworten() {
vi.mocked(api).mockImplementation((async (pfad: string) => {
if (pfad === "/api/system/services") return STAND
if (pfad === "/api/maintenance/logs?service=hermes-gateway&lines=200") {
return { ok: true, text: "Sep 25 13:07:30 box hermes[812]: Traceback (most recent call last):\nSep 25 13:07:33 box systemd[1]: hermes-gateway.service: Failed with result 'exit-code'." }
}
throw new Error(`unerwartete Abfrage ${pfad}`)
}) as typeof api)
}
function zeigen() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={qc}>
<DiensteSeite />
</QueryClientProvider>,
)
}
describe("Dienste und Protokolle", () => {
it("zeigt Probleme zuerst und je Dienst Zustand, CPU, Speicher, Laufzeit und Neustarts", async () => {
antworten()
zeigen()
const tabelle = await screen.findByRole("table", { name: "Dienste der KI-Box" })
const zeilen = within(tabelle).getAllByRole("row").slice(1)
expect(zeilen.map((z) => within(z).getByRole("rowheader").textContent)).toEqual([
"Hermes-Gateway (Agent & Gedächtnis)hermes-gateway", "Engine (llama-swap)llama-swap · Systemdienst",
"Lucys Stimmelucy-stimme", "Spracherkennung (Desktop-Lucy)voice-service",
])
expect(screen.getByText("1 Problem")).toBeInTheDocument()
const [hermes, engine, stimme, sprache] = zeilen
expect(hermes).toHaveTextContent("gescheitert")
expect(hermes).toHaveTextContent(/aus seit (heute|gestern) \d\d:\d\d/)
expect(within(hermes).getByText("5")).toHaveClass("text-bernstein")
// Die Engine samt Modellen im Grafikspeicher: 3,5 GB Dienst + 25,7 GB Modelle von 123 GB.
expect(engine).toHaveTextContent("29,2 GB · 24 %")
expect(engine).toHaveTextContent("davon 25,7 GB Modelle")
expect(engine).toHaveTextContent("2 %")
expect(engine).toHaveTextContent("1 h 41 min")
expect(stimme).toHaveTextContent("7,0 GB · 5,7 %")
expect(stimme).toHaveTextContent("0,4 %")
expect(stimme).toHaveTextContent("39 T 16 h")
expect(sprache).toHaveTextContent("schläft")
expect(sprache).toHaveTextContent("mit Absicht aus")
expect(within(sprache).getByRole("button", { name: "Spracherkennung (Desktop-Lucy): Wecken" })).toBeEnabled()
})
it("startet einen Dienst erst nach der Rückfrage", async () => {
antworten()
vi.mocked(post).mockResolvedValue({ ok: true })
zeigen()
fireEvent.click(await screen.findByRole("button", { name: "Hermes-Gateway (Agent & Gedächtnis): Starten" }))
const dialog = await screen.findByRole("dialog", { name: "Hermes-Gateway (Agent & Gedächtnis) starten?" })
expect(dialog).toHaveTextContent("Scheitert er wieder, steht der Grund im Protokoll.")
expect(post).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole("button", { name: "Starten" }))
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/maintenance/restart", { service: "hermes-gateway" }))
})
it("kommt mit einer Box ohne Kennzahlen zurecht (älterer Stand, etwa hinter dem Homelab-Teil)", async () => {
vi.mocked(api).mockResolvedValue({
services: [
{ name: "Engine (llama-swap)", unit: "llama-swap", ok: true, scope: "system", schlaeft: false },
{ name: "Lucys Stimme", unit: "lucy-stimme", ok: false, scope: "user", schlaeft: false },
{ name: "Spracherkennung (Desktop-Lucy)", unit: "voice-service", ok: false, scope: "user", schlaeft: true },
],
})
zeigen()
const tabelle = await screen.findByRole("table", { name: "Dienste der KI-Box" })
const [stimme, engine, sprache] = within(tabelle).getAllByRole("row").slice(1)
expect(stimme).toHaveTextContent("antwortet nicht")
expect(within(stimme).getByRole("button", { name: "Lucys Stimme: Neu starten" })).toBeInTheDocument()
expect(engine).toHaveTextContent("läuft")
expect(sprache).toHaveTextContent("schläft")
expect(screen.getByText(/Anteil an allen Kernen der Box/)).toBeInTheDocument()
})
it("klappt das Protokoll unter der Zeile auf und wieder zu", async () => {
antworten()
zeigen()
const knopf = await screen.findByRole("button", { name: "Protokoll von Hermes-Gateway (Agent & Gedächtnis)" })
fireEvent.click(knopf)
expect(knopf).toHaveAttribute("aria-expanded", "true")
const protokoll = await screen.findByLabelText("Letzte Protokollzeilen von Hermes-Gateway (Agent & Gedächtnis)")
await waitFor(() => expect(protokoll).toHaveTextContent("Traceback (most recent call last)"))
// Es steht in der Zeile des Dienstes, nicht irgendwo unten.
expect(knopf.closest("[role=row]")).toContainElement(protokoll)
fireEvent.click(knopf)
expect(knopf).toHaveAttribute("aria-expanded", "false")
expect(screen.queryByLabelText("Letzte Protokollzeilen von Hermes-Gateway (Agent & Gedächtnis)")).not.toBeInTheDocument()
})
})
+271 -77
View File
@@ -1,117 +1,311 @@
import { useState } from "react"
import { type ReactNode, useEffect, useRef, useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { type Dienst, useDienste, useProtokoll } from "@/lib/abfragen"
import { type Dienst, type DiensteStand, useDienste, useProtokoll } from "@/lib/abfragen"
import { fehlerText } from "@/lib/anzeige"
import { post } from "@/lib/api"
import { ApiFehler, post } from "@/lib/api"
import {
dauer, erfolgText, groesse, istProblem, knopfText, neustartFrage, prozent, rang, sortiert, speicherAnteil, ZUSTAND,
zeitpunkt, zustandVon, zustandZusatz,
} from "@/lib/dienste"
import { melden } from "@/lib/meldungen"
import type { AktionsErgebnis } from "@/lib/typen"
import { gbText } from "@/lib/zeit"
// Die Dienste der KI-Box als Tabelle wie die Geräte im Homelab-Cockpit (seit 25.09.2026 eine eigene Seite statt der
// Schublade): Zustand, CPU, Speicher, Laufzeit und Neustarts je Dienst, das Protokoll klappt unter der Zeile auf.
// Unter xl ist jede Zeile eine kleine Karte (zwei, ab sm vier Spalten); ab xl eine Tabelle. Die Knöpfe stehen erst
// ab 1400 px nebeneinander — bei 1280 px neben der Seitenleiste reichte der Platz sonst nicht für die Zahlen. Jede
// Zeile ist ein eigenes Raster: darum feste Breiten statt „auto“, sonst verschöbe ein kürzerer Knopf die Spalten.
const SPALTEN =
"grid-cols-2 gap-x-4 sm:grid-cols-4 xl:gap-x-3 " +
"xl:grid-cols-[minmax(0,1.7fr)_7rem_minmax(0,0.7fr)_minmax(0,1.2fr)_7.5rem_5rem_9.5rem] " +
"min-[87.5rem]:grid-cols-[minmax(0,1.7fr)_7rem_minmax(0,0.7fr)_minmax(0,1.2fr)_7.5rem_5rem_17.5rem]"
const uhrzeit = new Intl.DateTimeFormat("de-DE", {
hour: "2-digit", minute: "2-digit", second: "2-digit", timeZone: "Europe/Berlin",
})
/** Beschriftung der Zahl am Handy — in der Tabelle steht sie im Kopf. */
function Beschriftung({ children }: { children: ReactNode }) {
return <span className="block text-xs text-text-3 xl:hidden">{children}</span>
}
/** Wert mit Balken darunter wie im Homelab-Cockpit, aber ruhig in Blau: Ein Dienst hat keine Grenze, ab der er
* „zu viel“ hätte — was zählt, zeigt der Zustand. */
function Balken({ anteil, children }: { anteil: number | null; children: ReactNode }) {
return (
<span className="flex min-w-0 flex-col gap-1">
<span className="ziffern text-sm text-text-2">{children}</span>
{anteil !== null && (
<span className="h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-erhaben" aria-hidden>
<span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, anteil))}%`, background: "var(--reihe-1)" }} />
</span>
)}
</span>
)
}
/** Das Protokoll eines Dienstes, direkt unter seiner Zeile — am Handy sonst weit unten außer Sicht. */
function Protokoll({ unit }: { unit: string }) {
const protokoll = useProtokoll(unit)
function Protokoll({ d, id }: { d: Dienst; id: string }) {
const protokoll = useProtokoll(d.unit)
const feld = useRef<HTMLPreElement>(null)
let text: string
if (protokoll.isPending) text = "Wird gelesen …"
else if (protokoll.isError) text = `Das Protokoll lässt sich gerade nicht lesen: ${protokoll.error.message}`
else if (protokoll.data.ok === false) text = protokoll.data.err || protokoll.data.text || "Das Protokoll lässt sich nicht lesen."
else text = protokoll.data.text?.trim() || "Noch keine Einträge."
// Das Neueste steht unten — dorthin springen, sobald der Text da ist.
useEffect(() => {
if (feld.current) feld.current.scrollTop = feld.current.scrollHeight
}, [text])
return (
<pre className="ziffern m-0 max-h-[50vh] w-full basis-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-all text-text-2">
<pre
id={id}
ref={feld}
tabIndex={0}
aria-label={`Letzte Protokollzeilen von ${d.name}`}
className="ziffern m-0 max-h-[50vh] w-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-words text-text-2"
>
{text}
</pre>
)
}
/** Dienste der Box mit Protokoll und Neustart — die „Log-Ansicht der Dienste“. */
export function Dienste({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
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>
)
}