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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-DGxLseeM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DA_8pSCQ.css">
<script type="module" crossorigin src="/assets/index-C5hFzdtR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DoKkh4fz.css">
</head>
<body>
<div id="root"></div>
+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 }
}