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
};
}
}
+66 -1
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import StatusDot from './StatusDot.svelte';
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
import { uiNow } from '$ui/stores/servers.js';
import { gametypeLabel } from '$ui/gametype.js';
import { elapsedSince } from '$ui/duration.js';
interface Held {
target: string;
@@ -19,6 +21,10 @@
held?: Held[];
lastRestartedAt?: string | null;
restarting?: boolean;
activeSince?: string | null;
inactiveSince?: string | null;
gameStartedAt?: string | null;
gameEndedAt?: string | null;
}
let {
name,
@@ -31,7 +37,11 @@
lastError = null,
held = [],
lastRestartedAt = null,
restarting = false
restarting = false,
activeSince = null,
inactiveSince = null,
gameStartedAt = null,
gameEndedAt = null
}: Props = $props();
function ago(iso: string | null): string {
@@ -52,6 +62,16 @@
let showRoster = $derived(health !== 'offline');
let mapLabel = $derived(map ?? '—');
let gtLabel = $derived(gametypeLabel(gametype));
// Live-ticking occupancy + match timers. Anchored to server-issued
// ISO timestamps and re-derived from a shared 1s ticker.
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
let inactiveElapsed = $derived(inactiveSince ? elapsedSince(inactiveSince, $uiNow) : null);
let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt);
let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null);
let showTiming = $derived(
health !== 'offline' && (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
);
</script>
<a class="card" href="/servers/{name}" class:restarting class:offline={health === 'offline'}>
@@ -77,6 +97,28 @@
{/if}
</div>
{#if showTiming}
<div class="timing">
{#if activeElapsed}
<span class="timing-item active">
<span class="label">Active</span>
<span class="value">{activeElapsed}</span>
</span>
{:else if inactiveElapsed}
<span class="timing-item inactive">
<span class="label">Inactive</span>
<span class="value">{inactiveElapsed}</span>
</span>
{/if}
{#if gameElapsed}
<span class="timing-item game">
<span class="label">Game</span>
<span class="value">{gameElapsed}</span>
</span>
{/if}
</div>
{/if}
{#if showRoster}
{#if topRoster.length > 0}
<ul class="roster">
@@ -244,6 +286,29 @@
text-align: right;
}
.timing {
display: flex;
flex-wrap: wrap;
gap: 14px;
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
}
.timing-item {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.timing-item .label {
color: var(--ink-500);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
font-size: var(--text-xs);
}
.timing-item .value { color: var(--cream); }
.timing-item.active .value { color: var(--spark-500); }
.timing-item.inactive .value { color: var(--ink-300); }
.timing-item.game .value { color: var(--accent, var(--spark-500)); }
.lobby-empty {
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
+27
View File
@@ -0,0 +1,27 @@
/**
* Format an elapsed millisecond count as a compact human string:
* < 60s -> "12s"
* < 1h -> "5m 23s"
* >= 1h -> "2h 05m"
* Negatives clamp to 0s (clock skew tolerance).
*/
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) ms = 0;
const totalSeconds = Math.floor(ms / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}h ${String(mins).padStart(2, '0')}m`;
}
/**
* Convenience: elapsed since an ISO timestamp, formatted. Returns null when
* the anchor is null so callers can `{#if}` on it.
*/
export function elapsedSince(iso: string | null, now: number): string | null {
if (!iso) return null;
return formatDuration(now - new Date(iso).getTime());
}
+15 -1
View File
@@ -1,4 +1,4 @@
import { writable } from 'svelte/store';
import { writable, readonly } from 'svelte/store';
import { browser } from '$app/environment';
export interface RosterPlayer {
@@ -29,10 +29,21 @@ export interface ServerStatus {
endpoint: RconEndpoint | null;
reason: RconFailure | null;
replySnippet: string | null;
activeSince: string | null;
inactiveSince: string | null;
gameStartedAt: string | null;
gameEndedAt: string | null;
}
export const statusByServer = writable<Map<string, ServerStatus>>(new Map());
// Shared 1s ticker for elapsed-time displays. One interval feeds every card
// on the page instead of N per-card timers. Timer starts on the first
// startServerStream() call in the browser.
const _uiNow = writable(Date.now());
export const uiNow = readonly(_uiNow);
let tickInterval: ReturnType<typeof setInterval> | null = null;
let source: EventSource | null = null;
export function startServerStream(): void {
@@ -46,4 +57,7 @@ export function startServerStream(): void {
return m;
});
});
if (tickInterval === null) {
tickInterval = setInterval(() => _uiNow.set(Date.now()), 1000);
}
}