Improve metrics clarity, exploration, and refresh reliability
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { ONLINE_MAX_MS } from '../../server-freshness.js';
|
||||
import type { MapMetrics, MetricPoint, MetricsData, MetricsDays, ServerMetrics, Utilization } from '../../metrics.js';
|
||||
|
||||
export type { MapMetrics, Utilization } from '../../metrics.js';
|
||||
|
||||
export interface MetricSample {
|
||||
server: string;
|
||||
@@ -18,61 +21,105 @@ export function recordMetrics(samples: MetricSample[], at = Date.now()): void {
|
||||
})();
|
||||
}
|
||||
|
||||
export interface Utilization {
|
||||
observed: number;
|
||||
active: number;
|
||||
idle: number;
|
||||
unknown: number;
|
||||
playerHours: number;
|
||||
}
|
||||
export interface MapMetrics extends Utilization {
|
||||
game: string;
|
||||
map: string;
|
||||
full: number;
|
||||
capacityObserved: number;
|
||||
function emptyUtilization(): Utilization {
|
||||
return { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
|
||||
}
|
||||
|
||||
/** Treat each observation as valid until the next poll, capped at the freshness limit.
|
||||
* Longer collection gaps are excluded, never interpreted as empty servers. */
|
||||
export function readMetrics(days: number, now = Date.now()) {
|
||||
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(days: MetricsDays, now = Date.now()): MetricsData {
|
||||
const since = now - days * 86400_000;
|
||||
const bucketMs = days === 1 ? 3600_000 : 86400_000;
|
||||
const handle = db();
|
||||
const cte = `WITH times AS (
|
||||
SELECT at, LEAD(at, 1, @now) OVER (ORDER BY at) AS next_at
|
||||
FROM (SELECT DISTINCT at FROM metric_samples WHERE at >= @since - @fresh AND at <= @now)
|
||||
), samples AS (
|
||||
SELECT s.*, MAX(0, MIN(t.next_at, s.at + @fresh, @now) - MAX(s.at, @since)) / 3600000.0 AS hours
|
||||
FROM metric_samples s JOIN times t ON t.at = s.at
|
||||
)`;
|
||||
const params = { now, since, fresh: ONLINE_MAX_MS };
|
||||
const totals = `COALESCE(SUM(CASE WHEN players IS NOT NULL THEN hours ELSE 0 END), 0) AS observed,
|
||||
COALESCE(SUM(CASE WHEN players > 0 THEN hours ELSE 0 END), 0) AS active,
|
||||
COALESCE(SUM(CASE WHEN players = 0 THEN hours ELSE 0 END), 0) AS idle,
|
||||
COALESCE(SUM(CASE WHEN players IS NULL THEN hours ELSE 0 END), 0) AS unknown,
|
||||
COALESCE(SUM(players * hours), 0) AS playerHours`;
|
||||
const summary = handle.prepare(`${cte} SELECT ${totals} FROM samples`).get(params) as Utilization;
|
||||
const servers = handle.prepare(`${cte} SELECT server, ${totals} FROM samples GROUP BY server ORDER BY active DESC, server`).all(params) as (Utilization & { server: string })[];
|
||||
const maps = handle.prepare(`${cte}, pressure AS (
|
||||
SELECT game, map, at, MAX(hours) AS hours,
|
||||
MIN(CASE WHEN players IS NOT NULL AND capacity > 0 THEN 1 ELSE 0 END) AS known,
|
||||
MIN(CASE WHEN players >= capacity AND capacity > 0 THEN 1 ELSE 0 END) AS full
|
||||
FROM samples GROUP BY game, map, at
|
||||
), map_pressure AS (
|
||||
SELECT game, map, SUM(CASE WHEN known = 1 THEN hours ELSE 0 END) AS capacityObserved,
|
||||
SUM(CASE WHEN known = 1 AND full = 1 THEN hours ELSE 0 END) AS full
|
||||
FROM pressure GROUP BY game, map
|
||||
) SELECT s.game, s.map, ${totals}, p.full, p.capacityObserved
|
||||
FROM samples s JOIN map_pressure p ON p.game = s.game AND p.map = s.map
|
||||
GROUP BY s.game, s.map ORDER BY playerHours DESC, s.map`).all(params) as MapMetrics[];
|
||||
// Split intervals at chart boundaries so a midnight poll cannot shift time into the wrong day.
|
||||
const points: (Utilization & { at: number })[] = [];
|
||||
const metadata = handle.prepare(`SELECT
|
||||
(SELECT MIN(at) FROM metric_samples WHERE at <= @now) AS firstRetainedSampleAt,
|
||||
(SELECT MAX(at) FROM metric_samples WHERE at <= @now) AS latestSampleAt,
|
||||
(SELECT COUNT(*) FROM servers WHERE destroyed_at IS NULL) AS registeredServerCount`).get({ now }) as
|
||||
Pick<MetricsData, 'firstRetainedSampleAt' | 'latestSampleAt' | 'registeredServerCount'>;
|
||||
const summary = emptyUtilization();
|
||||
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);
|
||||
const point = handle.prepare(`${cte} SELECT ${totals} FROM samples`)
|
||||
.get({ ...params, since: start, now: end }) as Utilization;
|
||||
points.push({ at, ...point });
|
||||
points.push({ at, startAt: start, endAt: end, partial: start !== at || end !== at + bucketMs, ...emptyUtilization() });
|
||||
}
|
||||
return { days, since, now, summary, servers, maps, points };
|
||||
|
||||
type RecordedSample = MetricSample & { at: number };
|
||||
let batch: RecordedSample[] = [];
|
||||
function aggregateBatch(nextAt: number): void {
|
||||
if (!batch.length) return;
|
||||
const start = Math.max(batch[0].at, since);
|
||||
const end = Math.min(nextAt, batch[0].at + ONLINE_MAX_MS, now);
|
||||
const hours = Math.max(0, end - start) / 3600_000;
|
||||
const pressure = new Map<MapMetrics, { known: boolean; full: boolean }>();
|
||||
// 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 batch) {
|
||||
let server = serverTotals.get(sample.server);
|
||||
if (!server) {
|
||||
server = { server: 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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(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);
|
||||
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));
|
||||
return { days, since, now, ...metadata, summary, servers, maps, points };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user