feat(stream): v3-Umbau P4 — Messwerte kommen gepusht statt gepollt

Fuenfte Etappe. Der Ereignisstrom war bisher ein reiner Anstoss-Bus, und die
Messwerte holte sich das Frontend im 3-Sekunden-Takt selbst — zwei Dauer-Anfragen,
unabhaengig davon, ob sich etwas geaendert hatte (Befund B-12).

## Backend: /api/stream

routers/events.py bedient jetzt zwei Endpunkte aus EINEM Sammler:

  /api/stream   `invalidate` (bei Aenderung) + `metrik` (jede Sekunde)
  /api/events   nur `invalidate` — bleibt EINE Fassung lang stehen, weil ein
                Browser-Tab nach einem Deploy noch das vorige Buendel halten kann
                und dieses nur /api/events kennt

Dazu services/system.py → metrik_punkt(): ein bewusst LEICHTER Messpunkt.
`system_status()` waere hier falsch — es ruft `psutil.cpu_percent(interval=0.1)`
und blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt 10 % der Zeit),
plus den Versions-Check, den niemand sekuendlich braucht. Gemessen: 0,2 ms je
Punkt mit `interval=None`.

Token stehen als GESAMTZAEHLER im Ereignis, nicht als Rate. So bleibt der Server
zustandslos und ein verpasster Punkt verfaelscht nichts — der Klient rechnet die
Rate aus zwei Punkten.

Neu im Fingerabdruck: Jobs (Zustand + Fortschritt). Damit ist auch der 3-s-Poller
der System-Schublade nur noch Sicherheitsnetz.

## Was bewusst FEHLT

Kein `agent`-Thema fuer Lucys Denkschritte. MC2 kann Hermes' interne Schritte nicht
sehen, ohne dessen Quellcode zu patchen — per AGENTS.md verboten. Eine leere Leitung
zu bauen waere eine Zusage, die keiner einloest. Das betrifft die Agent-Matrix aus
§4.4 der Spezifikation; sie braucht zuerst eine Datenquelle.

## Frontend

lib/events.ts hoert auf /api/stream und schreibt `metrik` direkt in den
Metrik-Speicher. Der bleibt bewusst ein useSyncExternalStore AUSSERHALB von React
(nicht der Zustand-Store aus P3): Bei einem Wert pro Sekunde wuerde ein Store-Update
jede abonnierende Komponente neu rendern.

## Gedrosselt statt abgeschaltet — eine Korrektur am eigenen Entwurf

Der erste Wurf schaltete beide Poller bei stehendem Strom komplett ab (`false`).
Das waere falsch gewesen: Beide Antworten tragen mehr als Messwerte —
/api/system/status die Versions-Hashes fuer den Schienen-Fuss, /api/system/token-stats
die Gesamtsumme und die Cloud-Ersparnis, fuer die das Backend die Tarife aufloest
(die Preis-Logik ist dort die einzige Wahrheit; sie im Klienten nachzubauen waere
eine zweite). Beides waere eingefroren.

Jetzt 3 s → 60 s bei stehendem Strom: ein Zwanzigstel der Last, und die Randdaten
bleiben frisch. Die MESSWERTE selbst kommen aus dem Strom — useSystemHistory legt
den letzten Messpunkt ueber die Query-Antwort, damit Legende, Temperatur und
Betriebszeit nicht zwischen zwei Minuten-Abfragen stehen bleiben.

## Verifiziert

  Server: 12 `metrik`-Ereignisse in den ersten 4 kB des Stroms (1/s)
  Server-Log ueber die ganze Prozesslaufzeit: /api/system/status 3 Anfragen,
    /api/system/token-stats 3 Anfragen — vorher waere das eine je 3 Sekunden gewesen
  Browser: Statusleiste zaehlt live weiter (Speicher 14,6 → 12,9 GB, CPU 3 → 2 %,
    Betriebszeit 1:22 → 1:23) bei NULL fetch-Aufrufen im 49-s-Fenster
  Cockpit: beide Diagramme rendern (2 Container, 5 Flaechen)
  /api/events antwortet weiterhin (Alt-Tab im Log)

