Vierte Etappe. Der Client-Zustand hatte bisher keinen Ort: zwei handgebaute
useSyncExternalStore-Speicher, drei CustomEvent-Kanaele und ein localStorage-Griff
mitten in einem useState — verteilt ueber fuenf Dateien.
## Ein Store (Befund B-08/B-09)
app/store.ts (Zustand, 1,2 kB gzip) haelt genau vier Dinge: Bedienvorlieben
(Schiene, Expertenmodus, Palette), die Lage des Ereignisstroms und die
Meldungs-Warteschlange. Der Kopfkommentar nennt die Regel, nach der etwas dort
landen darf — und ein Test prueft sie: gespeichert werden AUSSCHLIESSLICH
Schienen- und Expertenmodus-Zustand, kein Strom, keine Meldungen, kein Geheimnis.
Der Metrik-Verlauf bleibt bewusst DRAUSSEN (lib/metricsStore.ts): Er nimmt ab P4
jede Sekunde einen Messpunkt entgegen, ein Store-Update pro Sekunde wuerde jede
abonnierende Komponente neu rendern.
Die drei CustomEvent-Kanaele sind ERSATZLOS weg — `grep -r dispatchEvent src`
liefert nichts mehr. Wer die Schublade oeffnen will, setzt `?system=`; wer springen
will, navigiert. Auch der letzte Rest von P2 (`mc-palette-open`) ist aufgeloest.
## Statusleiste (Spezifikation §4.1)
Der Zustand der Box stand bisher verstreut: die Ampel unten in der Sidebar, die
Auslastung nur im Cockpit, das aktive Modell nur im Modell-Manager. Wer in der
Konsole arbeitete, sah gar nichts.
Jetzt eine feste Leiste ueber allen Ansichten: Motor/Hirn-Ampel (klickbar zum
Agenten) · aktive Rolle + Durchsatz mit Sparkline · geteilter Speicher ALS BALKEN
(nicht als Prozent — 128 GB Unified Memory ist die Kernzahl dieser Box) · CPU/GPU
· Temperatur (ab 85 °C bernstein) · Betriebszeit · Token-Summe · Live-Anzeige.
Die Sparklines sind eigenes SVG, ~20 Zeilen. Fuer eine 56-Pixel-Linie waere eine
Diagramm-Bibliothek Verschwendung.
Dafuer neu im Backend: `uptime_s` in /api/system/status (psutil.boot_time).
Gehoert dorthin, weil die Box sich woechentlich selbst neu startet — dann ist
"laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
Liefert psutil nichts, steht dort None und die Leiste blendet das Feld aus,
statt eine Zahl zu erfinden.
## aria-live (Befund B-11) — und diesmal mit Absender
Im ganzen Frontend gab es KEIN einziges aria-live. app/shell/Meldungen.tsx ist
jetzt die eine Stelle fuer beilaeufige Rueckmeldungen; Fehler bekommen
`assertive` und bleiben stehen, alles andere `polite` und raeumt sich weg.
Wichtig: Die Region ist keine Attrappe. Gespeist wird sie von den Uebergaengen des
Ereignisstroms und vom Konsolen-Neustart (mit dem echten Grund aus ApiError.detail).
Der erste Verbindungsaufbau wird bewusst NICHT gemeldet — sonst begruesst jede
Seite den Nutzer mit "Verbindung wieder da".
## Ehrliche Anzeige statt stillem Altern (Befund B-15/B-18)
lib/events.ts fuehrt die Stromlage jetzt im Store: live · nachlauf · getrennt.
Beim Wechsel wird EINMAL invalidiert — vorher las relax() den Zustand erst beim
naechsten Refetch, nach einem Riss blieb die UI bis zu 150 s im langsamen Modus.
## Bahnbreiten-Deckel (Befund B-10)
Die Arbeitsflaeche endet bei 1600 px. Ohne Deckel zog sich das Cockpit auf einem
3440-px-Ultrawide auf ueber 3 000 px, waehrend die Wurzel-Schriftgroesse mitwuchs
und die Zeilen GROESSER statt lesbarer machte.
## Nebenbei
Der Buendel-Budget-Check aus P0 war fragil: `ls dist/assets/index-*.js | head -1`
kann den falschen treffen, weil Rollup auch kleine geteilte Module "index-*.js"
nennt (gemessen: ein 67-Byte-Chunk neben dem 374-kB-Einstieg). Er waere dann still
immer gruen gewesen. Beide Ampel-Dateien lesen den Einstieg jetzt aus index.html.
## Verifiziert im Browser
Statusleiste: alle Felder mit Live-Daten, "Laeuft seit 1 Std 10 min"
Backend abgewuergt -> Leiste springt auf "Nachlauf" UND die aria-live-Region
meldet "Verbindung zur Zentrale verloren" (polite)
Backend zurueck -> "Live", ohne Begruessungs-Meldung
Cockpit-Kachel -> /cockpit?system=logs, Schublade offen, Reiter "System-Logs"
Arbeitsflaeche -> max-width 1600 px
33/33 Tests gruen (7 neue fuer den Store) · ESLint 0 Fehler · tsc sauber ·
Einstieg 118 153 B gzip / Budget 125 000.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
383 lines
18 KiB
TypeScript
383 lines
18 KiB
TypeScript
import { useEffect, useState, useCallback, Suspense, lazy } from "react"
|
|
import { Link, Outlet, useNavigate, useRouterState } from "@tanstack/react-router"
|
|
import { Command as CommandIcon, ChevronLeft, ChevronRight, Loader2, Menu, X } from "lucide-react"
|
|
import { NAV, navFuerPfad } from "@/nav"
|
|
import { ExpertToggle } from "@/components/ExpertToggle"
|
|
|
|
import { useHealth, useSystemStatus } from "@/lib/queries"
|
|
import type { Health } from "@/lib/api"
|
|
import { useMetricsFeeder } from "@/lib/metricsStore"
|
|
import { useEventStream } from "@/lib/events"
|
|
import { useMC, usePaletteOffen, useSchieneEingeklappt } from "@/app/store"
|
|
import { Statusleiste } from "@/app/shell/Statusleiste"
|
|
import { Meldungen } from "@/app/shell/Meldungen"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
// Beide sind beim Start UNSICHTBAR und gehoeren deshalb nicht ins Start-Buendel (P2):
|
|
// die Schublade zieht Wartung, Logs, Einstellungen und den Update-Dialog mit, die Palette
|
|
// bringt cmdk mit. Zusammen waren das rund 30 kB gzip, die niemand beim ersten Bild braucht.
|
|
// Der Router kostete P2 ungefaehr dasselbe — so bleibt das Buendel-Budget der Ampel scharf,
|
|
// statt es einfach hochzusetzen.
|
|
const SystemDrawer = lazy(() => import("@/components/SystemDrawer").then((m) => ({ default: m.SystemDrawer })))
|
|
const CommandPalette = lazy(() => import("@/components/CommandPalette").then((m) => ({ default: m.CommandPalette })))
|
|
|
|
// Dezenter Lade-Zustand während eine View nachgeladen wird (lokales Netz: kaum sichtbar).
|
|
function ViewLoading() {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin" aria-label="Lädt …" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Tastatur-Hinweis passend zum Betriebssystem (Palette hört auf Ctrl UND Cmd).
|
|
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform)
|
|
|
|
/**
|
|
* Die Shell: Schiene, Kopfzeile, System-Schublade — alles, was über JEDER Ansicht liegt.
|
|
*
|
|
* v3-Umbau P2 (28.08.2026): Hier stand vorher der Ansichts-Umschalter
|
|
* (`{view === "ideen" && <IdeenView />}`), gespeist aus einem `useState`, das am URL-Hash
|
|
* hing. Das funktionierte für Reload und Lesezeichen — aber nur auf View-Ebene. Jetzt
|
|
* rendert der Router in den `<Outlet/>`, und der Zustand DARUNTER (offene Idee, Reiter,
|
|
* Schublade) liegt in echten Suchparametern. Siehe app/router.tsx.
|
|
*/
|
|
export function Shell() {
|
|
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
|
|
useEventStream() // SSE-Invalidation-Bus (P3a): sofortige Updates, Poller nur Netz
|
|
|
|
const navigate = useNavigate()
|
|
const pfad = useRouterState({ select: (s) => s.location.pathname })
|
|
// Die Schublade liegt über allem und gehört deshalb in die Wurzel-Suche (?system=).
|
|
// Vorher war sie lokaler State und wurde per CustomEvent von außen aufgestoßen —
|
|
// nicht teilbar, nicht reload-fest.
|
|
const drawerTab = useRouterState({
|
|
select: (s) => (s.location.search as { system?: "wartung" | "logs" }).system,
|
|
})
|
|
|
|
// v3-Umbau P3: Schienen-Zustand und Palette liegen jetzt im Store (Befund B-08/B-09).
|
|
// Vorher: ein localStorage-Zugriff mitten in useState plus ein GEFAELSCHTES
|
|
// KeyboardEvent, das der Kopfzeilen-Knopf aufs Dokument feuerte.
|
|
const schieneEingeklappt = useSchieneEingeklappt()
|
|
const schieneUmschalten = useMC((st) => st.schieneUmschalten)
|
|
const paletteOffen = usePaletteOffen()
|
|
const palette = useMC((st) => st.palette)
|
|
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
|
// Die Palette wird beim ERSTEN Ruf geladen und bleibt dann gemountet — sie haelt ihren
|
|
// Inhalt und soll nicht bei jedem Strg+K neu nachgeladen werden.
|
|
const [paletteBereit, setPaletteBereit] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (paletteOffen) setPaletteBereit(true)
|
|
}, [paletteOffen])
|
|
|
|
useEffect(() => {
|
|
const taste = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
|
e.preventDefault()
|
|
palette(!useMC.getState().ui.paletteOffen)
|
|
}
|
|
}
|
|
document.addEventListener("keydown", taste)
|
|
return () => document.removeEventListener("keydown", taste)
|
|
}, [palette])
|
|
|
|
const schubladeSetzen = useCallback((tab: "wartung" | "logs" | undefined) => {
|
|
navigate({ to: ".", search: (alt) => ({ ...alt, system: tab }), replace: true })
|
|
}, [navigate])
|
|
|
|
// isError mitnehmen: React Query behält bei Fehlern die letzten Daten — ohne den
|
|
// Fehler-Blick zeigte die Sidebar bei totem Backend ewig den letzten grünen Stand.
|
|
const { data: health, isError: backendDown } = useHealth()
|
|
|
|
useEffect(() => {
|
|
document.documentElement.classList.add("dark")
|
|
}, [])
|
|
|
|
// Hier hingen bis P2 zwei CustomEvent-Zuhörer (`open-system-drawer`, `mc-navigate`),
|
|
// über die andere Ansichten die Shell fernsteuerten. In P3 sind sie ersatzlos weg:
|
|
// Wer die Schublade öffnen will, setzt `?system=`; wer springen will, navigiert. Der
|
|
// Zustand liegt damit da, wo man ihn sehen und teilen kann (Befund B-08).
|
|
|
|
// Mobile-Navigation schließt nach jedem View-Wechsel (ein Tipp = ein Ziel).
|
|
useEffect(() => { setMobileNavOpen(false) }, [pfad])
|
|
|
|
const active = navFuerPfad(pfad)
|
|
|
|
return (
|
|
<div className="flex h-full relative">
|
|
{/* Ruhiger Ambient-Hintergrund: statische radial-gradients statt animierter
|
|
Fullscreen-Blur-Filter — gleicher Look, praktisch keine GPU-Last. */}
|
|
<div
|
|
className="fixed inset-0 -z-50 pointer-events-none"
|
|
style={{
|
|
backgroundColor: "hsl(224,30%,6%)",
|
|
backgroundImage: [
|
|
"radial-gradient(45% 40% at 12% 8%, hsl(172 72% 50% / 0.10), transparent 70%)",
|
|
"radial-gradient(50% 45% at 88% 92%, hsl(270 70% 60% / 0.10), transparent 70%)",
|
|
"radial-gradient(40% 35% at 75% 30%, hsl(239 70% 62% / 0.07), transparent 70%)",
|
|
].join(", "),
|
|
}}
|
|
/>
|
|
|
|
{/* Beilaeufige Rueckmeldungen — EINE Stelle, und endlich mit aria-live (B-11). */}
|
|
<Meldungen />
|
|
|
|
{/* Erst laden, wenn wirklich gebraucht — ohne Platzhalter, beide sind Overlays. */}
|
|
<Suspense fallback={null}>
|
|
{paletteBereit && <CommandPalette />}
|
|
{drawerTab && (
|
|
<SystemDrawer
|
|
open
|
|
onClose={() => schubladeSetzen(undefined)}
|
|
defaultTab={drawerTab === "logs" ? "logs" : "maintenance"}
|
|
/>
|
|
)}
|
|
</Suspense>
|
|
|
|
{/* Sidebar (Desktop, ab md) */}
|
|
<aside className={cn(
|
|
"hidden md:flex shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md transition-all duration-300 ease-in-out",
|
|
schieneEingeklappt ? "w-16" : "w-60"
|
|
)}>
|
|
<SidebarContent
|
|
collapsed={schieneEingeklappt}
|
|
onToggleCollapse={schieneUmschalten}
|
|
pfad={pfad}
|
|
health={health}
|
|
backendDown={backendDown}
|
|
/>
|
|
</aside>
|
|
|
|
{/* Sidebar (Mobile, Off-Canvas mit Backdrop) */}
|
|
<div
|
|
className={cn(
|
|
"md:hidden fixed inset-0 z-50 transition-opacity duration-200",
|
|
mobileNavOpen ? "opacity-100" : "pointer-events-none opacity-0"
|
|
)}
|
|
aria-hidden={!mobileNavOpen}
|
|
>
|
|
<div className="absolute inset-0 bg-black/60" onClick={() => setMobileNavOpen(false)} />
|
|
<aside className={cn(
|
|
"absolute inset-y-0 left-0 flex w-72 max-w-[85vw] flex-col border-r border-border/40 bg-[hsl(224,28%,8%)] transition-transform duration-300 ease-in-out",
|
|
mobileNavOpen ? "translate-x-0" : "-translate-x-full"
|
|
)}>
|
|
<SidebarContent
|
|
collapsed={false}
|
|
onClose={() => setMobileNavOpen(false)}
|
|
pfad={pfad}
|
|
health={health}
|
|
backendDown={backendDown}
|
|
/>
|
|
</aside>
|
|
</div>
|
|
|
|
{/* Main */}
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border/40 px-4 md:px-6 bg-card/20 backdrop-blur-sm">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<button
|
|
onClick={() => setMobileNavOpen(true)}
|
|
className="md:hidden p-1.5 -ml-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
|
|
aria-label="Navigation öffnen"
|
|
>
|
|
<Menu className="h-5 w-5" aria-hidden="true" />
|
|
</button>
|
|
<div className="truncate text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active?.hint}</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<ExpertToggle />
|
|
<a
|
|
href="https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2/src/branch/main/docs/BEDIENUNG.md"
|
|
target="_blank"
|
|
rel="noopener"
|
|
className="hidden sm:flex h-8 items-center rounded-md border border-border/40 bg-background/40 px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer font-semibold"
|
|
title="Bedien-Anleitung"
|
|
>
|
|
Hilfe
|
|
</a>
|
|
|
|
<button
|
|
onClick={() => palette(true)}
|
|
className="flex items-center gap-2 rounded-md border border-border/40 bg-background/40 px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer"
|
|
>
|
|
<CommandIcon className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">Suchen</span>
|
|
<kbd className="hidden sm:inline rounded bg-muted px-1.5 py-0.5 font-mono text-[9px]">{IS_MAC ? "⌘K" : "Strg+K"}</kbd>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Bahnbreiten-Deckel (Befund B-10): Ohne ihn zieht sich das Cockpit auf einem
|
|
3440-px-Ultrawide auf ueber 3 000 px Breite; die Wurzel-Schriftgroesse waechst
|
|
dabei mit und macht die Zeilen GROESSER statt lesbarer. 1600 px sind die Grenze,
|
|
ab der eine Zeile nicht mehr am Stueck ueberblickt wird — der Rest bleibt Rand. */}
|
|
<main className="flex-1 overflow-y-auto scrollbar-thin">
|
|
<div className="mx-auto w-full max-w-[1600px] p-4 md:p-6">
|
|
<Suspense fallback={<ViewLoading />}>
|
|
<Outlet />
|
|
</Suspense>
|
|
</div>
|
|
</main>
|
|
|
|
{/* Zeigt den Zustand der Box IMMER, ohne den Arbeitsbereich zu blockieren (§4.1). */}
|
|
<Statusleiste />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Sidebar-Inhalt (Logo, Navigation, Status-Fuß) — geteilt zwischen Desktop-Leiste
|
|
// und Mobile-Off-Canvas, damit beide immer identisch bleiben.
|
|
function SidebarContent({
|
|
collapsed, onToggleCollapse, onClose, pfad, health, backendDown,
|
|
}: {
|
|
collapsed: boolean
|
|
onToggleCollapse?: () => void
|
|
onClose?: () => void
|
|
pfad: string
|
|
health: Health | undefined
|
|
backendDown: boolean
|
|
}) {
|
|
const aktiv = navFuerPfad(pfad)
|
|
// Kein Prop-Drilling mehr: derselbe Query-Key, also dieselbe Anfrage — TanStack Query
|
|
// dedupliziert. Die Versionen stehen bewusst hier unten und nicht in der Statusleiste:
|
|
// Man schaut sie selten an, und die Leiste soll knapp bleiben.
|
|
const { data: sysStatus } = useSystemStatus()
|
|
|
|
return (
|
|
<>
|
|
<div className={cn("flex items-center py-4 border-b border-border/40 shrink-0", collapsed ? "flex-col gap-3 px-2" : "justify-between px-5")}>
|
|
<div className="flex items-center gap-2 overflow-hidden">
|
|
<div className="h-7 w-7 rounded-lg bg-primary shadow-md shadow-primary/20 shrink-0" />
|
|
{!collapsed && (
|
|
<div className="leading-tight">
|
|
<div className="text-sm font-semibold tracking-wide font-space">Mission Control</div>
|
|
<div className="text-xs text-muted-foreground">2.0</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{onToggleCollapse && (
|
|
<button
|
|
onClick={onToggleCollapse}
|
|
className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
|
|
aria-label={collapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
|
|
title={collapsed ? "Maximieren" : "Minimieren"}
|
|
>
|
|
{collapsed ? <ChevronRight className="h-4 w-4" aria-hidden="true" /> : <ChevronLeft className="h-4 w-4" aria-hidden="true" />}
|
|
</button>
|
|
)}
|
|
{onClose && (
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1 rounded-md hover:bg-accent text-muted-foreground transition-colors cursor-pointer"
|
|
aria-label="Navigation schließen"
|
|
>
|
|
<X className="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<nav className="flex-1 space-y-1 px-3 py-4 overflow-y-auto">
|
|
{(() => {
|
|
let lastGroup: string | null = null
|
|
return NAV.map((item) => {
|
|
const showGroupHeader = item.group !== lastGroup
|
|
lastGroup = item.group
|
|
const istAktiv = aktiv?.id === item.id
|
|
return (
|
|
<div key={item.id} className="space-y-1">
|
|
{showGroupHeader && (
|
|
collapsed ? (
|
|
lastGroup !== NAV[0].group && <div className="my-3 border-t border-border/30" />
|
|
) : (
|
|
<div className="mt-5 mb-1.5 px-3 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/40 first:mt-0 select-none">
|
|
{item.group}
|
|
</div>
|
|
)
|
|
)}
|
|
<Link
|
|
to={item.pfad}
|
|
onClick={onClose}
|
|
aria-current={istAktiv ? "page" : undefined}
|
|
className={cn(
|
|
"flex w-full items-center rounded-md text-sm transition-all cursor-pointer",
|
|
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2",
|
|
istAktiv
|
|
? (collapsed ? "nav-active-collapsed" : "nav-active")
|
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
|
)}
|
|
title={collapsed ? item.label : undefined}
|
|
aria-label={collapsed ? item.label : undefined}
|
|
>
|
|
<item.icon className="h-4.5 w-4.5 shrink-0" aria-hidden="true" />
|
|
{!collapsed && <span className="truncate">{item.label}</span>}
|
|
</Link>
|
|
</div>
|
|
)
|
|
})
|
|
})()}
|
|
</nav>
|
|
|
|
<div className={cn("py-3 text-xs text-muted-foreground border-t border-border/40 shrink-0", collapsed ? "px-2 text-center" : "px-5")}>
|
|
{collapsed ? (
|
|
<div className="flex justify-center">
|
|
<span className={cn(
|
|
"h-2.5 w-2.5 rounded-full ring-2 ring-black/40",
|
|
backendDown || !health
|
|
? "bg-red-500"
|
|
: (!health.engine_reachable ? "bg-amber-500"
|
|
: (health.brain && !health.brain.ready ? "bg-amber-500 animate-pulse" : "bg-emerald-500 animate-pulse"))
|
|
)} title={backendDown
|
|
? "Zentrale antwortet nicht — Anzeigen evtl. veraltet"
|
|
: health
|
|
? (!health.engine_reachable ? "Engine offline"
|
|
: (health.brain && !health.brain.ready ? `Hirn offline (${health.brain.model ?? "fast"})` : "Engine + Hirn online"))
|
|
: "Backend offline"} />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2 text-left">
|
|
{backendDown ? (
|
|
<span className="flex items-center gap-2 text-red-400">
|
|
<span className="h-2 w-2 rounded-full bg-red-500 animate-pulse" />
|
|
<span className="truncate">Zentrale antwortet nicht</span>
|
|
</span>
|
|
) : health ? (
|
|
<>
|
|
<span className="flex items-center gap-2">
|
|
<span className={cn("h-2 w-2 rounded-full animate-pulse", health.engine_reachable ? "bg-emerald-500" : "bg-amber-500")} />
|
|
<span className="truncate">Engine {health.engine_reachable ? "online" : "offline"}</span>
|
|
</span>
|
|
{health.brain && !health.brain.ready && (
|
|
<span className="flex items-center gap-2 text-amber-400" title={`Agent-Hirn '${health.brain.model ?? "fast"}' lädt nicht/abgestürzt`}>
|
|
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
|
|
<span className="truncate">Hirn offline{health.brain.model ? ` (${health.brain.model})` : ""}</span>
|
|
</span>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="flex items-center gap-2">
|
|
<span className="h-2 w-2 rounded-full bg-red-500" /> <span className="truncate">Backend offline</span>
|
|
</span>
|
|
)}
|
|
|
|
{sysStatus?.versions && (
|
|
<div className="space-y-1 text-[9px] text-muted-foreground/60 mt-2 pt-2 border-t border-border/30">
|
|
<div className="truncate" title={sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.branch}-${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""} (${sysStatus.versions.mc2.date})` : "nicht gefunden"}>
|
|
<strong>MC2:</strong> {sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""}` : "—"}
|
|
</div>
|
|
<div className="truncate" title={sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.branch}-${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""} (${sysStatus.versions.engine.date})` : sysStatus.versions.engine?.version_text || "unbekannt"}>
|
|
<strong>Engine:</strong> {sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""}` : (sysStatus.versions.engine?.version_text?.split(" ").pop() || "—")}
|
|
</div>
|
|
<div className="truncate" title={sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.branch}-${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""} (${sysStatus.versions.hermes_agent.date})` : "nicht gefunden"}>
|
|
<strong>Hermes Agent:</strong> {sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""}` : "—"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|