Fix: Live-Graphen ueberleben Tab-Wechsel (modul-globaler Store + App-Feeder)

Bisher lag der Verlauf in component-lokalem State (useSystemHistory / TokenPerformanceCard).
Beim Tab-Wechsel wurde die Zentrale unmountet -> Historie weg -> Graphen starteten leer
und mussten sich neu aufbauen.

Neu: lib/metricsStore.ts haelt den Verlauf modul-global (useSyncExternalStore) und wird
von useMetricsFeeder() gefuettert, das in App (immer gemountet) haengt. So sammelt der
Verlauf kontinuierlich weiter - unabhaengig vom aktiven Tab - und die Graphen zeigen beim
Zurueckwechseln sofort die volle Historie. Zentrale + Diagnose teilen denselben Store.

Verifiziert: Store waechst auch auf Modell-Manager weiter; Rueckkehr zeigt lueckenlosen
Verlauf statt Reset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 17:25:35 +02:00
parent 694aff9801
commit 707b812f8b
8 changed files with 166 additions and 143 deletions
+2
View File
@@ -11,9 +11,11 @@ import { GuideView } from "@/views/GuideView"
import { Placeholder } from "@/views/Placeholder"
import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth, useSystemStatus } from "@/lib/queries"
import { useMetricsFeeder } from "@/lib/metricsStore"
import { cn } from "@/lib/utils"
export default function App() {
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
const [view, setView] = useState<ViewId>("dashboard")
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
const [drawerOpen, setDrawerOpen] = useState(false)
@@ -1,36 +1,16 @@
import { useEffect, useRef, useState } from "react"
import { Activity } from "lucide-react"
import { useTokenStats } from "@/lib/queries"
import { useTokHistory } from "@/lib/metricsStore"
import { LiveAreaChart, type ChartSeries } from "./LiveAreaChart"
const MAX_POINTS = 40
type RateSample = { t: number; prompt: number; completion: number }
const SERIES: ChartSeries[] = [
{ key: "prompt", label: "Prompt", color: "#f59e0b" },
{ key: "completion", label: "Antwort", color: "#2dd4bf" },
]
export function TokenPerformanceCard() {
const { data: ts, dataUpdatedAt } = useTokenStats(3_000)
const [hist, setHist] = useState<RateSample[]>([])
const prev = useRef<{ p: number; c: number; t: number } | null>(null)
// Durchsatz (tok/s) aus den kumulativen Zählern ableiten: Delta / Zeitspanne pro Poll.
useEffect(() => {
if (!ts) return
const now = Date.now()
const p = ts.prompt_tokens, c = ts.completion_tokens
if (prev.current) {
const dt = Math.max((now - prev.current.t) / 1000, 0.001)
const prompt = Math.max(0, (p - prev.current.p) / dt)
const completion = Math.max(0, (c - prev.current.c) / dt)
setHist((h) => [...h, { t: now, prompt, completion }].slice(-MAX_POINTS))
}
prev.current = { p, c, t: now }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataUpdatedAt])
const { data: ts } = useTokenStats(3_000)
const hist = useTokHistory() // Durchsatz-Verlauf aus dem modul-globalen Store
const last = hist[hist.length - 1]
const curRate = last ? Math.round(last.prompt + last.completion) : 0
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useRef, useSyncExternalStore } from "react"
import { useSystemStatus, useTokenStats } from "@/lib/queries"
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
// Modul-globaler Verlauf: überlebt Mount/Unmount (Tab-Wechsel) → Graphen starten nie leer.
let sysHist: SysSample[] = []
let tokHist: RateSample[] = []
const listeners = new Set<() => void>()
const emit = () => listeners.forEach((l) => l())
function subscribe(l: () => void) {
listeners.add(l)
return () => { listeners.delete(l) }
}
export function pushSys(s: SysSample) { sysHist = [...sysHist, s].slice(-MAX_POINTS); emit() }
export function pushTok(s: RateSample) { tokHist = [...tokHist, s].slice(-MAX_POINTS); emit() }
export const useSysHistory = () => useSyncExternalStore(subscribe, () => sysHist)
export const useTokHistory = () => useSyncExternalStore(subscribe, () => tokHist)
/** 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. */
export function useMetricsFeeder() {
const { data: sys, dataUpdatedAt: sysAt } = useSystemStatus(3_000)
const { data: ts, dataUpdatedAt: tsAt } = useTokenStats(3_000)
const prevTok = useRef<{ p: number; c: number; t: number } | null>(null)
useEffect(() => {
if (!sys) return
pushSys({
t: Date.now(),
cpu: sys.cpu?.percent ?? 0,
ram: sys.ram?.percent ?? 0,
gpu: sys.gpu?.busy_percent ?? null,
disk: sys.disk?.percent ?? null,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sysAt])
useEffect(() => {
if (!ts) return
const now = Date.now()
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),
})
}
prevTok.current = { p, c, t: now }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tsAt])
}
+6 -25
View File
@@ -1,31 +1,12 @@
import { useEffect, useState } from "react"
import { useSystemStatus } from "@/lib/queries"
import { useSysHistory, type SysSample } from "@/lib/metricsStore"
export type SysSample = { t: number; cpu: number; ram: number; gpu: number | null; disk: number | null }
export type { SysSample }
const MAX_POINTS = 40 // bei 3s-Poll ~2 Min Live-Verlauf
/** Rollende Live-Historie der System-Metriken (CPU/RAM/GPU/Disk). Hängt bei jedem Poll
* (dataUpdatedAt) einen Messpunkt an und hält die letzten 40. Geteilt von Zentrale +
* Diagnose, damit beide denselben Verlauf aufbauen. */
/** Aktuelle System-Werte (für Legende) + rollende Live-Historie aus dem modul-globalen
* Store (überlebt Tab-Wechsel; gefüllt vom App-Feeder). */
export function useSystemHistory() {
const { data: sys, dataUpdatedAt, error } = useSystemStatus(3_000)
const [hist, setHist] = useState<SysSample[]>([])
useEffect(() => {
if (!sys) return
setHist((h) => [
...h,
{
t: Date.now(),
cpu: sys.cpu?.percent ?? 0,
ram: sys.ram?.percent ?? 0,
gpu: sys.gpu?.busy_percent ?? null,
disk: sys.disk?.percent ?? null,
},
].slice(-MAX_POINTS))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataUpdatedAt])
const { data: sys, error } = useSystemStatus(3_000)
const hist = useSysHistory()
return { sys, hist, error }
}