Replace unused server metrics with a count (Refs #139)
CI / checks (push) Successful in 4m51s

This commit is contained in:
2026-10-10 16:55:28 -04:00
parent aebfc7a783
commit 0bfda267ec
7 changed files with 80 additions and 88 deletions
+21 -1
View File
@@ -19,7 +19,7 @@ const report = (days: number) => ({
matches: emptyMatches,
points: [],
maps: [],
servers: [],
sampledServerCount: 0,
scope: { game: null, map: null, timeZone: 'UTC' },
scopeOptions: { games: [], maps: [] },
registeredServerCount: 0,
@@ -71,6 +71,26 @@ function setup(fetcher: typeof fetch) {
}
describe('metrics background refresh', () => {
it.each([undefined, null, -1, 1.5, '1'])(
'rejects an invalid sampled-server count (%s) and accepts a count without server rows',
async (sampledServerCount) => {
const value = { ...report(7), sampledServerCount: 2 };
const refresh = setup(
vi
.fn()
.mockResolvedValueOnce(Response.json({ ...value, sampledServerCount }))
.mockResolvedValueOnce(Response.json(value))
);
await refresh.refresh();
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError).toHaveBeenLastCalledWith(
'The metrics response was incomplete.'
);
await refresh.refresh();
expect(refresh.onData).toHaveBeenLastCalledWith(value);
}
);
it('keeps the previous report after failure and accepts a successful retry', async () => {
const fetcher = vi
.fn()
+19 -24
View File
@@ -79,7 +79,7 @@ describe('historical metrics', () => {
expect(result.maps[0].matches).toEqual(result.matches);
expect(result.maps[0].running).toEqual({ average: null, peak: 0, capturedHours: 0 });
expect(result.registeredServerCount).toBe(0);
expect(result.servers).toEqual([]);
expect(result.sampledServerCount).toBe(0);
expect(result.scopeOptions.maps).not.toContainEqual({ game: 't5', map: 'Future Map' });
}
);
@@ -304,7 +304,11 @@ describe('historical metrics', () => {
const at = Date.UTC(2026, 0, 2);
const sample = { server: 'removed', game: 't6', map: 'Town', players: 1, capacity: null };
recordMetrics([sample], at);
expect(readMetrics(1, at + 30_000).servers[0].server).toBe('removed');
expect(readMetrics(1, at + 30_000)).toMatchObject({
sampledServerCount: 1,
registeredServerCount: 0,
maps: [{ game: 't6', map: 'Town', active: 30 / 3600 }]
});
recordMetrics([sample], at + 32 * 86400_000);
expect(db().prepare('SELECT COUNT(*) AS n FROM metric_samples').get()).toEqual({ n: 1 });
});
@@ -318,7 +322,7 @@ describe('historical metrics', () => {
firstRetainedSampleAt: null,
latestSampleAt: null,
registeredServerCount: 0,
servers: [],
sampledServerCount: 0,
maps: []
});
db().prepare('INSERT INTO servers (name, destroyed_at) VALUES (?, ?)').run('current', null);
@@ -332,7 +336,7 @@ describe('historical metrics', () => {
firstRetainedSampleAt: at,
latestSampleAt: at,
registeredServerCount: 1,
servers: [],
sampledServerCount: 0,
maps: [],
summary: { observed: 0, unknown: 0 }
});
@@ -351,7 +355,7 @@ describe('historical metrics', () => {
);
const initial = readMetrics(1, at);
expect(initial.latestSampleAt).toBe(at);
expect(initial.servers).toHaveLength(1);
expect(initial.sampledServerCount).toBe(1);
expect(initial.summary).toEqual({
observed: 0,
active: 0,
@@ -404,9 +408,7 @@ describe('historical metrics', () => {
expect(result.summary.active * 3600).toBeCloseTo(90);
expect(result.summary.idle * 3600).toBeCloseTo(90);
expect(result.summary.playerHours * 3600).toBeCloseTo(300);
expect(
result.servers.find((server) => server.server === 'two')!.observed * 3600
).toBeCloseTo(30);
expect(result.sampledServerCount).toBe(2);
const town = result.maps.find((map) => map.game === 't6' && map.map === 'Town')!;
expect(town.observed * 3600).toBeCloseTo(60);
expect(town.availabilityObserved * 3600).toBeCloseTo(30);
@@ -418,9 +420,6 @@ describe('historical metrics', () => {
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(
result.summary[field]
);
expect(result.servers.reduce((sum, server) => sum + server[field], 0)).toBeCloseTo(
result.summary[field]
);
expect(result.maps.reduce((sum, map) => sum + map[field], 0)).toBeCloseTo(
result.summary[field]
);
@@ -436,6 +435,7 @@ describe('historical metrics', () => {
recordMetrics([{ ...sample, players: 4 }], at + 60_000);
const result = readMetrics(1, at + 30_000);
expect(result.latestSampleAt).toBe(at);
expect(result.sampledServerCount).toBe(1);
expect(result.summary.playerHours * 3600).toBeCloseTo(30);
});
@@ -520,7 +520,7 @@ describe('historical metrics', () => {
expect(result.summary.playerHours * 3600).toBeCloseTo(60);
expect(result.previous.summary.active * 3600).toBeCloseTo(30);
expect(result.previous.summary.playerHours * 3600).toBeCloseTo(120);
expect(result.servers.map((server) => server.server)).toEqual(['one']);
expect(result.sampledServerCount).toBe(1);
expect(result.maps.map((map) => [map.game, map.map])).toEqual([['t5', 'Moon']]);
expect(result.scopeOptions).toEqual({
games: ['t5', 't6'],
@@ -557,7 +557,7 @@ describe('historical metrics', () => {
scopeLatestSampleAt: null,
summary: { observed: 0, unknown: 0 },
maps: [],
servers: [],
sampledServerCount: 0,
pooledAvailability: { value: null },
running: { average: null, peak: 0, capturedHours: 0 },
previous: { firstSampleAt: null, lastSampleAt: null, partialHistory: true }
@@ -598,6 +598,7 @@ describe('historical metrics', () => {
recordMetrics([one, two], now - 30_000);
const result = readMetrics(1, now);
expect(result.running.average).toBeCloseTo(1.6);
expect(result.sampledServerCount).toBe(3);
expect(result.running.peak).toBe(3);
expect(result.running.capturedHours * 3600).toBeCloseTo(150);
expect(result.maps[0].running).toEqual(result.running);
@@ -939,7 +940,7 @@ describe('historical metrics', () => {
expect(result.heatmap.cells.filter((cell) => cell.observed > 0)).toHaveLength(1);
});
it('counts unknown heatmap evidence and distinguishes retained names from current registrations', async () => {
it('counts unknown heatmap evidence and sampled names independently of current registrations', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
@@ -949,8 +950,7 @@ describe('historical metrics', () => {
[sample, { ...sample, server: 'removed' }, { ...sample, server: 'tombstone' }],
at
);
// A newly registered name can link to the current server without asserting
// that its earlier historical observations belong to this registration.
// Current registration counts stay separate from sampled names, including reuse.
db()
.prepare('INSERT INTO servers (name, created_at) VALUES (?, ?)')
.run('reused', new Date(at + 15_000).toISOString());
@@ -958,14 +958,9 @@ describe('historical metrics', () => {
.prepare('INSERT INTO servers (name, destroyed_at) VALUES (?, ?)')
.run('tombstone', new Date(at).toISOString());
const result = readMetrics(1, at + 30_000);
expect(result.servers.find((server) => server.server === 'reused')!.currentRegistered).toBe(
true
);
expect(
result.servers
.filter((server) => server.server !== 'reused')
.every((server) => !server.currentRegistered)
).toBe(true);
expect(result.sampledServerCount).toBe(3);
expect(result.registeredServerCount).toBe(1);
expect(result).not.toHaveProperty('servers');
expect(
result.heatmap.cells.reduce((sum, cell) => sum + cell.unknown, 0) * 3600
).toBeCloseTo(90);
+25 -17
View File
@@ -1,11 +1,6 @@
import { beforeEach, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import type {
MetricsData,
MapMetrics,
ServerMetrics,
MatchMetrics
} from '../../src/lib/metrics.js';
import type { MetricsData, MapMetrics, 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';
@@ -53,15 +48,6 @@ const map = (i: number, changes: Partial<MapMetrics> = {}): MapMetrics => ({
policy: null,
...changes
});
const server = (i: number): ServerMetrics => ({
server: `t5_zombie_theater_${i}`,
currentRegistered: i !== 1,
observed: 10,
active: i / 10,
idle: 10 - i / 10,
unknown: 0,
playerHours: i
});
function report(): MetricsData {
const now = Date.parse('2026-10-06T12:00:00Z');
const summary = { observed: 10, active: 5, idle: 5, unknown: 2, playerHours: 20 };
@@ -81,7 +67,7 @@ function report(): MetricsData {
summary,
running: { average: 12, peak: 24, capturedHours: 1 },
maps: Array.from({ length: 24 }, (_, i) => map(i + 1)),
servers: Array.from({ length: 24 }, (_, i) => server(i + 1)),
sampledServerCount: 24,
points: [
{
...summary,
@@ -120,6 +106,28 @@ function scope(search: string, stateSearch?: string) {
}
beforeEach(() => scope(''));
it.each([
[0, 'No captured time in this scope'],
[1, 'Waiting for observed time']
] as const)('uses the sampled-server count %i for the empty state', (sampledServerCount, title) => {
const data = report();
data.summary = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
data.running = { average: null, peak: 0, capturedHours: 0 };
data.matches = matches({
started: 0,
withRecordedPlayers: 0,
averageGroupSize: null,
solo: 0,
soloShare: null
});
data.maps = [];
data.sampledServerCount = sampledServerCount;
const body = render(MetricsPage, {
props: { data: { ...data, user: null, setupRequired: false } }
}).body;
expect(body).toContain(title);
});
it('shows a summary and one paginated breakdown, preserving scope on range links', () => {
scope('?game=t5&days=7&view=maps&mapPage=2');
const body = render(MetricsPage, {
@@ -271,7 +279,7 @@ it('keeps match summaries and map rows visible without any retained occupancy sa
data.firstRetainedSampleAt = null;
data.scopeFirstSampleAt = null;
data.scopeLatestSampleAt = null;
data.servers = [];
data.sampledServerCount = 0;
data.maps = [
map(1, {
...data.summary,