Add Metrics tab with historical server utilization and map capacity

This commit is contained in:
2026-09-20 21:58:08 -04:00
parent 3c8b5b2e40
commit 3a63e6d3ce
9 changed files with 304 additions and 4 deletions
+10
View File
@@ -560,3 +560,13 @@ BEGIN
WHEN NEW.restart_state = 'manual' THEN 'Manual restart required'
WHEN NEW.restart_state = 'held' THEN 'Changes held' ELSE NEW.restart_state END || CASE WHEN NEW.error IS NOT NULL THEN ' — ' || NEW.error ELSE '' END);
END;
-- Historical occupancy survives server removal; no player identities are stored.
CREATE TABLE IF NOT EXISTS metric_samples (
at INTEGER NOT NULL,
server TEXT NOT NULL,
game TEXT NOT NULL,
map TEXT NOT NULL,
players INTEGER,
capacity INTEGER,
PRIMARY KEY (at, server)
);
+78
View File
@@ -0,0 +1,78 @@
import { db } from '../db/index.js';
import { ONLINE_MAX_MS } from '../../server-freshness.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);
handle.prepare('DELETE FROM metric_samples WHERE at < ?').run(at - 31 * 86400_000);
})();
}
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;
}
/** 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()) {
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 })[] = [];
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 });
}
return { days, since, now, summary, servers, maps, points };
}
+7
View File
@@ -310,6 +310,7 @@ export async function rconGetPlayerCount(server: string): Promise<number | null>
export interface RconStatus {
players: number;
capacity?: number | null;
map: string | null;
gametype: string | null;
roster: RconPlayer[];
@@ -437,6 +438,7 @@ export async function rconStatusResult(server: string): Promise<RconStatusResult
// Prefer statusResponse `\info\` line values when present, fall
// back to print-body `key: value` header lines otherwise.
players,
capacity: positiveCapacity(info.sv_maxclients ?? info.sv_maxClients ?? header.sv_maxclients ?? header.sv_maxClients),
map: info.mapname ?? info.sv_mapname ?? header.map ?? header.mapname ?? header.sv_mapname ?? null,
gametype: info.g_gametype ?? header.g_gametype ?? header.gametype ?? null,
roster: parsePlayerRoster(text)
@@ -444,3 +446,8 @@ export async function rconStatusResult(server: string): Promise<RconStatusResult
rawReply: formatRawReply(send.buf)
};
}
function positiveCapacity(value: string | undefined): number | null {
const count = Number(value);
return Number.isInteger(count) && count > 0 ? count : null;
}
+14
View File
@@ -11,6 +11,8 @@ import { restartSettling } from './restart-rollout.js';
import { classifyHealth } from '../fleet/health.js';
import { isProvisioning } from '../fleet/provisioning.js';
import { STATUS_REFRESH_MS } from '../../server-freshness.js';
import { recordMetrics } from '../fleet/metrics.js';
import { selectedZombieMap } from '../../map-aliases.js';
const TICK_MS = 15_000;
// Cap concurrent UDP RCON calls so a 15-server fleet doesn't blast the
@@ -68,6 +70,7 @@ async function refreshStatus(): Promise<void> {
if (servers.length === 0) return;
const entries: ServerStatusEntry[] = new Array(servers.length);
const capacities = new Map<string, number | null>();
let cursor = 0;
async function pump(): Promise<void> {
@@ -119,6 +122,7 @@ async function refreshStatus(): Promise<void> {
const now = new Date().toISOString();
if (result.ok) {
capacities.set(srv.name, result.status.capacity ?? null);
// Compare against the previously persisted count BEFORE we
// overwrite last_players — occupancy transitions are anchored
// to that boundary.
@@ -203,6 +207,16 @@ async function refreshStatus(): Promise<void> {
}
await Promise.all(Array.from({ length: STATUS_CONCURRENCY }, pump));
try {
recordMetrics(servers.map((srv, i) => ({
server: srv.name,
game: srv.game ?? 'unknown',
map: selectedZombieMap(srv.game, entries[i].map ?? srv.map, srv.map_slug)?.friendly ?? entries[i].map ?? srv.map ?? 'Unknown map',
players: capacities.has(srv.name) ? entries[i].players : null,
capacity: capacities.get(srv.name) ?? null
})));
} catch (err) { console.error('[worker] metrics error:', err); }
emit({ type: 'server-status', entries });
}