Make metrics reflect autoscaling policies (Refs #137)
CI / checks (push) Successful in 4m49s

This commit is contained in:
2026-10-10 14:48:52 -04:00
parent bd34c3c9e7
commit 33da4482a6
24 changed files with 1264 additions and 370 deletions
+29
View File
@@ -138,3 +138,32 @@ it('rejects a database from a newer application without changing its version or
inspect.close();
}
});
it('adds policy report invalidation when upgrading a database at version 12', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const handle = db();
for (const table of ['autoscaling_policies', 'settings'])
for (const action of ['INSERT', 'UPDATE', 'DELETE'])
handle.exec(`DROP TRIGGER report_metrics_${table}_${action}`);
handle.pragma('user_version = 12');
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
.prepare(
"INSERT INTO settings(key,value) VALUES('autoscaling_paused','1') ON CONFLICT(key) DO UPDATE SET value='1'"
)
.run();
expect(revision()).toBeGreaterThan(before);
const paused = revision();
upgraded.prepare("INSERT INTO settings(key,value) VALUES('unrelated-setting','1')").run();
expect(revision()).toBe(paused);
upgraded.prepare("DELETE FROM settings WHERE key='autoscaling_paused'").run();
expect(revision()).toBeGreaterThan(paused);
});
+145 -37
View File
@@ -2,6 +2,14 @@ import { describe, expect, it } from 'vitest';
import { metricRecommendations } from '../../src/lib/metrics-insights.js';
import type { MapMetrics } from '../../src/lib/metrics.js';
const policy = {
id: 't5-policy',
mode: 'create-destroy' as const,
minimum: 1,
maximum: 3,
spares: 1,
pausedReason: null
};
const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
game: 't5',
map: 'Moon',
@@ -10,44 +18,148 @@ const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
idle: 5,
unknown: 0,
playerHours: 10,
full: 0,
capacityObserved: 5,
noEmpty: 0,
availabilityObserved: 5,
availabilityUnknown: 0,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy,
...values
});
describe('advisory hosting experiments', () => {
it('uses map capacity time rather than summed server time for expansion', () => {
describe('autoscaling policy signals', () => {
it('compares map availability with the current maximum without needing player limits', () => {
const [result] = metricRecommendations([
map({ observed: 100, active: 50, idle: 50, full: 1, capacityObserved: 4 })
map({
observed: 100,
active: 50,
idle: 50,
noEmpty: 0.1,
availabilityObserved: 4,
noEmptyAtMaximum: 0.1
})
]);
expect(result.kind).toBe('expand');
expect(result.evidence).toContain('25.0%');
expect(result.tentative).toBe(false);
expect(result).toMatchObject({
kind: 'maximum',
tentative: false,
href: '/autoscaling#t5-policy',
action: 'Review policy'
});
expect(result.evidence).toContain('2.5%');
expect(result.evidence).toContain('At or above the current maximum (3)');
expect(result.policySummary).toBe('Current policy: 1–3 lobbies, 1 spare');
});
it('offers a tentative trial even with a tiny recorded sample', () => {
it('reviews the spare target and create time for no-empty time below the maximum', () => {
const [result] = metricRecommendations([
map({ noEmpty: 1, noEmptyBelowMaximum: 0.9, noEmptyAtMaximum: 0.1 })
]);
expect(result.kind).toBe('spares');
expect(result.reason).toContain('below the current maximum of 3');
expect(result.reason).toContain('raising the spare target from 1');
expect(result.title).toContain('create time');
expect(result.evidence).toContain('below it: 0.9 h');
});
it('uses the larger no-empty duration and favors maximum review on a tie', () => {
const [result] = metricRecommendations([
map({ noEmpty: 1, noEmptyBelowMaximum: 0.5, noEmptyAtMaximum: 0.5 })
]);
expect(result.kind).toBe('maximum');
expect(result.reason).toContain('At least half');
});
it('uses the two-percent threshold without treating occupied time as demand', () => {
expect(
metricRecommendations([
map({ active: 9.99, idle: 0.01, noEmpty: 0.099, noEmptyAtMaximum: 0.099 })
])[0].kind
).toBe('watch');
expect(metricRecommendations([map({ noEmpty: 0.1, noEmptyAtMaximum: 0.1 })])[0].kind).toBe(
'maximum'
);
expect(metricRecommendations([map({ active: 0.1, idle: 9.9 })])[0].kind).toBe('watch');
});
it('marks a tiny recorded sample as tentative', () => {
const [result] = metricRecommendations([
map({
observed: 1 / 120,
active: 1 / 120,
idle: 0,
full: 1 / 120,
capacityObserved: 1 / 120
noEmpty: 1 / 120,
availabilityObserved: 1 / 120,
noEmptyAtMaximum: 1 / 120
})
]);
expect(result.kind).toBe('expand');
expect(result.kind).toBe('maximum');
expect(result.tentative).toBe(true);
expect(result.evidence).toContain('30 s');
});
it('uses explicitly tentative occupancy advice when capacity is missing', () => {
const [result] = metricRecommendations([map({ active: 8, idle: 2, capacityObserved: 0 })]);
expect(result.kind).toBe('expand');
it('measures evidence coverage with map availability time', () => {
const [result] = metricRecommendations([
map({
noEmpty: 1,
noEmptyBelowMaximum: 1,
availabilityObserved: 5,
availabilityUnknown: 2,
observed: 100
})
]);
expect(result.kind).toBe('spares');
expect(result.tentative).toBe(true);
expect(result.reason).toContain('not evidence of saturation');
expect(
metricRecommendations([map({ availabilityObserved: 4, availabilityUnknown: 1 })])[0]
.tentative
).toBe(false);
});
it('does not confuse unknown occupancy with an idle fleet', () => {
it('suggests adding a policy for a map without one', () => {
const [result] = metricRecommendations([map({ policy: null })]);
expect(result).toMatchObject({
kind: 'policy',
title: 'Consider adding an autoscaling policy',
href: '/autoscaling',
action: 'Add policy',
tentative: false
});
});
it('keeps mixed no-empty evidence tentative when most lobby occupancy is unknown', () => {
const [result] = metricRecommendations([
map({
observed: 2,
active: 2,
idle: 0,
unknown: 18,
availabilityObserved: 2,
availabilityUnknown: 0,
noEmpty: 2,
noEmptyAtMaximum: 2
})
]);
expect(result.kind).toBe('maximum');
expect(result.tentative).toBe(true);
expect(result.evidence).toContain('18 h of lobby time has unknown occupancy');
});
it('reviews paused or disabled policies before suggesting limit changes', () => {
const values = { noEmpty: 1, noEmptyAtMaximum: 1 };
const [paused] = metricRecommendations([
map({ ...values, policy: { ...policy, pausedReason: 'Review a failed host command.' } })
]);
expect(paused.kind).toBe('policy');
expect(paused.title).toBe('Review and resume the autoscaling policy');
expect(paused.reason).toContain('Review a failed host command.');
const [disabled] = metricRecommendations([
map({ ...values, policy: { ...policy, mode: 'off' } })
]);
expect(disabled.kind).toBe('policy');
expect(disabled.title).toBe('Review and enable the autoscaling policy');
});
it('does not confuse all-unknown occupancy with an idle fleet or a policy signal', () => {
const [result] = metricRecommendations([
map({
observed: 0,
@@ -55,37 +167,33 @@ describe('advisory hosting experiments', () => {
idle: 0,
unknown: 10,
playerHours: 0,
capacityObserved: 0
availabilityObserved: 0,
availabilityUnknown: 5,
policy: null
})
]);
expect(result.kind).toBe('capacity');
expect(result.kind).toBe('reporting');
expect(result.title).toBe('Check occupancy reporting');
expect(result.tentative).toBe(true);
});
it('keeps advice scoped to input maps and considers idle trials with incomplete capacity', () => {
const [result] = metricRecommendations([
map({ game: 't6', map: 'Town', active: 1, idle: 9, capacityObserved: 0 })
]);
expect(result).toMatchObject({
game: 't6',
map: 'Town',
kind: 'consolidate',
tentative: true
});
expect(result.reason).toContain('preserve access');
expect(metricRecommendations([])).toEqual([]);
});
it('distinguishes a zero-duration sample from failed occupancy reporting', () => {
const [result] = metricRecommendations([
map({ observed: 0, active: 0, idle: 0, playerHours: 0, capacityObserved: 0 })
map({ observed: 0, active: 0, idle: 0, playerHours: 0, availabilityObserved: 0 })
]);
expect(result.title).toBe('Let observations accumulate');
expect(result.reason).toContain('no elapsed observations');
expect(result.reason).toContain('no elapsed availability observations');
});
it('leaves the lineup alone when measured evidence does not trigger a rule', () => {
expect(metricRecommendations([map({ active: 9, idle: 1, full: 0 })])[0].kind).toBe('watch');
it('keeps advice scoped to input maps and sorts policy actions before quiet findings', () => {
const results = metricRecommendations([
map({ map: 'Moon' }),
map({ game: 't6', map: 'Town', noEmpty: 1, noEmptyBelowMaximum: 1 })
]);
expect(results.map(({ game, map, kind }) => ({ game, map, kind }))).toEqual([
{ game: 't6', map: 'Town', kind: 'spares' },
{ game: 't5', map: 'Moon', kind: 'watch' }
]);
expect(metricRecommendations([])).toEqual([]);
});
});
+67 -1
View File
@@ -8,6 +8,7 @@ const report = (days: number) => ({
now: 1000,
since: 0,
summary: totals,
running: { average: null, peak: 0, capturedHours: 0 },
points: [],
maps: [],
servers: [],
@@ -22,12 +23,20 @@ const report = (days: number) => ({
since: -1000,
until: 0,
summary: totals,
running: { average: null, peak: 0, capturedHours: 0 },
firstSampleAt: null,
lastSampleAt: null,
partialHistory: true
},
heatmap: { timeZone: 'UTC', cells: [] },
pooledPressure: { value: null, fullMapHours: 0, knownMapHours: 0, coveredMaps: 0, totalMaps: 0 }
pooledAvailability: {
value: null,
noEmptyMapHours: 0,
knownMapHours: 0,
unknownMapHours: 0,
coveredMaps: 0,
totalMaps: 0
}
});
function setup(fetcher: typeof fetch) {
let query = 'days=7';
@@ -124,6 +133,63 @@ describe('metrics background refresh', () => {
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
});
it('accepts availability reports and rejects incomplete running or policy evidence', async () => {
const value = {
...report(7),
maps: [
{
...totals,
game: 't5',
map: 'Moon',
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 1,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy: {
id: 'moon',
mode: 'create-destroy',
minimum: 1,
maximum: 3,
spares: 1,
pausedReason: null
}
}
],
points: [
{
...totals,
at: 0,
startAt: 0,
endAt: 1000,
partial: true,
capturedHours: 1 / 3600
}
]
};
const invalid = [
{ ...value, running: undefined },
{ ...value, previous: { ...value.previous, running: undefined } },
{ ...value, maps: [{ ...value.maps[0], policy: { id: 'moon' } }] },
{ ...value, maps: [{ ...value.maps[0], availabilityUnknown: undefined }] },
{ ...value, points: [{ ...value.points[0], capturedHours: undefined }] },
{ ...value, pooledAvailability: undefined }
];
const fetcher = vi.fn();
for (const incomplete of invalid) fetcher.mockResolvedValueOnce(Response.json(incomplete));
fetcher.mockResolvedValueOnce(Response.json(value));
const refresh = setup(fetcher);
for (const _ of invalid) {
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('times out with retry feedback and clears loading', async () => {
vi.useFakeTimers();
const fetcher = vi.fn(
+241 -24
View File
@@ -31,18 +31,59 @@ describe('historical metrics', () => {
);
});
it('only counts map saturation when every lobby has known capacity and is full', async () => {
it('counts no-empty map time from players even without capacity or with unknown lobbies', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 12);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
recordMetrics([sample, { ...sample, server: 'two' }], at);
recordMetrics([sample, { ...sample, server: 'two', players: 2 }], at + 30_000);
recordMetrics([sample, { ...sample, server: 'two', capacity: null }], at + 60_000);
recordMetrics(
[
{ ...sample, players: 1 },
{ ...sample, server: 'two', players: 2 }
],
at + 30_000
);
recordMetrics(
[
{ ...sample, capacity: null },
{ ...sample, server: 'two', capacity: null }
],
at + 60_000
);
recordMetrics([sample, { ...sample, server: 'two', players: null }], at + 90_000);
const result = readMetrics(1, at + 120_000);
expect(result.maps[0].full * 3600).toBeCloseTo(30);
expect(result.maps[0].capacityObserved * 3600).toBeCloseTo(60);
expect(result.maps[0].noEmpty * 3600).toBeCloseTo(120);
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(120);
expect(result.maps[0].availabilityUnknown).toBe(0);
});
it('treats an empty spare as available and all-unknown map observations as unknown', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 12);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
recordMetrics([sample, { ...sample, server: 'two', players: 0 }], at);
recordMetrics(
[
{ ...sample, players: null },
{ ...sample, server: 'two', players: 0 }
],
at + 30_000
);
recordMetrics(
[
{ ...sample, players: null },
{ ...sample, server: 'two', players: null }
],
at + 60_000
);
const result = readMetrics(1, at + 90_000);
expect(result.maps[0].noEmpty).toBe(0);
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(60);
expect(result.maps[0].availabilityUnknown * 3600).toBeCloseTo(30);
expect(result.pooledAvailability.value).toBe(0);
expect(result.pooledAvailability.unknownMapHours * 3600).toBeCloseTo(30);
});
it('clips the reporting window and splits observations across UTC chart boundaries', async () => {
@@ -127,7 +168,10 @@ describe('historical metrics', () => {
const later = readMetrics(1, at + 120_000);
expect(later.summary.observed).toBe(0);
expect(later.summary.unknown * 3600).toBeCloseTo(90);
expect(later.maps[0].capacityObserved).toBe(0);
expect(later.maps[0].availabilityObserved).toBe(0);
expect(later.maps[0].availabilityUnknown * 3600).toBeCloseTo(90);
expect(later.pooledAvailability.value).toBeNull();
expect(initial.running).toEqual({ average: null, peak: 0, capturedHours: 0 });
});
it('returns clipped interval boundaries and marks only partial UTC buckets', async () => {
@@ -149,7 +193,7 @@ describe('historical metrics', () => {
).toBe(true);
});
it('uses complete collection boundaries across fleet changes and keeps map pressure in wall time', async () => {
it('uses complete collection boundaries across fleet changes and keeps map availability in wall time', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 23, 59, 40);
@@ -169,9 +213,11 @@ describe('historical metrics', () => {
).toBeCloseTo(30);
const town = result.maps.find((map) => map.game === 't6' && map.map === 'Town')!;
expect(town.observed * 3600).toBeCloseTo(60);
expect(town.capacityObserved * 3600).toBeCloseTo(30);
expect(town.full * 3600).toBeCloseTo(30);
expect(result.maps.find((map) => map.game === 't5')!.capacityObserved).toBe(0);
expect(town.availabilityObserved * 3600).toBeCloseTo(30);
expect(town.noEmpty * 3600).toBeCloseTo(30);
expect(
result.maps.find((map) => map.game === 't5')!.availabilityObserved * 3600
).toBeCloseTo(30);
for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) {
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(
result.summary[field]
@@ -290,7 +336,12 @@ describe('historical metrics', () => {
});
expect(result.scopeFirstSampleAt).toBe(since - 30_000);
expect(result.scopeLatestSampleAt).toBe(now - 30_000);
expect(result.pooledPressure.value).toBeCloseTo(100 / 3);
expect(result.pooledAvailability.value).toBeCloseTo(100 / 3);
expect(result.running.average).toBe(1);
expect(result.running.peak).toBe(1);
expect(result.running.capturedHours * 3600).toBeCloseTo(45);
expect(result.previous.running.average).toBe(1);
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(30);
for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) {
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(
result.summary[field]
@@ -301,6 +352,9 @@ describe('historical metrics', () => {
}
const gameOnly = readMetrics({ ...query, map: null }, now);
expect(gameOnly.maps.map((map) => map.map).sort()).toEqual(['Moon', 'Town']);
expect(gameOnly.running.average).toBeCloseTo(4 / 3);
expect(gameOnly.running.peak).toBe(2);
expect(gameOnly.running.capturedHours * 3600).toBeCloseTo(45);
const missing = readMetrics({ ...query, map: 'Unrecorded' }, now);
expect(missing).toMatchObject({
scopeFirstSampleAt: null,
@@ -308,7 +362,8 @@ describe('historical metrics', () => {
summary: { observed: 0, unknown: 0 },
maps: [],
servers: [],
pooledPressure: { value: null },
pooledAvailability: { value: null },
running: { average: null, peak: 0, capturedHours: 0 },
previous: { firstSampleAt: null, lastSampleAt: null, partialHistory: true }
});
expect(missing.scopeOptions).toEqual(result.scopeOptions);
@@ -332,6 +387,160 @@ describe('historical metrics', () => {
expect(result.scopeLatestSampleAt).toBe(at - 30_000);
});
it('averages concurrent tracked lobbies over captured wall time, including unknowns and excluding gaps', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 3, 12);
const since = now - 86400_000;
const one = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
const two = { ...one, server: 'two', players: 0 };
const three = { ...one, server: 'three', players: null };
const four = { ...one, server: 'four', players: null };
recordMetrics([one, two, three, four], since - 120_000);
recordMetrics([one, two, three], now - 180_000);
recordMetrics([three], now - 150_000);
recordMetrics([one, two], now - 30_000);
const result = readMetrics(1, now);
expect(result.running.average).toBeCloseTo(1.6);
expect(result.running.peak).toBe(3);
expect(result.running.capturedHours * 3600).toBeCloseTo(150);
expect(result.previous.running.average).toBe(4);
expect(result.previous.running.peak).toBe(4);
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(90);
expect(result.points.reduce((sum, point) => sum + point.capturedHours, 0)).toBeCloseTo(
result.running.capturedHours
);
const point = result.points.find((point) => point.capturedHours > 0)!;
expect((point.observed + point.unknown) / point.capturedHours).toBeCloseTo(1.6);
expect(result.summary.observed + result.summary.unknown).toBeCloseTo(
result.running.average! * result.running.capturedHours
);
});
it('matches current policy context through map aliases and prefers the durable owner', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = Date.UTC(2026, 0, 3, 12);
const insert = db().prepare(
`INSERT INTO autoscaling_policies
(id,game,map_slug,settings,mode,paused_reason,owns_map,updated_at)
VALUES (?,?,?,?,?,?,?,?)`
);
insert.run(
't5-all',
't5',
'*',
JSON.stringify({ minimum: 1, maximum: 4, spares: 2 }),
'create-destroy',
null,
1,
'now'
);
insert.run(
'moon',
't5',
'moon',
JSON.stringify({ minimum: 1, maximum: 2, spares: 1 }),
'off',
'Operator paused.',
0,
'now'
);
insert.run(
'town',
't6',
'town',
JSON.stringify({ minimum: 2, maximum: 3, spares: 1 }),
'create-destroy',
null,
1,
'now'
);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
recordMetrics(
[
sample,
{ ...sample, server: 'two', map: 'Kino Der Toten' },
{ ...sample, server: 'three', game: 't6', map: 'Town Survival' },
{ ...sample, server: 'four', game: 't6', map: 'zm_transit' },
{ ...sample, server: 'five', game: 't6', map: 'Moon' },
{ ...sample, server: 'six', map: 'zombie_cosmodrome' },
{ ...sample, server: 'seven', map: 'unknown-map' }
],
at
);
const result = readMetrics(1, at + 30_000);
expect(result.maps.find((map) => map.game === 't5' && map.map === 'Moon')!.policy).toEqual({
id: 't5-all',
mode: 'create-destroy',
minimum: 1,
maximum: 4,
spares: 2,
pausedReason: null
});
expect(result.maps.find((map) => map.map === 'Kino Der Toten')!.policy?.id).toBe('t5-all');
expect(result.maps.find((map) => map.map === 'zombie_cosmodrome')!.policy?.id).toBe(
't5-all'
);
expect(result.maps.find((map) => map.map === 'Town Survival')!.policy?.id).toBe('town');
expect(result.maps.find((map) => map.map === 'zm_transit')!.policy).toBeNull();
expect(
result.maps.find((map) => map.game === 't6' && map.map === 'Moon')!.policy
).toBeNull();
expect(result.maps.find((map) => map.map === 'unknown-map')!.policy).toBeNull();
db().prepare('UPDATE autoscaling_policies SET owns_map=0').run();
expect(
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
.policy
).toMatchObject({ id: 'moon', mode: 'off', pausedReason: 'Operator paused.' });
db().prepare("INSERT INTO settings(key,value) VALUES ('autoscaling_paused','1')").run();
expect(
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
.policy?.pausedReason
).toBe('Autoscaling is globally paused. Operator paused.');
});
it('compares no-empty intervals with the current maximum, counting unknown lobbies in the lineup', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = Date.UTC(2026, 0, 3, 12);
db()
.prepare(
`INSERT INTO autoscaling_policies
(id,game,map_slug,settings,mode,owns_map,updated_at) VALUES (?,?,?,?,?,?,?)`
)
.run(
'moon',
't5',
'moon',
JSON.stringify({ minimum: 1, maximum: 2, spares: 1 }),
'create-destroy',
1,
'now'
);
const one = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
const two = { ...one, server: 'two', players: null };
recordMetrics([one], at);
recordMetrics([one, two], at + 30_000);
recordMetrics([one, two, { ...one, server: 'three' }], at + 60_000);
recordMetrics([one, { ...two, players: 0 }], at + 90_000);
recordMetrics([{ ...one, players: null }, two], at + 120_000);
const result = readMetrics(1, at + 150_000);
expect(result.maps[0].noEmpty * 3600).toBeCloseTo(90);
expect(result.maps[0].noEmptyAtMaximum * 3600).toBeCloseTo(60);
expect(result.maps[0].noEmptyBelowMaximum * 3600).toBeCloseTo(30);
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(120);
expect(result.maps[0].availabilityUnknown * 3600).toBeCloseTo(30);
db()
.prepare("UPDATE autoscaling_policies SET settings=json_set(settings,'$.maximum',4)")
.run();
const revised = readMetrics(1, at + 150_000).maps[0];
expect(revised.noEmptyAtMaximum).toBe(0);
expect(revised.noEmptyBelowMaximum * 3600).toBeCloseTo(90);
});
it('compares adjacent equal windows and splits an observation at their shared boundary', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
@@ -391,35 +600,43 @@ describe('historical metrics', () => {
expect(result.previous.summary.unknown).toBeLessThan(24);
});
it('pools pressure by known map-hours rather than server-hours or fleet elapsed time', async () => {
it('pools availability by known map-hours rather than lobby-hours or fleet elapsed time', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 3, 12);
const full = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
const occupied = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
recordMetrics(
[
full,
{ ...full, server: 'two' },
{ ...full, server: 'three', map: 'Town', players: 0 },
{ ...full, server: 'four', map: 'Farm', players: 2, capacity: null }
occupied,
{ ...occupied, server: 'two' },
{ ...occupied, server: 'three', map: 'Town', players: 0 },
{ ...occupied, server: 'four', map: 'Farm', players: 2, capacity: null },
{ ...occupied, server: 'five', map: 'Unknown', players: null }
],
at
);
const result = readMetrics(1, at + 30_000);
expect(result.pooledPressure).toMatchObject({ value: 50, coveredMaps: 2, totalMaps: 3 });
expect(result.pooledPressure.fullMapHours * 3600).toBeCloseTo(30);
expect(result.pooledPressure.knownMapHours * 3600).toBeCloseTo(60);
expect(result.pooledAvailability).toMatchObject({ coveredMaps: 3, totalMaps: 4 });
expect(result.pooledAvailability.value).toBeCloseTo(200 / 3);
expect(result.pooledAvailability.noEmptyMapHours * 3600).toBeCloseTo(60);
expect(result.pooledAvailability.knownMapHours * 3600).toBeCloseTo(90);
expect(result.pooledAvailability.unknownMapHours * 3600).toBeCloseTo(30);
expect(result.summary.observed * 3600).toBeCloseTo(120);
const empty = readMetrics(
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Town' })),
at + 30_000
);
expect(empty.pooledPressure.value).toBe(0);
const missing = readMetrics(
expect(empty.pooledAvailability.value).toBe(0);
const missingCapacity = readMetrics(
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Farm' })),
at + 30_000
);
expect(missing.pooledPressure.value).toBeNull();
expect(missingCapacity.pooledAvailability.value).toBe(100);
const unknown = readMetrics(
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Unknown' })),
at + 30_000
);
expect(unknown.pooledAvailability.value).toBeNull();
});
it.each([
+56
View File
@@ -34,3 +34,59 @@ it('runs reports off the request thread and invalidates cached history after wri
expect(changed).toEqual(readMetrics(query, now));
expect(changed.summary.playerHours).toBeCloseTo((await first).summary.playerHours * 3);
});
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');
const { metricsReport } = await import('../../src/lib/server/reports/index.js');
const now = Date.UTC(2026, 9, 4, 12);
vi.spyOn(Date, 'now').mockReturnValue(now);
recordMetrics(
[{ server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null }],
now - 60_000
);
const query = parseMetricsQuery(new URLSearchParams('days=1'));
expect((await metricsReport(query)).maps[0].policy).toBeNull();
const settings = {
game: 't5',
mapSlug: '*',
mode: 'create-destroy',
minimum: 1,
maximum: 1,
spares: 1,
emptyMinutes: 15
};
db()
.prepare(
`INSERT INTO autoscaling_policies
(id,game,map_slug,settings,mode,owns_map,updated_at) VALUES(?,?,?,?,?,1,?)`
)
.run(
'moon-policy',
't5',
'*',
JSON.stringify(settings),
settings.mode,
new Date(now).toISOString()
);
const atMaximum = (await metricsReport(query)).maps[0];
expect(atMaximum.policy?.maximum).toBe(1);
expect(atMaximum.noEmptyAtMaximum).toBeCloseTo(1 / 60);
db()
.prepare('UPDATE autoscaling_policies SET settings=? WHERE id=?')
.run(JSON.stringify({ ...settings, maximum: 3 }), 'moon-policy');
const belowMaximum = (await metricsReport(query)).maps[0];
expect(belowMaximum.policy?.maximum).toBe(3);
expect(belowMaximum.noEmptyAtMaximum).toBe(0);
expect(belowMaximum.noEmptyBelowMaximum).toBeCloseTo(1 / 60);
db()
.prepare(
"INSERT INTO settings(key,value) VALUES('autoscaling_paused','1') ON CONFLICT(key) DO UPDATE SET value='1'"
)
.run();
expect((await metricsReport(query)).maps[0].policy?.pausedReason).toBeTruthy();
db().prepare("UPDATE settings SET value='0' WHERE key='autoscaling_paused'").run();
expect((await metricsReport(query)).maps[0].policy?.pausedReason).toBeNull();
db().prepare("DELETE FROM autoscaling_policies WHERE id='moon-policy'").run();
expect((await metricsReport(query)).maps[0].policy).toBeNull();
});
+2 -3
View File
@@ -228,9 +228,8 @@ describe('useful synthetic fixture data', () => {
expect(metrics.samples).toBe(7 * 24 * 60);
expect(metrics.newest).toBe(NOW);
expect(metrics.newest - metrics.oldest).toBe((metrics.samples - 1) * 60_000);
expect(
count('SELECT COUNT(*) n FROM metric_samples WHERE players<0 OR players>capacity')
).toBe(0);
expect(count('SELECT COUNT(*) n FROM metric_samples WHERE players<0 OR players>4')).toBe(0);
expect(count('SELECT COUNT(*) n FROM metric_samples WHERE capacity IS NOT NULL')).toBe(0);
});
it('supports actual leaderboard pagination and evidence for every exact squad size', async () => {
+141 -26
View File
@@ -24,8 +24,12 @@ const map = (i: number, changes: Partial<MapMetrics> = {}): MapMetrics => ({
idle: 5,
unknown: 0,
playerHours: i,
full: 1,
capacityObserved: 4,
noEmpty: 1,
availabilityObserved: 4,
availabilityUnknown: 0,
noEmptyAtMaximum: 0,
noEmptyBelowMaximum: 0,
policy: null,
...changes
});
const server = (i: number): ServerMetrics => ({
@@ -52,23 +56,33 @@ function report(): MetricsData {
scopeFirstSampleAt: now - 14 * 86400000,
scopeLatestSampleAt: now,
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)),
points: [
{ ...summary, at: now - 86400000, startAt: now - 86400000, endAt: now, partial: true }
{
...summary,
at: now - 86400000,
startAt: now - 86400000,
endAt: now,
partial: true,
capturedHours: 1
}
],
previous: {
since: now - 14 * 86400000,
until: now - 7 * 86400000,
summary: { ...summary, active: 4, idle: 6, playerHours: 10 },
running: { average: 12, peak: 24, capturedHours: 1 },
firstSampleAt: now - 14 * 86400000,
lastSampleAt: now - 7 * 86400000,
partialHistory: true
},
pooledPressure: {
pooledAvailability: {
value: 25,
fullMapHours: 1,
noEmptyMapHours: 1,
knownMapHours: 4,
unknownMapHours: 0,
coveredMaps: 24,
totalMaps: 24
},
@@ -95,6 +109,9 @@ it('shows a summary and one paginated breakdown, preserving scope on range links
expect(body).toContain('game=t5&amp;days=30&amp;view=maps');
expect(body).toContain('Partial previous history');
expect(body).toContain('About this report');
expect(body).toContain('Average lobbies running:');
expect(body.replace(/\s+/g, ' ')).toContain('Captured wall time:');
expect(body).not.toContain('Percentage points show');
expect(body).not.toContain('Explore this period');
expect(body).toContain('Updates every minute');
expect(body).not.toContain('>Refresh<');
@@ -107,28 +124,44 @@ it('shows a summary and one paginated breakdown, preserving scope on range links
expect(body).not.toContain('class="weekly');
});
it('keeps percentage points, relative changes and unavailable capacity distinct', () => {
it('shows concurrent lobbies and empty spare share with unknown lobby-hours in the denominator', () => {
const data = report();
let body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('+10 percentage points');
expect(body).toContain('12 avg');
expect(body).toContain('Peak 24 lobbies at once');
expect(body.replace(/\s+/g, ' ')).toContain(
'5 h of 12 h captured lobby-hours were empty spares (41.7%)'
);
expect(body).toContain('+100%');
data.pooledPressure = {
expect(body).toContain('Time with no empty lobby');
expect(body).toContain('25.0%');
expect(body).not.toContain('capacity data');
data.pooledAvailability = {
value: null,
fullMapHours: 0,
noEmptyMapHours: 0,
knownMapHours: 0,
unknownMapHours: 4,
coveredMaps: 0,
totalMaps: 24
};
data.maps = [map(1, { capacityObserved: 0 })];
data.maps = [map(1, { availabilityObserved: 0, availabilityUnknown: 4 })];
data.previous.summary.playerHours = 0;
body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('Capacity data unavailable');
expect(body).toContain('Lobby availability unavailable');
expect(body).toContain('New · no previous player-hours');
expect(body.replace(/\s+/g, ' ')).toContain('1 of 1 maps lack capacity data');
expect(body).not.toContain('Infinity');
data.summary.observed = 0;
data.summary = { observed: 0, active: 0, idle: 0, playerHours: 0, unknown: 12 };
body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('Comparison unavailable');
expect(body).toContain('12 avg');
expect(body.replace(/\s+/g, ' ')).toContain(
'0 h of 12 h captured lobby-hours were empty spares (0.0%)'
);
data.running = { average: null, peak: 0, capturedHours: 0 };
data.summary.unknown = 0;
body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('Peak unavailable');
expect(body).not.toContain('Peak 0 lobbies');
});
it('searches and sorts the complete map set before pagination and clamps invalid pages', () => {
@@ -147,14 +180,20 @@ 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('keeps missing capacity last when sorting and never presents it as zero full time', () => {
scope('?mapSort=full&mapDirection=asc');
const maps = [map(1, { capacityObserved: 0 }), map(2, { full: 0 }), map(3)];
it('keeps unknown availability last when sorting and distinguishes it from known empty lobbies', () => {
scope('?mapSort=noEmpty&mapDirection=asc');
const maps = [
map(1, { availabilityObserved: 0, availabilityUnknown: 4 }),
map(2, { noEmpty: 0 }),
map(3)
];
const body = render(MetricsTables, { props: { maps, servers: [] } }).body;
expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03'));
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01'));
expect(body).toContain('0.0%');
expect(body).toContain('Capacity data unavailable');
expect(body).toContain('Lobby availability unavailable');
expect(body).toContain('No empty lobby');
expect(body).not.toContain('All lobbies full');
});
it('preserves instance return context and distinguishes historic names without current links', () => {
@@ -170,18 +209,18 @@ it('preserves instance return context and distinguishes historic names without c
expect(body).not.toContain('Open current server');
});
it('highlights at most three supported trials and collapses tentative findings', () => {
it('highlights at most three supported policy changes and collapses tentative findings', () => {
const data = report();
data.maps.push(map(25, { observed: 0.1, active: 0.1, idle: 0, capacityObserved: 0 }));
data.maps.push(map(25, { observed: 0.1, active: 0.1, idle: 0, availabilityObserved: 0 }));
const body = render(MetricsExplore, { props: { report: data } }).body;
const fold = body.indexOf('Other findings');
expect(body.slice(0, fold).match(/class="recommendation /g)).toHaveLength(3);
expect(body.indexOf('Map 25')).toBeGreaterThan(fold);
expect(body).toContain('Limited evidence');
expect(body).not.toMatch(/<details[^>]*open/);
data.maps = [map(1, { capacityObserved: 0 })];
data.maps = [map(1, { availabilityObserved: 0 })];
expect(render(MetricsExplore, { props: { report: data } }).body).toContain(
'No hosting trials meet the evidence threshold'
'No policy changes meet the evidence threshold'
);
});
@@ -196,20 +235,65 @@ it('keeps accessible chart selection, partial periods and gaps while collapsing
active: 0,
idle: 0,
unknown: 0,
playerHours: 0
playerHours: 0,
capturedHours: 0
});
const body = render(MetricsChart, {
props: { points: data.points, days: data.days, timeZone: data.scope.timeZone }
}).body;
expect(body).toContain('aria-label="Selected period summary"');
expect(body).toContain('No captured time · not evidence of idle servers');
expect(body).toContain('No captured time · not evidence of empty lobbies');
expect(body).toContain('tabindex="0"');
expect(body).toContain('Inspect a period');
expect(body).toContain('Partial period');
expect(body).not.toMatch(/<details[^>]*open/);
expect(body).toContain('Weekdays show when each period starts in America/New York.');
expect(body).toContain('Daily periods are 24-hour windows.');
expect(body).toContain('Server activity over time');
expect(body).toContain('Lobbies running over time');
expect(body).toContain('Average lobbies running');
expect(body).toContain('5 with players · 5 empty spares · 2 unknown on average');
});
it('keeps average lobby bar heights comparable across partial periods and collection gaps', () => {
const data = report();
const point = data.points[0];
data.points = [
{
...point,
observed: 48,
active: 12,
idle: 36,
unknown: 24,
capturedHours: 12,
partial: false
},
{
...point,
at: data.now,
startAt: data.now,
endAt: data.now + 2 * 3600000,
observed: 8,
active: 2,
idle: 6,
unknown: 4,
capturedHours: 2,
partial: true
}
];
const body = render(MetricsChart, {
props: { points: data.points, days: data.days, timeZone: data.scope.timeZone }
}).body;
const bars = body.match(/<button class="bar\b[\s\S]*?<\/button>/g) ?? [];
expect(bars).toHaveLength(2);
const heights = bars.map((bar) =>
[...bar.matchAll(/style="height:\s*([^"]+)"/g)].map((match) => match[1])
);
expect(heights[0]).toHaveLength(3);
expect(heights[0]).toEqual(heights[1]);
expect(heights[0]).toContain('50%;');
expect(
bars.every((bar) => bar.includes('1 with players · 3 empty spares · 2 unknown on average'))
).toBe(true);
});
it('uses shallow URL state for the selected breakdown while retaining the common summary', () => {
@@ -225,15 +309,46 @@ it('uses shallow URL state for the selected breakdown while retaining the common
props: { data: { ...report(), user: null, setupRequired: false } }
}).body;
expect(body).toContain('id="metrics-explore-title"');
expect(body).toContain('Time all lobbies were full');
expect(body).toContain('Time with no empty lobby');
expect(body).toContain('Autoscaling signals');
expect(body).not.toContain('aria-label="Map metrics table"');
expect(body).not.toContain('aria-label="Instance metrics table"');
});
it('filters other insights without hiding the highlighted supported trials', () => {
it('filters other signals without hiding the highlighted supported policy changes', () => {
scope('?view=insights&insightQ=Map+24&insightSort=map');
const body = render(MetricsExplore, { props: { report: report() } }).body;
expect(body).toContain('Map 01');
expect(body).toContain('Map 24');
expect(body).not.toContain('Map 23');
});
it('links autoscaling signals to current policies and explains their historical comparison', () => {
const data = report();
data.maps = [
map(1, {
noEmptyAtMaximum: 0.75,
noEmptyBelowMaximum: 0.25,
policy: {
id: 'policy-1',
mode: 'create-destroy',
minimum: 1,
maximum: 3,
spares: 1,
pausedReason: null
}
})
];
let body = render(MetricsExplore, { props: { report: data } }).body;
expect(body).toContain('href="/autoscaling#policy-1"');
expect(body).toContain('Review policy');
expect(body).toContain('Current policy: 1–3 lobbies, 1 spare');
expect(body.replace(/\s+/g, ' ')).toContain(
'Current settings are applied to historical lobby counts'
);
expect(body).not.toContain('player limits, and observation');
data.maps[0].policy = null;
body = render(MetricsExplore, { props: { report: data } }).body;
expect(body).toContain('href="/autoscaling"');
expect(body).toContain('Add policy');
});
+9
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
compareMetricValues,
metricAverageLobbies,
metricDuration,
metricNumber,
metricPercent,
@@ -8,6 +9,14 @@ import {
} from '../../src/lib/ui/metrics.js';
describe('metric presentation', () => {
it('normalizes partial buckets by captured wall time and preserves unknown lobby counts', () => {
expect(metricAverageLobbies(14 * 24, 24)).toBe(14);
expect(metricAverageLobbies(14 * 0.5, 0.5)).toBe(14);
expect(metricAverageLobbies(12 + 2, 1)).toBe(14);
expect(metricAverageLobbies(0, 1)).toBe(0);
expect(metricAverageLobbies(0, 0)).toBeNull();
expect(metricAverageLobbies(4, Number.NaN)).toBeNull();
});
it('distinguishes unavailable evidence, zero, and tiny nonzero values', () => {
expect(metricPercent(0, 0)).toBe('—');
expect(metricPercent(0, 1)).toBe('0.0%');