diff --git a/src/App.tsx b/src/App.tsx index d92fe38..4629f4d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -84,7 +84,12 @@ export default function App() { aria-busy={disconnected} > - +
diff --git a/src/components/kpi-row.tsx b/src/components/kpi-row.tsx index 9f03520..f9c9f9d 100644 --- a/src/components/kpi-row.tsx +++ b/src/components/kpi-row.tsx @@ -80,12 +80,42 @@ function Tile({ ) } +/** + * Why the cumulative totals are unknown, or `null` when the numbers can be shown. + * + * Requests and error rate are sums over `metrics`, and an empty array sums to zero + * whether or not anything was recorded. A platform with per-module metrics off sends + * exactly that, so these tiles read "0 requests, 0.00%" over live traffic — the + * module table already knew better from the same ack, and the tiles did not ask. + * + * The order is the module table's (`emptyMetrics`), for the same reason: + * `metricsEnabled` is the effective value, so recording off reports metrics off too, + * and recording is the cause worth naming. Rows that did arrive are always shown — + * the setting is live and may have changed since the ack, but the rows are data. + * + * Not replaced with `summary.count`: that is capped by the ring `capacity`, so it + * would read lower the busier the platform got. A dash is the honest answer. + */ +function totalsUnknown( + metrics: ModuleMetricsSnapshot[], + metricsEnabled: boolean | null, + tracesEnabled: boolean | null, +): string | null { + if (metrics.length > 0) return null + if (tracesEnabled === false) return 'recording off' + if (metricsEnabled === false) return 'metrics off' + if (metricsEnabled === true) return null // on and idle: zero is the true count + return 'not reported' +} + /** * 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. + * a muted placeholder rather than a made-up delta. When the platform is not + * aggregating per-module metrics the totals do not exist, and the tiles say why + * instead of showing zero (`totalsUnknown`). * * 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 @@ -93,7 +123,18 @@ function Tile({ * 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 }) { +export function KpiRow({ + metrics, + summary, + metricsEnabled = null, + tracesEnabled = null, +}: { + metrics: ModuleMetricsSnapshot[] + summary: TraceSummary | null + metricsEnabled?: boolean | null + tracesEnabled?: boolean | null +}) { + const unknown = totalsUnknown(metrics, metricsEnabled, tracesEnabled) 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 @@ -103,7 +144,13 @@ export function KpiRow({ metrics, summary }: { metrics: ModuleMetricsSnapshot[]; return (
- + {/* The reason sits where the unit would, beside the dash: the sub-line keeps + saying what it always says, and the tile stays one line tall. */} + {summary?.requestsDeltaPct != null ? ( {Math.abs(summary.requestsDeltaPct * 100).toFixed(0)}% vs prev @@ -113,13 +160,23 @@ export function KpiRow({ metrics, summary }: { metrics: ModuleMetricsSnapshot[]; )} - + {summary?.errorRateDeltaPp != null ? ( {Math.abs(summary.errorRateDeltaPp).toFixed(2)}pp · {num(summary.errorCount)} err + ) : summary ? ( + {win} · {num(summary.errorCount)} err + ) : unknown ? ( + // The error count is the same sum as the rate above: no rows, no count. + {win} · no summary yet ) : ( - {win} · {num(summary?.errorCount ?? errors)} err + {win} · {num(errors)} err )} diff --git a/src/components/kpi-totals.test.tsx b/src/components/kpi-totals.test.tsx new file mode 100644 index 0000000..d81cefa --- /dev/null +++ b/src/components/kpi-totals.test.tsx @@ -0,0 +1,119 @@ +import { act, cleanup, render, renderHook, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { KpiRow } from '@/components/kpi-row' +import { useConsoleData } from '@/hooks/use-console-data' +import type { ModuleMetricsSnapshot, TraceSummary } from '@/lib/types' +import { GO_CONNECT, PROTEAN_CONNECT, SPRING_CONNECT } from '@/test/stream-fixtures' +import { FakeEventSource, installFakeEventSource, parseStream, replay } from '@/test/sse-stream' + +/** + * The request and error-rate tiles are sums over `metrics`. An empty array sums to + * zero whether or not anything was recorded, and a platform with per-module metrics + * off sends exactly that — measured live on 2026-10-12: quickstart under a + * 3,000-request burst, and the header read "Requests · total 0" and "0.00%". + * + * The ack already says which case it is, and the module table already reads it. + * These scenarios hold the tiles to the same reading, and replay real connect + * sequences so the ack is the one a platform actually sends. + */ + +afterEach(cleanup) + +function value(label: string): string { + return screen.getByText(label).nextElementSibling!.textContent! +} + +function subline(label: string): string { + return screen.getByText(label).parentElement!.lastElementChild!.textContent! +} + +const row = (count: number, errorCount: number): ModuleMetricsSnapshot => ({ + moduleId: 'greeter', + count, + errorCount, + errorRate: count ? errorCount / count : 0, + p50LatencyMs: 1, + p95LatencyMs: 2, + p99LatencyMs: 3, + maxLatencyMs: 4, + lastSeenEpochMillis: 1_790_000_000_000, +}) + +describe('on a live connect sequence', () => { + let restoreEventSource: () => void + beforeEach(() => { + restoreEventSource = installFakeEventSource() + }) + afterEach(() => restoreEventSource()) + + function connect(body: string) { + const { result } = renderHook(() => useConsoleData()) + act(() => { + const es = FakeEventSource.current() + es.emit('open') + replay(es, body) + }) + const { data, platform } = result.current + render( + , + ) + } + + it('says metrics are off, not that nothing happened, on a platform that turned them off', () => { + // 🔴 The protean capture: `metricsEnabled: false`, `metrics: []`, and a summary + // counting traffic. The tiles used to print a confident zero over it. + connect(PROTEAN_CONNECT) + expect(value('Requests · total')).toMatch(/^—\smetrics\soff$/) + expect(value('Error rate')).toMatch(/^—\smetrics\soff$/) + // The windowed trend is a different measurement, and it is still there. + expect(subline('Requests · total')).toContain('vs prev') + }) + + it('says it was not told, on a platform that sends no ack and no rows', () => { + connect(SPRING_CONNECT) + expect(value('Requests · total')).toMatch(/^—\snot\sreported$/) + }) + + it('shows the totals a platform did send', () => { + // Go sends module rows (and an older `hello`, not `ready`): the rows are data. + connect(GO_CONNECT) + const rows = JSON.parse(parseStream(GO_CONNECT).find((f) => f.event === 'metrics')!.data) as ModuleMetricsSnapshot[] + expect(value('Requests · total')).toBe(String(rows.reduce((a, r) => a + r.count, 0))) + expect(value('Requests · total')).not.toMatch(/off|not reported/) + }) +}) + +describe('the reading, case by case', () => { + const summary = JSON.parse(parseStream(PROTEAN_CONNECT).find((f) => f.event === 'summary')!.data) as TraceSummary + + it('names recording before metrics, as the module table does', () => { + // `metricsEnabled` is the effective value, so recording off reports metrics off + // too; "metrics off" would point at the wrong switch. + render() + expect(value('Requests · total')).toMatch(/^—\srecording\soff$/) + }) + + it('keeps a true zero: metrics on and no traffic yet', () => { + render() + expect(value('Requests · total')).toBe('0') + expect(value('Error rate')).toBe('0.00%') + }) + + it('shows rows that arrived even if the ack said off', () => { + // The setting is live: it can be switched on after the ack. Rows are data. + render() + expect(value('Requests · total')).toBe('40') + expect(value('Error rate')).toBe('10.00%') + }) + + it('does not invent an error count when there is no summary either', () => { + render() + expect(value('Error rate')).toMatch(/^—\smetrics\soff$/) + expect(subline('Error rate')).not.toMatch(/\d+ err/) + }) +})