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'; export type { MapMetrics, Utilization } from '../../metrics.js'; export interface MetricSample { server: string; game: string; map: string; players: number | null; capacity: number | null; } 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 (?, ?, ?, ?, ?, ?)'); 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); })(); } function emptyUtilization(): Utilization { return { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 }; } function addObservation(total: Utilization, players: number | null, hours: number): void { if (players === null) total.unknown += hours; else { total.observed += hours; if (players > 0) total.active += hours; else if (players === 0) total.idle += hours; total.playerHours += players * hours; } } /** Each collection is a complete fleet snapshot. Hold it until the next collection, * 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 { 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 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 }); 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 summary = emptyUtilization(); const previous: PreviousMetrics = { since: since - days * 86400_000, until: since, summary: emptyUtilization(), firstSampleAt: null, lastSampleAt: null, partialHistory: false }; const serverTotals = new Map(); const mapTotals = new Map>(); 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() }); } const cells: HeatmapCell[] = Array.from({ length: 7 * 24 }, (_, index) => ({ weekday: Math.floor(index / 24), hour: index % 24, distinctDates: 0, ...emptyUtilization() })); const cellDates = cells.map(() => new Set()); 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 = { 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 } 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. // Cache each minute once across the complete fleet collection batches. 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}` }; cachedMinute = minute; } const stop = Math.min(end, (minute + 1) * 60_000); const hours = (stop - cursor) / 3600_000; const cell = cells[localCell.index]; cell.observed += counts.known * hours; cell.active += counts.active * hours; cell.idle += counts.idle * hours; cell.unknown += counts.unknown * hours; cell.playerHours += counts.players * hours; cellDates[localCell.index].add(localCell.date); cursor = stop; } } type RecordedSample = MetricSample & { at: number }; let batch: RecordedSample[] = []; function aggregateBatch(nextAt: number): void { if (!batch.length) return; const at = batch[0].at; 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; if (priorHours > 0) { previous.firstSampleAt ??= at; previous.lastSampleAt = at; 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. if (at < since - ONLINE_MAX_MS) return; const start = Math.max(at, since); const hours = Math.max(0, end - start) / 3600_000; const pressure = new Map(); const counts: BatchCounts = { known: 0, active: 0, idle: 0, unknown: 0, players: 0 }; // Split once for the entire batch; observations crossing UTC hour/day // boundaries contribute to both buckets without shifting their duration. const slices: { point: MetricPoint; hours: number }[] = []; for (let cursor = start; cursor < end;) { const index = Math.floor((cursor - points[0].at) / bucketMs); const point = points[index]; const stop = Math.min(end, point.endAt); slices.push({ point, hours: (stop - cursor) / 3600_000 }); cursor = stop; } for (const sample of samples) { let server = serverTotals.get(sample.server); if (!server) { 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); } let map = game.get(sample.map); if (!map) { map = { game: sample.game, map: sample.map, full: 0, capacityObserved: 0, ...emptyUtilization() }; game.set(sample.map, map); } addObservation(summary, sample.players, hours); addObservation(server, sample.players, hours); addObservation(map, sample.players, hours); for (const slice of slices) addObservation(slice.point, sample.players, slice.hours); if (sample.players === null) counts.unknown++; else { counts.known++; counts.players += sample.players; 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 full = known && sample.players! >= sample.capacity!; const previous = pressure.get(map); pressure.set(map, { known: known && (previous?.known ?? true), full: full && (previous?.full ?? true) }); } for (const [map, state] of pressure) { if (state.known) { map.capacityObserved += hours; if (state.full) map.full += hours; } } 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; for (const [at, server, game, map, players, capacity] of samples) { if (batch.length && at !== batch[0].at) { aggregateBatch(at); batch = []; } batch.push({ at, server, game, map, players, capacity }); } 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 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 }; }