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:
@@ -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 {
|
||||
|
||||
@@ -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'))
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user