Status mix
- {num(total)} req
+
+ {total === 0 ? '0 req' : `last ${num(total)} req`}
+
{counts.map((b) => (
diff --git a/src/hooks/cross-tab-settings.test.ts b/src/hooks/cross-tab-settings.test.ts
index 97a753e..614e11a 100644
--- a/src/hooks/cross-tab-settings.test.ts
+++ b/src/hooks/cross-tab-settings.test.ts
@@ -50,14 +50,21 @@ describe('settings changed in another tab', () => {
})
it('arrive whole, one key per event', () => {
- // A save writes four keys; this tab may see the events before every write has
+ // A save writes one key per setting; this tab may see the events before every write has
// landed. Each event re-reads everything, so the last one leaves the full set.
const { result } = renderHook(() => useSettings())
otherTab('pc:retention', 80_000)
otherTab('pc:evictionFactor', 4)
otherTab('pc:pageSize', 500)
otherTab('pc:sampleDelayMs', 9_000)
- expect(result.current.settings).toEqual({ retention: 80_000, evictionFactor: 4, pageSize: 500, sampleDelayMs: 9_000 })
+ otherTab('pc:chartRange', '1h')
+ expect(result.current.settings).toEqual({
+ retention: 80_000,
+ evictionFactor: 4,
+ pageSize: 500,
+ sampleDelayMs: 9_000,
+ chartRange: '1h',
+ })
})
it('leave the settings object alone when nothing changed', () => {
diff --git a/src/hooks/use-console-data.ts b/src/hooks/use-console-data.ts
index ac5e197..812abf9 100644
--- a/src/hooks/use-console-data.ts
+++ b/src/hooks/use-console-data.ts
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
-import { deriveP95, type ConnReason, type LiveData } from '@/lib/api'
+import type { ConnReason, LiveData } from '@/lib/api'
+import { appendPoint, type LatencyPoint } from '@/lib/latency-series'
import { mockLatencySeries, mockMetrics, mockModules, mockSummary, mockTraces } from '@/lib/mock'
import { traceKey } from '@/lib/trace-db'
import type { ModuleMetricsSnapshot, ModuleStatus, RequestTrace, StreamReady, TraceSummary } from '@/lib/types'
@@ -33,8 +34,10 @@ export function platformOrigin(): string {
const configuredTarget = configured?.trim()
return (configuredTarget || window.location.origin).replace(/\/$/, '')
}
-// Rolling window of recent traces kept in memory for the chart / status mix.
-// Full history lives in IndexedDB (useTraceStore); this is just the live view.
+// Rolling window of recent traces kept in memory for the status mix and the trace
+// store's live merge. Full history lives in IndexedDB (useTraceStore). The latency
+// chart no longer reads it: a row count covers less time the busier the platform
+// is, and the chart collapsed to a dot under load.
const TRACE_WINDOW = 500
// If the stream never opens (no platform reachable), fall back to sample data.
// The wait is a user setting; this is the fallback for callers without one.
@@ -168,7 +171,7 @@ function mockData(now: number): LiveData {
metrics: mockMetrics(now),
modules: mockModules(),
summary: mockSummary(),
- latencyP95: mockLatencySeries(),
+ latency: mockLatencySeries(now),
}
}
@@ -176,8 +179,8 @@ function mockData(now: number): LiveData {
* Subscribes to the platform's SSE stream (`/platform/traces/stream`) and
* assembles the console snapshot from its multiplexed `trace` / `metrics` /
* `modules` events. Replaces the previous 5s REST polling: new data is pushed,
- * and the trace window accumulates deltas (deduped, capped) for the chart while
- * IndexedDB retains the full history. Mock data is shown only on a cold start
+ * the trace window accumulates deltas (deduped, capped) while IndexedDB retains
+ * the full history, and each `summary` frame adds a point to the latency series. Mock data is shown only on a cold start
* with no platform reachable.
*/
export function useConsoleData(settings?: { sampleDelayMs: number }) {
@@ -191,6 +194,10 @@ export function useConsoleData(settings?: { sampleDelayMs: number }) {
const metricsRef = useRef([])
const modulesRef = useRef([])
const summaryRef = useRef(null)
+ // The latency chart's series. Page-scoped, not connection-scoped: it survives a
+ // rebuilt stream and a pause, so an outage shows as a gap in one line rather
+ // than as a chart that starts over. A reload clears it.
+ const latencyRef = useRef([])
const everOpenRef = useRef(false)
const lastUpdatedRef = useRef(null)
// When a frame last arrived at all, readable or not. `lastUpdatedRef` only moves
@@ -221,6 +228,9 @@ export function useConsoleData(settings?: { sampleDelayMs: number }) {
// platform declares it. Reset per connection, since a rebuilt stream may reach
// a platform that pushes at a different rate.
let staleWindowMs = staleTimeoutFor(null)
+ // The push period this connection's `ready` stated, if it did. Stamped on every
+ // latency point so the chart judges a gap against the tick that was promised.
+ let tickMs: number | null = null
function publish() {
const traces = tracesRef.current
@@ -229,7 +239,7 @@ export function useConsoleData(settings?: { sampleDelayMs: number }) {
metrics: metricsRef.current,
modules: modulesRef.current,
summary: summaryRef.current,
- latencyP95: deriveP95(traces),
+ latency: latencyRef.current,
})
}
@@ -410,6 +420,7 @@ export function useConsoleData(settings?: { sampleDelayMs: number }) {
// on screen that nothing has made this connection — they are live settings,
// and the platform answering now may not be the one that answered before.
staleWindowMs = staleTimeoutFor(null)
+ tickMs = null
setPlatform((p) => (p === null ? p : null))
try {
es = new EventSource(STREAM_URL)
@@ -433,6 +444,7 @@ export function useConsoleData(settings?: { sampleDelayMs: number }) {
if (!info) return
setPlatform(info)
staleWindowMs = staleTimeoutFor(info.tickMs)
+ tickMs = info.tickMs != null && info.tickMs > 0 ? info.tickMs : null
markLive() // re-arms the watchdog on the window the platform just stated
})
@@ -461,6 +473,16 @@ export function useConsoleData(settings?: { sampleDelayMs: number }) {
const s = readObject(e, 'summary')
if (!s) return
summaryRef.current = s
+ // Only the frame's shape was checked. A p95 that is not a number would put a
+ // point on the chart this platform never measured, so it adds none — the
+ // KPI tiles already show what the frame said.
+ if (typeof s.p95LatencyMs === 'number' && Number.isFinite(s.p95LatencyMs)) {
+ latencyRef.current = appendPoint(latencyRef.current, {
+ at: Date.now(),
+ p95: s.p95LatencyMs,
+ tickMs: tickMs ?? ASSUMED_TICK_MS,
+ })
+ }
markLive()
publish()
})
diff --git a/src/hooks/use-settings.ts b/src/hooks/use-settings.ts
index b07372c..22d65fa 100644
--- a/src/hooks/use-settings.ts
+++ b/src/hooks/use-settings.ts
@@ -25,9 +25,10 @@ export function useSettings(): { settings: Settings; save: (next: Settings) => v
useEffect(
() =>
subscribeStored(SETTINGS_KEYS, () => {
- // A save writes four keys and arrives here as up to four events. Each reloads
- // all four, so the tab converges on the last; one that changes nothing keeps
- // the old identity, or every consumer keyed on `settings` would run again.
+ // A save writes one key per setting and arrives here as one event per key.
+ // Each reloads them all, so the tab converges on the last; one that changes
+ // nothing keeps the old identity, or every consumer keyed on `settings` would
+ // run again.
const next = loadSettings()
setSettings((prev) => (sameSettings(prev, next) ? prev : next))
}),
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 89ea682..8c42fa5 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -1,3 +1,4 @@
+import type { LatencyPoint } from './latency-series'
import type { ModuleMetricsSnapshot, ModuleStatus, RequestTrace, RouteInfo, TraceQuery, TraceSummary } from './types'
const BASE = '/platform'
@@ -21,7 +22,7 @@ export interface LiveData {
metrics: ModuleMetricsSnapshot[]
modules: ModuleStatus[] // joined into the module table for isolation mode / trust tier
summary: TraceSummary | null // windowed KPI header aggregate; null until the `summary` event arrives
- latencyP95: number[]
+ latency: LatencyPoint[] // the chart's series — see latency-series.ts
}
/** Why a fetch cycle failed — drives how the UI signals the outage. */
@@ -65,7 +66,9 @@ export async function fetchSnapshot(query: TraceQuery): Promise {
getJson(`/traces/metrics`, ctrl.signal),
getJson(`/modules`, ctrl.signal),
])
- return { ok: true, data: { traces, metrics, modules, summary: null, latencyP95: deriveP95(traces) } }
+ // No `summary` over REST, so no latency series: the chart plots the platform's
+ // windowed p95, and a trace list is not that.
+ return { ok: true, data: { traces, metrics, modules, summary: null, latency: [] } }
} catch (e) {
if (e instanceof HttpError) {
if (e.status === 401 || e.status === 403) return { ok: false, reason: 'auth', status: e.status }
@@ -101,20 +104,3 @@ export async function getModuleRoutes(id: string): Promise {
clearTimeout(timer)
}
}
-
-// Rough p95-per-minute bucketing from raw traces, for the live path.
-export function deriveP95(traces: RequestTrace[]): number[] {
- const buckets = new Map()
- for (const t of traces) {
- const min = Math.floor(t.epochMillis / 60000)
- const arr = buckets.get(min) ?? []
- arr.push(t.latencyMs)
- buckets.set(min, arr)
- }
- return [...buckets.keys()]
- .sort((a, b) => a - b)
- .map((k) => {
- const arr = buckets.get(k)!.sort((a, b) => a - b)
- return arr[Math.min(arr.length - 1, Math.floor(arr.length * 0.95))]
- })
-}
diff --git a/src/lib/latency-series.ts b/src/lib/latency-series.ts
new file mode 100644
index 0000000..5c88cd5
--- /dev/null
+++ b/src/lib/latency-series.ts
@@ -0,0 +1,79 @@
+import type { TimeRange } from '@/lib/types'
+
+/**
+ * The latency chart's data: the platform's windowed p95, one point per `summary`
+ * frame received on this page.
+ *
+ * It used to be derived from the in-memory trace window, bucketed per minute. That
+ * window is 500 rows, so the time it covered shrank with load: at 67 req/s it was
+ * 7.5 seconds, one bucket, and the chart was a dot exactly when an operator needed
+ * it. A burst cannot shrink this series — it grows by one point per platform tick,
+ * whatever the traffic.
+ *
+ * Only what arrived after this page connected is here. The trace dump a platform
+ * sends on connect is seconds of traffic from before the connection, and it no
+ * longer reaches the chart; a reload starts the series empty.
+ */
+export interface LatencyPoint {
+ /** When the frame arrived, on this browser's clock. `summary` carries no time of its own. */
+ at: number
+ /** `summary.p95LatencyMs` — the p95 over the platform's `windowMs`, not over the tick. */
+ p95: number
+ /**
+ * The push period of the connection that delivered it. Kept per point because a
+ * rebuilt stream may reach a platform with a different tick, and a gap is judged
+ * against the period that was promised at the time.
+ */
+ tickMs: number
+}
+
+export const TIME_RANGES: readonly TimeRange[] = ['5m', '15m', '1h', '6h']
+
+export const RANGE_MS: Record = {
+ '5m': 5 * 60_000,
+ '15m': 15 * 60_000,
+ '1h': 60 * 60_000,
+ '6h': 6 * 60 * 60_000,
+}
+
+/**
+ * How much history the page holds, whatever range is selected. Holding only the
+ * selected range would make widening it show nothing more than before; at one point
+ * a second, six hours is 21,600 points.
+ */
+export const SERIES_SPAN_MS = RANGE_MS['6h']
+
+/**
+ * How many promised ticks may pass between two points before the line breaks. Two,
+ * for the reason the silence watchdog gives itself slack: one late frame is jitter,
+ * not an outage. Past that, joining the points would draw measurements that were
+ * never taken.
+ */
+export const GAP_TICKS = 2
+
+/** Add a point and drop what has aged out of the held span. */
+export function appendPoint(series: readonly LatencyPoint[], point: LatencyPoint): LatencyPoint[] {
+ const floor = point.at - SERIES_SPAN_MS
+ let start = 0
+ while (start < series.length && series[start].at < floor) start++
+ return [...series.slice(start), point]
+}
+
+/**
+ * Split a time-ordered series wherever two neighbours are further apart than the
+ * tick either of them was promised allows. Each run is drawn as its own line.
+ */
+export function segments(points: readonly LatencyPoint[]): LatencyPoint[][] {
+ const runs: LatencyPoint[][] = []
+ let run: LatencyPoint[] = []
+ for (const p of points) {
+ const prev = run[run.length - 1]
+ if (prev && p.at - prev.at > GAP_TICKS * Math.max(prev.tickMs, p.tickMs)) {
+ runs.push(run)
+ run = []
+ }
+ run.push(p)
+ }
+ if (run.length > 0) runs.push(run)
+ return runs
+}
diff --git a/src/lib/mock.ts b/src/lib/mock.ts
index 24f9392..c3c4943 100644
--- a/src/lib/mock.ts
+++ b/src/lib/mock.ts
@@ -1,3 +1,4 @@
+import { SERIES_SPAN_MS, type LatencyPoint } from './latency-series'
import type { ModuleMetricsSnapshot, ModuleStatus, RequestTrace, TraceSummary } from './types'
// Deterministic PRNG so the demo data is stable across renders/screenshots.
@@ -140,14 +141,19 @@ export function mockSummary(): TraceSummary {
}
}
-// p95 latency series for the main chart (one point/minute).
-export function mockLatencySeries(points = 60): number[] {
+// p95 latency series for the main chart: one point every 15s for the six hours the
+// page can hold, ending at `now`, so every range the chart offers is full. The
+// chart labels it sample data and does not slide it with the clock.
+const SAMPLE_TICK_MS = 15_000
+
+export function mockLatencySeries(now: number): LatencyPoint[] {
const rnd = makeRng(90210)
- const out: number[] = []
- for (let i = 0; i < points; i++) {
- const base = 34 + Math.sin(i / 7) * 7 + rnd() * 9
- const spike = i === 41 || i === 42 ? 60 : i === 53 ? 30 : 0
- out.push(Math.round(base + spike))
+ const points = SERIES_SPAN_MS / SAMPLE_TICK_MS
+ const out: LatencyPoint[] = []
+ for (let i = 0; i <= points; i++) {
+ const base = 34 + Math.sin(i / 28) * 7 + rnd() * 9
+ const spike = i % 240 === 164 || i % 240 === 165 ? 60 : i % 240 === 212 ? 30 : 0
+ out.push({ at: now - (points - i) * SAMPLE_TICK_MS, p95: Math.round(base + spike), tickMs: SAMPLE_TICK_MS })
}
return out
}
diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts
index f040b38..e055488 100644
--- a/src/lib/settings.test.ts
+++ b/src/lib/settings.test.ts
@@ -18,7 +18,7 @@ import {
* out, prunes with `NaN` and does it silently.
*/
-const KEYS = ['pc:retention', 'pc:evictionFactor', 'pc:pageSize', 'pc:sampleDelayMs']
+const KEYS = ['pc:retention', 'pc:evictionFactor', 'pc:pageSize', 'pc:sampleDelayMs', 'pc:chartRange']
beforeEach(() => {
for (const k of KEYS) window.localStorage.removeItem(k)
@@ -61,7 +61,7 @@ describe('what a setting is allowed to be', () => {
describe('what comes back out of storage', () => {
it('round-trips a saved set', () => {
- const mine: Settings = { retention: 120_000, evictionFactor: 4, pageSize: 50, sampleDelayMs: 1_000 }
+ const mine: Settings = { retention: 120_000, evictionFactor: 4, pageSize: 50, sampleDelayMs: 1_000, chartRange: '6h' }
saveSettings(mine)
expect(loadSettings()).toEqual(mine)
})
@@ -78,16 +78,18 @@ describe('what comes back out of storage', () => {
window.localStorage.setItem('pc:retention', '"lots"')
window.localStorage.setItem('pc:evictionFactor', '3')
window.localStorage.setItem('pc:pageSize', '0')
+ window.localStorage.setItem('pc:chartRange', '"2h"') // not a range the chart offers
const loaded = loadSettings()
expect(loaded.retention).toBe(DEFAULT_SETTINGS.retention)
expect(loaded.evictionFactor).toBe(DEFAULT_SETTINGS.evictionFactor)
expect(loaded.pageSize).toBe(DEFAULT_SETTINGS.pageSize)
+ expect(loaded.chartRange).toBe(DEFAULT_SETTINGS.chartRange)
expect(Number.isFinite(loaded.retention)).toBe(true)
})
it('keeps the fields that are still usable when one is corrupt', () => {
- saveSettings({ retention: 90_000, evictionFactor: 8, pageSize: 20, sampleDelayMs: 800 })
+ saveSettings({ retention: 90_000, evictionFactor: 8, pageSize: 20, sampleDelayMs: 800, chartRange: '5m' })
window.localStorage.setItem('pc:pageSize', 'null')
const loaded = loadSettings()
@@ -95,6 +97,7 @@ describe('what comes back out of storage', () => {
expect(loaded.retention).toBe(90_000)
expect(loaded.evictionFactor).toBe(8)
expect(loaded.sampleDelayMs).toBe(800)
+ expect(loaded.chartRange).toBe('5m')
})
it('survives a below-floor retention left over from an earlier build', () => {
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index b2f1795..fd668f0 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -1,4 +1,6 @@
import { readStored, writeStored } from '@/hooks/use-persistent-state'
+import { TIME_RANGES } from '@/lib/latency-series'
+import type { TimeRange } from '@/lib/types'
/**
* User settings, each in its own `pc:` localStorage key.
@@ -36,6 +38,12 @@ export interface Settings {
pageSize: number
/** How long a cold start waits for a platform before showing sample data. */
sampleDelayMs: number
+ /**
+ * How much time the latency chart spans, ending now. A time, not a row count:
+ * the count-based window this replaced covered less time the busier the platform
+ * got. The page holds six hours whatever is chosen, so widening shows more.
+ */
+ chartRange: TimeRange
}
export const DEFAULT_SETTINGS: Settings = {
@@ -43,6 +51,7 @@ export const DEFAULT_SETTINGS: Settings = {
evictionFactor: 1,
pageSize: 200,
sampleDelayMs: 2500,
+ chartRange: '15m',
}
const KEYS = {
@@ -50,6 +59,7 @@ const KEYS = {
evictionFactor: 'pc:evictionFactor',
pageSize: 'pc:pageSize',
sampleDelayMs: 'pc:sampleDelayMs',
+ chartRange: 'pc:chartRange',
} as const
/** Every storage key a setting lives under — what another tab's change is matched against. */
@@ -92,6 +102,8 @@ export function validate(field: keyof Settings, value: unknown): string | null {
return (EVICTION_FACTORS as readonly number[]).includes(Number(value))
? null
: `must be one of ${EVICTION_FACTORS.join(', ')}`
+ case 'chartRange':
+ return (TIME_RANGES as readonly unknown[]).includes(value) ? null : `must be one of ${TIME_RANGES.join(', ')}`
}
}
@@ -102,6 +114,7 @@ export function loadSettings(): Settings {
evictionFactor: readStored(KEYS.evictionFactor, DEFAULT_SETTINGS.evictionFactor),
pageSize: readStored(KEYS.pageSize, DEFAULT_SETTINGS.pageSize),
sampleDelayMs: readStored(KEYS.sampleDelayMs, DEFAULT_SETTINGS.sampleDelayMs),
+ chartRange: readStored(KEYS.chartRange, DEFAULT_SETTINGS.chartRange),
}
return {
retention: validate('retention', raw.retention) === null ? Number(raw.retention) : DEFAULT_SETTINGS.retention,
@@ -114,6 +127,8 @@ export function loadSettings(): Settings {
validate('sampleDelayMs', raw.sampleDelayMs) === null
? Number(raw.sampleDelayMs)
: DEFAULT_SETTINGS.sampleDelayMs,
+ chartRange:
+ validate('chartRange', raw.chartRange) === null ? (raw.chartRange as TimeRange) : DEFAULT_SETTINGS.chartRange,
}
}
@@ -123,6 +138,7 @@ export function saveSettings(next: Settings): void {
writeStored(KEYS.evictionFactor, next.evictionFactor)
writeStored(KEYS.pageSize, next.pageSize)
writeStored(KEYS.sampleDelayMs, next.sampleDelayMs)
+ writeStored(KEYS.chartRange, next.chartRange)
}
/**
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 8b231ff..10a34a3 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -103,4 +103,5 @@ export interface TraceQuery {
beforeSeq?: number
}
+// How far back the latency chart reaches, ending now. A setting (`pc:chartRange`).
export type TimeRange = '5m' | '15m' | '1h' | '6h'