From d6041e40ef0168306c454d828af57db43ef18349 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 10 Oct 2026 17:57:02 -0400 Subject: [PATCH] Show configured metrics retention (Refs #141) --- src/lib/metrics.ts | 2 ++ src/lib/server/fleet/metrics.ts | 1 + src/lib/server/reports/worker.ts | 3 +++ src/lib/ui/metrics-refresh.ts | 2 ++ src/routes/metrics/+page.svelte | 8 +++++--- tests/unit/metrics-refresh.test.ts | 12 ++++++++++++ tests/unit/metrics.test.ts | 17 +++++++++++++++++ tests/unit/ui-metrics-page.test.ts | 20 ++++++++++++++++++++ 8 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 7dcc91f..3f1e713 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -145,6 +145,8 @@ export interface AutoscalingMetrics { export interface MetricsData { days: MetricsDays; + /** Configured occupancy history retention; zero keeps samples indefinitely. */ + metricsKeepDays: number; since: number; now: number; /** Retained sample timestamps describe collection, not gameplay freshness. */ diff --git a/src/lib/server/fleet/metrics.ts b/src/lib/server/fleet/metrics.ts index 1a9ce11..54bd3fe 100644 --- a/src/lib/server/fleet/metrics.ts +++ b/src/lib/server/fleet/metrics.ts @@ -507,6 +507,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) }; return { days, + metricsKeepDays: fleetSettings().metricsKeepDays, since, now, ...metadata, diff --git a/src/lib/server/reports/worker.ts b/src/lib/server/reports/worker.ts index 104125c..4436c22 100644 --- a/src/lib/server/reports/worker.ts +++ b/src/lib/server/reports/worker.ts @@ -1,5 +1,6 @@ import { parentPort } from 'node:worker_threads'; import { db } from '../db/index.js'; +import { fleetSettings } from '../settings.js'; import { readMetrics } from '../fleet/metrics.js'; import { getLeaderboard, getPlayerLeaderboardEntry } from '../fleet/leaderboard.js'; import { getPlayerProfile } from '../fleet/player-profile.js'; @@ -28,6 +29,8 @@ parentPort!.on('message', ({ id, kind, input }) => { const key = JSON.stringify([ kind, revision, + // Retention edits must be visible even without new occupancy samples. + kind === 'metrics' ? fleetSettings().metricsKeepDays : null, timed ? Math.floor(now / 30_000) : 0, timed ? input.query : input ]); diff --git a/src/lib/ui/metrics-refresh.ts b/src/lib/ui/metrics-refresh.ts index 96512f6..105c5e2 100644 --- a/src/lib/ui/metrics-refresh.ts +++ b/src/lib/ui/metrics-refresh.ts @@ -44,6 +44,8 @@ function validReport(value: unknown): value is MetricsData { if ( !record(value) || ![1, 7, 30].includes(value.days as number) || + !nonnegative(value.metricsKeepDays) || + !Number.isInteger(value.metricsKeepDays) || !numeric(value.now) || !numeric(value.since) || !totals(value.summary) || diff --git a/src/routes/metrics/+page.svelte b/src/routes/metrics/+page.svelte index 025765f..1c8fe64 100644 --- a/src/routes/metrics/+page.svelte +++ b/src/routes/metrics/+page.svelte @@ -405,9 +405,11 @@

Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection - gaps are excluded. Occupancy history defaults to 31 days; saved match retention is configured - separately. Player-hours include all players reported by the server and are estimates, not - individual session records. + gaps are excluded. Occupancy history is retained {report.metricsKeepDays === 0 + ? 'indefinitely' + : `for ${report.metricsKeepDays} ${report.metricsKeepDays === 1 ? 'day' : 'days'}`}; + saved match retention is configured separately. Player-hours include all players + reported by the server and are estimates, not individual session records.

Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll( diff --git a/tests/unit/metrics-refresh.test.ts b/tests/unit/metrics-refresh.test.ts index 7848b94..12b7d00 100644 --- a/tests/unit/metrics-refresh.test.ts +++ b/tests/unit/metrics-refresh.test.ts @@ -12,6 +12,7 @@ const emptyMatches = { }; const report = (days: number) => ({ days, + metricsKeepDays: 31, now: 1000, since: 0, summary: totals, @@ -80,6 +81,13 @@ function setup(fetcher: typeof fetch) { } describe('metrics background refresh', () => { + it.each([90, 1, 0])('accepts configured retention of %i days', async (metricsKeepDays) => { + const value = { ...report(7), metricsKeepDays }; + const refresh = setup(vi.fn().mockResolvedValue(Response.json(value))); + await refresh.refresh(); + expect(refresh.onData).toHaveBeenLastCalledWith(value); + }); + it.each([ null, { ...report(7).autoscaling, added: -1 }, @@ -297,6 +305,10 @@ describe('metrics background refresh', () => { maps: [{ ...value.maps[0], running: { average: 1, peak: 1, capturedHours: null } }] }, { ...value, autoscalingPaused: undefined }, + ...[undefined, null, '90', -1, 1.5].map((metricsKeepDays) => ({ + ...value, + metricsKeepDays + })), { ...value, running: undefined }, { ...value, previous: { ...value.previous, running: undefined } }, { ...value, maps: [{ ...value.maps[0], policy: { id: 'moon' } }] }, diff --git a/tests/unit/metrics.test.ts b/tests/unit/metrics.test.ts index df7d4cf..23fea3f 100644 --- a/tests/unit/metrics.test.ts +++ b/tests/unit/metrics.test.ts @@ -439,6 +439,23 @@ describe('historical metrics', () => { expect(result.summary.playerHours * 3600).toBeCloseTo(30); }); + it('updates retention in page loads and refreshes without new samples or cache expiry', async () => { + const { fleetSettings, saveFleetSettings } = + await import('../../src/lib/server/settings.js'); + const { GET } = await import('../../src/routes/api/metrics/+server.js'); + const { load } = await import('../../src/routes/metrics/+page.server.js'); + vi.spyOn(Date, 'now').mockReturnValue(Date.UTC(2026, 9, 10, 12)); + const url = new URL('http://localhost/metrics?days=7'); + const pageData = () => load({ url } as Parameters[0]); + expect(await pageData()).toMatchObject({ metricsKeepDays: 31 }); + for (const metricsKeepDays of [90, 0, 1]) { + saveFleetSettings({ ...fleetSettings(), metricsKeepDays }); + const payload = await (await GET({ url })).json(); + expect(payload.metricsKeepDays).toBe(metricsKeepDays); + expect(payload).toEqual(await pageData()); + } + }); + it('shares the range parser and response contract between SSR and the uncached refresh endpoint', async () => { const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const { GET } = await import('../../src/routes/api/metrics/+server.js'); diff --git a/tests/unit/ui-metrics-page.test.ts b/tests/unit/ui-metrics-page.test.ts index c90b265..06f1a75 100644 --- a/tests/unit/ui-metrics-page.test.ts +++ b/tests/unit/ui-metrics-page.test.ts @@ -54,6 +54,7 @@ function report(): MetricsData { const summary = { observed: 10, active: 5, idle: 5, unknown: 2, playerHours: 20 }; return { days: 7, + metricsKeepDays: 31, since: now - 7 * 86400000, now, firstRetainedSampleAt: now - 14 * 86400000, @@ -116,6 +117,25 @@ function scope(search: string, stateSearch?: string) { } beforeEach(() => scope('')); +it.each([ + [31, 'for 31 days'], + [90, 'for 90 days'], + [1, 'for 1 day'], + [0, 'indefinitely'] +] as const)( + 'shows configured occupancy retention of %i days in About this report', + (metricsKeepDays, expected) => { + const body = render(MetricsPage, { + props: { data: { ...report(), metricsKeepDays, user: null, setupRequired: false } } + }) + .body.replace(//g, '') + .replace(/\s+/g, ' '); + const about = body.slice(body.indexOf('About this report')); + expect(about).toContain(`Occupancy history is retained ${expected};`); + expect(about).toContain('saved match retention is configured separately'); + } +); + it('shows Provisioning outcomes and readiness with a policy link, even without occupancy', () => { const data = report(); const section = render(MetricsAutoscaling, { props: { metrics: data.autoscaling } }).body;