Show configured metrics retention (Refs #141)

This commit is contained in:
2026-10-10 17:57:02 -04:00
parent 528ed7f37a
commit d6041e40ef
8 changed files with 62 additions and 3 deletions
+2
View File
@@ -145,6 +145,8 @@ export interface AutoscalingMetrics {
export interface MetricsData { export interface MetricsData {
days: MetricsDays; days: MetricsDays;
/** Configured occupancy history retention; zero keeps samples indefinitely. */
metricsKeepDays: number;
since: number; since: number;
now: number; now: number;
/** Retained sample timestamps describe collection, not gameplay freshness. */ /** Retained sample timestamps describe collection, not gameplay freshness. */
+1
View File
@@ -507,6 +507,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
}; };
return { return {
days, days,
metricsKeepDays: fleetSettings().metricsKeepDays,
since, since,
now, now,
...metadata, ...metadata,
+3
View File
@@ -1,5 +1,6 @@
import { parentPort } from 'node:worker_threads'; import { parentPort } from 'node:worker_threads';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { fleetSettings } from '../settings.js';
import { readMetrics } from '../fleet/metrics.js'; import { readMetrics } from '../fleet/metrics.js';
import { getLeaderboard, getPlayerLeaderboardEntry } from '../fleet/leaderboard.js'; import { getLeaderboard, getPlayerLeaderboardEntry } from '../fleet/leaderboard.js';
import { getPlayerProfile } from '../fleet/player-profile.js'; import { getPlayerProfile } from '../fleet/player-profile.js';
@@ -28,6 +29,8 @@ parentPort!.on('message', ({ id, kind, input }) => {
const key = JSON.stringify([ const key = JSON.stringify([
kind, kind,
revision, revision,
// Retention edits must be visible even without new occupancy samples.
kind === 'metrics' ? fleetSettings().metricsKeepDays : null,
timed ? Math.floor(now / 30_000) : 0, timed ? Math.floor(now / 30_000) : 0,
timed ? input.query : input timed ? input.query : input
]); ]);
+2
View File
@@ -44,6 +44,8 @@ function validReport(value: unknown): value is MetricsData {
if ( if (
!record(value) || !record(value) ||
![1, 7, 30].includes(value.days as number) || ![1, 7, 30].includes(value.days as number) ||
!nonnegative(value.metricsKeepDays) ||
!Number.isInteger(value.metricsKeepDays) ||
!numeric(value.now) || !numeric(value.now) ||
!numeric(value.since) || !numeric(value.since) ||
!totals(value.summary) || !totals(value.summary) ||
+5 -3
View File
@@ -405,9 +405,11 @@
</p> </p>
<p> <p>
Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection 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 gaps are excluded. Occupancy history is retained {report.metricsKeepDays === 0
separately. Player-hours include all players reported by the server and are estimates, not ? 'indefinitely'
individual session records. : `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.
</p> </p>
<p> <p>
Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll( Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll(
+12
View File
@@ -12,6 +12,7 @@ const emptyMatches = {
}; };
const report = (days: number) => ({ const report = (days: number) => ({
days, days,
metricsKeepDays: 31,
now: 1000, now: 1000,
since: 0, since: 0,
summary: totals, summary: totals,
@@ -80,6 +81,13 @@ function setup(fetcher: typeof fetch) {
} }
describe('metrics background refresh', () => { 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([ it.each([
null, null,
{ ...report(7).autoscaling, added: -1 }, { ...report(7).autoscaling, added: -1 },
@@ -297,6 +305,10 @@ describe('metrics background refresh', () => {
maps: [{ ...value.maps[0], running: { average: 1, peak: 1, capturedHours: null } }] maps: [{ ...value.maps[0], running: { average: 1, peak: 1, capturedHours: null } }]
}, },
{ ...value, autoscalingPaused: undefined }, { ...value, autoscalingPaused: undefined },
...[undefined, null, '90', -1, 1.5].map((metricsKeepDays) => ({
...value,
metricsKeepDays
})),
{ ...value, running: undefined }, { ...value, running: undefined },
{ ...value, previous: { ...value.previous, running: undefined } }, { ...value, previous: { ...value.previous, running: undefined } },
{ ...value, maps: [{ ...value.maps[0], policy: { id: 'moon' } }] }, { ...value, maps: [{ ...value.maps[0], policy: { id: 'moon' } }] },
+17
View File
@@ -439,6 +439,23 @@ describe('historical metrics', () => {
expect(result.summary.playerHours * 3600).toBeCloseTo(30); 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<typeof load>[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 () => { 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 { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const { GET } = await import('../../src/routes/api/metrics/+server.js'); const { GET } = await import('../../src/routes/api/metrics/+server.js');
+20
View File
@@ -54,6 +54,7 @@ function report(): MetricsData {
const summary = { observed: 10, active: 5, idle: 5, unknown: 2, playerHours: 20 }; const summary = { observed: 10, active: 5, idle: 5, unknown: 2, playerHours: 20 };
return { return {
days: 7, days: 7,
metricsKeepDays: 31,
since: now - 7 * 86400000, since: now - 7 * 86400000,
now, now,
firstRetainedSampleAt: now - 14 * 86400000, firstRetainedSampleAt: now - 14 * 86400000,
@@ -116,6 +117,25 @@ function scope(search: string, stateSearch?: string) {
} }
beforeEach(() => scope('')); 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(/<!--[\s\S]*?-->/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', () => { it('shows Provisioning outcomes and readiness with a policy link, even without occupancy', () => {
const data = report(); const data = report();
const section = render(MetricsAutoscaling, { props: { metrics: data.autoscaling } }).body; const section = render(MetricsAutoscaling, { props: { metrics: data.autoscaling } }).body;