diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index ed858d9..249c7a0 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -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 { diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index afcfca5..64215a0 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -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')) ); diff --git a/src/lib/server/fleet/match-log.ts b/src/lib/server/fleet/match-log.ts index 8fcc8fb..f8ebf7f 100644 --- a/src/lib/server/fleet/match-log.ts +++ b/src/lib/server/fleet/match-log.ts @@ -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 { + 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 +} diff --git a/src/lib/server/fleet/servers.ts b/src/lib/server/fleet/servers.ts index e684045..2d09526 100644 --- a/src/lib/server/fleet/servers.ts +++ b/src/lib/server/fleet/servers.ts @@ -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); diff --git a/src/lib/server/pipeline/bus.ts b/src/lib/server/pipeline/bus.ts index be0693f..b408b3b 100644 --- a/src/lib/server/pipeline/bus.ts +++ b/src/lib/server/pipeline/bus.ts @@ -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 = diff --git a/src/lib/server/pipeline/worker.ts b/src/lib/server/pipeline/worker.ts index 639e7bd..014348a 100644 --- a/src/lib/server/pipeline/worker.ts +++ b/src/lib/server/pipeline/worker.ts @@ -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 { } 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 { + const servers = listServers(); + if (servers.length === 0) return; + + let cursor = 0; + async function pump(): Promise { + 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 { 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 { 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 { 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 { 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 }; } } diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index c676de4..ba8716d 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -1,7 +1,9 @@ @@ -77,6 +97,28 @@ {/if} + {#if showTiming} +
+ {#if activeElapsed} + + Active + {activeElapsed} + + {:else if inactiveElapsed} + + Inactive + {inactiveElapsed} + + {/if} + {#if gameElapsed} + + Game + {gameElapsed} + + {/if} +
+ {/if} + {#if showRoster} {#if topRoster.length > 0}
    @@ -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); diff --git a/src/lib/ui/duration.ts b/src/lib/ui/duration.ts new file mode 100644 index 0000000..84b9aa8 --- /dev/null +++ b/src/lib/ui/duration.ts @@ -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()); +} diff --git a/src/lib/ui/stores/servers.ts b/src/lib/ui/stores/servers.ts index 1604196..5e68888 100644 --- a/src/lib/ui/stores/servers.ts +++ b/src/lib/ui/stores/servers.ts @@ -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>(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 | 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); + } } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 8ad6e28..2c0580d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -21,6 +21,10 @@ health: Health; endpoint: { host: string; port: number } | null; held: HeldRow[]; + active_since: string | null; + inactive_since: string | null; + game_started_at: string | null; + game_ended_at: string | null; } let servers = $state([]); @@ -47,7 +51,11 @@ roster: fallback.roster, health: fallback.health, lastSeenAt: fallback.last_seen_at, - lastError: fallback.last_error + lastError: fallback.last_error, + activeSince: fallback.active_since, + inactiveSince: fallback.inactive_since, + gameStartedAt: fallback.game_started_at, + gameEndedAt: fallback.game_ended_at }; } return { @@ -57,7 +65,11 @@ roster: live.roster, health: live.health, lastSeenAt: live.lastSeenAt, - lastError: live.lastError + lastError: live.lastError, + activeSince: live.activeSince, + inactiveSince: live.inactiveSince, + gameStartedAt: live.gameStartedAt, + gameEndedAt: live.gameEndedAt }; } @@ -91,6 +103,10 @@ lastSeenAt={live.lastSeenAt} lastError={live.lastError} held={s.held} + activeSince={live.activeSince} + inactiveSince={live.inactiveSince} + gameStartedAt={live.gameStartedAt} + gameEndedAt={live.gameEndedAt} /> {/each} diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index 4365a40..c21ce7d 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -2,8 +2,9 @@ import { onMount, onDestroy } from 'svelte'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; - import { statusByServer } from '$ui/stores/servers.js'; + import { statusByServer, uiNow } from '$ui/stores/servers.js'; import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js'; + import { elapsedSince } from '$ui/duration.js'; import StatusDot from '$ui/components/StatusDot.svelte'; import PlayerRoster from '$ui/components/PlayerRoster.svelte'; import LogPanel from '$ui/components/LogPanel.svelte'; @@ -78,6 +79,10 @@ endpoint: RconEndpoint | null; replySnippet: string | null; held: Array<{ target: string; betaId: number }>; + active_since: string | null; + inactive_since: string | null; + game_started_at: string | null; + game_ended_at: string | null; } interface InventoryEntry { relPath: string; @@ -159,6 +164,29 @@ rconProbe?.rawReply ?? live?.replySnippet ?? server?.replySnippet ?? null ); + // Occupancy + match timers — live SSE first, DB row as fallback so the + // display works on initial load before the first SSE event arrives. + let effectiveActiveSince = $derived( + live?.activeSince ?? server?.active_since ?? null + ); + let effectiveInactiveSince = $derived( + live?.inactiveSince ?? server?.inactive_since ?? null + ); + let effectiveGameStartedAt = $derived( + live?.gameStartedAt ?? server?.game_started_at ?? null + ); + let effectiveGameEndedAt = $derived( + live?.gameEndedAt ?? server?.game_ended_at ?? null + ); + let activeElapsed = $derived(effectiveActiveSince ? elapsedSince(effectiveActiveSince, $uiNow) : null); + let inactiveElapsed = $derived(effectiveInactiveSince ? elapsedSince(effectiveInactiveSince, $uiNow) : null); + let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt); + let gameElapsed = $derived(gameInProgress ? elapsedSince(effectiveGameStartedAt, $uiNow) : null); + let showTiming = $derived( + effectiveHealth !== 'offline' && + (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null) + ); + function ago(iso: string | null): string { if (!iso) return 'never'; const d = Date.now() - new Date(iso).getTime(); @@ -321,6 +349,27 @@ last seen {ago(effectiveSeen)} {/if} + {#if showTiming} +
    + {#if activeElapsed} +
    +
    Active
    +
    {activeElapsed}
    +
    + {:else if inactiveElapsed} +
    +
    Inactive
    +
    {inactiveElapsed}
    +
    + {/if} + {#if gameElapsed} +
    +
    Game
    +
    {gameElapsed}
    +
    + {/if} +
    + {/if} {#if effectiveHealth !== 'online' && integrations} @@ -677,6 +726,32 @@ .v.health-stale { color: var(--amber-500); } .v.health-offline { color: var(--danger-500); } + .hero-timing { + display: flex; + flex-wrap: wrap; + gap: 32px; + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid var(--border); + } + .tblock { display: flex; flex-direction: column; gap: 4px; } + .tlabel { + font-family: var(--font-body); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--ink-500); + } + .tvalue { + font-family: var(--font-mono); + font-size: var(--text-lg); + color: var(--cream); + letter-spacing: 0.5px; + } + .tblock.active .tvalue { color: var(--spark-500); } + .tblock.inactive .tvalue { color: var(--ink-300); } + .tblock.game .tvalue { color: var(--accent, var(--spark-500)); } + .panel { background: var(--bg-nav); border: 1px solid var(--border);