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:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user