251 lines
9.0 KiB
TypeScript
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 };
|
|
}
|