From ac193e1c1f09b536ad5f8b53aeda980acca80f95 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 23 Sep 2026 21:29:57 +0800 Subject: [PATCH 1/2] Keep Runtime metrics responsive across tabs --- .../dashboard/RuntimeTrendCharts.test.tsx | 25 +++++- .../features/dashboard/RuntimeTrendCharts.tsx | 20 +++-- .../features/dashboard/RuntimeTrendPanel.tsx | 19 ++++- .../src/features/sessions/SessionsView.tsx | 76 +++++++++---------- 4 files changed, 86 insertions(+), 54 deletions(-) diff --git a/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx b/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx index 491109507..5fb94734f 100644 --- a/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx +++ b/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx @@ -42,15 +42,36 @@ describe("Runtime live-window chart accessibility", () => { })).toBe("Memory usage all series hidden; use the legend to show a series"); }); - it("reports a current gap as unavailable instead of announcing a stale value as latest", () => { + it("renders an unavailable current value as zero without retaining a stale value", () => { const html = renderToStaticMarkup( , ); - expect(html).toContain("Runtime workerUnavailable1"); + expect(html).toContain("Runtime worker0%0"); expect(html).not.toContain("Runtime worker50%1"); }); + it("renders empty retained buckets as continuous zero-value chart series", () => { + const empty = (sampledAt: number): RuntimeTrendSample => ({ + ...sample(sampledAt, null), + targets: [], + memoryUsageBytes: null, + memoryLimitBytes: null, + inputTokensPerMinute: null, + outputTokensPerMinute: null, + }); + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("usage0%0"); + expect(html).toContain("used0 B0"); + expect(html).toContain("input0/min0"); + expect(html).not.toContain("No retained CPU samples"); + expect(html).not.toContain("No complete retained memory samples"); + expect(html).not.toContain("No retained token samples"); + }); + it("renders uPlot chart mounts and reports trends only after two real samples", () => { const html = renderToStaticMarkup( , diff --git a/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx b/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx index a11c299e3..546f3344d 100644 --- a/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx +++ b/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx @@ -486,7 +486,7 @@ function targetIds(samples: readonly RuntimeTrendSample[], field: "cpuRatio" | " for (const sample of samples) { for (const target of sample.targets) { const value = target[field]; - if (value !== null) latest.set(target.seriesId, value); + latest.set(target.seriesId, value ?? latest.get(target.seriesId) ?? 0); } } return [...latest.entries()].sort((left, right) => right[1] - left[1]).slice(0, 3).map(([id]) => id); @@ -522,16 +522,22 @@ export function RuntimeTrendCharts({ id, label: targetLabel(samples, id), tone: tones[index] ?? "blue", - points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.targets.find((target) => target.seriesId === id)?.cpuRatio ?? null })).map((point) => ({ ...point, value: point.value === null ? null : point.value * 100 })), + points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: (sample.targets.find((target) => target.seriesId === id)?.cpuRatio ?? 0) * 100 })), })); - const memoryUsed = samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.memoryUsageBytes })); - const memoryLimit = samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.memoryLimitBytes })); + if (cpu.length === 0 && samples.length > 0) { + cpu.push({ id: "cpu", label: "usage", tone: "orange", points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: 0 })) }); + } + const memoryUsed = samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.memoryUsageBytes ?? 0 })); + const memoryLimit = samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.memoryLimitBytes ?? 0 })); const uptime = uptimeIds.map((id, index): TrendSeries => ({ id, label: targetLabel(samples, id), tone: tones[(index + 2) % tones.length] ?? "blue", - points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.targets.find((target) => target.seriesId === id)?.uptimeSeconds ?? null })), + points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.targets.find((target) => target.seriesId === id)?.uptimeSeconds ?? 0 })), })); + if (uptime.length === 0 && samples.length > 0) { + uptime.push({ id: "uptime", label: "Runtime", tone: "blue", points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: 0 })) }); + } const throughput = tokenThroughput(samples); return { cpu, @@ -541,8 +547,8 @@ export function RuntimeTrendCharts({ ] satisfies TrendSeries[], uptime, tokens: [ - { id: "input", label: "input", tone: "orange", points: throughput.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.inputPerMinute })) }, - { id: "output", label: "output", tone: "green", points: throughput.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.outputPerMinute })) }, + { id: "input", label: "input", tone: "orange", points: throughput.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.inputPerMinute ?? 0 })) }, + { id: "output", label: "output", tone: "green", points: throughput.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.outputPerMinute ?? 0 })) }, ] satisfies TrendSeries[], }; }, [samples]); diff --git a/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx b/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx index a8683d4bd..adc55f667 100644 --- a/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx +++ b/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { RuntimeDashboardSnapshot } from "./runtime-snapshot"; import { RUNTIME_SNAPSHOT_REFRESH_MS } from "./runtime-snapshot"; @@ -46,7 +46,15 @@ export function RuntimeTrendPanel({ const [durableSnapshot, setDurableSnapshot] = useState(null); const [durableState, setDurableState] = useState<"connecting" | "ready" | "unavailable" | "failed">("connecting"); const [durableError, setDurableError] = useState(null); + const [durableRefresh, setDurableRefresh] = useState(0); const [sourcePreference, setSourcePreference] = useState("durable"); + const latestSnapshotRef = useRef(snapshot); + latestSnapshotRef.current = snapshot; + const durableTargetKey = useMemo(() => snapshot.observations + .filter((observation) => observation.mode === "openai_hosted" && observation.environment_id !== null) + .map((observation) => observation.session_id) + .sort() + .join("|"), [snapshot.observations]); const visibleTrendSamples = useMemo( () => runtimeTrendRange(trendSamples, selectedTrendRange), [selectedTrendRange, trendSamples], @@ -73,11 +81,16 @@ export function RuntimeTrendPanel({ setTrendSamples((current) => appendRuntimeTrendSample(current, snapshot)); }, [snapshot]); + useEffect(() => { + const timer = window.setInterval(() => setDurableRefresh((current) => current + 1), 30_000); + return () => window.clearInterval(timer); + }, []); + useEffect(() => { const controller = new AbortController(); setDurableState("connecting"); setDurableError(null); - void loadRuntimeHistory(snapshot, selectedDurableRange, controller.signal).then((result) => { + void loadRuntimeHistory(latestSnapshotRef.current, selectedDurableRange, controller.signal).then((result) => { if (controller.signal.aborted) return; if (result === null) { setDurableSnapshot(null); @@ -92,7 +105,7 @@ export function RuntimeTrendPanel({ setDurableError(error instanceof Error ? error.message : "Durable Runtime history request failed."); }); return () => controller.abort(); - }, [loadRuntimeHistory, selectedDurableRange, snapshot]); + }, [durableRefresh, durableTargetKey, loadRuntimeHistory, selectedDurableRange]); const rangeOptions = source === "durable" ? RUNTIME_DURABLE_RANGES : RUNTIME_TREND_RANGES; const sourceStatus = source === "durable" diff --git a/apps/web/src/features/sessions/SessionsView.tsx b/apps/web/src/features/sessions/SessionsView.tsx index 54e145024..62a12365e 100644 --- a/apps/web/src/features/sessions/SessionsView.tsx +++ b/apps/web/src/features/sessions/SessionsView.tsx @@ -909,48 +909,40 @@ export function SessionsView({ hidden /> )} - {sessionView === "metrics" ? ( -
- {sessionRuntimeSnapshot && loadRuntimeHistory ? ( - - ) : runtimeError ? ( - - ) : ( -
- - - - -
- )} -
- ) : ( - ) : (
From 8f85fd6f727cc021fc4d96a03c2647e47b55c482 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 23 Sep 2026 21:47:38 +0800 Subject: [PATCH 2/2] Cache Runtime dashboards across navigation --- apps/web/src/App.tsx | 13 ++-- .../features/dashboard/DashboardView.test.tsx | 19 +++--- .../features/dashboard/RuntimeTrendPanel.tsx | 11 ++- .../src/features/sessions/SessionsView.tsx | 68 +++++++++++++------ apps/web/src/style.css | 12 ++++ 5 files changed, 87 insertions(+), 36 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 14f106421..adb727e71 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1048,8 +1048,6 @@ export function App() { }, [refreshAgents, refreshEnvironmentTemplates, refreshSessions, refreshStartupConfiguration, refreshVaults]); useEffect(() => { - if (view !== "dashboard" && view !== "sessions") return; - let timer: number | null = null; void (async () => { if ( sessionCollectionState === "ready" && @@ -1059,6 +1057,11 @@ export function App() { await refreshRuntimeSnapshot(); } })(); + }, [refreshRuntimeSnapshot, sessionCollectionState]); + + useEffect(() => { + if (view !== "dashboard" && view !== "sessions") return; + let timer: number | null = null; const schedule = () => { const jitter = Math.floor(Math.random() * 5_000); timer = window.setTimeout(() => { @@ -2289,7 +2292,7 @@ export function App() { onStartSession={() => openSessionSetup()} /> -
+
{view === "templates" ? ( setConnectionOpen(true)} /> ) : null} - {view === "dashboard" ? ( + {view === "sessions" ? ( { expect(html).not.toContain("Execution ready"); }); - it("renders current Docker resources without inventing a CPU percentage or history", () => { + it("renders current Docker resources immediately while durable history loads", () => { const hosted = session("11111111-1111-4111-8111-111111111111", { metadata: { title: "Managed research" }, environment: { @@ -246,20 +246,19 @@ describe("Dashboard loaded-result presentation", () => { expect(html).toContain("Cumulative CPU / capacity"); expect(html).toContain("1m 13s / 2 cores"); expect(html).toContain("512 MiB / 2.00 GiB"); - expect(html).toContain('aria-label="Runtime durable-history charts"'); + expect(html).toContain('aria-label="Runtime live-window charts"'); expect(html).toContain("Resource trends"); - expect(html).toContain("Retained samples · durable history"); + expect(html).toContain("Browser-local samples · reset on reload"); expect(html).not.toContain('aria-label="Runtime trend source"'); - expect(html).toContain("History · loading"); - expect(html).toContain("0 buckets"); - expect(html).toContain('aria-label="Runtime durable range"'); + expect(html).toContain("Live · loading history"); + expect(html).toContain("1 sample"); + expect(html).toContain('aria-label="Runtime live range"'); expect(html).toContain('aria-pressed="true">1h'); expect(html).toContain("CPU usage"); expect(html).toContain("Memory usage"); - expect(html).not.toContain("Compute uptime"); + expect(html).toContain("Compute uptime"); expect(html).toContain("Token throughput"); - expect(html).toContain("No retained CPU samples"); - expect(html).toContain("0/2 valid points · 0 snapshots · no history is synthesized"); + expect(html).not.toContain("No retained CPU samples"); expect(html).toContain("Latest value"); expect(html).toContain("Missing samples"); expect(html).toContain("Runtime targets"); @@ -360,7 +359,7 @@ describe("Dashboard loaded-result presentation", () => { }); expect(html).toContain("Runtime: Runtime refresh failed"); - expect(html).toContain("History · loading"); + expect(html).toContain("Live · loading history"); expect(html).not.toContain("Live · 30s"); }); diff --git a/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx b/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx index adc55f667..fc9c51d71 100644 --- a/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx +++ b/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx @@ -62,7 +62,7 @@ export function RuntimeTrendPanel({ const durableAvailable = durableState !== "unavailable" || durableSnapshot !== null; const source: RuntimeTrendSource = allowSourceSelection && sourcePreference === "live" ? "live" - : durableAvailable + : durableSnapshot !== null ? "durable" : "live"; const selectedSamples = source === "durable" @@ -108,16 +108,23 @@ export function RuntimeTrendPanel({ }, [durableRefresh, durableTargetKey, loadRuntimeHistory, selectedDurableRange]); const rangeOptions = source === "durable" ? RUNTIME_DURABLE_RANGES : RUNTIME_TREND_RANGES; + const waitingForHistory = source === "live" && (!allowSourceSelection || sourcePreference === "durable"); const sourceStatus = source === "durable" ? durableState === "failed" ? "History stale" : durableState === "connecting" ? "History · loading" : `Durable · ${durableSnapshot?.resolutionSeconds ?? 0}s` + : waitingForHistory + ? durableState === "failed" + ? "Live · history retrying" + : durableState === "unavailable" + ? "Live · history unavailable" + : "Live · loading history" : stale ? "Stale · retrying" : `Live · ${RUNTIME_SNAPSHOT_REFRESH_MS / 1_000}s`; - const sourceStatusStale = source === "durable" ? durableState === "failed" : stale; + const sourceStatusStale = source === "durable" ? durableState === "failed" : stale || waitingForHistory && durableState === "failed"; return (
diff --git a/apps/web/src/features/sessions/SessionsView.tsx b/apps/web/src/features/sessions/SessionsView.tsx index 62a12365e..e2d2f9785 100644 --- a/apps/web/src/features/sessions/SessionsView.tsx +++ b/apps/web/src/features/sessions/SessionsView.tsx @@ -62,6 +62,7 @@ import { SessionActionsDialog } from "./actions/SessionActionsDialog"; export type StreamState = "idle" | "connecting" | "listening" | "recovering" | "failed"; export type SessionDetailState = "idle" | "loading" | "ready" | "failed"; type SessionView = "conversation" | "trace" | "metrics"; +const SESSION_METRICS_CACHE_LIMIT = 6; export interface SessionCreateRequest { agentId: string | null; @@ -320,6 +321,7 @@ export function SessionsView({ const newSessionUnavailableReason = null; const [message, setMessage] = useState(""); const [sessionView, setSessionView] = useState("conversation"); + const [metricsSessionIds, setMetricsSessionIds] = useState([]); const [newSessionOpen, setNewSessionOpen] = useState(false); const [environmentDialogSessionId, setEnvironmentDialogSessionId] = useState(null); const [preselectedAgentId, setPreselectedAgentId] = useState(null); @@ -416,13 +418,36 @@ export function SessionsView({ environmentPresentation?.visible && (environmentPresentation.status === "failed" || environmentPresentation.status === "expired"), ); - const sessionRuntimeSnapshot = useMemo((): RuntimeDashboardSnapshot | null => { - if (!selected || !runtimeSnapshot) return null; - const runtimeSession = runtimeSnapshot.sessions.find((session) => session.id === selected.id); - const observation = runtimeSnapshot.observations.find((candidate) => candidate.session_id === selected.id); - if (!runtimeSession || !observation) return null; - return { sessions: [runtimeSession], observations: [observation], loadedAt: runtimeSnapshot.loadedAt }; - }, [runtimeSnapshot, selected]); + const retainedMetricsSessionIds = useMemo(() => { + if (!selected) return metricsSessionIds; + return [selected.id, ...metricsSessionIds.filter((sessionId) => sessionId !== selected.id)] + .slice(0, SESSION_METRICS_CACHE_LIMIT); + }, [metricsSessionIds, selected]); + useEffect(() => { + if (!selected) return; + setMetricsSessionIds((current) => { + const next = [selected.id, ...current.filter((sessionId) => sessionId !== selected.id)] + .slice(0, SESSION_METRICS_CACHE_LIMIT); + return next.length === current.length && next.every((sessionId, index) => sessionId === current[index]) + ? current + : next; + }); + }, [selected]); + const sessionRuntimeSnapshots = useMemo(() => { + const result = new Map(); + if (!runtimeSnapshot) return result; + const runtimeSessions = new Map(runtimeSnapshot.sessions.map((session) => [session.id, session])); + const observations = new Map(runtimeSnapshot.observations.map((observation) => [observation.session_id, observation])); + for (const sessionId of retainedMetricsSessionIds) { + const runtimeSession = runtimeSessions.get(sessionId); + const observation = observations.get(sessionId); + if (runtimeSession && observation) { + result.set(sessionId, { sessions: [runtimeSession], observations: [observation], loadedAt: runtimeSnapshot.loadedAt }); + } + } + return result; + }, [retainedMetricsSessionIds, runtimeSnapshot]); + const sessionRuntimeSnapshot = selected ? sessionRuntimeSnapshots.get(selected.id) ?? null : null; const send = async (event?: FormEvent) => { event?.preventDefault(); @@ -916,18 +941,23 @@ export function SessionsView({ aria-labelledby="session-metrics-tab" hidden={sessionView !== "metrics"} > - {sessionRuntimeSnapshot && loadRuntimeHistory ? ( - - ) : runtimeError ? ( + {loadRuntimeHistory ? retainedMetricsSessionIds.map((sessionId) => { + const cachedSnapshot = sessionRuntimeSnapshots.get(sessionId); + return cachedSnapshot ? ( + + ) : null; + }) : null} + {sessionRuntimeSnapshot ? null : runtimeError ? (