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:
co-authored by
Claude Opus 5
parent
5aa5a484a7
commit
b74e98ffbb
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user