Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
+196
-49
@@ -1,8 +1,18 @@
|
||||
import { fleetSettings } from '../settings.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { ONLINE_MAX_MS } from '../../server-freshness.js';
|
||||
import { parseMetricsQuery, type HeatmapCell, type MapMetrics, type MetricPoint, type MetricsData,
|
||||
type MetricsDays, type MetricsQuery, type PreviousMetrics, type ServerMetrics, type Utilization } from '../../metrics.js';
|
||||
import {
|
||||
parseMetricsQuery,
|
||||
type HeatmapCell,
|
||||
type MapMetrics,
|
||||
type MetricPoint,
|
||||
type MetricsData,
|
||||
type MetricsDays,
|
||||
type MetricsQuery,
|
||||
type PreviousMetrics,
|
||||
type ServerMetrics,
|
||||
type Utilization
|
||||
} from '../../metrics.js';
|
||||
|
||||
export type { MapMetrics, Utilization } from '../../metrics.js';
|
||||
|
||||
@@ -17,10 +27,13 @@ export interface MetricSample {
|
||||
export function recordMetrics(samples: MetricSample[], at = Date.now()): void {
|
||||
const handle = db();
|
||||
handle.transaction(() => {
|
||||
const insert = handle.prepare('INSERT OR REPLACE INTO metric_samples (at, server, game, map, players, capacity) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
const insert = handle.prepare(
|
||||
'INSERT OR REPLACE INTO metric_samples (at, server, game, map, players, capacity) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
for (const s of samples) insert.run(at, s.server, s.game, s.map, s.players, s.capacity);
|
||||
const days = fleetSettings().metricsKeepDays;
|
||||
if (days) handle.prepare('DELETE FROM metric_samples WHERE at < ?').run(at - days * 86400_000);
|
||||
if (days)
|
||||
handle.prepare('DELETE FROM metric_samples WHERE at < ?').run(at - days * 86400_000);
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -42,52 +55,120 @@ function addObservation(total: Utilization, players: number | null, hours: numbe
|
||||
* capped at the freshness limit; longer gaps contribute no observed or unknown time.
|
||||
* Read the indexed history once and share the intervals across every aggregation. */
|
||||
export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()): MetricsData {
|
||||
const query = parseMetricsQuery(new URLSearchParams(typeof input === 'number' ? { days: String(input) }
|
||||
: { days: String(input.days), game: input.game ?? '', map: input.map ?? '', timeZone: input.timeZone }));
|
||||
const query = parseMetricsQuery(
|
||||
new URLSearchParams(
|
||||
typeof input === 'number'
|
||||
? { days: String(input) }
|
||||
: {
|
||||
days: String(input.days),
|
||||
game: input.game ?? '',
|
||||
map: input.map ?? '',
|
||||
timeZone: input.timeZone
|
||||
}
|
||||
)
|
||||
);
|
||||
const { days, ...scope } = query;
|
||||
const since = now - days * 86400_000;
|
||||
const bucketMs = days === 1 ? 3600_000 : 86400_000;
|
||||
const handle = db();
|
||||
// These small grouped results keep options and retained evidence independent
|
||||
// of the chosen scope/window without transferring a second raw history scan.
|
||||
const groups = handle.prepare(`SELECT game, map, MIN(at) AS first, MAX(at) AS latest
|
||||
FROM metric_samples WHERE at <= ? GROUP BY game, map ORDER BY game, map`).all(now) as
|
||||
{ game: string; map: string; first: number; latest: number }[];
|
||||
const groups = handle
|
||||
.prepare(
|
||||
`SELECT game, map, MIN(at) AS first, MAX(at) AS latest
|
||||
FROM metric_samples WHERE at <= ? GROUP BY game, map ORDER BY game, map`
|
||||
)
|
||||
.all(now) as { game: string; map: string; first: number; latest: number }[];
|
||||
const matchesScope = (sample: { game: string; map: string }) =>
|
||||
(scope.game === null || sample.game === scope.game) && (scope.map === null || sample.map === scope.map);
|
||||
const evidenceBounds = (values: typeof groups) => values.reduce<{ first: number | null; latest: number | null }>((bounds, group) => ({
|
||||
first: bounds.first === null ? group.first : Math.min(bounds.first, group.first),
|
||||
latest: bounds.latest === null ? group.latest : Math.max(bounds.latest, group.latest)
|
||||
}), { first: null, latest: null });
|
||||
(scope.game === null || sample.game === scope.game) &&
|
||||
(scope.map === null || sample.map === scope.map);
|
||||
const evidenceBounds = (values: typeof groups) =>
|
||||
values.reduce<{ first: number | null; latest: number | null }>(
|
||||
(bounds, group) => ({
|
||||
first: bounds.first === null ? group.first : Math.min(bounds.first, group.first),
|
||||
latest:
|
||||
bounds.latest === null ? group.latest : Math.max(bounds.latest, group.latest)
|
||||
}),
|
||||
{ first: null, latest: null }
|
||||
);
|
||||
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 metadata = { firstRetainedSampleAt: globalBounds.first, latestSampleAt: globalBounds.latest,
|
||||
registeredServerCount: registered.size, scopeFirstSampleAt: scopedBounds.first, scopeLatestSampleAt: scopedBounds.latest };
|
||||
const scopeOptions = { games: [...new Set(groups.map(group => group.game))], maps: groups.map(({ game, map }) => ({ game, map })) };
|
||||
const registered = new Set(
|
||||
(
|
||||
handle.prepare('SELECT name FROM servers WHERE destroyed_at IS NULL').all() as {
|
||||
name: string;
|
||||
}[]
|
||||
).map((server) => server.name)
|
||||
);
|
||||
const metadata = {
|
||||
firstRetainedSampleAt: globalBounds.first,
|
||||
latestSampleAt: globalBounds.latest,
|
||||
registeredServerCount: registered.size,
|
||||
scopeFirstSampleAt: scopedBounds.first,
|
||||
scopeLatestSampleAt: scopedBounds.latest
|
||||
};
|
||||
const scopeOptions = {
|
||||
games: [...new Set(groups.map((group) => group.game))],
|
||||
maps: groups.map(({ game, map }) => ({ game, map }))
|
||||
};
|
||||
const summary = emptyUtilization();
|
||||
const previous: PreviousMetrics = { since: since - days * 86400_000, until: since, summary: emptyUtilization(),
|
||||
firstSampleAt: null, lastSampleAt: null, partialHistory: false };
|
||||
const previous: PreviousMetrics = {
|
||||
since: since - days * 86400_000,
|
||||
until: since,
|
||||
summary: emptyUtilization(),
|
||||
firstSampleAt: null,
|
||||
lastSampleAt: null,
|
||||
partialHistory: false
|
||||
};
|
||||
const serverTotals = new Map<string, ServerMetrics>();
|
||||
const mapTotals = new Map<string, Map<string, MapMetrics>>();
|
||||
const points: MetricPoint[] = [];
|
||||
for (let at = Math.floor(since / bucketMs) * bucketMs; at < now; at += bucketMs) {
|
||||
const end = Math.min(at + bucketMs, now);
|
||||
const start = Math.max(at, since);
|
||||
points.push({ at, startAt: start, endAt: end, partial: start !== at || end !== at + bucketMs, ...emptyUtilization() });
|
||||
points.push({
|
||||
at,
|
||||
startAt: start,
|
||||
endAt: end,
|
||||
partial: start !== at || end !== at + bucketMs,
|
||||
...emptyUtilization()
|
||||
});
|
||||
}
|
||||
|
||||
const cells: HeatmapCell[] = Array.from({ length: 7 * 24 }, (_, index) => ({
|
||||
weekday: Math.floor(index / 24), hour: index % 24, distinctDates: 0, ...emptyUtilization()
|
||||
weekday: Math.floor(index / 24),
|
||||
hour: index % 24,
|
||||
distinctDates: 0,
|
||||
...emptyUtilization()
|
||||
}));
|
||||
const cellDates = cells.map(() => new Set<string>());
|
||||
const calendar = new Intl.DateTimeFormat('en-US', { timeZone: scope.timeZone,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short', hour: 'numeric', hourCycle: 'h23' });
|
||||
const weekdays: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
|
||||
const calendar = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: scope.timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
weekday: 'short',
|
||||
hour: 'numeric',
|
||||
hourCycle: 'h23'
|
||||
});
|
||||
const weekdays: Record<string, number> = {
|
||||
Mon: 0,
|
||||
Tue: 1,
|
||||
Wed: 2,
|
||||
Thu: 3,
|
||||
Fri: 4,
|
||||
Sat: 5,
|
||||
Sun: 6
|
||||
};
|
||||
let cachedMinute = NaN;
|
||||
let localCell = { index: 0, date: '' };
|
||||
interface BatchCounts { known: number; active: number; idle: number; unknown: number; players: number }
|
||||
interface BatchCounts {
|
||||
known: number;
|
||||
active: number;
|
||||
idle: number;
|
||||
unknown: number;
|
||||
players: number;
|
||||
}
|
||||
function addHeatmap(counts: BatchCounts, start: number, end: number): void {
|
||||
// Samples are bounded by the shared freshness window. Splitting at UTC minute boundaries
|
||||
// handles local hour/day boundaries, DST, and fractional-hour zones.
|
||||
@@ -95,8 +176,13 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
for (let cursor = start; cursor < end;) {
|
||||
const minute = Math.floor(cursor / 60_000);
|
||||
if (minute !== cachedMinute) {
|
||||
const parts = Object.fromEntries(calendar.formatToParts(cursor).map(part => [part.type, part.value]));
|
||||
localCell = { index: weekdays[parts.weekday] * 24 + Number(parts.hour), date: `${parts.year}-${parts.month}-${parts.day}` };
|
||||
const parts = Object.fromEntries(
|
||||
calendar.formatToParts(cursor).map((part) => [part.type, part.value])
|
||||
);
|
||||
localCell = {
|
||||
index: weekdays[parts.weekday] * 24 + Number(parts.hour),
|
||||
date: `${parts.year}-${parts.month}-${parts.day}`
|
||||
};
|
||||
cachedMinute = minute;
|
||||
}
|
||||
const stop = Math.min(end, (minute + 1) * 60_000);
|
||||
@@ -120,11 +206,13 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
const samples = scope.game === null ? batch : batch.filter(matchesScope);
|
||||
if (!samples.length) return;
|
||||
const end = Math.min(nextAt, at + ONLINE_MAX_MS, now);
|
||||
const priorHours = Math.max(0, Math.min(end, previous.until) - Math.max(at, previous.since)) / 3600_000;
|
||||
const priorHours =
|
||||
Math.max(0, Math.min(end, previous.until) - Math.max(at, previous.since)) / 3600_000;
|
||||
if (priorHours > 0) {
|
||||
previous.firstSampleAt ??= at;
|
||||
previous.lastSampleAt = at;
|
||||
for (const sample of samples) addObservation(previous.summary, sample.players, priorHours);
|
||||
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.
|
||||
@@ -146,14 +234,27 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
for (const sample of samples) {
|
||||
let server = serverTotals.get(sample.server);
|
||||
if (!server) {
|
||||
server = { server: sample.server, currentRegistered: registered.has(sample.server), ...emptyUtilization() };
|
||||
server = {
|
||||
server: sample.server,
|
||||
currentRegistered: registered.has(sample.server),
|
||||
...emptyUtilization()
|
||||
};
|
||||
serverTotals.set(sample.server, server);
|
||||
}
|
||||
let game = mapTotals.get(sample.game);
|
||||
if (!game) { game = new Map(); mapTotals.set(sample.game, game); }
|
||||
if (!game) {
|
||||
game = new Map();
|
||||
mapTotals.set(sample.game, game);
|
||||
}
|
||||
let map = game.get(sample.map);
|
||||
if (!map) {
|
||||
map = { game: sample.game, map: sample.map, full: 0, capacityObserved: 0, ...emptyUtilization() };
|
||||
map = {
|
||||
game: sample.game,
|
||||
map: sample.map,
|
||||
full: 0,
|
||||
capacityObserved: 0,
|
||||
...emptyUtilization()
|
||||
};
|
||||
game.set(sample.map, map);
|
||||
}
|
||||
addObservation(summary, sample.players, hours);
|
||||
@@ -167,10 +268,14 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
if (sample.players > 0) counts.active++;
|
||||
else if (sample.players === 0) counts.idle++;
|
||||
}
|
||||
const known = sample.players !== null && sample.capacity !== null && sample.capacity > 0;
|
||||
const known =
|
||||
sample.players !== null && sample.capacity !== null && sample.capacity > 0;
|
||||
const full = known && sample.players! >= sample.capacity!;
|
||||
const previous = pressure.get(map);
|
||||
pressure.set(map, { known: known && (previous?.known ?? true), full: full && (previous?.full ?? true) });
|
||||
pressure.set(map, {
|
||||
known: known && (previous?.known ?? true),
|
||||
full: full && (previous?.full ?? true)
|
||||
});
|
||||
}
|
||||
for (const [map, state] of pressure) {
|
||||
if (state.known) {
|
||||
@@ -181,9 +286,21 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
addHeatmap(counts, start, end);
|
||||
}
|
||||
|
||||
type SampleRow = [at: number, server: string, game: string, map: string, players: number | null, capacity: number | null];
|
||||
const samples = handle.prepare(`SELECT at, server, game, map, players, capacity FROM metric_samples
|
||||
WHERE at >= ? AND at <= ? ORDER BY at`).raw().iterate(previous.since - ONLINE_MAX_MS, now) as IterableIterator<SampleRow>;
|
||||
type SampleRow = [
|
||||
at: number,
|
||||
server: string,
|
||||
game: string,
|
||||
map: string,
|
||||
players: number | null,
|
||||
capacity: number | null
|
||||
];
|
||||
const samples = handle
|
||||
.prepare(
|
||||
`SELECT at, server, game, map, players, capacity FROM metric_samples
|
||||
WHERE at >= ? AND at <= ? ORDER BY at`
|
||||
)
|
||||
.raw()
|
||||
.iterate(previous.since - ONLINE_MAX_MS, now) as IterableIterator<SampleRow>;
|
||||
for (const [at, server, game, map, players, capacity] of samples) {
|
||||
if (batch.length && at !== batch[0].at) {
|
||||
aggregateBatch(at);
|
||||
@@ -193,16 +310,46 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
|
||||
}
|
||||
aggregateBatch(now);
|
||||
|
||||
const compareText = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
|
||||
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((a, b) => b.playerHours - a.playerHours || compareText(a.map, b.map) || compareText(a.game, b.game));
|
||||
previous.partialHistory = scopedBounds.first === null || scopedBounds.first > previous.since || previous.firstSampleAt === null;
|
||||
for (let index = 0; index < cells.length; index++) cells[index].distinctDates = cellDates[index].size;
|
||||
const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
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(
|
||||
(a, b) =>
|
||||
b.playerHours - a.playerHours ||
|
||||
compareText(a.map, b.map) ||
|
||||
compareText(a.game, b.game)
|
||||
);
|
||||
previous.partialHistory =
|
||||
scopedBounds.first === null ||
|
||||
scopedBounds.first > previous.since ||
|
||||
previous.firstSampleAt === null;
|
||||
for (let index = 0; index < cells.length; index++)
|
||||
cells[index].distinctDates = cellDates[index].size;
|
||||
const fullMapHours = maps.reduce((total, map) => total + map.full, 0);
|
||||
const knownMapHours = maps.reduce((total, map) => total + map.capacityObserved, 0);
|
||||
const pooledPressure = { value: knownMapHours > 0 ? 100 * fullMapHours / knownMapHours : null,
|
||||
fullMapHours, knownMapHours, coveredMaps: maps.filter(map => map.capacityObserved > 0).length, totalMaps: maps.length };
|
||||
return { days, since, now, ...metadata, scope, scopeOptions, summary, servers, maps, points, previous,
|
||||
heatmap: { timeZone: scope.timeZone, cells }, pooledPressure };
|
||||
const pooledPressure = {
|
||||
value: knownMapHours > 0 ? (100 * fullMapHours) / knownMapHours : null,
|
||||
fullMapHours,
|
||||
knownMapHours,
|
||||
coveredMaps: maps.filter((map) => map.capacityObserved > 0).length,
|
||||
totalMaps: maps.length
|
||||
};
|
||||
return {
|
||||
days,
|
||||
since,
|
||||
now,
|
||||
...metadata,
|
||||
scope,
|
||||
scopeOptions,
|
||||
summary,
|
||||
servers,
|
||||
maps,
|
||||
points,
|
||||
previous,
|
||||
heatmap: { timeZone: scope.timeZone, cells },
|
||||
pooledPressure
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user