diff --git a/src/components/SparkPage/LlmTrendChart.tsx b/src/components/SparkPage/LlmTrendChart.tsx
new file mode 100644
index 00000000..dbc19598
--- /dev/null
+++ b/src/components/SparkPage/LlmTrendChart.tsx
@@ -0,0 +1,171 @@
+import { useMemo } from "react";
+import { HISTORY_MAX, useMetricsHistory, avgPositive } from "../../hooks/metricsStore";
+
+const VIEW_W = 300;
+const VIEW_H = 64;
+const PAD = 2;
+/**
+ * Fixed display window: 30 minutes of 2 s samples. The x-axis is anchored to
+ * this constant — never the current sample count — so the line grows into the
+ * chart left-to-right and then scrolls, instead of re-stretching (rewriting
+ * history) on every tick. Averages below still span full HISTORY_MAX retention.
+ */
+const DISPLAY_WINDOW = 900;
+
+function fmt(n: number | null): string {
+ if (n == null || !Number.isFinite(n)) return "—";
+ return n >= 100 ? n.toFixed(0) : n.toFixed(1);
+}
+
+/**
+ * Polyline points for one series, normalised to the shared max. x maps onto a
+ * FIXED window: the newest sample sits at the right edge once the window is
+ * full; while filling, points occupy only the left fraction and the line grows.
+ */
+function buildPoints(raw: readonly number[], max: number): string {
+ // Only the newest DISPLAY_WINDOW samples are drawn; older ones still feed
+ // the averages below. Once full, the window scrolls (newest at right edge).
+ const data = raw.length > DISPLAY_WINDOW ? raw.slice(-DISPLAY_WINDOW) : raw;
+ if (data.length < 2) return "";
+ const span = max || 1;
+ const pts = data.map((v, i) => {
+ const x = (i / (DISPLAY_WINDOW - 1)) * VIEW_W;
+ const y = VIEW_H - PAD - (Math.min(v, max) / span) * (VIEW_H - PAD * 2);
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ });
+ return pts.join(" ");
+}
+
+function areaPath(points: string): string {
+ const seg = points.split(" ");
+ const first = seg[0]?.split(",")[0] ?? "0";
+ const last = seg[seg.length - 1]?.split(",")[0] ?? first;
+ return `M${first},${VIEW_H} L${points} L${last},${VIEW_H} Z`;
+}
+
+/** Human label: chart shows the last window; averages span full retention. */
+function fmtSpan(seconds: number): string {
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
+ const h = seconds / 3600;
+ return `${h % 1 === 0 ? h : h.toFixed(1)}h`;
+}
+
+function historyLabel(): string {
+ return `chart ~${fmtSpan(DISPLAY_WINDOW * 2)} · avgs ~${fmtSpan(HISTORY_MAX * 2)} · 2s samples`;
+}
+
+/** Newest DISPLAY_WINDOW samples — the slice the chart draws. */
+function windowed(data: readonly number[]): readonly number[] {
+ return data.length > DISPLAY_WINDOW ? data.slice(-DISPLAY_WINDOW) : data;
+}
+
+/**
+ * tok/s trend chart for one LLM port. The x-axis is a FIXED 30-minute window:
+ * the line grows left-to-right while filling, then scrolls — history already
+ * drawn never re-stretches, so the chart can't "rewrite" its own past. The
+ * averages below span the full retention (VITE_HISTORY_HOURS, default 8 h).
+ *
+ * TTFT is deliberately NOT drawn here: vLLM reports it only while serving, so
+ * the series is sparse and not tick-aligned — overlaying it on this chart would
+ * misplace it in time. It is also near-redundant with the prefill spikes it
+ * tracks. The busy-sample TTFT average badge is the useful signal and reads the
+ * sparse series directly (no x-axis involved).
+ */
+export function LlmTrendChart({
+ sparkId,
+ llmPort,
+}: {
+ sparkId: string;
+ llmPort: number;
+}) {
+ const gen = useMetricsHistory(sparkId, `llm:${llmPort}.tps`);
+ const prefill = useMetricsHistory(sparkId, `llm:${llmPort}.prefill`);
+ const ttft = useMetricsHistory(sparkId, `llm:${llmPort}.ttft`);
+
+ const genAvg = useMemo(() => avgPositive(gen), [gen]);
+ const prefillAvg = useMemo(() => avgPositive(prefill), [prefill]);
+ const ttftAvg = useMemo(() => avgPositive(ttft), [ttft]);
+
+ // Chart draws only the newest hour; averages above use the full series.
+ const genWin = useMemo(() => windowed(gen), [gen]);
+ const prefillWin = useMemo(() => windowed(prefill), [prefill]);
+
+ // Normalise each series to its OWN max: prefill (thousands) and generation
+ // (tens) differ by ~100x, so a shared scale would flatten gen into the floor.
+ // Max is over the drawn window so old spikes can't squash recent detail.
+ const genMax = useMemo(() => Math.max(1, ...genWin), [genWin]);
+ const prefillMax = useMemo(() => Math.max(1, ...prefillWin), [prefillWin]);
+ const genPts = useMemo(() => buildPoints(genWin, genMax), [genWin, genMax]);
+ const prefillPts = useMemo(() => buildPoints(prefillWin, prefillMax), [prefillWin, prefillMax]);
+
+ const hasData = genWin.length > 1 || prefillWin.length > 1;
+
+ return (
+
+ );
+}
diff --git a/src/hooks/metricsStore.ts b/src/hooks/metricsStore.ts
index 5bb23137..345417c4 100644
--- a/src/hooks/metricsStore.ts
+++ b/src/hooks/metricsStore.ts
@@ -21,7 +21,12 @@ import type { SparkSnapshot } from "../api/types";
* All listeners are woken on notify; unchanged keys keep the same ref → no render.
*/
-const HISTORY_MAX = 1800; // 1 h at 2 s poll — the WS interval, not wall-clock guarantees
+// History depth, configurable at build time. VITE_HISTORY_HOURS = wall-clock
+// hours to retain (default 8 h at the 2 s WS poll ≈ 112 KB per series — the
+// browser tab is the only thing that pays for it).
+const SAMPLES_PER_HOUR = 1800; // 2 s poll
+const HISTORY_HOURS = Number(import.meta.env.VITE_HISTORY_HOURS ?? 8) || 8;
+export const HISTORY_MAX = Math.round(SAMPLES_PER_HOUR * HISTORY_HOURS);
/** Samples shown in inline sparklines (≈1 min at 2 s poll). Full series stays in HISTORY_MAX. */
export const SPARKLINE_TAIL = 30;
@@ -33,6 +38,23 @@ const listeners = new Set<() => void>();
const EMPTY: readonly number[] = Object.freeze([] as number[]);
+/**
+ * Mean of a metric series over samples where the reading is > 0, or null when
+ * there are no busy samples. Skipping zeros keeps idle/off phases from dragging
+ * the average toward zero (a prefill that runs at 500 tok/s is "500", not 0.5).
+ */
+export function avgPositive(values: readonly number[]): number | null {
+ let sum = 0;
+ let count = 0;
+ for (const v of values) {
+ if (v > 0) {
+ sum += v;
+ count += 1;
+ }
+ }
+ return count > 0 ? sum / count : null;
+}
+
function notify() {
for (const l of listeners) l();
}
@@ -112,6 +134,13 @@ export function ingestSnapshots(sparks: SparkSnapshot[]): void {
const portKey = port != null ? `:${port}` : `:${i}`;
pushHistory(`${s.id}:llm${portKey}.tps`, llm.generationTps);
pushHistory(`${s.id}:llm${portKey}.prefill`, llm.prefillTps);
+ // TTFT is sparse: vLLM reports live TTFT only while serving. It is NOT
+ // index-aligned with the tick-dense series above — that is fine because
+ // the ttft series feeds only the busy-sample average badge, never the
+ // overlaid chart (see LlmTrendChart).
+ if (llm.ttftSeconds != null) {
+ pushHistory(`${s.id}:llm${portKey}.ttft`, llm.ttftSeconds);
+ }
if (llm.cachedPrefillTps != null) {
pushHistory(`${s.id}:llm${portKey}.prefillCached`, llm.cachedPrefillTps);
}