Files
electric-cherry/src/lib/ui/metrics-refresh.ts
T
2026-10-10 18:47:56 -04:00

251 lines
9.0 KiB
TypeScript

import type { MetricsData } from '$lib/metrics.js';
import { validTimeZone } from '$lib/time-zone.js';
const record = (value: unknown): value is Record<string, unknown> =>
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 players = (value: unknown) =>
record(value) &&
['uniquePlayers', 'newPlayers'].every(
(key) => value[key] === null || (nonnegative(value[key]) && Number.isInteger(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) ||
!nonnegative(value.metricsKeepDays) ||
!Number.isInteger(value.metricsKeepDays) ||
!numeric(value.now) ||
!numeric(value.since) ||
!totals(value.summary) ||
!running(value.running) ||
!matches(value.matches) ||
!record(value.players) ||
!players(value.players) ||
!timestamp(value.players.historyStartAt) ||
!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) &&
players(item) &&
typeof item.playerHistoryPartial === 'boolean' &&
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<string, unknown>)[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<void>;
timer?: ReturnType<typeof setTimeout>;
} | null = null;
function cancel() {
generation++;
if (pending) {
clearTimeout(pending.timer);
pending.controller.abort();
pending = null;
}
options.onLoading(false);
}
function refresh(): Promise<void> {
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<typeof setTimeout> | 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 };
}