diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 2342e9e..b859970 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -50,12 +50,6 @@ export interface Utilization { playerHours: number; } -export interface ServerMetrics extends Utilization { - server: string; - /** A current registration with this name exists; historical identity may differ. */ - currentRegistered: boolean; -} - export interface MatchMetrics { /** Retained matches whose recorded start is within the selected window. */ started: number; @@ -153,7 +147,8 @@ export interface MetricsData { scopeLatestSampleAt: number | null; summary: Utilization; running: RunningMetrics; - servers: ServerMetrics[]; + /** Distinct recorded names sampled in this scope/window, including zero elapsed time. */ + sampledServerCount: number; matches: MatchMetrics; maps: MapMetrics[]; points: MetricPoint[]; diff --git a/src/lib/server/fleet/metrics.ts b/src/lib/server/fleet/metrics.ts index 6b13f29..ece64f1 100644 --- a/src/lib/server/fleet/metrics.ts +++ b/src/lib/server/fleet/metrics.ts @@ -13,7 +13,6 @@ import { type MetricsQuery, type PreviousMetrics, type RunningMetrics, - type ServerMetrics, type Utilization } from '../../metrics.js'; @@ -191,17 +190,13 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) ); const globalBounds = evidenceBounds(groups); const scopedBounds = evidenceBounds(groups.filter(matchesScope)); - const registered = new Set( - ( - handle.prepare('SELECT name FROM servers WHERE destroyed_at IS NULL').all() as { - name: string; - }[] - ).map((server) => server.name) - ); + const registered = handle + .prepare('SELECT COUNT(*) AS count FROM servers WHERE destroyed_at IS NULL') + .get() as { count: number }; const metadata = { firstRetainedSampleAt: globalBounds.first, latestSampleAt: globalBounds.latest, - registeredServerCount: registered.size, + registeredServerCount: registered.count, scopeFirstSampleAt: scopedBounds.first, scopeLatestSampleAt: scopedBounds.latest }; @@ -236,7 +231,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) lastSampleAt: null, partialHistory: false }; - const serverTotals = new Map(); + const sampledServers = new Set(); const mapTotals = new Map>(); function mapFor(gameName: string, label: string): MapMetrics { let game = mapTotals.get(gameName); @@ -401,8 +396,8 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) for (const sample of samples) addObservation(previous.summary, sample.players, priorHours); } - // Preserve current-window zero-duration rows without bringing rows from - // the separate comparison window into the visible server/map tables. + // Preserve current-window zero-duration evidence without bringing samples + // from the separate comparison window into the current count or map table. if (at < since - ONLINE_MAX_MS) return; const start = Math.max(at, since); const hours = Math.max(0, end - start) / 3600_000; @@ -425,18 +420,9 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) cursor = stop; } for (const sample of samples) { - let server = serverTotals.get(sample.server); - if (!server) { - server = { - server: sample.server, - currentRegistered: registered.has(sample.server), - ...emptyUtilization() - }; - serverTotals.set(sample.server, server); - } + sampledServers.add(sample.server); const map = mapFor(sample.game, sample.map); addObservation(summary, sample.players, hours); - addObservation(server, sample.players, hours); addObservation(map, sample.players, hours); for (const slice of slices) addObservation(slice.point, sample.players, slice.hours); if (sample.players === null) counts.unknown++; @@ -493,9 +479,6 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) } aggregateBatch(now); - const servers = [...serverTotals.values()].sort( - (a, b) => b.active - a.active || compareText(a.server, b.server) - ); const maps = [...mapTotals.values()] .flatMap((game) => [...game.values()]) .sort( @@ -531,7 +514,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) summary, running, matches: matchMetrics(totalMatches), - servers, + sampledServerCount: sampledServers.size, maps, points, previous, diff --git a/src/lib/ui/metrics-refresh.ts b/src/lib/ui/metrics-refresh.ts index debcd80..d314d13 100644 --- a/src/lib/ui/metrics-refresh.ts +++ b/src/lib/ui/metrics-refresh.ts @@ -63,6 +63,8 @@ function validReport(value: unknown): value is MetricsData { return false; if ( !numeric(value.registeredServerCount) || + !nonnegative(value.sampledServerCount) || + !Number.isInteger(value.sampledServerCount) || typeof value.autoscalingPaused !== 'boolean' || ![ 'firstRetainedSampleAt', @@ -107,17 +109,6 @@ function validReport(value: unknown): value is MetricsData { ) ) return false; - if ( - !Array.isArray(value.servers) || - !value.servers.every( - (item) => - record(item) && - totals(item) && - typeof item.server === 'string' && - typeof item.currentRegistered === 'boolean' - ) - ) - return false; if ( !record(value.previous) || !totals(value.previous.summary) || diff --git a/src/routes/metrics/+page.svelte b/src/routes/metrics/+page.svelte index 845bd9b..bc25e15 100644 --- a/src/routes/metrics/+page.svelte +++ b/src/routes/metrics/+page.svelte @@ -257,14 +257,14 @@ 0 + : report.sampledServerCount > 0 ? 'Waiting for observed time' : 'No captured time in this scope'} description={report.latestSampleAt === null ? report.registeredServerCount > 0 ? 'Leave the manager running to collect server status and build a history.' : 'Register a server to begin collecting utilization history.' - : report.servers.length > 0 + : report.sampledServerCount > 0 ? 'Captured samples have no elapsed time in this window. Time builds between status checks.' : 'There are no elapsed observations for the selected game, map, and time window. Try another scope or a wider range.'} > diff --git a/tests/unit/metrics-refresh.test.ts b/tests/unit/metrics-refresh.test.ts index 3100a4f..ee14951 100644 --- a/tests/unit/metrics-refresh.test.ts +++ b/tests/unit/metrics-refresh.test.ts @@ -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() diff --git a/tests/unit/metrics.test.ts b/tests/unit/metrics.test.ts index ca6866d..df7d4cf 100644 --- a/tests/unit/metrics.test.ts +++ b/tests/unit/metrics.test.ts @@ -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); diff --git a/tests/unit/ui-metrics-page.test.ts b/tests/unit/ui-metrics-page.test.ts index cd6bc1a..3cbd85c 100644 --- a/tests/unit/ui-metrics-page.test.ts +++ b/tests/unit/ui-metrics-page.test.ts @@ -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 => ({ 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,