Show match demand in metrics (Refs #138)
CI / checks (push) Successful in 4m44s

This commit is contained in:
2026-10-10 16:26:32 -04:00
parent ea932ec563
commit 616b1937ed
15 changed files with 775 additions and 42 deletions
+38
View File
@@ -167,3 +167,41 @@ it('adds policy report invalidation when upgrading a database at version 12', as
upgraded.prepare("DELETE FROM settings WHERE key='autoscaling_paused'").run();
expect(revision()).toBeGreaterThan(paused);
});
it('adds match report invalidation on upgrade without invalidating for score heartbeats', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const handle = db();
for (const table of ['leaderboard_matches', 'leaderboard_match_players'])
for (const action of ['INSERT', 'UPDATE', 'DELETE'])
handle.exec(`DROP TRIGGER report_metrics_${table}_${action}`);
handle.pragma('user_version = 13');
closeDb();
const upgraded = db();
const revision = () =>
(
upgraded
.prepare("SELECT revision FROM report_revisions WHERE topic='metrics'")
.get() as { revision: number }
).revision;
const before = revision();
upgraded.exec(`INSERT INTO leaderboard_matches
(id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
VALUES(1,'removed','t5','zombie_moon','t5:zombie_moon','Moon',
'2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z','game_start','start');`);
expect(revision()).toBeGreaterThan(before);
const started = revision();
upgraded.exec(`INSERT INTO leaderboard_match_players (match_id,guid,first_seen_at,last_seen_at)
VALUES(1,'player','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z');`);
expect(revision()).toBeGreaterThan(started);
const joined = revision();
upgraded.exec(`UPDATE leaderboard_matches SET highest_round=2,last_event='heartbeat',
last_received_at='2026-10-10T12:00:30.000Z' WHERE id=1;
UPDATE leaderboard_match_players SET kills=10,present=0 WHERE match_id=1;`);
expect(revision()).toBe(joined);
upgraded.exec("UPDATE leaderboard_matches SET map_name='Custom Moon' WHERE id=1");
expect(revision()).toBeGreaterThan(joined);
const renamed = revision();
upgraded.exec('DELETE FROM leaderboard_matches WHERE id=1');
expect(revision()).toBeGreaterThan(renamed);
expect(upgraded.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual([]);
});
+7
View File
@@ -13,6 +13,13 @@ const policy = {
const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
game: 't5',
map: 'Moon',
matches: {
started: 0,
withRecordedPlayers: 0,
averageGroupSize: null,
solo: 0,
soloShare: null
},
observed: 10,
active: 5,
idle: 5,
+63
View File
@@ -3,12 +3,20 @@ import { createMetricsRefresh } from '../../src/lib/ui/metrics-refresh.js';
afterEach(() => vi.useRealTimers());
const totals = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
const emptyMatches = {
started: 0,
withRecordedPlayers: 0,
averageGroupSize: null,
solo: 0,
soloShare: null
};
const report = (days: number) => ({
days,
now: 1000,
since: 0,
summary: totals,
running: { average: null, peak: 0, capturedHours: 0 },
matches: emptyMatches,
points: [],
maps: [],
servers: [],
@@ -134,6 +142,60 @@ describe('metrics background refresh', () => {
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
});
it('preserves successful match reports when fleet or map match data is malformed', async () => {
const matches = {
started: 3,
withRecordedPlayers: 2,
averageGroupSize: 1.5,
solo: 1,
soloShare: 50
};
const map = {
...totals,
game: 't5',
map: 'Moon',
matches,
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 0,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy: null
};
const value = { ...report(7), matches, maps: [map] };
const invalidMatches = [
undefined,
null,
{ ...matches, withRecordedPlayers: undefined },
{ ...matches, started: -1 },
{ ...matches, solo: 0.5 },
{ ...matches, averageGroupSize: '1.5' },
{ ...matches, soloShare: 101 }
];
const invalid = invalidMatches.flatMap((incomplete) => [
{ ...value, matches: incomplete },
{ ...value, maps: [{ ...map, matches: incomplete }] }
]);
const fetcher = vi.fn().mockResolvedValueOnce(Response.json(value));
for (const incomplete of invalid) fetcher.mockResolvedValueOnce(Response.json(incomplete));
const empty = { ...report(7), maps: [{ ...map, matches: emptyMatches }] };
fetcher.mockResolvedValueOnce(Response.json(empty));
const refresh = setup(fetcher);
await refresh.refresh();
expect(refresh.onData).toHaveBeenLastCalledWith(value);
for (const _ of invalid) {
await refresh.refresh();
expect(refresh.onData).toHaveBeenCalledTimes(1);
expect(refresh.onError).toHaveBeenLastCalledWith(
'The metrics response was incomplete.'
);
}
await refresh.refresh();
expect(refresh.onData).toHaveBeenLastCalledWith(empty);
expect(refresh.onData).toHaveBeenCalledTimes(2);
expect(refresh.onError).toHaveBeenLastCalledWith(null);
});
it('accepts availability reports and rejects incomplete running or policy evidence', async () => {
const value = {
...report(7),
@@ -142,6 +204,7 @@ describe('metrics background refresh', () => {
...totals,
game: 't5',
map: 'Moon',
matches: emptyMatches,
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 1,
+193
View File
@@ -12,7 +12,200 @@ afterEach(async () => {
await fixture.cleanup();
});
async function seedMatches(
records: Array<{
id: number;
at: number;
players: number;
game?: string;
map?: string;
status?: 'active' | 'completed' | 'interrupted';
}>
) {
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
const match = handle.prepare(`INSERT INTO leaderboard_matches
(id, server, server_deleted_at, game, map, map_key, map_name, started_at,
last_received_at, status, start_observed, start_players, last_event, last_fingerprint)
VALUES (?, 'removed-server', ?, ?, 'zm_transit', 'shared-key', ?, ?, ?, ?, 0, 4, 'round_change', 'test')`);
const player = handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, present, has_stats, first_seen_at, last_seen_at)
VALUES (?, ?, 0, 0, ?, ?)`);
for (const record of records) {
const at = new Date(record.at).toISOString();
match.run(
record.id,
at,
record.game ?? 't5',
record.map ?? 'Moon',
at,
at,
record.status ?? 'completed'
);
for (let index = 0; index < record.players; index++) {
player.run(record.id, `player-${index}`, at, at);
}
}
return handle;
}
describe('historical metrics', () => {
it.each([1, 7, 30] as const)(
'counts retained matches started within the inclusive %i-day window regardless of status or observed start',
async (days) => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 1, 1, 12);
const since = now - days * 86400_000;
const handle = await seedMatches([
{ id: 1, at: since - 1, players: 4 },
{ id: 2, at: since, players: 1, status: 'completed' },
{ id: 3, at: now, players: 2, status: 'active' },
{ id: 4, at: since + 1, players: 0, status: 'interrupted' },
{ id: 5, at: now + 1, players: 4, map: 'Future Map' }
]);
// A recent update does not bring an earlier match into the selected window.
handle
.prepare('UPDATE leaderboard_matches SET last_received_at=?, ended_at=? WHERE id=1')
.run(new Date(now).toISOString(), new Date(now).toISOString());
const result = readMetrics(days, now);
expect(result.matches).toEqual({
started: 3,
withRecordedPlayers: 2,
averageGroupSize: 1.5,
solo: 1,
soloShare: 50
});
expect(result.maps).toHaveLength(1);
expect(result.maps[0].matches).toEqual(result.matches);
expect(result.registeredServerCount).toBe(0);
expect(result.servers).toEqual([]);
expect(result.scopeOptions.maps).not.toContainEqual({ game: 't5', map: 'Future Map' });
}
);
it('weights group sizes by matches and counts unique participants per match across repeated updates', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 1, 1, 12);
const handle = await seedMatches([
{ id: 1, at: now - 5000, players: 1 },
{ id: 2, at: now - 4000, players: 1 },
{ id: 3, at: now - 3000, players: 4 },
{ id: 4, at: now - 2000, players: 0 },
{ id: 5, at: now - 1000, players: 4, map: 'Town' }
]);
for (let update = 0; update < 3; update++) {
handle
.prepare(
`INSERT INTO leaderboard_match_players
(match_id, guid, first_seen_at, last_seen_at) VALUES (1, 'player-0', ?, ?)
ON CONFLICT(match_id, guid) DO UPDATE SET kills=kills+1, last_seen_at=excluded.last_seen_at`
)
.run(new Date(now).toISOString(), new Date(now).toISOString());
}
const result = readMetrics(1, now);
expect(result.matches).toEqual({
started: 5,
withRecordedPlayers: 4,
averageGroupSize: 2.5,
solo: 2,
soloShare: 50
});
const moon = result.maps.find((map) => map.map === 'Moon')!.matches;
expect(moon).toMatchObject({
started: 4,
withRecordedPlayers: 3,
averageGroupSize: 2,
solo: 2
});
expect(moon.soloShare).toBeCloseTo((2 / 3) * 100);
expect(result.maps.find((map) => map.map === 'Town')!.matches).toEqual({
started: 1,
withRecordedPlayers: 1,
averageGroupSize: 4,
solo: 0,
soloShare: 0
});
expect(result.firstRetainedSampleAt).toBeNull();
expect(result.maps.every((map) => map.observed === 0 && map.playerHours === 0)).toBe(true);
});
it('distinguishes no matches, unknown rosters, and occupancy-only maps', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 1, 1, 12);
const empty = {
started: 0,
withRecordedPlayers: 0,
averageGroupSize: null,
solo: 0,
soloShare: null
};
expect(readMetrics(1, now).matches).toEqual(empty);
recordMetrics(
[{ server: 'sample-only', game: 't5', map: 'Moon', players: 1, capacity: 4 }],
now - 30_000
);
await seedMatches([{ id: 1, at: now - 60_000, players: 0, map: 'Town' }]);
const result = readMetrics(1, now);
expect(result.matches).toEqual({ ...empty, started: 1 });
expect(result.maps.find((map) => map.map === 'Moon')!.matches).toEqual(empty);
expect(result.maps.find((map) => map.map === 'Town')).toMatchObject({
matches: { ...empty, started: 1 },
observed: 0,
active: 0,
idle: 0,
unknown: 0,
playerHours: 0,
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 0
});
});
it('filters matches by game and exact recorded map label while retaining historical match scope options', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 1, 1, 12);
await seedMatches([
{ id: 1, at: now - 1000, players: 1, game: 't6', map: 'Town Survival' },
{ id: 2, at: now - 2000, players: 2, game: 't6', map: 'Town Grief' },
{ id: 3, at: now - 3000, players: 4, game: 't5', map: 'Town Survival' },
{ id: 4, at: now - 2 * 86400_000, players: 3, game: 't6', map: 'Farm Survival' },
{ id: 5, at: now + 1, players: 4, game: 't6', map: 'Future Map' }
]);
const query = parseMetricsQuery(
new URLSearchParams({ days: '1', game: 't6', map: 'Town Survival' })
);
const selected = readMetrics(query, now);
expect(selected.matches).toEqual({
started: 1,
withRecordedPlayers: 1,
averageGroupSize: 1,
solo: 1,
soloShare: 100
});
expect(selected.maps.map((map) => [map.game, map.map])).toEqual([['t6', 'Town Survival']]);
expect(selected.scopeOptions).toEqual({
games: ['t5', 't6'],
maps: [
{ game: 't5', map: 'Town Survival' },
{ game: 't6', map: 'Farm Survival' },
{ game: 't6', map: 'Town Grief' },
{ game: 't6', map: 'Town Survival' }
]
});
const game = readMetrics({ ...query, map: null }, now);
expect(game.matches).toMatchObject({ started: 2, averageGroupSize: 1.5, soloShare: 50 });
expect(game.maps.map((map) => map.map).sort()).toEqual(['Town Grief', 'Town Survival']);
expect(readMetrics({ ...query, days: 7, map: null }, now).matches.started).toBe(3);
expect(readMetrics({ ...query, game: 't5' }, now).matches.averageGroupSize).toBe(4);
expect(readMetrics({ ...query, map: 'zm_transit' }, now).matches.started).toBe(0);
expect(readMetrics({ ...query, map: 'shared-key' }, now).matches.started).toBe(0);
expect(readMetrics({ ...query, map: 'Farm Survival' }, now)).toMatchObject({
matches: { started: 0, averageGroupSize: null, soloShare: null },
maps: []
});
});
it('weights occupancy by time and excludes failed checks and collection gaps', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
+64
View File
@@ -35,6 +35,70 @@ it('runs reports off the request thread and invalidates cached history after wri
expect(changed.summary.playerHours).toBeCloseTo((await first).summary.playerHours * 3);
});
it('refreshes cached match metrics after active matches and recorded participants change without new samples', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const { metricsReport } = await import('../../src/lib/server/reports/index.js');
const now = Date.UTC(2026, 9, 4, 12);
vi.spyOn(Date, 'now').mockReturnValue(now);
const at = new Date(now - 30_000).toISOString();
const query = parseMetricsQuery(new URLSearchParams('days=1'));
const report = async () => {
const result = await metricsReport(query);
expect(result).toEqual(readMetrics(query, now));
return result;
};
expect((await report()).matches.started).toBe(0);
const match = db().prepare(`INSERT INTO leaderboard_matches
(id, server, game, map, map_key, map_name, started_at, last_received_at,
status, last_event, last_fingerprint)
VALUES (?, 'removed-server', 't5', 'zombie_moon', 't5:moon', 'Moon', ?, ?, 'active', 'game_start', 'test')`);
match.run(1, at, at);
expect((await report()).matches).toEqual({
started: 1,
withRecordedPlayers: 0,
averageGroupSize: null,
solo: 0,
soloShare: null
});
const player = db().prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, first_seen_at, last_seen_at) VALUES (1, ?, ?, ?)`);
player.run('one', at, at);
expect((await report()).matches).toMatchObject({ averageGroupSize: 1, soloShare: 100 });
player.run('two', at, at);
expect((await report()).matches).toMatchObject({ averageGroupSize: 2, soloShare: 0 });
match.run(2, at, at);
expect((await report()).matches).toMatchObject({ started: 2, withRecordedPlayers: 1 });
db().prepare("UPDATE leaderboard_match_players SET match_id=2 WHERE guid='two'").run();
expect((await report()).matches).toMatchObject({
withRecordedPlayers: 2,
averageGroupSize: 1,
solo: 2,
soloShare: 100
});
db().prepare("DELETE FROM leaderboard_match_players WHERE guid='one'").run();
expect((await report()).matches).toMatchObject({ withRecordedPlayers: 1, solo: 1 });
db().prepare('DELETE FROM leaderboard_matches WHERE id=2').run();
expect((await report()).matches).toMatchObject({
started: 1,
withRecordedPlayers: 0,
averageGroupSize: null
});
db().prepare("UPDATE leaderboard_matches SET map_name='Town Survival', game='t6'").run();
expect((await report()).maps.map((map) => [map.game, map.map])).toEqual([
['t6', 'Town Survival']
]);
db()
.prepare('UPDATE leaderboard_matches SET started_at=?')
.run(new Date(now - 2 * 86400_000).toISOString());
const outsideWindow = await report();
expect(outsideWindow.matches.started).toBe(0);
expect(outsideWindow.maps).toEqual([]);
expect(outsideWindow.scopeOptions.maps).toEqual([{ game: 't6', map: 'Town Survival' }]);
db().prepare('DELETE FROM leaderboard_matches').run();
expect((await report()).scopeOptions).toEqual({ games: [], maps: [] });
});
it('refreshes policy signals after policy edits and global pauses without new samples', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js');
+115 -1
View File
@@ -1,6 +1,11 @@
import { beforeEach, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import type { MetricsData, MapMetrics, ServerMetrics } from '../../src/lib/metrics.js';
import type {
MetricsData,
MapMetrics,
ServerMetrics,
MatchMetrics
} from '../../src/lib/metrics.js';
import MetricsPage from '../../src/routes/metrics/+page.svelte';
import MetricsSummary from '../../src/lib/ui/components/metrics/MetricsSummary.svelte';
import MetricsTables from '../../src/lib/ui/components/metrics/MetricsTables.svelte';
@@ -16,9 +21,24 @@ vi.mock('$app/navigation', () => ({
goto: vi.fn(),
replaceState: vi.fn()
}));
const matches = (changes: Partial<MatchMetrics> = {}): MatchMetrics => ({
started: 271,
withRecordedPlayers: 271,
averageGroupSize: 2.4,
solo: 48,
soloShare: (100 * 48) / 271,
...changes
});
const map = (i: number, changes: Partial<MapMetrics> = {}): MapMetrics => ({
game: 't5',
map: `Map ${String(i).padStart(2, '0')}`,
matches: matches({
started: i,
withRecordedPlayers: i,
averageGroupSize: 2,
solo: 0,
soloShare: 0
}),
observed: 10,
active: 5,
idle: 5,
@@ -56,6 +76,7 @@ function report(): MetricsData {
scopeOptions: { games: ['t5'], maps: [] },
scopeFirstSampleAt: now - 14 * 86400000,
scopeLatestSampleAt: now,
matches: matches(),
summary,
running: { average: 12, peak: 24, capturedHours: 1 },
maps: Array.from({ length: 24 }, (_, i) => map(i + 1)),
@@ -181,6 +202,99 @@ it('searches and sorts the complete map set before pagination and clamps invalid
expect(metricPage([], '20')).toMatchObject({ page: 1, start: 0, end: 0, total: 0 });
});
it('shows match counts, average group and solo share with explicit missing roster coverage', () => {
const data = report();
let body = render(MetricsSummary, { props: { report: data } }).body.replace(/\s+/g, ' ');
expect(body).toContain('Matches started');
expect(body).toContain('>271<');
expect(body).toContain('Average group 2.4 players');
expect(body).toContain('48 solo · 17.7% of matches with recorded players');
data.matches = matches({ started: 300 });
body = render(MetricsSummary, { props: { report: data } }).body.replace(/\s+/g, ' ');
expect(body).toContain('48 solo · 17.7% of matches with recorded players');
expect(body).toContain('Players recorded for 271 of 300 matches');
data.matches = matches({
started: 3,
withRecordedPlayers: 0,
averageGroupSize: null,
solo: 0,
soloShare: null
});
body = render(MetricsSummary, { props: { report: data } }).body.replace(/\s+/g, ' ');
expect(body).toContain('>3<');
expect(body).toContain('Average group unavailable');
expect(body).toContain('Solo share unavailable');
expect(body).toContain('Players recorded for 0 of 3 matches');
expect(body).not.toContain('0 solo');
});
it('sorts map match counts numerically, including maps without occupancy or recorded players', () => {
const noRoster = matches({
started: 100,
withRecordedPlayers: 0,
averageGroupSize: null,
solo: 0,
soloShare: null
});
const maps = [
map(1, { matches: noRoster, observed: 0, active: 0, idle: 0, playerHours: 0 }),
map(2, { matches: matches({ started: 2, withRecordedPlayers: 2 }) }),
map(3, { matches: matches({ started: 10, withRecordedPlayers: 10 }) }),
map(4, { matches: { ...noRoster, started: 0 } })
];
scope('?mapSort=matches&mapDirection=asc');
let body = render(MetricsTables, { props: { maps, servers: [] } }).body;
expect(body.indexOf('Map 04')).toBeLessThan(body.indexOf('Map 02'));
expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03'));
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01'));
expect(body).toContain('aria-sort="ascending"');
expect(body).toContain('avg group 2.4');
expect(body).toContain('Average group unavailable');
expect(body.replace(/\s+/g, ' ')).toContain('0 of 100');
scope('?mapSort=matches&mapDirection=desc');
body = render(MetricsTables, { props: { maps, servers: [] } }).body;
expect(body.indexOf('Map 01')).toBeLessThan(body.indexOf('Map 03'));
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 02'));
expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 04'));
expect(body).toContain('aria-sort="descending"');
});
it('keeps match summaries and map rows visible without any retained occupancy samples', () => {
const data = report();
data.summary = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
data.running = { average: null, peak: 0, capturedHours: 0 };
data.latestSampleAt = null;
data.firstRetainedSampleAt = null;
data.scopeFirstSampleAt = null;
data.scopeLatestSampleAt = null;
data.servers = [];
data.maps = [
map(1, {
...data.summary,
noEmpty: 0,
availabilityObserved: 0,
matches: data.matches
})
];
const body = render(MetricsPage, {
props: { data: { ...data, user: null, setupRequired: false } }
}).body;
expect(body).toContain('Matches started');
expect(body).toContain('Average group 2.4 players');
expect(body).toContain('aria-label="Map metrics table"');
expect(body).toContain('Map 01');
expect(body).toContain('No retained observations yet');
expect(body).not.toContain('Lobbies running over time');
expect(body.replace(/\s+/g, ' ')).toContain('including both endpoints and all match statuses');
expect(body.replace(/\s+/g, ' ')).toContain(
'distinct recorded players across each match’s lifetime'
);
expect(body.replace(/\s+/g, ' ')).toContain('missing or purged records are not estimated');
});
it('keeps unknown availability last when sorting and distinguishes it from known empty lobbies', () => {
scope('?mapSort=noEmpty&mapDirection=asc');
const maps = [