Files
mission-control-v2/frontend/src/app/store.ts
T
HitonabiandClaude Opus 5 5aa5a484a7 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>
2026-08-28 09:07:09 +02:00

120 lines
4.8 KiB
TypeScript

import { create } from "zustand"
import { persist } from "zustand/middleware"
// Der EINE Client-Speicher (v3-Umbau P3, Befund B-08/B-09).
//
// Vorher verteilte sich derselbe Zweck über fünf Dateien: zwei handgebaute
// `useSyncExternalStore`-Speicher (Metrik-Verlauf, Expertenmodus) und drei
// `CustomEvent`-Kanäle (`mc-navigate`, `open-system-drawer`, `mc-expert-mode-change`,
// dazu in P2 `mc-palette-open`). Das funktionierte — aber ein Tippfehler im
// Ereignisnamen fiel erst zur Laufzeit auf, und niemand konnte auf einen Blick sagen,
// welcher Zustand es überhaupt gibt.
//
// DIE REGEL, nach der hier etwas landet:
// · Steht es in der URL? → gehört in den Router (app/router.tsx), nicht hierher.
// · Kommt es vom Server? → gehört in TanStack Query, nicht hierher.
// · Ist es flüchtig (Hover, Entwurf im Eingabefeld)? → useState in der Komponente.
// · Sonst: hier.
//
// Der Metrik-Verlauf bleibt bewusst DRAUSSEN (lib/metricsStore.ts). Er nimmt ab P4
// jede Sekunde einen Messpunkt entgegen; ein Store-Update pro Sekunde würde jede
// abonnierende Komponente neu rendern. Ein eigener `useSyncExternalStore` außerhalb
// von React ist dafür das richtige Werkzeug — und bleibt es.
export type MeldungsArt = "info" | "erfolg" | "warnung" | "fehler"
export interface Meldung {
id: string
art: MeldungsArt
text: string
/** Zeitpunkt in ms — die Meldungsliste blendet Alte selbst aus. */
seit: number
}
/** Zustand des Ereignisstroms. Ab P4 füttert ihn der Stream selbst; in P3 setzt ihn
* lib/events.ts, damit die Statusleiste schon jetzt die Wahrheit zeigt. */
export type StromLage = "live" | "nachlauf" | "getrennt"
interface MC {
ui: {
schieneEingeklappt: boolean
paletteOffen: boolean
expertenmodus: boolean
}
strom: {
lage: StromLage
letztesEreignis: number | null
}
meldungen: Meldung[]
schieneUmschalten: () => void
palette: (offen: boolean) => void
expertenmodus: (an: boolean) => void
stromLage: (lage: StromLage) => void
stromEreignis: () => void
melden: (art: MeldungsArt, text: string) => void
meldungWeg: (id: string) => void
}
// Eigener Zähler statt Date.now()/Math.random(): zwei Meldungen in derselben
// Millisekunde bekamen sonst denselben Schlüssel, und React zeigte nur eine an.
let meldungsZaehler = 0
export const useMC = create<MC>()(
persist(
(set) => ({
ui: { schieneEingeklappt: false, paletteOffen: false, expertenmodus: false },
strom: { lage: "getrennt", letztesEreignis: null },
meldungen: [],
schieneUmschalten: () =>
set((s) => ({ ui: { ...s.ui, schieneEingeklappt: !s.ui.schieneEingeklappt } })),
palette: (offen) => set((s) => ({ ui: { ...s.ui, paletteOffen: offen } })),
expertenmodus: (an) => set((s) => ({ ui: { ...s.ui, expertenmodus: an } })),
stromLage: (lage) => set((s) => ({ strom: { ...s.strom, lage } })),
stromEreignis: () =>
set((s) => ({ strom: { ...s.strom, letztesEreignis: Date.now() } })),
melden: (art, text) =>
set((s) => ({
meldungen: [
...s.meldungen,
{ id: `m${++meldungsZaehler}`, art, text, seit: Date.now() },
].slice(-6), // mehr als sechs liest ohnehin niemand
})),
meldungWeg: (id) => set((s) => ({ meldungen: s.meldungen.filter((m) => m.id !== id) })),
}),
{
name: "mc_ui",
// NUR Bedienvorlieben werden gemerkt. Kein Geheimnis (siehe P1/B-01), kein
// Serverdatum, kein Stromzustand — der wäre beim nächsten Start ohnehin gelogen.
partialize: (s) => ({
ui: { schieneEingeklappt: s.ui.schieneEingeklappt, expertenmodus: s.ui.expertenmodus },
}),
merge: (gespeichert, aktuell) => {
const g = gespeichert as { ui?: Partial<MC["ui"]> } | undefined
return { ...aktuell, ui: { ...aktuell.ui, ...(g?.ui ?? {}) } }
},
},
),
)
// ── Schmale Zugriffe ────────────────────────────────────────────────────────
// Komponenten sollen nur das abonnieren, was sie wirklich brauchen — sonst rendert
// die Schiene neu, weil eine Meldung kam.
export const useExpertenmodus = () => useMC((s) => s.ui.expertenmodus)
export const useSchieneEingeklappt = () => useMC((s) => s.ui.schieneEingeklappt)
export const usePaletteOffen = () => useMC((s) => s.ui.paletteOffen)
export const useStromLage = () => useMC((s) => s.strom.lage)
export const useMeldungen = () => useMC((s) => s.meldungen)
/** Für Code außerhalb von React (lib/events.ts, api-Fehlerbehandlung). */
export const melden = (art: MeldungsArt, text: string) => useMC.getState().melden(art, text)
export const stromLageSetzen = (lage: StromLage) => useMC.getState().stromLage(lage)
export const stromEreignis = () => useMC.getState().stromEreignis()