feat(frontend): v3-Umbau P3 — ein Store, eine Statusleiste, endlich aria-live

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>
This commit is contained in:
Hitonabi
2026-08-28 09:07:09 +02:00
co-authored by Claude Opus 5
parent 464b0d20b8
commit 5aa5a484a7
57 changed files with 981 additions and 432 deletions
+54 -55
View File
@@ -5,9 +5,12 @@ import { NAV, navFuerPfad } from "@/nav"
import { ExpertToggle } from "@/components/ExpertToggle"
import { useHealth, useSystemStatus } from "@/lib/queries"
import type { Health, SystemStatus } from "@/lib/api"
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):
@@ -52,60 +55,49 @@ export function Shell() {
select: (s) => (s.location.search as { system?: "wartung" | "logs" }).system,
})
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
// 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
// eigenen Offen-Zustand und soll nicht bei jedem Strg+K neu nachgeladen werden.
// Inhalt und soll nicht bei jedem Strg+K neu nachgeladen werden.
const [paletteBereit, setPaletteBereit] = useState(false)
useEffect(() => {
const wecken = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") setPaletteBereit(true)
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)
}
}
const rufen = () => setPaletteBereit(true)
document.addEventListener("keydown", wecken)
window.addEventListener("mc-palette-open", rufen)
return () => {
document.removeEventListener("keydown", wecken)
window.removeEventListener("mc-palette-open", rufen)
}
}, [])
document.addEventListener("keydown", taste)
return () => document.removeEventListener("keydown", taste)
}, [palette])
const schubladeSetzen = useCallback((tab: "wartung" | "logs" | undefined) => {
navigate({ to: ".", search: (alt: Record<string, unknown>) => ({ ...alt, system: tab }), replace: true })
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()
const { data: sysStatus } = useSystemStatus()
useEffect(() => {
document.documentElement.classList.add("dark")
}, [])
// Zwei Alt-Kanäle aus der Zeit vor dem Router. Sie bleiben in P2 bestehen, damit die
// Aufrufer (Cockpit-Kacheln, Agent-Ansicht, Auftragsbuch) unverändert weiterlaufen —
// gefüttert wird jetzt aber der Router, nicht mehr ein lokales useState.
// In P3 lösen Store und Palette diese Kanäle ganz ab (Befund B-08).
useEffect(() => {
const handleOpen = (e: Event) => {
const tab = (e as CustomEvent).detail?.tab
schubladeSetzen(tab === "logs" ? "logs" : "wartung")
}
window.addEventListener("open-system-drawer", handleOpen)
return () => window.removeEventListener("open-system-drawer", handleOpen)
}, [schubladeSetzen])
useEffect(() => {
const handleNav = (e: Event) => {
const v = (e as CustomEvent).detail?.view as string | undefined
const ziel = NAV.find((n) => n.id === v)
if (ziel) navigate({ to: ziel.pfad })
}
window.addEventListener("mc-navigate", handleNav)
return () => window.removeEventListener("mc-navigate", handleNav)
}, [navigate])
// 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])
@@ -128,6 +120,9 @@ export function Shell() {
}}
/>
{/* 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 />}
@@ -143,21 +138,14 @@ export function Shell() {
{/* 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",
sidebarCollapsed ? "w-16" : "w-60"
schieneEingeklappt ? "w-16" : "w-60"
)}>
<SidebarContent
collapsed={sidebarCollapsed}
onToggleCollapse={() => {
setSidebarCollapsed((c) => {
const next = !c
localStorage.setItem("mc_sidebar_collapsed", next.toString())
return next
})
}}
collapsed={schieneEingeklappt}
onToggleCollapse={schieneUmschalten}
pfad={pfad}
health={health}
backendDown={backendDown}
sysStatus={sysStatus}
/>
</aside>
@@ -180,7 +168,6 @@ export function Shell() {
pfad={pfad}
health={health}
backendDown={backendDown}
sysStatus={sysStatus}
/>
</aside>
</div>
@@ -211,7 +198,7 @@ export function Shell() {
</a>
<button
onClick={() => window.dispatchEvent(new CustomEvent("mc-palette-open"))}
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" />
@@ -221,11 +208,20 @@ export function Shell() {
</div>
</header>
<main className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin">
<Suspense fallback={<ViewLoading />}>
<Outlet />
</Suspense>
{/* 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>
)
@@ -234,7 +230,7 @@ export function Shell() {
// 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, sysStatus,
collapsed, onToggleCollapse, onClose, pfad, health, backendDown,
}: {
collapsed: boolean
onToggleCollapse?: () => void
@@ -242,9 +238,12 @@ function SidebarContent({
pfad: string
health: Health | undefined
backendDown: boolean
sysStatus: SystemStatus | undefined
}) {
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 (
<>