Replace instance metrics with map lobby counts (Refs #139)
CI / checks (push) Successful in 4m55s

This commit is contained in:
2026-10-10 16:40:07 -04:00
parent 616b1937ed
commit aebfc7a783
10 changed files with 278 additions and 278 deletions
+1
View File
@@ -13,6 +13,7 @@ const policy = {
const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
game: 't5',
map: 'Moon',
running: { average: 2, peak: 2, capturedHours: 5 },
matches: {
started: 0,
withRecordedPlayers: 0,
+15
View File
@@ -155,6 +155,7 @@ describe('metrics background refresh', () => {
game: 't5',
map: 'Moon',
matches,
running: { average: null, peak: 0, capturedHours: 0 },
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 0,
@@ -205,6 +206,7 @@ describe('metrics background refresh', () => {
game: 't5',
map: 'Moon',
matches: emptyMatches,
running: { average: 1, peak: 1, capturedHours: 1 },
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 1,
@@ -232,6 +234,19 @@ describe('metrics background refresh', () => {
]
};
const invalid = [
{ ...value, maps: [{ ...value.maps[0], running: undefined }] },
{
...value,
maps: [{ ...value.maps[0], running: { average: -1, peak: 1, capturedHours: 1 } }]
},
{
...value,
maps: [{ ...value.maps[0], running: { average: 1, peak: '1', capturedHours: 1 } }]
},
{
...value,
maps: [{ ...value.maps[0], running: { average: 1, peak: 1, capturedHours: null } }]
},
{ ...value, autoscalingPaused: undefined },
{ ...value, running: undefined },
{ ...value, previous: { ...value.previous, running: undefined } },
+48
View File
@@ -77,6 +77,7 @@ describe('historical metrics', () => {
});
expect(result.maps).toHaveLength(1);
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.scopeOptions.maps).not.toContainEqual({ game: 't5', map: 'Future Map' });
@@ -365,6 +366,8 @@ describe('historical metrics', () => {
expect(later.maps[0].availabilityUnknown * 3600).toBeCloseTo(90);
expect(later.pooledAvailability.value).toBeNull();
expect(initial.running).toEqual({ average: null, peak: 0, capturedHours: 0 });
expect(initial.maps[0].running).toEqual(initial.running);
expect(later.maps[0].running).toEqual({ average: 1, peak: 1, capturedHours: 90 / 3600 });
});
it('returns clipped interval boundaries and marks only partial UTC buckets', async () => {
@@ -597,6 +600,7 @@ describe('historical metrics', () => {
expect(result.running.average).toBeCloseTo(1.6);
expect(result.running.peak).toBe(3);
expect(result.running.capturedHours * 3600).toBeCloseTo(150);
expect(result.maps[0].running).toEqual(result.running);
expect(result.previous.running.average).toBe(4);
expect(result.previous.running.peak).toBe(4);
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(90);
@@ -610,6 +614,50 @@ describe('historical metrics', () => {
);
});
it('counts concurrent lobbies per recorded game/map across reused names, clipped intervals and collection 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: 0, capacity: null };
const two = { ...one, server: 'two', players: null };
const otherGame = { ...one, server: 't6-one', game: 't6' };
recordMetrics([one, two, otherGame], since - 30_000);
recordMetrics([{ ...one, server: 'three', players: 2 }], since + 15_000);
recordMetrics(
[
otherGame,
{ ...otherGame, server: 't6-two' },
{ ...otherGame, server: 't6-three' },
{ ...one, map: 'Kino Der Toten' }
],
since + 45_000
);
// Both lobby names are reused after the map was absent from a collection.
recordMetrics([one, { ...two, players: 0 }], now - 30_000);
// A snapshot with no elapsed time must not inflate the captured peak.
recordMetrics([one, two, { ...one, server: 'three' }], now);
const result = readMetrics(1, now);
const moon = result.maps.find((map) => map.game === 't5' && map.map === 'Moon')!;
expect(moon.running.average).toBeCloseTo(1.6);
expect(moon.running.peak).toBe(2);
expect(moon.running.capturedHours * 3600).toBeCloseTo(75);
expect(moon.idle * 3600).toBeCloseTo(75);
expect(moon.unknown * 3600).toBeCloseTo(15);
expect(moon.observed + moon.unknown).toBeCloseTo(
moon.running.average! * moon.running.capturedHours
);
const t6 = result.maps.find((map) => map.game === 't6')!;
expect(t6.running.average).toBeCloseTo(285 / 105);
expect(t6.running.peak).toBe(3);
expect(t6.running.capturedHours * 3600).toBeCloseTo(105);
const kino = result.maps.find((map) => map.map === 'Kino Der Toten')!;
expect(kino.running).toEqual({ average: 1, peak: 1, capturedHours: 90 / 3600 });
const scoped = readMetrics({ days: 1, game: 't5', map: 'Moon', timeZone: 'UTC' }, now);
expect(scoped.maps).toEqual([moon]);
expect(scoped.running).toEqual(moon.running);
});
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');
+61 -21
View File
@@ -30,6 +30,7 @@ const matches = (changes: Partial<MatchMetrics> = {}): MatchMetrics => ({
...changes
});
const map = (i: number, changes: Partial<MapMetrics> = {}): MapMetrics => ({
running: { average: 2.5, peak: 3, capturedHours: 4 },
game: 't5',
map: `Map ${String(i).padStart(2, '0')}`,
matches: matches({
@@ -189,11 +190,11 @@ it('shows concurrent lobbies and empty spare share with unknown lobby-hours in t
it('searches and sorts the complete map set before pagination and clamps invalid pages', () => {
const data = report();
scope('?mapPage=2');
let body = render(MetricsTables, { props: { maps: data.maps, servers: data.servers } }).body;
let body = render(MetricsTables, { props: { maps: data.maps } }).body;
expect(body).toContain('Map 09');
expect(body).not.toContain('Map 24');
scope('?mapPage=2', '?mapQ=Map+24&mapPage=100&mapSort=playerHours');
body = render(MetricsTables, { props: { maps: data.maps, servers: data.servers } }).body;
body = render(MetricsTables, { props: { maps: data.maps } }).body;
expect(body).toContain('Map 24');
expect(body.replace(/\s+/g, ' ')).toContain('1–1 of 1 maps');
expect(body).not.toContain('>Next<');
@@ -245,7 +246,7 @@ it('sorts map match counts numerically, including maps without occupancy or reco
map(4, { matches: { ...noRoster, started: 0 } })
];
scope('?mapSort=matches&mapDirection=asc');
let body = render(MetricsTables, { props: { maps, servers: [] } }).body;
let body = render(MetricsTables, { props: { maps } }).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'));
@@ -255,7 +256,7 @@ it('sorts map match counts numerically, including maps without occupancy or reco
expect(body.replace(/\s+/g, ' ')).toContain('0 of 100');
scope('?mapSort=matches&mapDirection=desc');
body = render(MetricsTables, { props: { maps, servers: [] } }).body;
body = render(MetricsTables, { props: { maps } }).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'));
@@ -274,6 +275,7 @@ it('keeps match summaries and map rows visible without any retained occupancy sa
data.maps = [
map(1, {
...data.summary,
running: data.running,
noEmpty: 0,
availabilityObserved: 0,
matches: data.matches
@@ -302,7 +304,7 @@ it('keeps unknown availability last when sorting and distinguishes it from known
map(2, { noEmpty: 0 }),
map(3)
];
const body = render(MetricsTables, { props: { maps, servers: [] } }).body;
const body = render(MetricsTables, { props: { maps } }).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%');
@@ -311,18 +313,56 @@ it('keeps unknown availability last when sorting and distinguishes it from known
expect(body).not.toContain('All lobbies full');
});
it('preserves instance return context and distinguishes historic names without current links', () => {
scope('?view=instances&serverQ=theater&days=30');
const body = render(MetricsTables, {
props: { maps: [], servers: [server(1), server(2)], view: 'instances' }
}).body;
expect(body).toContain('Historical');
expect(body).not.toContain('href="/servers/t5_zombie_theater_1');
expect(body).toContain('href="/servers/t5_zombie_theater_2?returnTo=');
expect(body).toContain('serverQ%3Dtheater');
expect(body).not.toContain('>Idle</button>');
expect(body).not.toContain('Open current server');
});
it.each(['', '?view=instances&serverQ=theater&days=30'])(
'shows map lobby metrics without an Instances tab for %s',
(search) => {
scope(search);
const body = render(MetricsPage, {
props: { data: { ...report(), user: null, setupRequired: false } }
}).body;
expect(body).toContain('aria-label="Map metrics table"');
expect(body).toContain('aria-labelledby="metrics-views-maps"');
expect(body).toContain('Lobbies (avg · peak)');
expect(body).not.toContain('Instances');
expect(body).not.toContain('Instance utilization');
expect(body).not.toContain('Historical');
expect(body).not.toContain('grouped by name');
}
);
it.each(['asc', 'desc'])(
'sorts average lobby counts numerically %s with missing evidence last and shows empty time',
(direction) => {
scope(`?mapSort=lobbies&mapDirection=${direction}`);
const maps = [
map(1, { running: { average: null, peak: 0, capturedHours: 0 }, observed: 0, idle: 0 }),
map(2, { running: { average: 2.5, peak: 4, capturedHours: 2 }, idle: 0 }),
map(3, { running: { average: 10, peak: 12, capturedHours: 1 }, idle: 1.2 }),
map(4, {
running: { average: 3, peak: 3, capturedHours: 1 },
observed: 0,
idle: 0,
unknown: 3
})
];
const body = render(MetricsTables, { props: { maps } }).body;
const ordered = direction === 'asc' ? [2, 4, 3, 1] : [3, 4, 2, 1];
for (let i = 1; i < ordered.length; i++) {
expect(body.indexOf(`Map 0${ordered[i - 1]}`)).toBeLessThan(
body.indexOf(`Map 0${ordered[i]}`)
);
}
expect(body).toContain(`aria-sort="${direction === 'asc' ? 'ascending' : 'descending'}"`);
expect(body).toContain('2.5 · 4');
expect(body).toContain('10 · 12');
expect(body).toContain('3 · 3');
expect(body).toContain('0 h empty');
expect(body).toContain('1.2 h empty');
expect(body).toContain('Empty time unavailable');
expect(body).toContain('Captured map time');
expect(body).toMatch(/class="lobbies [^"]*">—\s*<small/);
}
);
it('highlights at most three supported policy changes and collapses tentative findings', () => {
const data = report();
@@ -475,13 +515,13 @@ it('renders whole-lobby axis ticks without rounding fractional averages or other
});
it('uses shallow URL state for the selected breakdown while retaining the common summary', () => {
scope('?view=maps', '?view=instances&serverQ=theater&serverPage=2');
scope('?view=insights', '?view=maps&mapPage=2');
let body = render(MetricsPage, {
props: { data: { ...report(), user: null, setupRequired: false } }
}).body;
expect(body).toContain('aria-label="Instance metrics table"');
expect(body).not.toContain('aria-label="Map metrics table"');
expect(body.replace(/\s+/g, ' ')).toContain('16–24 of 24 instances');
expect(body).toContain('aria-label="Map metrics table"');
expect(body).not.toContain('id="metrics-explore-title"');
expect(body.replace(/\s+/g, ' ')).toContain('16–24 of 24 maps');
scope('?view=maps', '?view=insights');
body = render(MetricsPage, {
props: { data: { ...report(), user: null, setupRequired: false } }