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