Add active/inactive/game-time timers to fleet + server pages

Fleet cards and the per-server detail hero now show three live-ticking
timers: Active (since players > 0), Inactive (since server emptied), and
Game (since InitGame in games_sp.log, back-computed from the log's own
MM:SS prefix so it stays accurate across restarts). Anchors are
persisted on `servers` and fanned out via the SSE bus; a single 1s
ticker feeds every card. Idle map cycles are filtered out so the game
timer doesn't flicker.
This commit is contained in:
2026-09-13 21:45:54 -04:00
parent 79b2ba1464
commit 3d111880d3
11 changed files with 403 additions and 13 deletions
+6 -1
View File
@@ -37,7 +37,12 @@ const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [
{ name: 'game_id', ddl: 'ALTER TABLE servers ADD COLUMN game_id TEXT' },
{ name: 'image', ddl: 'ALTER TABLE servers ADD COLUMN image TEXT' },
{ name: 'created_by', ddl: 'ALTER TABLE servers ADD COLUMN created_by TEXT' },
{ name: 'destroyed_at', ddl: 'ALTER TABLE servers ADD COLUMN destroyed_at TEXT' }
{ name: 'destroyed_at', ddl: 'ALTER TABLE servers ADD COLUMN destroyed_at TEXT' },
// Occupancy + match timers — see schema.sql for semantics.
{ name: 'active_since', ddl: 'ALTER TABLE servers ADD COLUMN active_since TEXT' },
{ name: 'inactive_since', ddl: 'ALTER TABLE servers ADD COLUMN inactive_since TEXT' },
{ name: 'game_started_at', ddl: 'ALTER TABLE servers ADD COLUMN game_started_at TEXT' },
{ name: 'game_ended_at', ddl: 'ALTER TABLE servers ADD COLUMN game_ended_at TEXT' }
];
function migrateServers(handle: Database.Database): void {
+8
View File
@@ -32,6 +32,14 @@ CREATE TABLE IF NOT EXISTS servers (
image TEXT, -- e.g. 'ghcr.io/ayymoss/plutainer:latest'
created_by TEXT, -- 'ui' | operator identifier
destroyed_at TEXT, -- ISO ts when tombstoned (row usually deleted; kept as safety net)
-- Occupancy + match timers. active_since / inactive_since are mutually
-- exclusive: exactly one is non-null while a server is reachable. RCON
-- outages do NOT clear either — timers survive transient unreachability
-- and resume from the same anchor when the server comes back.
active_since TEXT, -- ISO ts of last 0 -> >0 player transition
inactive_since TEXT, -- ISO ts of last >0 -> 0 player transition
game_started_at TEXT, -- ISO ts we first observed InitGame: for the current match
game_ended_at TEXT, -- ISO ts we observed ExitLevel/ShutdownGame ending it
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
+87
View File
@@ -11,6 +11,12 @@ import { resolveLogPath, tailLog } from './logs.js';
// would kill the container before ExitLevel fires and take RCON with it).
export const MATCH_START = /InitGame:/;
export const MATCH_END = /ExitLevel:\s*executed/;
export const MATCH_SHUTDOWN = /ShutdownGame:/;
// Every log line starts with a whitespace-padded MM:SS in-game time. Capture
// it so we can back-compute a wall-clock game_started_at from the most recent
// line — this stays accurate across process restarts and matches what the
// game itself reports (e.g. `83:55 ExitLevel: executed`).
const TIME_PREFIX = /^\s*(\d+):(\d{2})\s/;
/**
* Poll a server's games_sp.log tail for match markers. Returns whether a
@@ -36,3 +42,84 @@ export async function scanForMatchEnd(
}
return { ended, started };
}
export type MatchScanKind = 'no-signal' | 'idle' | 'in-progress' | 'ended';
export interface MatchScanResult {
kind: MatchScanKind;
// Populated when kind === 'in-progress': wall-clock ISO of the InitGame moment,
// back-computed from the most recent line's MM:SS prefix so it stays accurate
// across restarts and matches the log's own timer.
startedAt?: string;
// Populated when kind === 'ended': wall-clock ISO of observation.
endedAt?: string;
}
/**
* Read the games_sp.log tail and classify the current match state:
* - `in-progress`: the most recent InitGame in the tail has no ShutdownGame
* or ExitLevel after it. Emits `startedAt` = now - MM:SS_of_last_line.
* - `ended`: the most recent InitGame is followed by ExitLevel/ShutdownGame,
* and the shutdown wasn't the trivial 0:00 idle cycle. Emits `endedAt` = now.
* - `idle`: the visible cycle was an idle map load (adjacent InitGame ->
* ShutdownGame at 0:00 with no ExitLevel between them). No timer.
* - `no-signal`: log file missing / no InitGame anywhere in the tail —
* caller should leave persisted timer state alone.
*/
export async function scanMatchState(serverName: string): Promise<MatchScanResult> {
const path = resolveLogPath(serverName, 'games_sp');
if (!path) return { kind: 'no-signal' };
const tail = await tailLog(path, 2000);
if (!tail || tail.lines.length === 0) return { kind: 'no-signal' };
const lines = tail.lines;
let lastInit = -1;
let lastExit = -1;
let lastShut = -1;
for (let i = 0; i < lines.length; i++) {
if (MATCH_START.test(lines[i])) lastInit = i;
if (MATCH_END.test(lines[i])) lastExit = i;
if (MATCH_SHUTDOWN.test(lines[i])) lastShut = i;
}
if (lastInit === -1) return { kind: 'no-signal' };
const endAfterInit = Math.max(lastExit, lastShut) > lastInit;
if (endAfterInit) {
// Was it an idle cycle? Idle = ShutdownGame at 0:00 with no ExitLevel
// between the InitGame and that ShutdownGame.
const exitBetween = lastExit > lastInit && lastExit < lastShut;
const shutIsIdle = !exitBetween && lastShut > lastInit && isZeroTime(lines[lastShut]);
if (shutIsIdle) return { kind: 'idle' };
return { kind: 'ended', endedAt: new Date().toISOString() };
}
// In progress. Back-compute the wall-clock start from the most recent
// line's MM:SS prefix. Walk backwards from the tail to find the last line
// with a parseable time — falls back to the InitGame line itself (0:00).
let elapsedMs = 0;
for (let i = lines.length - 1; i > lastInit; i--) {
const t = parseTimePrefix(lines[i]);
if (t !== null) {
elapsedMs = t;
break;
}
}
const startedAt = new Date(Date.now() - elapsedMs).toISOString();
return { kind: 'in-progress', startedAt };
}
function parseTimePrefix(line: string): number | null {
const m = TIME_PREFIX.exec(line);
if (!m) return null;
const mm = Number(m[1]);
const ss = Number(m[2]);
if (!Number.isFinite(mm) || !Number.isFinite(ss)) return null;
return (mm * 60 + ss) * 1000;
}
function isZeroTime(line: string): boolean {
const t = parseTimePrefix(line);
return t !== null && t < 2000; // <2s — idle cycles are at 0:00
}
+9 -1
View File
@@ -84,6 +84,10 @@ export interface ServerRow {
image: string | null;
created_by: string | null;
destroyed_at: string | null;
active_since: string | null;
inactive_since: string | null;
game_started_at: string | null;
game_ended_at: string | null;
created_at: string;
}
@@ -169,7 +173,11 @@ export function setServerField(
'game_id',
'image',
'created_by',
'destroyed_at'
'destroyed_at',
'active_since',
'inactive_since',
'game_started_at',
'game_ended_at'
];
if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`);
db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name);
+4
View File
@@ -18,6 +18,10 @@ export interface ServerStatusEntry {
endpoint: RconEndpoint | null;
reason: RconFailure | null;
replySnippet: string | null;
activeSince: string | null;
inactiveSince: string | null;
gameStartedAt: string | null;
gameEndedAt: string | null;
}
export type BetaStage =
+87 -6
View File
@@ -6,6 +6,7 @@ import { rconStatusResult } from '../integrations/rcon.js';
import type { RconFailure } from '../integrations/rcon.js';
import { listServers, seedServerRegistry, setServerField } from '../fleet/servers.js';
import { classifyHealth } from '../fleet/health.js';
import { scanMatchState } from '../fleet/match-log.js';
const TICK_MS = 15_000;
const STATUS_REFRESH_MS = 30_000;
@@ -28,6 +29,58 @@ async function tick(): Promise<void> {
} catch (err) {
console.error('[worker] beta-runner error:', err);
}
try {
await scanMatches();
} catch (err) {
console.error('[worker] match-scan error:', err);
}
}
/**
* Read every server's games_sp.log tail to advance game_started_at /
* game_ended_at. Concurrency-capped so 15 servers don't hammer disk at once.
*/
async function scanMatches(): Promise<void> {
const servers = listServers();
if (servers.length === 0) return;
let cursor = 0;
async function pump(): Promise<void> {
while (true) {
const i = cursor++;
if (i >= servers.length) return;
const srv = servers[i];
const result = await scanMatchState(srv.name).catch((err) => {
console.error(`[worker] scanMatchState(${srv.name}) failed:`, err);
return { kind: 'no-signal' as const };
});
const persistedInProgress = !!srv.game_started_at && !srv.game_ended_at;
if (result.kind === 'in-progress' && result.startedAt) {
// Transition to in-progress on fresh state OR when a new match
// has begun after a crash left us with a stale in-progress
// anchor. The back-computed startedAt for the SAME match stays
// stable across polls (log time and wall time advance in
// lockstep), so a new anchor >30s later means a new match.
const newStart = new Date(result.startedAt).getTime();
const persistedStart = srv.game_started_at
? new Date(srv.game_started_at).getTime()
: 0;
if (!persistedInProgress || newStart > persistedStart + 30_000) {
setServerField(srv.name, 'game_started_at', result.startedAt);
setServerField(srv.name, 'game_ended_at', null);
}
} else if (result.kind === 'ended') {
if (persistedInProgress && result.endedAt) {
setServerField(srv.name, 'game_ended_at', result.endedAt);
}
}
// 'idle' / 'no-signal': leave state alone. If we were previously
// in-progress and the log has rolled past the match, we'll only
// learn that later when a new InitGame appears — that's fine.
}
}
await Promise.all(Array.from({ length: STATUS_CONCURRENCY }, pump));
}
/**
@@ -61,8 +114,26 @@ async function fanoutStatus(): Promise<void> {
const now = new Date().toISOString();
if (result.ok) {
// Compare against the previously persisted count BEFORE we
// overwrite last_players — occupancy transitions are anchored
// to that boundary.
const prevPlayers = srv.last_players;
const nextPlayers = result.status.players;
let activeSince = srv.active_since;
let inactiveSince = srv.inactive_since;
if (nextPlayers > 0 && (activeSince === null || prevPlayers === 0 || prevPlayers === null)) {
activeSince = now;
inactiveSince = null;
setServerField(srv.name, 'active_since', activeSince);
setServerField(srv.name, 'inactive_since', null);
} else if (nextPlayers === 0 && (inactiveSince === null || (prevPlayers !== null && prevPlayers > 0))) {
inactiveSince = now;
activeSince = null;
setServerField(srv.name, 'inactive_since', inactiveSince);
setServerField(srv.name, 'active_since', null);
}
// Persist last-observed state. last_seen_at is the health anchor.
setServerField(srv.name, 'last_players', result.status.players);
setServerField(srv.name, 'last_players', nextPlayers);
setServerField(srv.name, 'last_map', result.status.map);
setServerField(srv.name, 'last_gametype', result.status.gametype);
setServerField(srv.name, 'last_roster', JSON.stringify(result.status.roster));
@@ -72,7 +143,7 @@ async function fanoutStatus(): Promise<void> {
setServerField(srv.name, 'last_reply_snippet', result.rawReply);
entries[i] = {
server: srv.name,
players: result.status.players,
players: nextPlayers,
map: result.status.map,
gametype: result.status.gametype,
roster: result.status.roster,
@@ -81,11 +152,17 @@ async function fanoutStatus(): Promise<void> {
lastError: null,
endpoint: result.endpoint,
reason: null,
replySnippet: result.rawReply
replySnippet: result.rawReply,
activeSince,
inactiveSince,
gameStartedAt: srv.game_started_at,
gameEndedAt: srv.game_ended_at
};
} else {
// RCON unreachable — leave last_seen_at alone (health decays
// to offline naturally), record the structured failure.
// RCON unreachable — leave last_seen_at, active_since, and
// inactive_since alone (health decays to offline naturally;
// occupancy timers survive transient outages). Record the
// structured failure.
const encoded = encodeError(result.reason, result.detail);
const snippet = result.rawReply ?? null;
setServerField(srv.name, 'last_checked_at', now);
@@ -102,7 +179,11 @@ async function fanoutStatus(): Promise<void> {
lastError: encoded,
endpoint: result.endpoint,
reason: result.reason,
replySnippet: snippet
replySnippet: snippet,
activeSince: srv.active_since,
inactiveSince: srv.inactive_since,
gameStartedAt: srv.game_started_at,
gameEndedAt: srv.game_ended_at
};
}
}