From 9d243496d8294bace1275925ad0aa97751da7ecc Mon Sep 17 00:00:00 2001 From: htjulia Date: Mon, 12 Oct 2026 14:19:42 +0900 Subject: [PATCH] feat(chart): plot the summary p95 over time, not the trace window The latency chart bucketed the in-memory trace window (500 rows) per minute, so the time it covered shrank with load. At 67 req/s that was 7.5 seconds, one bucket, and during a 4,000-request burst the chart was a single dot. Its first points on connect were the ring-buffer dump, seconds of traffic from before the page connected, drawn as "over time". - The chart plots summary.p95LatencyMs, one point per summary frame received on this page, stamped with the tick the ready ack stated (1 s when none). Traces no longer reach it, so traffic cannot shrink it and the connect dump does not appear on it - The x axis is time: it spans the selected range and ends now, not at the newest point. Silence slides the line left. Before the range fills, the left side stays empty. The line breaks where two points are more than two ticks apart, so an outage is not joined up - The series is page-scoped and holds six hours whatever range is chosen. It survives a rebuilt stream and is cleared by a reload - New setting pc:chartRange (5m, 15m, 1h, 6h; default 15m), using the TimeRange type that had no users - The header names what each point is: "p95 of 60s" and the platform's capacity cap. Sample data stays labelled, fills every range and is anchored to its own last point - The p95 tile reads summary.p95LatencyMs, so it equals the chart's newest point; it showed the worst per-module p95 from metrics before. It shows a dash until a summary arrives - Status mix says "last N req": it counts the newest rows, not traffic - deriveP95 is gone. The REST snapshot path returns no series, since it has no summary - The chart is dimmed by the summary channel, which now feeds it Closes #78 Tags: #chart #latency #honesty Co-Authored-By: htjulia --- src/App.tsx | 11 +- src/components/kpi-row.tsx | 26 +- src/components/latency-chart.test.tsx | 438 ++++++++++++++++++------ src/components/latency-chart.tsx | 198 ++++++++--- src/components/settings-dialog.test.tsx | 6 +- src/components/settings-dialog.tsx | 41 ++- src/components/status-mix.tsx | 14 +- src/hooks/cross-tab-settings.test.ts | 11 +- src/hooks/use-console-data.ts | 36 +- src/hooks/use-settings.ts | 7 +- src/lib/api.ts | 24 +- src/lib/latency-series.ts | 79 +++++ src/lib/mock.ts | 20 +- src/lib/settings.test.ts | 9 +- src/lib/settings.ts | 16 + src/lib/types.ts | 1 + 16 files changed, 735 insertions(+), 202 deletions(-) create mode 100644 src/lib/latency-series.ts diff --git a/src/App.tsx b/src/App.tsx index 739ced5..d92fe38 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -88,8 +88,15 @@ export default function App() {
- - + {/* Fed by `summary`, not by traces: dimmed with the KPI tiles it agrees with. */} + + diff --git a/src/components/kpi-row.tsx b/src/components/kpi-row.tsx index 3267a9c..9f03520 100644 --- a/src/components/kpi-row.tsx +++ b/src/components/kpi-row.tsx @@ -81,18 +81,22 @@ function Tile({ } /** - * Header KPIs. The big values are the platform's all-time cumulative aggregates - * (from `metrics`); the sub-line under each is the recent-window trend from the - * `summary` SSE event (windowed count / error rate / p95 vs the previous window). - * When `summary` is absent or a trend field is null, the sub-line renders a muted - * placeholder rather than a made-up delta. + * Header KPIs. Requests and error rate are the platform's all-time cumulative + * aggregates (from `metrics`); the sub-line under each is the recent-window trend + * from the `summary` SSE event (windowed count / error rate / p95 vs the previous + * window). When `summary` is absent or a trend field is null, the sub-line renders + * a muted placeholder rather than a made-up delta. + * + * Latency p95 is the exception: it is `summary`'s windowed p95, the same number the + * latency chart plots, so the tile reads as the chart's newest point. It used to be + * the worst per-module p95 from `metrics`, which sat beside a chart drawn from a + * third source and agreed with neither. Until a `summary` arrives it shows a dash — + * the per-module figure is a different measurement, not a stand-in for this one. */ export function KpiRow({ metrics, summary }: { metrics: ModuleMetricsSnapshot[]; summary: TraceSummary | null }) { const total = metrics.reduce((a, m) => a + m.count, 0) const errors = metrics.reduce((a, m) => a + m.errorCount, 0) const errorRate = total ? errors / total : 0 - const p95 = Math.max(0, ...metrics.map((m) => m.p95LatencyMs)) - const p99 = Math.max(0, ...metrics.map((m) => m.p99LatencyMs)) const activeModules = metrics.filter((m) => m.moduleId !== '(platform)').length const win = summary ? `last ${Math.round(summary.windowMs / 1000)}s` : 'window' @@ -119,13 +123,17 @@ export function KpiRow({ metrics, summary }: { metrics: ModuleMetricsSnapshot[]; )} - + {summary?.p95DeltaMs != null ? ( {Math.abs(summary.p95DeltaMs)}ms · p99 {summary.p99LatencyMs}ms ) : ( - {win} · p99 {summary?.p99LatencyMs ?? p99}ms + {summary ? `${win} · p99 ${summary.p99LatencyMs}ms` : 'no summary yet'} )} diff --git a/src/components/latency-chart.test.tsx b/src/components/latency-chart.test.tsx index bbbcf68..3ca51ba 100644 --- a/src/components/latency-chart.test.tsx +++ b/src/components/latency-chart.test.tsx @@ -1,112 +1,316 @@ -import { act, cleanup, render, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { act, cleanup, render, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { KpiRow } from '@/components/kpi-row' import { LatencyChart } from '@/components/latency-chart' import { StatusMix } from '@/components/status-mix' -import type { RequestTrace } from '@/lib/types' -import { useConsoleUnderTest } from '@/test/console-driver' -import { GO_CONNECT, SPRING_CONNECT } from '@/test/stream-fixtures' -import { FakeEventSource, installFakeEventSource, replay } from '@/test/sse-stream' -import { clearTraces } from '@/lib/trace-db' +import { useConsoleData } from '@/hooks/use-console-data' +import { appendPoint, RANGE_MS, segments, SERIES_SPAN_MS, type LatencyPoint } from '@/lib/latency-series' +import { mockLatencySeries } from '@/lib/mock' +import { DEFAULT_SETTINGS } from '@/lib/settings' +import type { ModuleMetricsSnapshot, RequestTrace, TraceSummary } from '@/lib/types' +import { PLATFORMS, PROTEAN_CONNECT, traceFrame } from '@/test/stream-fixtures' +import { FakeEventSource, installFakeEventSource, parseStream, replay } from '@/test/sse-stream' /** - * The chart is driven by whatever `deriveP95` buckets out of the live window, so - * the shape of the series is not a free parameter — it follows from the traffic - * the platform sent. These scenarios feed real connect sequences and assert the - * rendered geometry, because the failure mode was invalid SVG rather than a wrong - * number: coordinates came out `NaN` and the browser rejected the marks outright, - * leaving an empty card and a console full of errors. + * The latency chart plots the platform's windowed p95 — one point per `summary` + * frame received on this page — on a time axis that ends now. * - * The two fixtures land on either side of the boundary, which is why both run: - * the Go capture's traces share one minute (one bucket, no span to interpolate - * across), the Spring capture's straddle two. + * It used to bucket the in-memory trace window (500 rows) per minute. The time + * those rows covered shrank with load, and during a 4,000-request burst the chart + * was a single dot. The scenarios below pin the replacement against the same + * failure: traffic of any size must not shrink the chart, the connect dump must + * not reach it, and silence must look like silence. */ -function Harness() { - const { data } = useConsoleUnderTest() - return data ? : null +/** Chart geometry, mirrored from latency-chart.tsx. */ +const W = 720 +const PAD_L = 30 +const PAD_R = 10 +const PLOT_W = W - PAD_L - PAD_R + +const T0 = 1_790_000_000_000 + +/** A run of points `tickMs` apart, starting at `from`. */ +function run(from: number, count: number, tickMs = 1000, p95 = 20): LatencyPoint[] { + return Array.from({ length: count }, (_, i) => ({ at: from + i * tickMs, p95: p95 + i, tickMs })) } -let restoreEventSource: () => void +/** The summary frame a fixture carries, as the platform sent it. */ +function summaryOf(connect: string): TraceSummary { + return JSON.parse(parseStream(connect).find((f) => f.event === 'summary')!.data) as TraceSummary +} -beforeEach(async () => { - restoreEventSource = installFakeEventSource() - await clearTraces() -}) +function summaryFrame(p95: number): string { + return JSON.stringify({ ...summaryOf(PROTEAN_CONNECT), p95LatencyMs: p95 }) +} -afterEach(() => { - restoreEventSource() -}) +function lines(container: HTMLElement): number { + return container.querySelectorAll('polyline').length +} + +function newestX(container: HTMLElement): number { + return Number(container.querySelector('circle[data-newest]')!.getAttribute('cx')) +} -describe.each([ - { name: 'one bucket (Go capture — all traces inside one minute)', connect: GO_CONNECT, buckets: 1 }, - { name: 'two buckets (Spring capture — traces straddle a minute)', connect: SPRING_CONNECT, buckets: 2 }, -])('latency chart, $name', ({ connect, buckets }) => { - it('renders valid coordinates', async () => { - const { container } = render() +afterEach(cleanup) - // ① nothing rendered before the stream delivers — the marks asserted below - // can only come from the replayed traffic. - expect(container.querySelector('svg')).toBeNull() +describe.each(PLATFORMS)('on connect to $name', ({ connect, newestEpoch }) => { + let restoreEventSource: () => void + beforeEach(() => { + restoreEventSource = installFakeEventSource() + // Seconds after the dump's newest trace, so its rows sit well inside the span + // the series holds: if they reached it, nothing would age them out first. + vi.useFakeTimers() + vi.setSystemTime(newestEpoch + 2_000) + }) + afterEach(() => { + vi.useRealTimers() + restoreEventSource() + }) - await act(async () => { + it('plots the summary the platform sent, and nothing from the trace dump', () => { + // The dump is seconds of traffic from before this page connected. It used to + // become the chart's first points, drawn under "over time"; now only the + // windowed p95 the platform reports does. + const { result } = renderHook(() => useConsoleData()) + act(() => { const es = FakeEventSource.current() es.emit('open') replay(es, connect) }) + const latency = result.current.data!.latency + expect(latency).toHaveLength(1) + expect(latency[0].p95).toBe(summaryOf(connect).p95LatencyMs) + }) +}) + +describe('the series the stream builds', () => { + let restoreEventSource: () => void + beforeEach(() => { + restoreEventSource = installFakeEventSource() + vi.useFakeTimers() + vi.setSystemTime(T0) + }) + afterEach(() => { + vi.useRealTimers() + restoreEventSource() + }) - const svg = await waitFor(() => { - const found = container.querySelector('svg') - expect(found).not.toBeNull() - return found! + function connected() { + const hook = renderHook(() => useConsoleData()) + act(() => { + const es = FakeEventSource.current() + es.emit('open') + replay(es, PROTEAN_CONNECT) }) + return hook + } - // The series really is the width this scenario claims to cover. - const polyline = svg.querySelector('polyline') - const pointCount = polyline ? polyline.getAttribute('points')!.trim().split(/\s+/).length : 1 - expect(pointCount).toBe(buckets) - - // Every coordinate the browser has to parse is a number. - expect(svg.outerHTML).not.toContain('NaN') - for (const el of svg.querySelectorAll('polyline, polygon')) { - for (const pair of el.getAttribute('points')!.trim().split(/\s+/)) { - const [x, y] = pair.split(',').map(Number) - expect(Number.isFinite(x)).toBe(true) - expect(Number.isFinite(y)).toBe(true) - } - } - for (const el of svg.querySelectorAll('circle')) { - expect(Number.isFinite(Number(el.getAttribute('cx')))).toBe(true) - expect(Number.isFinite(Number(el.getAttribute('cy')))).toBe(true) + /** One platform tick: a second passes and a summary arrives. */ + function tick(p95: number) { + act(() => { + vi.advanceTimersByTime(1000) + FakeEventSource.current().emit('summary', summaryFrame(p95)) + }) + } + + it('stays a line through a burst that used to collapse it to a dot', () => { + // 🔴 The failure this replaces: 600 traces inside one second filled the + // 500-row window with a single minute, and the chart drew one dot. + const { result } = connected() + for (let s = 1; s <= 5; s++) { + act(() => { + const es = FakeEventSource.current() + for (let i = 0; i < 120; i++) es.emit('trace', traceFrame(s * 1000 + i, T0 + s * 1000 + i)) + }) + tick(10 + s) } + const latency = result.current.data!.latency + expect(latency).toHaveLength(6) // the connect summary + one per tick, whatever the traffic + + const { container } = render() + expect(lines(container)).toBe(1) + expect(container.querySelector('polyline')!.getAttribute('points')!.trim().split(/\s+/)).toHaveLength(6) }) - it('draws a line only when there are two points to join', async () => { - // With a single bucket there is no line and no area: filling from a zero - // baseline would draw a ramp the data never measured. - const { container } = render() - await act(async () => { + it('stamps each point with the tick the platform stated', () => { + const { result } = renderHook(() => useConsoleData()) + act(() => { const es = FakeEventSource.current() es.emit('open') - replay(es, connect) + es.emit('ready', JSON.stringify({ platform: 'protean', tickMs: 5000, capacity: 200 })) + es.emit('summary', summaryFrame(7)) + }) + expect(result.current.data!.latency[0].tickMs).toBe(5000) + }) + + it('falls back to the assumed 1 Hz for a platform that states no tick', () => { + const { result } = renderHook(() => useConsoleData()) + act(() => { + const es = FakeEventSource.current() + es.emit('open') + es.emit('summary', summaryFrame(7)) + }) + expect(result.current.data!.latency[0].tickMs).toBe(1000) + }) + + it('adds no point for a p95 that is not a number', () => { + const { result } = connected() + act(() => { + FakeEventSource.current().emit('summary', JSON.stringify({ ...summaryOf(PROTEAN_CONNECT), p95LatencyMs: 'n/a' })) }) - const svg = await waitFor(() => { - const found = container.querySelector('svg') - expect(found).not.toBeNull() - return found! + expect(result.current.data!.latency).toHaveLength(1) + }) + + it('keeps the series across a reconnect, with the outage as a break', () => { + // Page-scoped, not connection-scoped: a dropped stream must not start the + // chart over, and the time it was gone must not be joined up. + const { result } = connected() + tick(4) + tick(5) + act(() => { + vi.advanceTimersByTime(6_000) // silence past the 6 s window: the watchdog drops the stream + }) + act(() => { + vi.advanceTimersByTime(5_000) // and rebuilds it after the reconnect delay + }) + act(() => { + const es = FakeEventSource.current() + es.emit('open') + replay(es, PROTEAN_CONNECT) }) + tick(6) - expect(svg.querySelector('polyline') === null).toBe(buckets < 2) - expect(svg.querySelector('polygon') === null).toBe(buckets < 2) - // The newest sample is marked in both cases. - expect(svg.querySelectorAll('circle').length).toBeGreaterThanOrEqual(1) + const latency = result.current.data!.latency + expect(latency).toHaveLength(5) + expect(segments(latency).map((r) => r.length)).toEqual([3, 2]) }) }) -describe('latency chart with no samples', () => { - // This file has no automatic cleanup (the scenarios above keep their renders), so - // these unmount themselves — two charts in one DOM make every text query ambiguous. - afterEach(cleanup) +describe('the time axis', () => { + it('ends at now, so the newest point sits on the right edge while data flows', () => { + const points = run(T0, 10) + const { container } = render() + expect(newestX(container)).toBeCloseTo(W - PAD_R) + }) + + it('slides the line left when the stream goes quiet', () => { + // 🔴 Anchored to the last point, a frozen chart looks exactly like a live one. + const points = run(T0, 10) + const quiet = 5 * 60_000 + const { container } = render() + const expected = PAD_L + ((RANGE_MS['15m'] - quiet) / RANGE_MS['15m']) * PLOT_W + expect(newestX(container)).toBeCloseTo(expected) + }) + it('goes back to the empty frame once everything has aged out of the range', () => { + const points = run(T0, 10) + const { container, getByText } = render( + , + ) + expect(getByText('No samples in this window yet')).toBeTruthy() + expect(container.querySelector('polyline')).toBeNull() + }) + + it('draws only what exists before the range has filled', () => { + // Connected for a minute with a 15-minute range: the line covers the right + // fifteenth and the rest stays empty, which is what says how much there is. + const points = run(T0, 61) + const { container } = render() + const xs = container + .querySelector('polyline')! + .getAttribute('points')! + .trim() + .split(/\s+/) + .map((p) => Number(p.split(',')[0])) + expect(Math.min(...xs)).toBeCloseTo(PAD_L + (14 / 15) * PLOT_W) + expect(Math.max(...xs)).toBeCloseTo(W - PAD_R) + }) + + it('shows more of what it holds when the range is widened', () => { + // An hour of points: a 15-minute range shows the last quarter of them, an + // hour shows them all — nothing was thrown away by the narrower choice. + const points = run(T0, 3600) + const now = T0 + 3599_000 + const count = (range: '15m' | '1h') => { + const { container, unmount } = render() + const n = container.querySelector('polyline')!.getAttribute('points')!.trim().split(/\s+/).length + unmount() + return n + } + expect(count('15m')).toBe(901) + expect(count('1h')).toBe(3600) + }) + + it('names the range on the axis', () => { + const { getByText } = render() + expect(getByText('−1h')).toBeTruthy() + expect(getByText('now')).toBeTruthy() + }) +}) + +describe('gaps', () => { + it('breaks the line where points are further apart than two ticks', () => { + const points = [...run(T0, 5), ...run(T0 + 30_000, 5)] + const { container } = render() + expect(lines(container)).toBe(2) + }) + + it('does not break it for a frame that is merely late', () => { + // 1.9 s apart on a 1 s tick is jitter, not an outage. + const points = [...run(T0, 5), ...run(T0 + 5_900, 5)] + const { container } = render() + expect(lines(container)).toBe(1) + }) + + it('judges the gap by the tick each point was promised', () => { + // A 5 s platform: 8 s between frames is within two of its ticks. + expect(segments([...run(T0, 3, 5000), ...run(T0 + 18_000, 3, 5000)])).toHaveLength(1) + expect(segments([...run(T0, 3, 1000), ...run(T0 + 10_000, 3, 1000)])).toHaveLength(2) + }) + + it('draws a lone point between two gaps as a dot, not a ramp', () => { + const points = [...run(T0, 3), { at: T0 + 20_000, p95: 9, tickMs: 1000 }, ...run(T0 + 40_000, 3)] + const { container } = render() + expect(lines(container)).toBe(2) + expect(container.querySelectorAll('polygon')).toHaveLength(2) + }) +}) + +describe('the held series', () => { + it('keeps six hours whatever range is selected, and no more', () => { + let series: LatencyPoint[] = [] + const first = { at: T0, p95: 1, tickMs: 1000 } + series = appendPoint(series, first) + series = appendPoint(series, { at: T0 + SERIES_SPAN_MS, p95: 2, tickMs: 1000 }) + expect(series).toContain(first) // exactly six hours old: still held + series = appendPoint(series, { at: T0 + SERIES_SPAN_MS + 1, p95: 3, tickMs: 1000 }) + expect(series).not.toContain(first) + expect(series).toHaveLength(2) + }) + + it('defaults to a 15-minute range', () => { + expect(DEFAULT_SETTINGS.chartRange).toBe('15m') + }) +}) + +describe('what the header says', () => { + it('names the window each point is a p95 of, and the cap on its sample', () => { + // Each point is smoothed over the platform's window and, under load, drawn + // from at most `capacity` traces. Both change how the curve should be read. + const { getByText } = render( + , + ) + expect(getByText('ms · p95 of 60s · ≤200 traces')).toBeTruthy() + }) + + it('leaves out what the platform did not state', () => { + const { getByText } = render() + expect(getByText('ms · p95 of 60s')).toBeTruthy() + }) +}) + +describe('latency chart with no samples', () => { /** * An idle platform is the normal state of a console someone just opened, and the * card used to vanish on it — taking half of the row it shares with the status @@ -116,7 +320,7 @@ describe('latency chart with no samples', () => { * an area or a point would all be data this console never received. */ it('draws the frame but nothing to read as data', () => { - const { container } = render() + const { container } = render() const svg = container.querySelector('svg') expect(svg).not.toBeNull() expect(svg!.querySelector('polyline')).toBeNull() @@ -125,55 +329,83 @@ describe('latency chart with no samples', () => { }) it('says the scale is a default rather than a measurement', () => { - // The axis numbers are this console's choice, not the platform's data. Left - // unlabelled they read as a measured range — which is the lie the vanished - // card at least never told. - const { getByText } = render() + const { getByText } = render() expect(getByText(/default scale/)).toBeTruthy() expect(getByText('No samples in this window yet')).toBeTruthy() }) it('goes back to the measured scale as soon as a sample lands', () => { - const { container, getByText, rerender } = render() - rerender() - expect(getByText(/rolling window/)).toBeTruthy() + const { container, queryByText, rerender } = render() + rerender() + expect(queryByText(/default scale/)).toBeNull() expect(container.querySelector('polyline')).not.toBeNull() }) }) describe('latency chart on sample data', () => { - afterEach(cleanup) - /** * With no platform the console draws a generated curve. The top bar says SAMPLE - * DATA, but this header said "rolling window" — a claim about where the numbers - * came from that a generated curve cannot make. The header has to say it instead. + * DATA, and the chart's header says it too: a generated curve is not a window + * onto anything. */ - it('says the curve is sample data, not a window', () => { - const { getByText, queryByText, getByRole } = render() + it('says the curve is sample data', () => { + const { getByText, getByRole } = render() expect(getByText(/sample data/)).toBeTruthy() - expect(queryByText(/rolling window/), 'a generated curve claimed to be a window').toBeNull() expect(getByRole('img').getAttribute('aria-label')).toMatch(/sample data/) }) - it('still draws the curve, so the demo stays explorable', () => { - // Every other card shows sample values too; an empty chart beside them would - // single this one out without making the screen any more honest. - const { container } = render() - expect(container.querySelector('polyline')).not.toBeNull() + it('fills every range, and stays put rather than sliding away with the clock', () => { + // Generated long before "now": anchored to the clock it would have slid out of + // view and left the demo empty. + for (const range of ['5m', '6h'] as const) { + const { container, unmount } = render() + expect(lines(container)).toBe(1) + expect(newestX(container)).toBeCloseTo(W - PAD_R) + const first = Number(container.querySelector('polyline')!.getAttribute('points')!.split(',')[0]) + expect(first).toBeCloseTo(PAD_L) + unmount() + } }) it('drops the label once real samples replace it', () => { - const { getByText, queryByText, rerender } = render() - rerender() - expect(getByText(/rolling window/)).toBeTruthy() + const { queryByText, rerender } = render() + rerender() expect(queryByText(/sample data/)).toBeNull() }) }) -describe('status mix', () => { - afterEach(cleanup) +describe('the p95 tile', () => { + const metrics: ModuleMetricsSnapshot[] = [ + { + moduleId: 'slow', + count: 10, + errorCount: 0, + errorRate: 0, + p50LatencyMs: 400, + p95LatencyMs: 900, + p99LatencyMs: 950, + maxLatencyMs: 990, + lastSeenEpochMillis: T0, + }, + ] + + it('shows the windowed p95 the chart plots, not the worst module', () => { + // The tile used to print the worst per-module p95 (900 here) beside a chart + // that plotted something else; now it reads as the chart's newest point. + const { getByText, queryByText } = render() + expect(getByText('3')).toBeTruthy() + expect(queryByText('900')).toBeNull() + }) + it('shows a dash until a summary arrives, rather than a different measurement', () => { + const { getAllByText, queryByText } = render() + expect(queryByText('900')).toBeNull() + expect(getAllByText('—').length).toBeGreaterThan(0) + expect(queryByText('no summary yet')).toBeTruthy() + }) +}) + +describe('status mix', () => { /** * The header count and the bar geometry were one number, and the `|| 1` that * keeps a width from dividing by zero was also what the header printed: an idle @@ -185,7 +417,7 @@ describe('status mix', () => { expect(getByText('0 req')).toBeTruthy() }) - it('still counts what did arrive', () => { + it('says the count is of the latest requests, not of all traffic', () => { const t = (status: number, seq: number): RequestTrace => ({ seq, epochMillis: 1789971324000 + seq, @@ -199,6 +431,6 @@ describe('status mix', () => { traceId: `dddd${seq}`, }) const { getByText } = render() - expect(getByText('3 req')).toBeTruthy() + expect(getByText('last 3 req')).toBeTruthy() }) }) diff --git a/src/components/latency-chart.tsx b/src/components/latency-chart.tsx index 7020808..22126b0 100644 --- a/src/components/latency-chart.tsx +++ b/src/components/latency-chart.tsx @@ -1,5 +1,8 @@ -import { useId, useRef, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { Card, CardHeader, CardTitle } from '@/components/ui/card' +import { ago, num } from '@/lib/format' +import { RANGE_MS, segments, type LatencyPoint } from '@/lib/latency-series' +import type { TimeRange } from '@/lib/types' const W = 720 const H = 190 @@ -33,49 +36,122 @@ function niceTicks(max: number, count = 4): number[] { */ const EMPTY_SCALE_MAX = 100 +/** First index whose point is at or after `t`, in a time-ordered series. */ +function firstAtOrAfter(points: readonly LatencyPoint[], t: number): number { + let lo = 0 + let hi = points.length + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (points[mid].at < t) lo = mid + 1 + else hi = mid + } + return lo +} + +/** The point nearest in time to `t`. `points` is non-empty and time-ordered. */ +function nearest(points: readonly LatencyPoint[], t: number): LatencyPoint { + const i = firstAtOrAfter(points, t) + if (i === 0) return points[0] + if (i === points.length) return points[points.length - 1] + return t - points[i - 1].at <= points[i].at - t ? points[i - 1] : points[i] +} + +/** The clock the right edge follows, ticking while `running`. */ +function useClock(running: boolean): number { + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + if (!running) return + const id = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(id) + }, [running]) + return now +} + /** - * Single-series p95 latency area with a crosshair+tooltip. One hue (telemetry), no legend needed. + * p95 latency over time, one hue (telemetry), with a crosshair and tooltip. * - * `sample` marks a series this console generated because no platform answered. The - * top bar already says SAMPLE DATA, but the chart is the one card whose header made - * a claim of its own — "rolling window" — and a generated curve is not a window onto - * anything. The header names what it is instead. + * The x axis is time: it spans `range` and ends **now**, not at the newest point. + * When the platform goes quiet the line slides left and the silence shows as empty + * space on the right; a chart pinned to its last point would look live while + * frozen. Before the range has filled, the left side is empty for the same reason — + * the series only holds what arrived after this page connected. + * + * The line breaks wherever the points are further apart than their tick allows + * (`segments`): joining across an outage would draw measurements never taken. + * + * Each point is the platform's p95 over its own `windowMs`, computed from at most + * `capacity` traces — so the curve is smoothed by that window, and under load the + * p95 is of the newest traces in it, not all of them. The header says both. + * + * `sample` marks the generated series shown when no platform answered. It is + * anchored to its own last point rather than the clock: it is not a feed, and + * sliding it away would only empty the demo. */ -export function LatencyChart({ series, sample = false }: { series: number[]; sample?: boolean }) { +export function LatencyChart({ + points, + range, + sample = false, + windowMs = null, + capacity = null, + now, +}: { + points: readonly LatencyPoint[] + range: TimeRange + sample?: boolean + windowMs?: number | null + capacity?: number | null + /** Pins the right edge. Tests use it; the app leaves it to the clock. */ + now?: number +}) { const gradId = useId() const svgRef = useRef(null) - const [hover, setHover] = useState(null) - const n = series.length - const empty = n === 0 + const [hoverX, setHoverX] = useState(null) + const clock = useClock(now == null && !sample) + + const span = RANGE_MS[range] + const end = now ?? (sample && points.length > 0 ? points[points.length - 1].at : clock) + const start = end - span + const visible = points.slice(firstAtOrAfter(points, start), firstAtOrAfter(points, end + 1)) + const runs = segments(visible) + const empty = visible.length === 0 + const newest = empty ? null : visible[visible.length - 1] + + let peak = 0 + for (const p of visible) if (p.p95 > peak) peak = p.p95 // Nice round ticks give the scale; the top tick already clears the data max, // so the axis has headroom without an arbitrary multiplier. - const gridVals = niceTicks(empty ? EMPTY_SCALE_MAX : Math.max(...series)) + const gridVals = niceTicks(empty ? EMPTY_SCALE_MAX : peak) const max = gridVals[gridVals.length - 1] || 1 - // A single bucket has no span to interpolate across — `i / (n - 1)` would be - // 0/0 and poison every coordinate with NaN. Pin the lone sample to the right - // edge, where the newest sample always sits for n > 1. - const x = (i: number) => (n === 1 ? W - PAD.r : PAD.l + (i / (n - 1)) * (W - PAD.l - PAD.r)) + const plotW = W - PAD.l - PAD.r + const x = (t: number) => PAD.l + ((t - start) / span) * plotW const y = (v: number) => PAD.t + (1 - v / max) * (H - PAD.t - PAD.b) - const pts = series.map((v, i) => `${x(i)},${y(v)}`).join(' ') - const area = empty ? '' : `${PAD.l},${y(0)} ${pts} ${x(n - 1)},${y(0)}` + const hover = hoverX == null || empty ? null : nearest(visible, start + ((hoverX - PAD.l) / plotW) * span) function onMove(e: React.PointerEvent) { - if (empty) return // nothing to point at, and `x`/`y` have no sample to resolve + if (empty) return // nothing to point at const r = svgRef.current!.getBoundingClientRect() - const relX = ((e.clientX - r.left) / r.width) * W - const i = Math.max(0, Math.min(n - 1, Math.round((relX - PAD.l) / (W - PAD.l - PAD.r) * (n - 1)))) - setHover(i) + setHoverX(((e.clientX - r.left) / r.width) * W) } + const header = sample + ? 'ms · sample data' + : empty + ? 'ms · default scale' + : [ + 'ms', + windowMs != null ? `p95 of ${Math.round(windowMs / 1000)}s` : null, + capacity != null ? `≤${num(capacity)} traces` : null, + ] + .filter(Boolean) + .join(' · ') + return ( Latency p95 over time - - {sample ? 'ms · sample data' : empty ? 'ms · default scale' : 'ms · rolling window'} - + {header}
setHover(null)} + onPointerLeave={() => setHoverX(null)} > @@ -102,21 +178,59 @@ export function LatencyChart({ series, sample = false }: { series: number[]; sam ))} - {/* Line and area need two points to mean anything; with one bucket the - area would ramp from a zero baseline it never measured. Show the dot alone. - With no samples at all, nothing is drawn over the grid — an empty chart - must not be given a shape to read. */} - {n > 1 && } - {n > 1 && ( - - )} - {!empty && ( - + + −{range} + + + {sample ? 'latest' : 'now'} + + {/* One line per unbroken run. A run of one has nothing to join: drawing an + area from a zero baseline would be a ramp the data never measured, so + it is a dot. With no samples at all, nothing is drawn over the grid. */} + {runs.map((run) => { + const key = run[0].at + if (run.length === 1) { + return + } + const pts = run.map((p) => `${x(p.at)},${y(p.p95)}`).join(' ') + const area = `${x(run[0].at)},${y(0)} ${pts} ${x(run[run.length - 1].at)},${y(0)}` + return ( + + + + + ) + })} + {newest && ( + )} - {hover != null && ( + {hover && ( <> - - + + )} @@ -125,12 +239,12 @@ export function LatencyChart({ series, sample = false }: { series: number[]; sam No samples in this window yet

)} - {hover != null && ( + {hover && (
- {series[hover]}ms · −{n - 1 - hover}m + {hover.p95}ms · {ago(hover.at, end)}
)}
diff --git a/src/components/settings-dialog.test.tsx b/src/components/settings-dialog.test.tsx index da14494..320da2b 100644 --- a/src/components/settings-dialog.test.tsx +++ b/src/components/settings-dialog.test.tsx @@ -127,6 +127,7 @@ describe('saving', () => { retype('Load-older page size', '25') retype('Sample-data delay', '1200') fireEvent.click(screen.getByRole('button', { name: '4×' })) + fireEvent.click(screen.getByRole('button', { name: '6h' })) click('Save') await waitFor(() => @@ -135,6 +136,7 @@ describe('saving', () => { evictionFactor: 4, pageSize: 25, sampleDelayMs: 1_200, + chartRange: '6h', }), ) }) @@ -160,7 +162,7 @@ describe('saving', () => { describe('cancelling', () => { it('leaves storage exactly as it was', async () => { - const stored: Settings = { retention: 70_000, evictionFactor: 8, pageSize: 10, sampleDelayMs: 900 } + const stored: Settings = { retention: 70_000, evictionFactor: 8, pageSize: 10, sampleDelayMs: 900, chartRange: '1h' } saveSettings(stored) render() @@ -191,7 +193,7 @@ describe('cancelling', () => { describe('reset to defaults', () => { it('fills the form without saving anything by itself', async () => { - const stored: Settings = { retention: 70_000, evictionFactor: 8, pageSize: 10, sampleDelayMs: 900 } + const stored: Settings = { retention: 70_000, evictionFactor: 8, pageSize: 10, sampleDelayMs: 900, chartRange: '1h' } saveSettings(stored) render() diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 1bc5b14..89c7b58 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -21,15 +21,24 @@ import { type Settings, } from '@/lib/settings' import { num } from '@/lib/format' +import { TIME_RANGES } from '@/lib/latency-series' +import type { TimeRange } from '@/lib/types' /** Draft values are strings so a half-typed number is not silently coerced. */ -type Draft = { retention: string; evictionFactor: EvictionFactor; pageSize: string; sampleDelayMs: string } +type Draft = { + retention: string + evictionFactor: EvictionFactor + pageSize: string + sampleDelayMs: string + chartRange: TimeRange +} const toDraft = (s: Settings): Draft => ({ retention: String(s.retention), evictionFactor: s.evictionFactor, pageSize: String(s.pageSize), sampleDelayMs: String(s.sampleDelayMs), + chartRange: s.chartRange, }) export function SettingsDialog({ @@ -83,6 +92,7 @@ function SettingsForm({ evictionFactor: draft.evictionFactor, pageSize: Number(draft.pageSize), sampleDelayMs: Number(draft.sampleDelayMs), + chartRange: draft.chartRange, }) onDone() } @@ -190,6 +200,35 @@ function SettingsForm({ )} + + + Latency chart range + { + // Single-select, as above: clicking the pressed item again keeps it. + const picked = next[0] as TimeRange | undefined + if (picked) setDraft((d) => ({ ...d, chartRange: picked })) + }} + > + {TIME_RANGES.map((r) => ( + + {r} + + ))} + + + How far back the chart reaches, ending now. It only holds what arrived since this page connected, so a + longer range fills in as the page stays open. + + diff --git a/src/components/status-mix.tsx b/src/components/status-mix.tsx index c1d6aa6..f1c0ad4 100644 --- a/src/components/status-mix.tsx +++ b/src/components/status-mix.tsx @@ -9,7 +9,15 @@ const BUCKETS = [ { key: '5xx', label: 'Server err', color: 'var(--crit)', test: (s: number) => s >= 500 }, ] -/** Status-code distribution over the sampled traces — magnitude bars, one hue per status. */ +/** + * Status-code distribution over the traces held in memory — magnitude bars, one + * hue per status. + * + * That is a count of the most recent rows, not a time window, and the header says + * so ("last N req"). `summary` carries no per-status split, so a time window here + * would mean solving the trace window's problem again; naming what the count is + * costs nothing and stops "500 req" reading as all the traffic there was. + */ export function StatusMix({ traces }: { traces: RequestTrace[] }) { const counts = BUCKETS.map((b) => ({ ...b, n: traces.filter((t) => b.test(t.status)).length })) const total = counts.reduce((a, b) => a + b.n, 0) @@ -22,7 +30,9 @@ export function StatusMix({ traces }: { traces: RequestTrace[] }) { 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'