import type { MetricsData } from '$lib/metrics.js'; import { validTimeZone } from '$lib/time-zone.js'; const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const numeric = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); const nonnegative = (value: unknown): value is number => numeric(value) && value >= 0; const timestamp = (value: unknown) => value === null || numeric(value); const totals = (value: unknown) => record(value) && ['observed', 'active', 'idle', 'unknown', 'playerHours'].every( (key) => numeric(value[key]) && value[key] >= 0 ); const running = (value: unknown) => record(value) && (value.average === null || nonnegative(value.average)) && nonnegative(value.peak) && nonnegative(value.capturedHours); const matches = (value: unknown) => record(value) && ['started', 'withRecordedPlayers', 'solo'].every( (key) => nonnegative(value[key]) && Number.isInteger(value[key]) ) && (value.averageGroupSize === null || nonnegative(value.averageGroupSize)) && (value.soloShare === null || (nonnegative(value.soloShare) && value.soloShare <= 100)); const autoscaling = (value: unknown) => record(value) && ['added', 'removed', 'failed', 'cancelled', 'readinessSamples'].every( (key) => nonnegative(value[key]) && Number.isInteger(value[key]) ) && ['readinessMeanSeconds', 'readinessMaxSeconds'].every( (key) => value[key] === null || nonnegative(value[key]) ); const policy = (value: unknown) => value === null || (record(value) && typeof value.id === 'string' && ['off', 'create-destroy'].includes(value.mode as string) && ['minimum', 'maximum', 'spares'].every((key) => nonnegative(value[key])) && (value.pausedReason === null || typeof value.pausedReason === 'string')); function validReport(value: unknown): value is MetricsData { if ( !record(value) || ![1, 7, 30].includes(value.days as number) || !numeric(value.now) || !numeric(value.since) || !totals(value.summary) || !running(value.running) || !matches(value.matches) || !autoscaling(value.autoscaling) ) return false; if ( !record(value.scope) || !validTimeZone(value.scope.timeZone) || ![value.scope.game, value.scope.map].every( (item) => item === null || typeof item === 'string' ) ) return false; if ( !record(value.scopeOptions) || !Array.isArray(value.scopeOptions.games) || !value.scopeOptions.games.every((item) => typeof item === 'string') || !Array.isArray(value.scopeOptions.maps) || !value.scopeOptions.maps.every( (item) => record(item) && typeof item.game === 'string' && typeof item.map === 'string' ) ) return false; if ( !numeric(value.registeredServerCount) || !nonnegative(value.sampledServerCount) || !Number.isInteger(value.sampledServerCount) || typeof value.autoscalingPaused !== 'boolean' || ![ 'firstRetainedSampleAt', 'latestSampleAt', 'scopeFirstSampleAt', 'scopeLatestSampleAt' ].every((key) => timestamp(value[key])) ) return false; if ( !Array.isArray(value.points) || !value.points.every( (item) => record(item) && totals(item) && numeric(item.at) && numeric(item.startAt) && numeric(item.endAt) && nonnegative(item.capturedHours) && typeof item.partial === 'boolean' ) ) return false; if ( !Array.isArray(value.maps) || !value.maps.every( (item) => record(item) && totals(item) && matches(item.matches) && running(item.running) && typeof item.game === 'string' && typeof item.map === 'string' && [ 'noEmpty', 'availabilityObserved', 'availabilityUnknown', 'noEmptyAtMaximum', 'noEmptyBelowMaximum' ].every((key) => nonnegative(item[key])) && policy(item.policy) ) ) return false; if ( !record(value.previous) || !totals(value.previous.summary) || !running(value.previous.running) || !numeric(value.previous.since) || !numeric(value.previous.until) || !timestamp(value.previous.firstSampleAt) || !timestamp(value.previous.lastSampleAt) || typeof value.previous.partialHistory !== 'boolean' ) return false; if ( !record(value.heatmap) || !validTimeZone(value.heatmap.timeZone) || !Array.isArray(value.heatmap.cells) || !value.heatmap.cells.every( (item) => record(item) && totals(item) && numeric(item.weekday) && numeric(item.hour) && numeric(item.distinctDates) ) ) return false; return ( record(value.pooledAvailability) && (value.pooledAvailability.value === null || nonnegative(value.pooledAvailability.value)) && ['noEmptyMapHours', 'knownMapHours', 'unknownMapHours', 'coveredMaps', 'totalMaps'].every( (key) => nonnegative((value.pooledAvailability as Record)[key]) ) ); } interface Options { getQuery: () => string; onData: (report: MetricsData) => void; onError: (message: string | null) => void; onLoading: (loading: boolean) => void; fetcher?: typeof fetch; timeoutMs?: number; } /** Keeps background failures and obsolete responses out of the last successful report. */ export function createMetricsRefresh(options: Options) { const request = options.fetcher ?? fetch; let generation = 0; let pending: { query: string; controller: AbortController; promise: Promise; timer?: ReturnType; } | null = null; function cancel() { generation++; if (pending) { clearTimeout(pending.timer); pending.controller.abort(); pending = null; } options.onLoading(false); } function refresh(): Promise { const query = options.getQuery(); if (pending?.query === query) return pending.promise; cancel(); const id = generation; const controller = new AbortController(); let timedOut = false; const current = () => generation === id && options.getQuery() === query; options.onLoading(true); options.onError(null); const entry = { query, controller, promise: Promise.resolve(), timer: undefined as ReturnType | undefined }; pending = entry; entry.timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? 15_000); entry.promise = (async () => { try { const response = await request(`/api/metrics?${query}`, { signal: controller.signal, cache: 'no-store' }); if (!response.ok) throw new Error(`Report refresh failed (${response.status}).`); const report: unknown = await response.json(); if (!validReport(report)) { throw new Error('The metrics response was incomplete.'); } if (current()) options.onData(report); } catch (error) { if (current() && (!controller.signal.aborted || timedOut)) { options.onError( timedOut ? 'Report refresh timed out. Try again.' : error instanceof Error ? error.message : 'Unable to refresh metrics. Try again.' ); } } finally { clearTimeout(entry.timer); if (pending === entry) pending = null; if (current()) options.onLoading(false); } })(); return entry.promise; } return { refresh, cancel }; }