Show configured metrics retention (Refs #141)
This commit is contained in:
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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) ||
|
||||||
|
|||||||
@@ -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,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' } }] },
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user