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
+51 -5
View File
@@ -26,8 +26,37 @@ history or the original installation date.
Background refresh keeps the last successful report visible on failure and offers
Retry. Returning to a visible tab refreshes immediately. Game and map filters apply
to the whole report, including comparisons, signals, and the heatmap. Map choices
preserve the exact recorded game/map pair. Range and scope are shareable through
the URL; refreshes preserve table controls and chart selection.
preserve the exact recorded game/map pair and include retained occupancy and match
history. Range and scope are shareable through the URL; refreshes preserve table
controls and chart selection.
## Matches and groups
**Matches started** counts retained matches whose recorded start falls within the
selected period, including both endpoints. A missing start event uses the first
recorded match observation, which may be later than the actual start. Active,
completed, and interrupted matches all count, including matches that never reach
round 2 and matches on removed servers. These counts do not use the leaderboard's
completed-match or round qualification rules.
**Average group size** is the average number of distinct recorded player GUIDs per
match. A player counts once across all reports for that match, including late
joins and players who left before its end. Each match has equal weight in the
fleet average; the average is not an average of map averages. This measures
recorded participation over the match's lifetime, not its starting squad or peak
simultaneous occupancy. Active matches can change as more players are recorded.
**Solo games** counts matches with exactly one distinct recorded player. Its share
uses only matches with at least one recorded player. Matches with no recorded
players still count as started, but their group size is unknown and they are
excluded from both the average and solo-share denominator. If no matches have
recorded players, those two values are unavailable rather than zero.
The map table includes these same match statistics. A map with match history in
the selected period remains visible even without occupancy samples; its match
statistics do not establish lobby-hours, availability, or an autoscaling shortage.
Match statistics cover the selected period only and have no previous-period
comparison.
## Timeline and tables
@@ -125,11 +154,18 @@ Map variants use the configured variant when it matches the reported map. Player
capacity may be retained in raw samples, but no metric or signal requires
`sv_maxclients` or an assumed four-player limit.
Collection begins after this feature is installed; earlier occupancy cannot be
Occupancy collection begins after this feature is installed; earlier occupancy cannot be
reconstructed. Samples default to 31 days of retention; **Admin → Settings** can
change this period. Cleanup runs during collection and in the background. Samples
survive removal of a server and contain no player identities.
Match statistics use existing saved match history, independently of occupancy
samples and raw telemetry diagnostics. **Match history retention** under
**Admin → Settings → Matches & players** defaults to indefinite. Expiring a
completed or interrupted match also removes its player participation and changes
these statistics; active matches are protected. Increasing retention cannot
restore expired matches. Saved match history survives removal of a server.
## API
`GET /api/metrics` supplies the same report as the page and disables HTTP response
@@ -140,9 +176,19 @@ from selected-scope evidence timestamps. Collection boundaries are determined
before scope filtering, so samples from other groups still end a batch interval.
`running` contains `average`, `peak`, and `capturedHours`; timeline points include
their own `capturedHours`. Map rows expose `noEmpty`, `availabilityObserved`, and
their own `capturedHours`. Report-level `matches` and each map row's `matches`
contain `started`, `withRecordedPlayers`, `averageGroupSize`, `solo`, and
`soloShare`. `withRecordedPlayers` is the denominator for the group average and
solo share; `soloShare` is a percentage from 0 to 100. When that denominator is
zero, `averageGroupSize` and `soloShare` are `null`, `solo` is zero, and `started`
retains the recorded number of starts. Match scope uses the exact saved `game` and `map_name`
labels, and its time window uses the saved `started_at` timestamp. Occupancy
collection and evidence timestamps remain independent of match history.
Map rows expose `noEmpty`, `availabilityObserved`, and
`availabilityUnknown` durations, plus current `policy` context and
`noEmptyAtMaximum` / `noEmptyBelowMaximum` durations. `pooledAvailability` combines
map durations and coverage. `autoscalingPaused` reports the current global pause
separately from each policy's own `pausedReason`. Policy edits and global pause changes invalidate
cached metrics reports.
cached metrics reports, as do changes to the match and player participation data
used by the report.
+14
View File
@@ -56,9 +56,22 @@ export interface ServerMetrics extends Utilization {
currentRegistered: boolean;
}
export interface MatchMetrics {
/** Retained matches whose recorded start is within the selected window. */
started: number;
/** Matches with at least one distinct recorded participant. */
withRecordedPlayers: number;
/** Distinct participants over each match's lifetime, not simultaneous players. */
averageGroupSize: number | null;
solo: number;
/** Percentage of matches with recorded participants, from 0 to 100. */
soloShare: number | null;
}
export interface MapMetrics extends Utilization {
game: string;
map: string;
matches: MatchMetrics;
/** Map wall time, not summed lobby time. Capacity does not affect availability. */
noEmpty: number;
availabilityObserved: number;
@@ -139,6 +152,7 @@ export interface MetricsData {
summary: Utilization;
running: RunningMetrics;
servers: ServerMetrics[];
matches: MatchMetrics;
maps: MapMetrics[];
points: MetricPoint[];
previous: PreviousMetrics;
+10 -1
View File
@@ -5,7 +5,11 @@ import { ensureAutoscalingSchema } from '../autoscaling/schema.js';
import { ensureRebalancingSchema } from '../rebalancing/schema.js';
import { migrateFileBlobs } from './file-blobs.js';
import { installPerformanceIndexes } from './performance-indexes.js';
import { installMetricsPolicyRevisions, installReportRevisions } from '../reports/revisions.js';
import {
installMetricsMatchRevisions,
installMetricsPolicyRevisions,
installReportRevisions
} from '../reports/revisions.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
import { migrateHostSchema } from '../hosts/schema.js';
import { migrateAuthSchema } from '../auth/schema.js';
@@ -70,6 +74,11 @@ export const migrations: readonly Migration[] = [
version: 13,
name: 'metrics policy report revisions',
run: installMetricsPolicyRevisions
},
{
version: 14,
name: 'metrics match report revisions',
run: installMetricsMatchRevisions
}
];
+110 -23
View File
@@ -6,6 +6,7 @@ import {
parseMetricsQuery,
type HeatmapCell,
type MapMetrics,
type MatchMetrics,
type MetricPoint,
type MetricsData,
type MetricsDays,
@@ -47,6 +48,27 @@ function emptyRunning(): RunningMetrics {
return { average: null, peak: 0, capturedHours: 0 };
}
interface MatchTotals {
started: number;
withRecordedPlayers: number;
participants: number;
solo: number;
}
function matchMetrics(totals: MatchTotals): MatchMetrics {
return {
started: totals.started,
withRecordedPlayers: totals.withRecordedPlayers,
averageGroupSize:
totals.withRecordedPlayers > 0
? totals.participants / totals.withRecordedPlayers
: null,
solo: totals.solo,
soloShare:
totals.withRecordedPlayers > 0 ? (100 * totals.solo) / totals.withRecordedPlayers : null
};
}
function addRunning(total: RunningMetrics, count: number, hours: number): void {
if (hours <= 0) return;
const lobbyHours = (total.average ?? 0) * total.capturedHours + count * hours;
@@ -183,9 +205,25 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
scopeFirstSampleAt: scopedBounds.first,
scopeLatestSampleAt: scopedBounds.latest
};
// Match history has independent retention and may outlive occupancy samples.
// Preserve exact recorded labels, including distinct T6 variants.
const matchMaps = handle
.prepare(
`SELECT DISTINCT game, map_name AS map FROM leaderboard_matches WHERE started_at <= ?`
)
.all(new Date(now).toISOString()) as { game: string; map: string }[];
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
const availableMaps = [
...new Map(
[...groups, ...matchMaps].map(({ game, map }) => [
JSON.stringify([game, map]),
{ game, map }
])
).values()
].sort((a, b) => compareText(a.game, b.game) || compareText(a.map, b.map));
const scopeOptions = {
games: [...new Set(groups.map((group) => group.game))],
maps: groups.map(({ game, map }) => ({ game, map }))
games: [...new Set(availableMaps.map((group) => group.game))],
maps: availableMaps
};
const summary = emptyUtilization();
const running = emptyRunning();
@@ -200,6 +238,74 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
};
const serverTotals = new Map<string, ServerMetrics>();
const mapTotals = new Map<string, Map<string, MapMetrics>>();
function mapFor(gameName: string, label: string): MapMetrics {
let game = mapTotals.get(gameName);
if (!game) {
game = new Map();
mapTotals.set(gameName, game);
}
let map = game.get(label);
if (!map) {
map = {
game: gameName,
map: label,
matches: matchMetrics({
started: 0,
withRecordedPlayers: 0,
participants: 0,
solo: 0
}),
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 0,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy: policyFor(gameName, label),
...emptyUtilization()
};
game.set(label, map);
}
return map;
}
const matchConditions = ['m.started_at >= ?', 'm.started_at <= ?'];
const matchParams = [new Date(since).toISOString(), new Date(now).toISOString()];
if (scope.game !== null) {
matchConditions.push('m.game = ?');
matchParams.push(scope.game);
}
if (scope.map !== null) {
matchConditions.push('m.map_name = ?');
matchParams.push(scope.map);
}
// Group participants per match before aggregating maps, so roster size never
// multiplies the start count. The participant PK deduplicates returning players.
const matchGroups = handle
.prepare(
`SELECT game, map, COUNT(*) AS started,
SUM(players > 0) AS withRecordedPlayers,
SUM(players) AS participants, SUM(players = 1) AS solo
FROM (
SELECT m.game, m.map_name AS map, COUNT(p.guid) AS players
FROM leaderboard_matches m
LEFT JOIN leaderboard_match_players p ON p.match_id = m.id
WHERE ${matchConditions.join(' AND ')}
GROUP BY m.id
) GROUP BY game, map`
)
.all(...matchParams) as (MatchTotals & { game: string; map: string })[];
const totalMatches: MatchTotals = {
started: 0,
withRecordedPlayers: 0,
participants: 0,
solo: 0
};
for (const group of matchGroups) {
mapFor(group.game, group.map).matches = matchMetrics(group);
totalMatches.started += group.started;
totalMatches.withRecordedPlayers += group.withRecordedPlayers;
totalMatches.participants += group.participants;
totalMatches.solo += group.solo;
}
const points: MetricPoint[] = [];
for (let at = Math.floor(since / bucketMs) * bucketMs; at < now; at += bucketMs) {
const end = Math.min(at + bucketMs, now);
@@ -327,26 +433,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
};
serverTotals.set(sample.server, server);
}
let game = mapTotals.get(sample.game);
if (!game) {
game = new Map();
mapTotals.set(sample.game, game);
}
let map = game.get(sample.map);
if (!map) {
map = {
game: sample.game,
map: sample.map,
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 0,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy: policyFor(sample.game, sample.map),
...emptyUtilization()
};
game.set(sample.map, map);
}
const map = mapFor(sample.game, sample.map);
addObservation(summary, sample.players, hours);
addObservation(server, sample.players, hours);
addObservation(map, sample.players, hours);
@@ -404,7 +491,6 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
}
aggregateBatch(now);
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
const servers = [...serverTotals.values()].sort(
(a, b) => b.active - a.active || compareText(a.server, b.server)
);
@@ -442,6 +528,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
scopeOptions,
summary,
running,
matches: matchMetrics(totalMatches),
servers,
maps,
points,
+17
View File
@@ -1,5 +1,22 @@
import type Database from 'better-sqlite3';
/** Match demand includes active and interrupted matches, independent of leaderboard eligibility. */
export function installMetricsMatchRevisions(handle: Database.Database) {
for (const table of ['leaderboard_matches', 'leaderboard_match_players'])
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const fields =
table === 'leaderboard_matches' ? ['game', 'map_name', 'started_at'] : ['match_id'];
// Score/presence heartbeats do not change distinct participant counts.
const condition =
action === 'UPDATE'
? `WHEN ${fields.map((field) => `OLD.${field} IS NOT NEW.${field}`).join(' OR ')}`
: '';
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_metrics_${table}_${action}
AFTER ${action} ON ${table} ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
}
}
/** Current policy settings are inputs to historical metrics advice. */
export function installMetricsPolicyRevisions(handle: Database.Database) {
for (const table of ['autoscaling_policies', 'settings'])
@@ -63,6 +63,31 @@
<p>Players × observed time. One player for one hour equals one player-hour.</p>
</details>
</Card>
<Card
><Stat
label="Matches started"
value={metricNumber(report.matches.started)}
size="lg"
tween={false}
/>
<p>
{report.matches.averageGroupSize === null
? 'Average group unavailable'
: `Average group ${metricNumber(report.matches.averageGroupSize)} ${plural(report.matches.averageGroupSize, 'player')}`}
</p>
<small
>{report.matches.soloShare === null
? 'Solo share unavailable · no recorded players'
: `${metricNumber(report.matches.solo)} solo · ${metricPercent(report.matches.soloShare, 100)} of matches with recorded players`}</small
>
{#if report.matches.withRecordedPlayers < report.matches.started}<small
>Players recorded for {metricNumber(report.matches.withRecordedPlayers)} of {countLabel(
report.matches.started,
'match',
'matches'
)}</small
>{/if}
</Card>
<Card
><Stat
label="Time with no empty lobby"
@@ -89,7 +114,7 @@
<style>
.summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
p {
@@ -117,6 +142,11 @@
margin: 0;
font-size: var(--text-xs);
}
@media (max-width: 1100px) {
.summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.summary {
grid-template-columns: 1fr;
@@ -23,6 +23,7 @@
}: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
const mapColumns = [
{ key: 'map', label: 'Map' },
{ key: 'matches', label: 'Matches' },
{ key: 'playerHours', label: 'Player-hours' },
{ key: 'active', label: 'With players' },
{ key: 'noEmpty', label: 'No empty lobby' },
@@ -103,12 +104,14 @@
const mapPage = $derived(metricPage(mapRows, params.get('mapPage')));
const serverPage = $derived(metricPage(serverRows, params.get('serverPage')));
const currentPage = $derived(view === 'maps' ? mapPage : serverPage);
const currentQuery = $derived(view === 'maps' ? mapQuery : serverQuery);
const pageKey = $derived(view === 'maps' ? 'mapPage' : 'serverPage');
function gameName(game: string) {
return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
}
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
if (key === 'details') return null;
if (key === 'matches') return map.matches.started;
if (key === 'noEmpty')
return map.availabilityObserved ? map.noEmpty / map.availabilityObserved : null;
return (key === 'playerHours' || key === 'active') && !map.observed ? null : map[key];
@@ -233,6 +236,13 @@
: ''}</small
></th
>
<td
>{metricNumber(map.matches.started)}<small
>{map.matches.averageGroupSize === null
? 'Average group unavailable'
: `avg group ${metricNumber(map.matches.averageGroupSize)}`}</small
></td
>
<td class="player-hours"
>{map.observed ? metricNumber(map.playerHours) : '—'}
<div class="comparison" aria-hidden="true">
@@ -249,6 +259,18 @@
><span class="sr-only">{map.map} </span>Details</summary
>
<dl>
<dt>Matches with recorded players</dt>
<dd>
{metricNumber(map.matches.withRecordedPlayers)} of {metricNumber(
map.matches.started
)}
</dd>
<dt>Solo matches</dt>
<dd>
{map.matches.soloShare === null
? 'Unavailable · no recorded players'
: `${metricNumber(map.matches.solo)} · ${metricPercent(map.matches.soloShare, 100)} of matches with recorded players`}
</dd>
<dt>Observed lobby time</dt>
<dd>{metricDuration(map.observed)}</dd>
<dt>Unknown status</dt>
@@ -308,12 +330,19 @@
</table>
</div>
{:else}<EmptyState
title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}
>{#snippet actions()}<Button
title={currentQuery.trim()
? view === 'maps'
? 'No maps match this search'
: 'No instances match this search'
: view === 'maps'
? 'No map history in this window'
: 'No instance observations in this window'}
>{#snippet actions()}{#if currentQuery.trim()}<Button
size="sm"
onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}
onclick={() =>
updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}
>Clear search</Button
>{/snippet}</EmptyState
>{/if}{/snippet}</EmptyState
>{/if}
<div class="footer">
<span
@@ -342,7 +371,7 @@
<summary>About these metrics</summary>
<p>
{view === 'maps'
? 'No empty lobby measures map time when at least one tracked lobby has known occupancy and none report zero players. If every lobby is unknown, map availability is unknown. Player limits are not required. This does not measure failed joins or unmet demand. A dash means unavailable, not zero.'
? 'Matches counts retained matches started in this window, including ongoing and interrupted matches. Average group counts distinct recorded players across each match’s lifetime and excludes matches with no recorded players. No empty lobby measures map time when at least one tracked lobby has known occupancy and none report zero players. If every lobby is unknown, map availability is unknown. Player limits are not required. This does not measure failed joins or unmet demand. A dash means unavailable, not zero.'
: 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}
</p>
</details>
+10 -1
View File
@@ -17,6 +17,13 @@ const running = (value: unknown) =>
(value.average === null || nonnegative(value.average)) &&
nonnegative(value.peak) &&
nonnegative(value.capturedHours);
const matches = (value: unknown) =>
record(value) &&
['started', 'withRecordedPlayers', 'solo'].every(
(key) => nonnegative(value[key]) && Number.isInteger(value[key])
) &&
(value.averageGroupSize === null || nonnegative(value.averageGroupSize)) &&
(value.soloShare === null || (nonnegative(value.soloShare) && value.soloShare <= 100));
const policy = (value: unknown) =>
value === null ||
(record(value) &&
@@ -32,7 +39,8 @@ function validReport(value: unknown): value is MetricsData {
!numeric(value.now) ||
!numeric(value.since) ||
!totals(value.summary) ||
!running(value.running)
!running(value.running) ||
!matches(value.matches)
)
return false;
if (
@@ -84,6 +92,7 @@ function validReport(value: unknown): value is MetricsData {
(item) =>
record(item) &&
totals(item) &&
matches(item.matches) &&
typeof item.game === 'string' &&
typeof item.map === 'string' &&
[
+16 -3
View File
@@ -255,7 +255,7 @@
</div>
{/if}
<p class="range">{scopeLabel} · {format.period(report.since)} – {format.period(report.now)}</p>
{#if captured > 0}<MetricsSummary {report} />{/if}
<MetricsSummary {report} />
{#if captured === 0}
<Card>
<EmptyState
@@ -312,6 +312,8 @@
disabled={scopeNavigating}
onTimeZoneChange={(value) => changeScope({ timeZone: value })}
/>
{/if}
{#if captured > 0 || report.matches.started > 0}
<Tabs
tabs={[
{ id: 'maps', label: 'Maps' },
@@ -379,6 +381,16 @@
including unknown status. Empty spares are expected when an autoscaling policy keeps
lobbies ready.
</p>
<p>
Matches started counts retained matches whose start time falls within this report’s
window, including both endpoints and all match statuses. If a match did not report a
start time, its first observation is used. Average group is the mean number of distinct
recorded players across each match’s lifetime, including later arrivals. Solo means
exactly one recorded player. Both average group and solo share exclude matches with no
recorded players; those matches still count as started. Ongoing matches can change as
players join. Match records are independent of sampled lobby occupancy, and missing or
purged records are not estimated.
</p>
<p>
No empty lobby measures map time when at least one lobby has known occupancy and no
tracked lobby reports zero players. If every lobby is unknown, map availability is
@@ -389,8 +401,9 @@
</p>
<p>
Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection
gaps are excluded. History is retained for 31 days. Player-hours include all players reported
by the server and are estimates, not individual session records.
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.
</p>
<p>
Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll(
+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 = [