From 2dd40cf0941e5e5c241cf94997e065838c733581 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 18:56:06 +0000 Subject: [PATCH 1/4] metrics: the tiles read over the selected range The scalar request carries the range the charts are on, and the requests tile is captioned by the window the proxy says it read over (MoonBase#1507), not a fixed five minutes. Older proxies keep their old caption. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM --- src/apps/metrics-systems/api.ts | 18 ++++++---- .../components/ServiceDashboard.tsx | 15 ++++---- .../__tests__/ServiceDashboard.test.tsx | 35 +++++++++++++++++-- 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/apps/metrics-systems/api.ts b/src/apps/metrics-systems/api.ts index 21a24c3..aebf3f5 100644 --- a/src/apps/metrics-systems/api.ts +++ b/src/apps/metrics-systems/api.ts @@ -17,8 +17,8 @@ export interface ServiceCatalog { export interface StandardMetrics { requests_total: number rate_per_sec: number - success_count_5m: number - failure_count_5m: number + success_count: number + failure_count: number error_rate_percent: number avg_duration_microseconds: number p95_duration_microseconds: number @@ -54,6 +54,10 @@ export interface ServiceMetricsResponse { // proxy, which is also the signal that `requests_total` is still a lifetime // total rather than a windowed count — see requestsTotalLabel. view?: MetricView + // The window every windowed tile was read over — the ?range= the request + // named, echoed as the PromQL duration the queries carried (MoonBase#1507). + // Absent from a proxy that still windows every tile at five minutes. + window?: string custom: CustomMetricGroup[] } @@ -72,15 +76,17 @@ export function hasToggleableMetrics(response: ServiceMetricsResponse | null): b // What to call the standard block's `requests_total` tile. // // MoonBase#1287 changed that field from sum(x) — cumulative since process -// start, and so reset by every deploy — to a 5-minute increase(). The JSON key +// start, and so reset by every deploy — to a windowed increase(). The JSON key // could not change without breaking this UI, so the name still says "total" // while the number no longer is one; calling the tile "Total" would repeat the // same wrong label the API is stuck with. // -// `view` is the tell: it is present exactly when the proxy carries that change, -// because both landed in the same commit. An older host therefore keeps the -// old label, which is still honest about the number it is actually sending. +// The window is whatever the proxy says it read over: the selected range once +// it takes one (MoonBase#1507), five minutes on the proxy before that, which +// echoes `view` but no `window`. A host older than both keeps the old label, +// which is still honest about the number it is actually sending. export function requestsTotalLabel(response: ServiceMetricsResponse | null): string { + if (response?.window) return `Req (${response.window})` return response?.view ? 'Req (5m)' : 'Total' } diff --git a/src/apps/metrics-systems/components/ServiceDashboard.tsx b/src/apps/metrics-systems/components/ServiceDashboard.tsx index 83de130..7ae7bfa 100644 --- a/src/apps/metrics-systems/components/ServiceDashboard.tsx +++ b/src/apps/metrics-systems/components/ServiceDashboard.tsx @@ -260,11 +260,14 @@ const ServiceDashboard = ({ service, onConnectionStateChange }: ServiceDashboard // service name server-side, so this no longer pulls the whole host // payload — every other container's stats — to use one row of it. const [scalarData, seriesData, containerData] = await Promise.all([ - // ?view= is safe against an older proxy: Go's mux ignores query - // parameters nothing reads, so the request still succeeds and comes - // back in the only form that proxy has. The switch stays hidden in - // that case anyway — see hasToggleableMetrics. - fetchJson(`${METRICS_API_URL}/service/${service}?view=${view}`), + // ?view= and ?range= are safe against an older proxy: Go's mux + // ignores query parameters nothing reads, so the request still + // succeeds and comes back in the only form that proxy has. The + // switch stays hidden in that case anyway — see hasToggleableMetrics. + // The range is the tiles' window (MoonBase#1507), so a tile and the + // chart under it describe the same span; the label reads it back + // from the response — see requestsTotalLabel. + fetchJson(`${METRICS_API_URL}/service/${service}?view=${view}&range=${timeRange}`), // No ?view= here: the timeseries endpoint always answers with both // request_rate and request_count, and the chart below picks between // them locally — see STANDARD_SERIES. @@ -391,7 +394,7 @@ const ServiceDashboard = ({ service, onConnectionStateChange }: ServiceDashboard
{/* Not "Total" once the proxy is new enough: the field still - says total, but MoonBase#1287 made it a 5-minute count. */} + says total, but MoonBase#1287 made it a windowed count. */}
{requestsTotalLabel(scalar)}
{formatValue(standard.requests_total || 0)}
diff --git a/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx b/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx index 3dd268e..185f73b 100644 --- a/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx +++ b/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx @@ -32,8 +32,8 @@ const scalarResponse = { standard: { requests_total: 1234, rate_per_sec: 1.25, - success_count_5m: 100, - failure_count_5m: 5, + success_count: 100, + failure_count: 5, error_rate_percent: 4.8, avg_duration_microseconds: 1500, p95_duration_microseconds: 9500, @@ -58,6 +58,11 @@ const rateScalarResponse = { } // A host still running a pre-MoonBase#1287 proxy: no `view`, no `toggleable`. +// A proxy that windows every tile by the request's range echoes which one +// (MoonBase#1507); the fixture above stands for the one before it, which +// echoed a view and read every tile over five minutes. +const rangedScalarResponse = { ...scalarResponse, window: '1d' } + const legacyScalarResponse = { ...scalarResponse, view: undefined, @@ -119,6 +124,7 @@ const containerDetail = (overrides: Record = {}) => ({ // longer matches it. Compare the path and read the view separately. const pathOf = (url: string) => url.split('?')[0] const viewOf = (url: string) => new URLSearchParams(url.split('?')[1] ?? '').get('view') ?? 'count' +const rangeOf = (url: string) => new URLSearchParams(url.split('?')[1] ?? '').get('range') const ok = (body: unknown) => Promise.resolve({ ok: true, text: () => Promise.resolve(JSON.stringify(body)) }) const notFound = () => Promise.resolve({ ok: false, text: () => Promise.resolve('') }) @@ -267,6 +273,8 @@ describe('ServiceDashboard', () => { // toggle picks between them client-side — so plain endsWith matches it. const urls = mockFetch.mock.calls.map((call) => String(call[0])) expect(urls.some((url) => url.endsWith('/service/golf_hub/timeseries/30m'))).toBe(true) + // The tiles read over the same range as the charts (MoonBase#1507). + expect(urls.some((url) => pathOf(url).endsWith('/service/golf_hub') && rangeOf(url) === '30m')).toBe(true) }) it('reports failure and keeps the page shape when the API is down', async () => { @@ -712,6 +720,29 @@ describe('ServiceDashboard', () => { expect(screen.queryByText('Total')).toBeNull() }) + it('names the requests tile by the window the proxy read it over', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('/service/golf_hub/timeseries/')) return ok(timeseriesResponse) + if (pathOf(url).endsWith('/service/golf_hub')) return ok({ ...rangedScalarResponse, window: rangeOf(url) }) + if (url.endsWith('/container/golf_hub')) return ok(containerDetail()) + return notFound() + }) + const { container } = render() + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + expect(screen.getByText('Req (1d)')).toBeTruthy() + + // The label follows the range, read back from the response rather than + // from the select, so it never says a window the number was not. + fireEvent.change(container.querySelector('select')!, { target: { value: '7d' } }) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + expect(screen.getByText('Req (7d)')).toBeTruthy() + expect(screen.queryByText('Req (5m)')).toBeNull() + }) + it('keeps the old label for a proxy still sending a lifetime total', async () => { mockFetch.mockImplementation((url: string) => { if (url.includes('/service/golf_hub/timeseries/')) return ok(timeseriesResponse) From 4d3edb0bc077a92af2d5f63460b456beb1c29c75 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 18:59:25 +0000 Subject: [PATCH 2/4] metrics: the range select says the span once for every tile Options read "last 1d" so the one control the reader already uses names the window every chart and tile is over; Active, a gauge, is captioned as the exception. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM --- .../metrics-systems/components/ServiceDashboard.tsx | 12 ++++++++---- .../components/__tests__/ServiceDashboard.test.tsx | 4 ++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/apps/metrics-systems/components/ServiceDashboard.tsx b/src/apps/metrics-systems/components/ServiceDashboard.tsx index 7ae7bfa..9886e24 100644 --- a/src/apps/metrics-systems/components/ServiceDashboard.tsx +++ b/src/apps/metrics-systems/components/ServiceDashboard.tsx @@ -327,9 +327,12 @@ const ServiceDashboard = ({ service, onConnectionStateChange }: ServiceDashboard onChange={(e) => setTimeRange(e.target.value as '30m' | '1d' | '7d')} className={styles.timeRangeSelect} > - - - + {/* The one cue for the whole page: every chart and, from a + proxy that takes a range (MoonBase#1507), every tile but + Active reads over this span. */} + + + {/* Only when the proxy is new enough to answer a view at all. Every service's Serving chart has a toggleable Request Rate — even one @@ -389,7 +392,8 @@ const ServiceDashboard = ({ service, onConnectionStateChange }: ServiceDashboard
{((standard.p95_duration_microseconds || 0) / 1000).toFixed(1)}
-
Active
+ {/* A gauge, and the one tile not over the selected range. */} +
Active (now)
{(standard.active_requests || 0).toFixed(0)}
diff --git a/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx b/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx index 185f73b..36a40cd 100644 --- a/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx +++ b/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx @@ -741,6 +741,10 @@ describe('ServiceDashboard', () => { }) expect(screen.getByText('Req (7d)')).toBeTruthy() expect(screen.queryByText('Req (5m)')).toBeNull() + // The range select says the span once for every tile; the gauge says + // it is the exception. + expect(screen.getByRole('option', { name: 'last 7d' })).toBeTruthy() + expect(screen.getByText('Active (now)')).toBeTruthy() }) it('keeps the old label for a proxy still sending a lifetime total', async () => { From fec78c0d4ba635d3793d2627447c35ef7d4d0133 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 19:02:27 +0000 Subject: [PATCH 3/4] metrics: the caption is pinned to the response, not the select While a range change is still out, the old number keeps its old caption; the tiles request is checked to name view and range together. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM --- .../__tests__/ServiceDashboard.test.tsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx b/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx index 36a40cd..dab2d25 100644 --- a/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx +++ b/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx @@ -273,8 +273,9 @@ describe('ServiceDashboard', () => { // toggle picks between them client-side — so plain endsWith matches it. const urls = mockFetch.mock.calls.map((call) => String(call[0])) expect(urls.some((url) => url.endsWith('/service/golf_hub/timeseries/30m'))).toBe(true) - // The tiles read over the same range as the charts (MoonBase#1507). - expect(urls.some((url) => pathOf(url).endsWith('/service/golf_hub') && rangeOf(url) === '30m')).toBe(true) + // The tiles read over the same range as the charts (MoonBase#1507), + // on the same request that names the view. + expect(urls.some((url) => pathOf(url).endsWith('/service/golf_hub') && rangeOf(url) === '30m' && viewOf(url) === 'count')).toBe(true) }) it('reports failure and keeps the page shape when the API is down', async () => { @@ -734,11 +735,29 @@ describe('ServiceDashboard', () => { expect(screen.getByText('Req (1d)')).toBeTruthy() // The label follows the range, read back from the response rather than - // from the select, so it never says a window the number was not. + // from the select, so it never says a window the number was not: while + // the 7d answer is still out, the 1d number keeps its 1d caption. + let answer: (body: unknown) => void = () => {} + mockFetch.mockImplementation((url: string) => { + if (url.includes('/service/golf_hub/timeseries/')) return ok(timeseriesResponse) + if (pathOf(url).endsWith('/service/golf_hub')) { + return new Promise((resolve) => { + answer = (body) => resolve({ ok: true, text: () => Promise.resolve(JSON.stringify(body)) }) + }) + } + if (url.endsWith('/container/golf_hub')) return ok(containerDetail()) + return notFound() + }) fireEvent.change(container.querySelector('select')!, { target: { value: '7d' } }) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)) }) + expect(screen.getByText('Req (1d)')).toBeTruthy() + expect(screen.queryByText('Req (7d)')).toBeNull() + await act(async () => { + answer({ ...rangedScalarResponse, window: '7d' }) + await new Promise((resolve) => setTimeout(resolve, 50)) + }) expect(screen.getByText('Req (7d)')).toBeTruthy() expect(screen.queryByText('Req (5m)')).toBeNull() // The range select says the span once for every tile; the gauge says From 21297b6fb263941a8a8a3b69ca365d9d71d23110 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 19:21:22 +0000 Subject: [PATCH 4/4] metrics: a week's tiles refresh every five minutes At 7d every tile is a week-long instant query (MoonBase#1507), and a page of them every thirty seconds is the load this dashboard adds to Prometheus; a week's count does not move in half a minute. Shorter ranges stay at thirty seconds. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM --- .../metrics-systems/__tests__/api.test.ts | 9 ++++++ src/apps/metrics-systems/api.ts | 9 ++++++ .../components/ServiceDashboard.tsx | 3 +- .../__tests__/ServiceDashboard.test.tsx | 29 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/apps/metrics-systems/__tests__/api.test.ts b/src/apps/metrics-systems/__tests__/api.test.ts index 691fc8c..6fde6c8 100644 --- a/src/apps/metrics-systems/__tests__/api.test.ts +++ b/src/apps/metrics-systems/__tests__/api.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest' import { + refreshMs, bucketMs, byHealthThenName, containerDisplayName, @@ -359,3 +360,11 @@ describe('formatBytes', () => { expect(formatBytes(0)).toBe('0 B') }) }) + +describe('refreshMs', () => { + it('keeps short ranges live and slows a week to five minutes', () => { + expect(refreshMs('30m')).toBe(30_000) + expect(refreshMs('1d')).toBe(30_000) + expect(refreshMs('7d')).toBe(300_000) + }) +}) diff --git a/src/apps/metrics-systems/api.ts b/src/apps/metrics-systems/api.ts index aebf3f5..140a70f 100644 --- a/src/apps/metrics-systems/api.ts +++ b/src/apps/metrics-systems/api.ts @@ -85,6 +85,15 @@ export function hasToggleableMetrics(response: ServiceMetricsResponse | null): b // it takes one (MoonBase#1507), five minutes on the proxy before that, which // echoes `view` but no `window`. A host older than both keeps the old label, // which is still honest about the number it is actually sending. +// How often a service page refetches. A week's numbers are refetched every +// five minutes rather than every thirty seconds: at 7d every tile is an +// instant query with a week-long lookback (MoonBase#1507), a page of them +// every half minute is the one load on Prometheus this dashboard adds, and +// a week's count does not move in half a minute. Shorter ranges stay live. +export function refreshMs(timeRange: string): number { + return timeRange === '7d' ? 5 * 60_000 : 30_000 +} + export function requestsTotalLabel(response: ServiceMetricsResponse | null): string { if (response?.window) return `Req (${response.window})` return response?.view ? 'Req (5m)' : 'Total' diff --git a/src/apps/metrics-systems/components/ServiceDashboard.tsx b/src/apps/metrics-systems/components/ServiceDashboard.tsx index 9886e24..1c3de47 100644 --- a/src/apps/metrics-systems/components/ServiceDashboard.tsx +++ b/src/apps/metrics-systems/components/ServiceDashboard.tsx @@ -9,6 +9,7 @@ import { fetchJson, fillWindow, hasToggleableMetrics, + refreshMs, requestsTotalLabel, seriesWindow, serviceDisplayName, @@ -292,7 +293,7 @@ const ServiceDashboard = ({ service, onConnectionStateChange }: ServiceDashboard } const start = setTimeout(fetchMetrics, 0) - const interval = setInterval(fetchMetrics, 30000) + const interval = setInterval(fetchMetrics, refreshMs(timeRange)) return () => { active = false clearTimeout(start) diff --git a/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx b/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx index dab2d25..a9a60fc 100644 --- a/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx +++ b/src/apps/metrics-systems/components/__tests__/ServiceDashboard.test.tsx @@ -478,6 +478,35 @@ describe('ServiceDashboard', () => { } }) + it('refetches a week every five minutes, not every thirty seconds', async () => { + vi.useFakeTimers() + try { + const { container } = render() + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + fireEvent.change(container.querySelector('select')!, { target: { value: '7d' } }) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + const scalarFetches = () => + mockFetch.mock.calls.filter((call) => pathOf(String(call[0])).endsWith('/service/golf_hub') && rangeOf(String(call[0])) === '7d').length + expect(scalarFetches()).toBe(1) + // A week-long lookback per tile is the load this dashboard adds to + // Prometheus; a week's count does not move in half a minute. + await act(async () => { + await vi.advanceTimersByTimeAsync(30000) + }) + expect(scalarFetches()).toBe(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(5 * 60000 - 30000) + }) + expect(scalarFetches()).toBe(2) + } finally { + vi.useRealTimers() + } + }) + it('falls back to a dash when the image carries no tag', async () => { mockFetch.mockImplementation((url: string) => { if (url.endsWith('/container/golf_hub')) return ok(containerDetail({ image: 'golf_hub', version: '' }))