Improve metrics clarity, exploration, and refresh reliability
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createMetricsRefresh } from '../../src/lib/ui/metrics-refresh.js';
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
const report = (days: number) => ({ days, summary: {}, points: [], maps: [], servers: [] });
|
||||
function setup(fetcher: typeof fetch) {
|
||||
let query = 'days=7';
|
||||
const onData = vi.fn(), onError = vi.fn(), onLoading = vi.fn();
|
||||
return { ...createMetricsRefresh({ getQuery: () => query, onData, onError, onLoading, fetcher, timeoutMs: 100 }),
|
||||
onData, onError, onLoading, setQuery: (value: string) => { query = value; } };
|
||||
}
|
||||
|
||||
describe('metrics background refresh', () => {
|
||||
it('keeps the previous report after failure and accepts a successful retry', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(new Response('', { status: 503 }))
|
||||
.mockResolvedValueOnce(Response.json(report(7)));
|
||||
const refresh = setup(fetcher);
|
||||
await refresh.refresh();
|
||||
expect(refresh.onData).not.toHaveBeenCalled();
|
||||
expect(refresh.onError).toHaveBeenLastCalledWith('Report refresh failed (503).');
|
||||
await refresh.refresh();
|
||||
expect(refresh.onData).toHaveBeenLastCalledWith(report(7));
|
||||
expect(refresh.onError).toHaveBeenLastCalledWith(null);
|
||||
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('coalesces duplicate requests and ignores old replies even when transport ignores abort', async () => {
|
||||
const resolve: ((r: Response) => void)[] = [];
|
||||
const fetcher = vi.fn(() => new Promise<Response>(done => resolve.push(done)));
|
||||
const refresh = setup(fetcher);
|
||||
const old = refresh.refresh();
|
||||
expect(refresh.refresh()).toBe(old);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
refresh.setQuery('days=1');
|
||||
const next = refresh.refresh();
|
||||
resolve[1](Response.json(report(1)));
|
||||
await next;
|
||||
resolve[0](Response.json(report(7)));
|
||||
await old;
|
||||
expect(refresh.onData).toHaveBeenCalledTimes(1);
|
||||
expect(refresh.onData).toHaveBeenLastCalledWith(report(1));
|
||||
});
|
||||
|
||||
it('suppresses errors from a cancelled request', async () => {
|
||||
let reject!: (error: Error) => void;
|
||||
const refresh = setup(vi.fn(() => new Promise<Response>((_, fail) => { reject = fail; })));
|
||||
const pending = refresh.refresh();
|
||||
refresh.cancel();
|
||||
reject(new Error('Obsolete response'));
|
||||
await pending;
|
||||
expect(refresh.onData).not.toHaveBeenCalled();
|
||||
expect(refresh.onError.mock.calls).toEqual([[null]]);
|
||||
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('times out with retry feedback and clears loading', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetcher = vi.fn((_input, init) => new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
})) as typeof fetch;
|
||||
const refresh = setup(fetcher);
|
||||
const pending = refresh.refresh();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await pending;
|
||||
expect(refresh.onError).toHaveBeenLastCalledWith('Report refresh timed out. Try again.');
|
||||
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
+102
-1
@@ -3,7 +3,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||
afterEach(async () => { await fixture.cleanup(); });
|
||||
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
|
||||
|
||||
describe('historical metrics', () => {
|
||||
it('weights occupancy by time and excludes failed checks and collection gaps', async () => {
|
||||
@@ -56,4 +56,105 @@ describe('historical metrics', () => {
|
||||
recordMetrics([sample], at + 32 * 86400_000);
|
||||
expect(db().prepare('SELECT COUNT(*) AS n FROM metric_samples').get()).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it('distinguishes retained history, an empty range, and current collection targets', 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, 2);
|
||||
expect(readMetrics(1, at)).toMatchObject({ firstRetainedSampleAt: null, latestSampleAt: null,
|
||||
registeredServerCount: 0, servers: [], maps: [] });
|
||||
db().prepare('INSERT INTO servers (name, komodo_name, destroyed_at) VALUES (?, ?, ?)').run('current', 'current', null);
|
||||
db().prepare('INSERT INTO servers (name, komodo_name, destroyed_at) VALUES (?, ?, ?)').run('removed', 'removed', new Date(at).toISOString());
|
||||
const sample = { server: 'removed', game: 't6', map: 'Town', players: 1, capacity: null };
|
||||
recordMetrics([sample], at);
|
||||
const emptyRange = readMetrics(1, at + 2 * 86400_000);
|
||||
expect(emptyRange).toMatchObject({ firstRetainedSampleAt: at, latestSampleAt: at,
|
||||
registeredServerCount: 1, servers: [], maps: [], summary: { observed: 0, unknown: 0 } });
|
||||
expect(emptyRange.points.every(point => point.observed === 0 && point.unknown === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps unknown status separate from no samples and preserves zero elapsed observations', async () => {
|
||||
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const at = Date.UTC(2026, 0, 2, 12);
|
||||
recordMetrics([{ server: 'one', game: 't5', map: 'Moon', players: null, capacity: null }], at);
|
||||
const initial = readMetrics(1, at);
|
||||
expect(initial.latestSampleAt).toBe(at);
|
||||
expect(initial.servers).toHaveLength(1);
|
||||
expect(initial.summary).toEqual({ observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 });
|
||||
const later = readMetrics(1, at + 120_000);
|
||||
expect(later.summary.observed).toBe(0);
|
||||
expect(later.summary.unknown * 3600).toBeCloseTo(45);
|
||||
expect(later.maps[0].capacityObserved).toBe(0);
|
||||
});
|
||||
|
||||
it('returns clipped interval boundaries and marks only partial UTC buckets', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const now = Date.UTC(2026, 0, 8, 12, 30);
|
||||
for (const days of [1, 7, 30] as const) {
|
||||
const result = readMetrics(days, now);
|
||||
expect(result.points[0].startAt).toBe(result.since);
|
||||
expect(result.points.at(-1)?.endAt).toBe(now);
|
||||
expect(result.points[0].partial).toBe(true);
|
||||
expect(result.points.at(-1)?.partial).toBe(true);
|
||||
expect(result.points.slice(1, -1).every(point => !point.partial)).toBe(true);
|
||||
expect(result.points.reduce((ms, point) => ms + point.endAt - point.startAt, 0)).toBe(days * 86400_000);
|
||||
}
|
||||
expect(readMetrics(1, Date.UTC(2026, 0, 8, 12)).points.every(point => !point.partial)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses complete collection boundaries across fleet changes and keeps map pressure 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);
|
||||
const one = { server: 'one', game: 't6', map: 'Town', players: 4, capacity: 4 };
|
||||
const two = { ...one, server: 'two' };
|
||||
recordMetrics([one, two], at);
|
||||
// Removing a lobby ends its previous observation at the next complete collection.
|
||||
recordMetrics([{ ...one, map: 'Farm', players: 0 }], at + 30_000);
|
||||
// A collection outage is excluded; an identically named map in another game remains separate.
|
||||
recordMetrics([{ ...one, game: 't5', players: 2, capacity: null }], at + 120_000);
|
||||
const result = readMetrics(1, at + 150_000);
|
||||
expect(result.summary.active * 3600).toBeCloseTo(90);
|
||||
expect(result.summary.idle * 3600).toBeCloseTo(45);
|
||||
expect(result.summary.playerHours * 3600).toBeCloseTo(300);
|
||||
expect(result.servers.find(server => server.server === 'two')!.observed * 3600).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);
|
||||
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]);
|
||||
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]);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not include future samples in collection metadata or aggregates', 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: 4 };
|
||||
recordMetrics([sample], at);
|
||||
recordMetrics([{ ...sample, players: 4 }], at + 60_000);
|
||||
const result = readMetrics(1, at + 30_000);
|
||||
expect(result.latestSampleAt).toBe(at);
|
||||
expect(result.summary.playerHours * 3600).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
it('shares the range parser and response contract between SSR and the uncached refresh endpoint', async () => {
|
||||
const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const { GET } = await import('../../src/routes/api/metrics/+server.js');
|
||||
const { load } = await import('../../src/routes/metrics/+page.server.js');
|
||||
const at = Date.UTC(2026, 0, 2, 12);
|
||||
recordMetrics([{ server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }], at);
|
||||
vi.spyOn(Date, 'now').mockReturnValue(at + 30_000);
|
||||
for (const range of ['1', '7', '30', 'invalid', '365', '-1', '']) {
|
||||
const url = new URL(`http://localhost/metrics?days=${range}`);
|
||||
const response = GET({ url });
|
||||
const payload = await response.json();
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(payload).toEqual(await load({ url } as Parameters<typeof load>[0]));
|
||||
expect(payload.days).toBe(['1', '7', '30'].includes(range) ? Number(range) : 7);
|
||||
}
|
||||
expect((await GET({ url: new URL('http://localhost/api/metrics') }).json()).days).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compareMetricValues, metricDuration, metricNumber, metricPercent } from '../../src/lib/ui/metrics.js';
|
||||
|
||||
describe('metric presentation', () => {
|
||||
it('distinguishes unavailable evidence, zero, and tiny nonzero values', () => {
|
||||
expect(metricPercent(0, 0)).toBe('—');
|
||||
expect(metricPercent(0, 1)).toBe('0.0%');
|
||||
expect(metricPercent(0.0001, 1)).toBe('<0.1%');
|
||||
expect(metricDuration(0)).toBe('0 h');
|
||||
expect(metricDuration(0.1 / 3600)).toBe('<1 s');
|
||||
expect(metricDuration(45 / 3600)).toBe('45 s');
|
||||
expect(metricDuration(90 / 3600)).toBe('1.5 min');
|
||||
expect(metricNumber(0.0125)).toBe('<0.1');
|
||||
});
|
||||
|
||||
it('sorts numerically while putting unavailable observations last in both directions', () => {
|
||||
const values = [2, null, 100, 0, 10];
|
||||
expect([...values].sort((a, b) => compareMetricValues(a, b, 'desc'))).toEqual([100, 10, 2, 0, null]);
|
||||
expect([...values].sort((a, b) => compareMetricValues(a, b, 'asc'))).toEqual([0, 2, 10, 100, null]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user