Einschraenkung, ehrlich: Die Browser-Pane war waehrend der Messung verborgen, und
TanStack Query pausiert Intervalle in Hintergrund-Tabs. Die Null im Klienten ist
daher KEIN sauberer Beleg fuer die Drosselung — der Server-Log ist es. Nebenbefund:
Der Strom laeuft auch im Hintergrund-Tab weiter, die Poller nicht.

37/37 Tests gruen (4 neue fuer pushMetrik: Ratenbildung, Zaehler-Ruecksprung,
letzter Messpunkt; MAX_POINTS ist jetzt exportiert, damit der Deckel-Test nicht
wieder gegen eine veraltete Kopie prueft) · ESLint 0 Fehler · Einstieg 118 582 B
gzip / Budget 125 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 09:16:29 +02:00
co-authored by Claude Opus 5
parent 5aa5a484a7
commit b74e98ffbb
40 changed files with 663 additions and 379 deletions
+16 -9
View File
@@ -1,6 +1,6 @@
import { Link } from "@tanstack/react-router"
import { useHealth, useRouting, useSystemStatus, useTokenStats } from "@/lib/queries"
import { useSysHistory, useTokHistory } from "@/lib/metricsStore"
import { useLetzteMetrik, useSysHistory, useTokHistory } from "@/lib/metricsStore"
import { useStromLage } from "@/app/store"
import { gb } from "@/lib/format"
import { cn } from "@/lib/utils"
@@ -80,10 +80,17 @@ export function Statusleiste() {
// (Merksatz aus dem Projekt: Rollen-Aliase statt Modellnamen.)
const aktivesModell = health?.brain?.role ?? routing?.lanes?.[0]?.name ?? null
const ramProzent = sys?.ram?.percent ?? 0
const tempMax = Math.max(sys?.temp?.cpu ?? 0, sys?.temp?.gpu ?? 0)
// Live-Werte kommen aus dem Strom (jede Sekunde); die Query ist nur der Stand vom
// Seitenaufbau und der Rückfall, wenn der Strom gerissen ist (v3-Umbau P4).
const live = useLetzteMetrik()
const cpuProzent = live?.cpu ?? sys?.cpu?.percent ?? 0
const gpuProzent = live?.gpu ?? sys?.gpu?.busy_percent ?? null
const ramProzent = live?.ram ?? sys?.ram?.percent ?? 0
const ramBelegt = live?.ram_used ?? sys?.ram?.used
const ramGesamt = live?.ram_total ?? sys?.ram?.total
const tempMax = Math.max(live?.temp_cpu ?? sys?.temp?.cpu ?? 0, live?.temp_gpu ?? sys?.temp?.gpu ?? 0)
const durchsatz = tokVerlauf.length ? tokVerlauf[tokVerlauf.length - 1].completion : 0
const zeit = betriebszeit(sys?.uptime_s)
const zeit = betriebszeit(live?.uptime_s ?? sys?.uptime_s)
const LAGE_STIL: Record<typeof lage, { punkt: string; text: string; titel: string }> = {
live: { punkt: "bg-emerald-500", text: "Live", titel: "Ereignisstrom steht — Änderungen erscheinen sofort." },
@@ -117,7 +124,7 @@ export function Statusleiste() {
</Feld>
{/* Geteilter Speicher — die Kernzahl dieser Box, deshalb als Balken. */}
<Feld titel={sys ? `Geteilter Speicher (Unified Memory): ${gb(sys.ram.used)} von ${gb(sys.ram.total)} GB belegt` : "Speicher"}>
<Feld titel={ramBelegt != null && ramGesamt != null ? `Geteilter Speicher (Unified Memory): ${gb(ramBelegt)} von ${gb(ramGesamt)} GB belegt` : "Speicher"}>
<span className="text-muted-foreground">Speicher</span>
<div className="h-2 w-24 overflow-hidden rounded-sm border border-border/50 bg-background/60" aria-hidden="true">
<div
@@ -127,19 +134,19 @@ export function Statusleiste() {
/>
</div>
<span className="tabular-nums text-foreground">
{sys ? `${gb(sys.ram.used)}/${gb(sys.ram.total)} GB` : "—"}
{ramBelegt != null && ramGesamt != null ? `${gb(ramBelegt)}/${gb(ramGesamt)} GB` : "—"}
</span>
</Feld>
{/* Auslastung */}
<Feld titel="Auslastung von Prozessor und Grafikeinheit">
<span className="text-muted-foreground">CPU</span>
<span className="tabular-nums text-foreground">{Math.round(sys?.cpu?.percent ?? 0)} %</span>
<span className="tabular-nums text-foreground">{Math.round(cpuProzent)} %</span>
<Sparkline werte={sysVerlauf.map((p) => p.cpu)} farbe="hsl(199 89% 58%)" />
{sys?.gpu?.busy_percent != null && (
{gpuProzent != null && (
<>
<span className="ml-1 text-muted-foreground">GPU</span>
<span className="tabular-nums text-foreground">{Math.round(sys.gpu.busy_percent)} %</span>
<span className="tabular-nums text-foreground">{Math.round(gpuProzent)} %</span>
</>
)}
</Feld>
+40 -14
View File
@@ -1,31 +1,51 @@
// SSE-Eventstrom — Gegenstück zu backend/routers/events.py.
// Ereignisstrom — Gegenstück zu backend/routers/events.py.
//
// EIN EventSource auf /api/events; `invalidate`-Events stoßen gezielt die React-Query-
// Caches an. Solange der Strom steht, sind die Poller nur noch Sicherheitsnetz
// (queries.ts entspannt sie ×5 über sseVerbunden()). Reißt der Strom (MC2-Deploy,
// Netz), reconnectet EventSource von selbst — bis dahin pollt die UI wie früher.
// EIN EventSource auf /api/stream. Zwei Ereignisarten:
// · `invalidate` stößt gezielt die betroffenen React-Query-Caches an
// · `metrik` schreibt jede Sekunde direkt in den Metrik-Speicher (v3-Umbau P4)
//
// v3-Umbau P3 (28.08.2026): Der Verbindungszustand wandert zusätzlich in den Store,
// damit die Statusleiste ihn ANZEIGEN kann. Vorher war er eine Modul-Variable, die nur
// queries.ts las — die Oberfläche alterte bei gerissenem Strom still weiter (Befund
// B-15). Jetzt steht dort „Live", „Nachlauf" oder „Getrennt", und man weiß, worauf man
// gerade schaut.
// Solange der Strom steht, sind die Poller nur noch Sicherheitsnetz (queries.ts entspannt
// sie ×5 über sseVerbunden()). Reißt er, reconnectet EventSource von selbst — bis dahin
// pollt die UI wie früher, und die Statusleiste sagt es ehrlich.
//
// Ebenfalls P3 (Befund B-18): Beim Wechsel des Zustands wird EINMAL invalidiert. Vorher
// las `relax()` den Zustand erst beim nächsten Refetch — nach einem Riss blieb die UI
// bis zu einem vollen entspannten Takt (bis 150 s) im langsamen Modus.
// v3-Umbau P4: Die Messpunkte kommen jetzt GEPUSHT statt gepollt. Vorher fragten
// `system-status` und `token-stats` im 3-Sekunden-Takt nach — zwei Dauer-Anfragen,
// unabhängig davon, ob sich etwas geändert hatte. Beide Poller sind ersatzlos weg,
// solange der Strom steht.
//
// WICHTIG (der Grund, warum der Metrik-Speicher kein Store ist): `metrik` feuert jede
// Sekunde. Der Speicher liegt bewusst AUSSERHALB von React (lib/metricsStore.ts,
// useSyncExternalStore) — so rendern nur die Karten neu, die den Verlauf wirklich
// abonnieren, nicht die ganze Shell.
import { useEffect } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { melden, stromEreignis, stromLageSetzen } from "@/app/store"
import { pushMetrik } from "@/lib/metricsStore"
let verbunden = false
export const sseVerbunden = () => verbunden
/** Rohform eines `metrik`-Ereignisses (siehe services/system.py → metrik_punkt). */
export interface MetrikPunkt {
cpu: number
ram: number
ram_used: number
ram_total: number
gpu: number | null
disk: number | null
temp_cpu: number | null
temp_gpu: number | null
uptime_s: number | null
/** GESAMTZÄHLER, keine Rate — die rechnet der Speicher aus zwei Punkten selbst. */
tok_p: number
tok_c: number
}
export function useEventStream() {
const qc = useQueryClient()
useEffect(() => {
const es = new EventSource("/api/events")
const es = new EventSource("/api/stream")
// Der erste Verbindungsaufbau ist der Normalfall und wird NICHT gemeldet — sonst
// begrüßt jede Seite den Nutzer mit „Verbindung wieder da". Gemeldet wird nur der
// Übergang: einmal beim Reißen, einmal beim Heilen.
@@ -60,6 +80,12 @@ export function useEventStream() {
} catch { /* kaputtes Event bricht den Strom nicht */ }
})
es.addEventListener("metrik", (e) => {
try {
pushMetrik(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
} catch { /* ein kaputter Messpunkt darf den Strom nicht reißen */ }
})
return () => {
verbunden = false
stromLageSetzen("getrennt")
+74 -13
View File
@@ -1,13 +1,21 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { act, renderHook } from "@testing-library/react"
import type { MetrikPunkt } from "./events"
// Der Verlaufsspeicher ist modul-global (er soll Tab-Wechsel ueberleben). Fuer jeden Test
// eine frische Modul-Instanz, sonst tragen sich die Faelle gegenseitig Daten ein.
// Der Verlaufsspeicher ist modul-global (er soll Tab-Wechsel überleben). Für jeden Test
// eine frische Modul-Instanz, sonst tragen sich die Fälle gegenseitig Daten ein.
async function frischerStore() {
vi.resetModules()
return await import("./metricsStore")
}
function punkt(p: Partial<MetrikPunkt> = {}): MetrikPunkt {
return {
cpu: 10, ram: 20, ram_used: 1, ram_total: 2, gpu: null, disk: null,
temp_cpu: null, temp_gpu: null, uptime_s: 100, tok_p: 0, tok_c: 0, ...p,
}
}
describe("metricsStore", () => {
let store: Awaited<ReturnType<typeof frischerStore>>
@@ -27,19 +35,20 @@ describe("metricsStore", () => {
expect(result.current[0].cpu).toBe(10)
})
// Ohne Deckel waechst der Verlauf unbegrenzt — bei 3-s-Takt sind das ~28 800 Punkte
// pro Tag in einem Dauerlaeufer-Tab.
it("deckelt den Verlauf und behaelt die JUENGSTEN Punkte", () => {
// Ohne Deckel wächst der Verlauf unbegrenzt — bei 1-s-Takt sind das ~86 400 Punkte
// pro Tag in einem Dauerläufer-Tab.
it("deckelt den Verlauf und behält die JÜNGSTEN Punkte", () => {
const { result } = renderHook(() => store.useSysHistory())
const n = store.MAX_POINTS + 20
act(() => {
for (let i = 0; i < 60; i++) store.pushSys({ t: i, cpu: i, ram: 0, gpu: null, disk: null })
for (let i = 0; i < n; i++) store.pushSys({ t: i, cpu: i, ram: 0, gpu: null, disk: null })
})
expect(result.current).toHaveLength(40)
expect(result.current[0].t).toBe(20)
expect(result.current[39].t).toBe(59)
expect(result.current).toHaveLength(store.MAX_POINTS)
expect(result.current[0].t).toBe(n - store.MAX_POINTS)
expect(result.current[store.MAX_POINTS - 1].t).toBe(n - 1)
})
it("haelt System- und Token-Verlauf auseinander", () => {
it("hält System- und Token-Verlauf auseinander", () => {
const { result: sys } = renderHook(() => store.useSysHistory())
const { result: tok } = renderHook(() => store.useTokHistory())
act(() => store.pushTok({ t: 1, prompt: 5, completion: 7 }))
@@ -48,12 +57,64 @@ describe("metricsStore", () => {
expect(tok.current[0].completion).toBe(7)
})
// useSyncExternalStore vergleicht per Referenz: wuerde pushSys das Array an Ort und
// Stelle veraendern, bliebe die Anzeige stehen, obwohl Daten da sind.
it("liefert bei jeder Aenderung ein NEUES Array (Referenzwechsel)", () => {
// useSyncExternalStore vergleicht per Referenz: würde pushSys das Array an Ort und
// Stelle verändern, bliebe die Anzeige stehen, obwohl Daten da sind.
it("liefert bei jeder Änderung ein NEUES Array (Referenzwechsel)", () => {
const { result } = renderHook(() => store.useSysHistory())
const vorher = result.current
act(() => store.pushSys({ t: 1, cpu: 1, ram: 1, gpu: null, disk: null }))
expect(result.current).not.toBe(vorher)
})
})
// ── Der Einstieg des Ereignisstroms (v3-Umbau P4) ───────────────────────────
// Der Server schickt Token-GESAMTZÄHLER, keine Raten — die Umrechnung passiert hier.
// Genau dort steckt die Fehleranfälligkeit: ein Backend-Neustart setzt die Zähler
// zurück, und aus einem Rückwärtssprung darf keine negative Rate werden.
describe("pushMetrik (Strom-Einstieg)", () => {
let store: Awaited<ReturnType<typeof frischerStore>>
beforeEach(async () => {
store = await frischerStore()
vi.useFakeTimers()
})
it("schreibt beim ersten Punkt NOCH KEINE Rate — dafür fehlt der Vorgänger", () => {
const { result: sys } = renderHook(() => store.useSysHistory())
const { result: tok } = renderHook(() => store.useTokHistory())
act(() => store.pushMetrik(punkt({ tok_p: 100, tok_c: 50 })))
expect(sys.current).toHaveLength(1)
expect(tok.current).toHaveLength(0)
})
it("rechnet aus zwei Zählerständen eine Rate pro Sekunde", () => {
const { result } = renderHook(() => store.useTokHistory())
act(() => store.pushMetrik(punkt({ tok_p: 100, tok_c: 50 })))
act(() => {
vi.advanceTimersByTime(2_000)
store.pushMetrik(punkt({ tok_p: 300, tok_c: 90 }))
})
expect(result.current).toHaveLength(1)
expect(result.current[0].prompt).toBeCloseTo(100, 1) // 200 Token in 2 s
expect(result.current[0].completion).toBeCloseTo(20, 1) // 40 Token in 2 s
})
it("macht aus einem Zähler-Rücksprung keine negative Rate", () => {
const { result } = renderHook(() => store.useTokHistory())
act(() => store.pushMetrik(punkt({ tok_p: 5_000, tok_c: 5_000 })))
act(() => {
vi.advanceTimersByTime(1_000)
store.pushMetrik(punkt({ tok_p: 0, tok_c: 0 })) // Backend neu gestartet
})
expect(result.current[0].prompt).toBe(0)
expect(result.current[0].completion).toBe(0)
})
it("hält den letzten Messpunkt für die Statusleiste bereit", () => {
const { result } = renderHook(() => store.useLetzteMetrik())
expect(result.current).toBeNull()
act(() => store.pushMetrik(punkt({ uptime_s: 4_242, temp_cpu: 71 })))
expect(result.current?.uptime_s).toBe(4_242)
expect(result.current?.temp_cpu).toBe(71)
})
})
+63 -9
View File
@@ -1,12 +1,23 @@
import { useEffect, useRef, useSyncExternalStore } from "react"
import { useSystemStatus, useTokenStats } from "@/lib/queries"
import type { MetrikPunkt } from "@/lib/events"
export type SysSample = { t: number; cpu: number; ram: number; gpu: number | null; disk: number | null }
export type RateSample = { t: number; prompt: number; completion: number }
const MAX_POINTS = 40 // bei 3s-Poll ~2 Min Live-Verlauf
// v3-Umbau P4: Der Strom liefert jede Sekunde einen Punkt statt alle drei. Bei gleicher
// Fenstergröße (~2 Min Live-Verlauf) heißt das dreimal so viele Punkte.
// Exportiert, damit der Test gegen DIESEN Wert prüft und nicht gegen eine Kopie, die
// beim nächsten Takt-Wechsel still danebenliegt.
export const MAX_POINTS = 120
// Modul-globaler Verlauf: überlebt Mount/Unmount (Tab-Wechsel) → Graphen starten nie leer.
//
// Bewusst KEIN Zustand-Store (v3-Umbau P3/P4): Hier landet jede Sekunde ein Wert. Ein
// Store-Update pro Sekunde würde jede abonnierende Komponente neu rendern; mit einem
// eigenen `useSyncExternalStore` außerhalb von React rendern nur die Karten neu, die den
// Verlauf wirklich lesen. Das ist der Grund, warum diese Datei nicht in app/store.ts
// aufgegangen ist.
let sysHist: SysSample[] = []
let tokHist: RateSample[] = []
const listeners = new Set<() => void>()
@@ -20,11 +31,50 @@ function subscribe(l: () => void) {
export function pushSys(s: SysSample) { sysHist = [...sysHist, s].slice(-MAX_POINTS); emit() }
export function pushTok(s: RateSample) { tokHist = [...tokHist, s].slice(-MAX_POINTS); emit() }
// Der ZULETZT empfangene Messpunkt. Warum eigens gehalten: Seit P4 ist der Poller von
// /api/system/status abgeschaltet, solange der Strom steht — die Query liefert dann nur
// noch den Stand vom Seitenaufbau. Ohne diesen Wert froeren Betriebszeit, Temperatur und
// die aktuellen Prozentzahlen bei der ersten gemessenen Sekunde ein.
let letzteMetrik: MetrikPunkt | null = null
export const useSysHistory = () => useSyncExternalStore(subscribe, () => sysHist)
export const useTokHistory = () => useSyncExternalStore(subscribe, () => tokHist)
export const useLetzteMetrik = () => useSyncExternalStore(subscribe, () => letzteMetrik)
/** Immer-laufender Sammler (in App eingehängt, also stets gemountet): füttert den
* Store bei jedem Poll — unabhängig davon, welcher Tab gerade sichtbar ist. */
// Letzter Token-Gesamtstand, um daraus die Rate zu bilden. Der Server schickt bewusst
// Zähler statt Raten — so ist er zustandslos und ein verpasster Punkt verfälscht nichts.
let letzteToken: { p: number; c: number; t: number } | null = null
/** Einstieg für den Ereignisstrom (lib/events.ts). Ein Messpunkt → beide Verläufe. */
export function pushMetrik(m: MetrikPunkt) {
const jetzt = Date.now()
letzteMetrik = m
pushSys({ t: jetzt, cpu: m.cpu ?? 0, ram: m.ram ?? 0, gpu: m.gpu, disk: m.disk })
if (letzteToken) {
const dt = Math.max((jetzt - letzteToken.t) / 1000, 0.001)
// Nie negativ: Startet das Backend neu, springen die Zähler zurück — daraus eine
// negative Rate zu zeichnen wäre schlicht falsch.
pushTok({
t: jetzt,
prompt: Math.max(0, (m.tok_p - letzteToken.p) / dt),
completion: Math.max(0, (m.tok_c - letzteToken.c) / dt),
})
}
letzteToken = { p: m.tok_p, c: m.tok_c, t: jetzt }
}
/** Sammler für den Rückfall-Betrieb (in der Shell eingehängt, also stets gemountet).
*
* Seit P4 kommen die Messpunkte über den Strom. Dieser Sammler füttert den Verlauf nur
* noch aus den POLLERN — also genau dann, wenn der Strom gerissen ist und die Queries
* wieder im Normaltakt nachfragen. Ohne ihn stünden die Graphen bei einem Ausfall still,
* obwohl die Daten da wären.
*
* Doppelte Punkte kann es nicht geben: Steht der Strom, sind die Poller entspannt und
* liefern kaum neue Zeitstempel; der Filter unten wirft alles weg, was jünger als eine
* Sekunde nach dem letzten Punkt ist.
*/
export function useMetricsFeeder() {
const { data: sys, dataUpdatedAt: sysAt } = useSystemStatus()
const { data: ts, dataUpdatedAt: tsAt } = useTokenStats()
@@ -32,8 +82,10 @@ export function useMetricsFeeder() {
useEffect(() => {
if (!sys) return
const jetzt = Date.now()
if (sysHist.length && jetzt - sysHist[sysHist.length - 1].t < 1_000) return
pushSys({
t: Date.now(),
t: jetzt,
cpu: sys.cpu?.percent ?? 0,
ram: sys.ram?.percent ?? 0,
gpu: sys.gpu?.busy_percent ?? null,
@@ -48,11 +100,13 @@ export function useMetricsFeeder() {
const p = ts.prompt_tokens, c = ts.completion_tokens
if (prevTok.current) {
const dt = Math.max((now - prevTok.current.t) / 1000, 0.001)
pushTok({
t: now,
prompt: Math.max(0, (p - prevTok.current.p) / dt),
completion: Math.max(0, (c - prevTok.current.c) / dt),
})
if (!tokHist.length || now - tokHist[tokHist.length - 1].t >= 1_000) {
pushTok({
t: now,
prompt: Math.max(0, (p - prevTok.current.p) / dt),
completion: Math.max(0, (c - prevTok.current.c) / dt),
})
}
}
prevTok.current = { p, c, t: now }
// eslint-disable-next-line react-hooks/exhaustive-deps
+21 -4
View File
@@ -68,7 +68,7 @@ export const qk = {
// Polling ist nur das Sicherheitsnetz. Komponenten übergeben KEINE eigenen
// Intervalle mehr (mehrere Beobachter = kleinstes Intervall gewinnt).
const TAKT = {
graph: 3_000, // Live-Graphen im Cockpit (System, Token-Durchsatz)
graph: 3_000, // System + Token: seit P4 nur noch Rueckfall/Randdaten (siehe unten)
schnell: 8_000, // „fühlt sich live an": Modelle, Voice-Trace, Auftragsbuch
normal: 10_000, // Dienste, Routing, Agent-Status, Health
gemuetlich: 30_000, // Chronik, Ideen
@@ -81,6 +81,19 @@ const TAKT = {
// reconnectet selbst). Live-Graphen/Status bleiben bewusst beim Polling.
const relax = (ms: number) => () => (sseVerbunden() ? ms * 5 : ms)
// UMBAU v3 P4: Die MESSWERTE (CPU, RAM, GPU, Temperatur, Token-Zähler, Betriebszeit)
// kommen jetzt gepusht — Ereignis `metrik`, jede Sekunde. Der Poller dieser beiden
// Endpunkte war die halbe Grundlast der Startseite (Befund B-12).
//
// Er wird trotzdem NICHT abgeschaltet, sondern gedrosselt. Grund: Beide Antworten tragen
// mehr als die Messwerte — `/api/system/status` liefert die Versions-Hashes (Sidebar-Fuß),
// `/api/system/token-stats` die Gesamtsumme und die Cloud-Ersparnis, für die das Backend
// die Tarife auflöst (Preis-Logik ist dort die einzige Wahrheit, sie hier nachzubauen wäre
// eine zweite). Beides ändert sich langsam — einmal pro Minute reicht dafür völlig.
//
// 3 s → 60 s ist eine Zwanzigstel-Last; abschalten hätte diese Felder einfrieren lassen.
const langsamWennStrom = (ms: number) => () => (sseVerbunden() ? 60_000 : ms)
// Auftragsbuch: Vorschlags-Inbox (Branches + Skill-Kandidaten). Pollt, damit laufende
// Annahme-Läufe (Status aus der JSON-Datei des Runners) live sichtbar werden.
export const useAuftragsbuch = (refetchInterval: number = TAKT.schnell) =>
@@ -158,7 +171,8 @@ export const useHealth = () =>
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: TAKT.normal })
export const useSystemStatus = (refetchInterval: number = TAKT.graph) =>
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval })
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"),
refetchInterval: langsamWennStrom(refetchInterval) })
// Metrik-Verlauf (Cockpit-Zeitachse): 1h/24h aus dem Backend-Ringpuffer. Nur aktiv,
// wenn eine Karte gerade diesen Bereich zeigt (enabled) — Live bleibt beim 3s-Store.
@@ -193,13 +207,16 @@ export const useJobs = (refetchInterval = 3_000, enabled = true) =>
useQuery({
queryKey: qk.jobs,
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
refetchInterval,
// Seit P4 meldet der Fingerabdruck-Sammler Zustand UND Fortschritt der Jobs —
// der Poller ist damit auch hier nur noch Sicherheitsnetz.
refetchInterval: relax(refetchInterval),
enabled,
select: (d) => d.jobs ?? [],
})
export const useTokenStats = (refetchInterval: number = TAKT.graph) =>
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"), refetchInterval })
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"),
refetchInterval: langsamWennStrom(refetchInterval) })
export const useAgentStatus = (refetchInterval: number = TAKT.normal) =>
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
+34 -4
View File
@@ -1,12 +1,42 @@
import { useMemo } from "react"
import { useSystemStatus } from "@/lib/queries"
import { useSysHistory, type SysSample } from "@/lib/metricsStore"
import { useLetzteMetrik, useSysHistory, type SysSample } from "@/lib/metricsStore"
import type { SystemStatus } from "@/lib/api"
export type { SysSample }
/** Aktuelle System-Werte (für Legende) + rollende Live-Historie aus dem modul-globalen
* Store (überlebt Tab-Wechsel; gefüllt vom App-Feeder). */
/** Aktuelle System-Werte (für die Legende) + rollende Live-Historie.
*
* v3-Umbau P4: Die Query läuft nur noch minütlich, solange der Ereignisstrom steht —
* ihre MESSWERTE wären damit bis zu 60 s alt. Deshalb werden sie hier mit dem letzten
* Messpunkt aus dem Strom überschrieben. Alles, was der Strom NICHT trägt (VRAM-Detail,
* Kernzahl, Versionen), bleibt aus der Query: Der Strom soll schlank bleiben, und diese
* Felder ändern sich ohnehin selten.
*/
export function useSystemHistory() {
const { data: sys, error } = useSystemStatus()
const { data: query, error } = useSystemStatus()
const live = useLetzteMetrik()
const hist = useSysHistory()
const sys = useMemo<SystemStatus | undefined>(() => {
if (!query) return query
if (!live) return query
return {
...query,
cpu: { ...query.cpu, percent: live.cpu ?? query.cpu.percent },
ram: {
...query.ram,
percent: live.ram ?? query.ram.percent,
used: live.ram_used ?? query.ram.used,
total: live.ram_total ?? query.ram.total,
},
// gtt_used/gtt_total bleiben aus der Query — der Strom trägt nur die Auslastung.
gpu: query.gpu ? { ...query.gpu, busy_percent: live.gpu ?? query.gpu.busy_percent } : query.gpu,
disk: query.disk ? { ...query.disk, percent: live.disk ?? query.disk.percent } : query.disk,
temp: { cpu: live.temp_cpu ?? query.temp?.cpu, gpu: live.temp_gpu ?? query.temp?.gpu },
uptime_s: live.uptime_s ?? query.uptime_s,
}
}, [query, live])
return { sys, hist, error }
}