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;