Merge branch 'worktree-agent-a872d2361a1cdaa60' into wartung/helfer-nutzung-dienste
This commit is contained in:
@@ -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