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'