Replace unused server metrics with a count (Refs #139)
CI / checks (push) Successful in 4m51s
CI / checks (push) Successful in 4m51s
This commit is contained in:
+2
-7
@@ -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[];
|
||||
|
||||
@@ -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<string, ServerMetrics>();
|
||||
const sampledServers = new Set<string>();
|
||||
const mapTotals = new Map<string, Map<string, MapMetrics>>();
|
||||
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,
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -257,14 +257,14 @@
|
||||
<EmptyState
|
||||
title={report.latestSampleAt === null
|
||||
? 'No retained observations yet'
|
||||
: report.servers.length > 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.'}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user