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: 'game_id', ddl: 'ALTER TABLE servers ADD COLUMN game_id TEXT' },
|
||||||
{ name: 'image', ddl: 'ALTER TABLE servers ADD COLUMN image TEXT' },
|
{ name: 'image', ddl: 'ALTER TABLE servers ADD COLUMN image TEXT' },
|
||||||
{ name: 'created_by', ddl: 'ALTER TABLE servers ADD COLUMN created_by 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 {
|
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'
|
image TEXT, -- e.g. 'ghcr.io/ayymoss/plutainer:latest'
|
||||||
created_by TEXT, -- 'ui' | operator identifier
|
created_by TEXT, -- 'ui' | operator identifier
|
||||||
destroyed_at TEXT, -- ISO ts when tombstoned (row usually deleted; kept as safety net)
|
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'))
|
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).
|
// would kill the container before ExitLevel fires and take RCON with it).
|
||||||
export const MATCH_START = /InitGame:/;
|
export const MATCH_START = /InitGame:/;
|
||||||
export const MATCH_END = /ExitLevel:\s*executed/;
|
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
|
* 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 };
|
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;
|
image: string | null;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
destroyed_at: 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;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +173,11 @@ export function setServerField(
|
|||||||
'game_id',
|
'game_id',
|
||||||
'image',
|
'image',
|
||||||
'created_by',
|
'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}`);
|
if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`);
|
||||||
db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name);
|
db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name);
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export interface ServerStatusEntry {
|
|||||||
endpoint: RconEndpoint | null;
|
endpoint: RconEndpoint | null;
|
||||||
reason: RconFailure | null;
|
reason: RconFailure | null;
|
||||||
replySnippet: string | null;
|
replySnippet: string | null;
|
||||||
|
activeSince: string | null;
|
||||||
|
inactiveSince: string | null;
|
||||||
|
gameStartedAt: string | null;
|
||||||
|
gameEndedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BetaStage =
|
export type BetaStage =
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { rconStatusResult } from '../integrations/rcon.js';
|
|||||||
import type { RconFailure } from '../integrations/rcon.js';
|
import type { RconFailure } from '../integrations/rcon.js';
|
||||||
import { listServers, seedServerRegistry, setServerField } from '../fleet/servers.js';
|
import { listServers, seedServerRegistry, setServerField } from '../fleet/servers.js';
|
||||||
import { classifyHealth } from '../fleet/health.js';
|
import { classifyHealth } from '../fleet/health.js';
|
||||||
|
import { scanMatchState } from '../fleet/match-log.js';
|
||||||
|
|
||||||
const TICK_MS = 15_000;
|
const TICK_MS = 15_000;
|
||||||
const STATUS_REFRESH_MS = 30_000;
|
const STATUS_REFRESH_MS = 30_000;
|
||||||
@@ -28,6 +29,58 @@ async function tick(): Promise<void> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[worker] beta-runner error:', 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();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
if (result.ok) {
|
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.
|
// 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_map', result.status.map);
|
||||||
setServerField(srv.name, 'last_gametype', result.status.gametype);
|
setServerField(srv.name, 'last_gametype', result.status.gametype);
|
||||||
setServerField(srv.name, 'last_roster', JSON.stringify(result.status.roster));
|
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);
|
setServerField(srv.name, 'last_reply_snippet', result.rawReply);
|
||||||
entries[i] = {
|
entries[i] = {
|
||||||
server: srv.name,
|
server: srv.name,
|
||||||
players: result.status.players,
|
players: nextPlayers,
|
||||||
map: result.status.map,
|
map: result.status.map,
|
||||||
gametype: result.status.gametype,
|
gametype: result.status.gametype,
|
||||||
roster: result.status.roster,
|
roster: result.status.roster,
|
||||||
@@ -81,11 +152,17 @@ async function fanoutStatus(): Promise<void> {
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
endpoint: result.endpoint,
|
endpoint: result.endpoint,
|
||||||
reason: null,
|
reason: null,
|
||||||
replySnippet: result.rawReply
|
replySnippet: result.rawReply,
|
||||||
|
activeSince,
|
||||||
|
inactiveSince,
|
||||||
|
gameStartedAt: srv.game_started_at,
|
||||||
|
gameEndedAt: srv.game_ended_at
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// RCON unreachable — leave last_seen_at alone (health decays
|
// RCON unreachable — leave last_seen_at, active_since, and
|
||||||
// to offline naturally), record the structured failure.
|
// 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 encoded = encodeError(result.reason, result.detail);
|
||||||
const snippet = result.rawReply ?? null;
|
const snippet = result.rawReply ?? null;
|
||||||
setServerField(srv.name, 'last_checked_at', now);
|
setServerField(srv.name, 'last_checked_at', now);
|
||||||
@@ -102,7 +179,11 @@ async function fanoutStatus(): Promise<void> {
|
|||||||
lastError: encoded,
|
lastError: encoded,
|
||||||
endpoint: result.endpoint,
|
endpoint: result.endpoint,
|
||||||
reason: result.reason,
|
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">
|
<script lang="ts">
|
||||||
import StatusDot from './StatusDot.svelte';
|
import StatusDot from './StatusDot.svelte';
|
||||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||||
|
import { uiNow } from '$ui/stores/servers.js';
|
||||||
import { gametypeLabel } from '$ui/gametype.js';
|
import { gametypeLabel } from '$ui/gametype.js';
|
||||||
|
import { elapsedSince } from '$ui/duration.js';
|
||||||
|
|
||||||
interface Held {
|
interface Held {
|
||||||
target: string;
|
target: string;
|
||||||
@@ -19,6 +21,10 @@
|
|||||||
held?: Held[];
|
held?: Held[];
|
||||||
lastRestartedAt?: string | null;
|
lastRestartedAt?: string | null;
|
||||||
restarting?: boolean;
|
restarting?: boolean;
|
||||||
|
activeSince?: string | null;
|
||||||
|
inactiveSince?: string | null;
|
||||||
|
gameStartedAt?: string | null;
|
||||||
|
gameEndedAt?: string | null;
|
||||||
}
|
}
|
||||||
let {
|
let {
|
||||||
name,
|
name,
|
||||||
@@ -31,7 +37,11 @@
|
|||||||
lastError = null,
|
lastError = null,
|
||||||
held = [],
|
held = [],
|
||||||
lastRestartedAt = null,
|
lastRestartedAt = null,
|
||||||
restarting = false
|
restarting = false,
|
||||||
|
activeSince = null,
|
||||||
|
inactiveSince = null,
|
||||||
|
gameStartedAt = null,
|
||||||
|
gameEndedAt = null
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function ago(iso: string | null): string {
|
function ago(iso: string | null): string {
|
||||||
@@ -52,6 +62,16 @@
|
|||||||
let showRoster = $derived(health !== 'offline');
|
let showRoster = $derived(health !== 'offline');
|
||||||
let mapLabel = $derived(map ?? '—');
|
let mapLabel = $derived(map ?? '—');
|
||||||
let gtLabel = $derived(gametypeLabel(gametype));
|
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>
|
</script>
|
||||||
|
|
||||||
<a class="card" href="/servers/{name}" class:restarting class:offline={health === 'offline'}>
|
<a class="card" href="/servers/{name}" class:restarting class:offline={health === 'offline'}>
|
||||||
@@ -77,6 +97,28 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 showRoster}
|
||||||
{#if topRoster.length > 0}
|
{#if topRoster.length > 0}
|
||||||
<ul class="roster">
|
<ul class="roster">
|
||||||
@@ -244,6 +286,29 @@
|
|||||||
text-align: right;
|
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 {
|
.lobby-empty {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--text-mono-sm);
|
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';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
export interface RosterPlayer {
|
export interface RosterPlayer {
|
||||||
@@ -29,10 +29,21 @@ export interface ServerStatus {
|
|||||||
endpoint: RconEndpoint | null;
|
endpoint: RconEndpoint | null;
|
||||||
reason: RconFailure | null;
|
reason: RconFailure | null;
|
||||||
replySnippet: string | 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());
|
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;
|
let source: EventSource | null = null;
|
||||||
|
|
||||||
export function startServerStream(): void {
|
export function startServerStream(): void {
|
||||||
@@ -46,4 +57,7 @@ export function startServerStream(): void {
|
|||||||
return m;
|
return m;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if (tickInterval === null) {
|
||||||
|
tickInterval = setInterval(() => _uiNow.set(Date.now()), 1000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -21,6 +21,10 @@
|
|||||||
health: Health;
|
health: Health;
|
||||||
endpoint: { host: string; port: number } | null;
|
endpoint: { host: string; port: number } | null;
|
||||||
held: HeldRow[];
|
held: HeldRow[];
|
||||||
|
active_since: string | null;
|
||||||
|
inactive_since: string | null;
|
||||||
|
game_started_at: string | null;
|
||||||
|
game_ended_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let servers = $state<ServerRow[]>([]);
|
let servers = $state<ServerRow[]>([]);
|
||||||
@@ -47,7 +51,11 @@
|
|||||||
roster: fallback.roster,
|
roster: fallback.roster,
|
||||||
health: fallback.health,
|
health: fallback.health,
|
||||||
lastSeenAt: fallback.last_seen_at,
|
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 {
|
return {
|
||||||
@@ -57,7 +65,11 @@
|
|||||||
roster: live.roster,
|
roster: live.roster,
|
||||||
health: live.health,
|
health: live.health,
|
||||||
lastSeenAt: live.lastSeenAt,
|
lastSeenAt: live.lastSeenAt,
|
||||||
lastError: live.lastError
|
lastError: live.lastError,
|
||||||
|
activeSince: live.activeSince,
|
||||||
|
inactiveSince: live.inactiveSince,
|
||||||
|
gameStartedAt: live.gameStartedAt,
|
||||||
|
gameEndedAt: live.gameEndedAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -91,6 +103,10 @@
|
|||||||
lastSeenAt={live.lastSeenAt}
|
lastSeenAt={live.lastSeenAt}
|
||||||
lastError={live.lastError}
|
lastError={live.lastError}
|
||||||
held={s.held}
|
held={s.held}
|
||||||
|
activeSince={live.activeSince}
|
||||||
|
inactiveSince={live.inactiveSince}
|
||||||
|
gameStartedAt={live.gameStartedAt}
|
||||||
|
gameEndedAt={live.gameEndedAt}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
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 type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
|
||||||
|
import { elapsedSince } from '$ui/duration.js';
|
||||||
import StatusDot from '$ui/components/StatusDot.svelte';
|
import StatusDot from '$ui/components/StatusDot.svelte';
|
||||||
import PlayerRoster from '$ui/components/PlayerRoster.svelte';
|
import PlayerRoster from '$ui/components/PlayerRoster.svelte';
|
||||||
import LogPanel from '$ui/components/LogPanel.svelte';
|
import LogPanel from '$ui/components/LogPanel.svelte';
|
||||||
@@ -78,6 +79,10 @@
|
|||||||
endpoint: RconEndpoint | null;
|
endpoint: RconEndpoint | null;
|
||||||
replySnippet: string | null;
|
replySnippet: string | null;
|
||||||
held: Array<{ target: string; betaId: number }>;
|
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 {
|
interface InventoryEntry {
|
||||||
relPath: string;
|
relPath: string;
|
||||||
@@ -159,6 +164,29 @@
|
|||||||
rconProbe?.rawReply ?? live?.replySnippet ?? server?.replySnippet ?? null
|
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<string | null>(
|
||||||
|
live?.activeSince ?? server?.active_since ?? null
|
||||||
|
);
|
||||||
|
let effectiveInactiveSince = $derived<string | null>(
|
||||||
|
live?.inactiveSince ?? server?.inactive_since ?? null
|
||||||
|
);
|
||||||
|
let effectiveGameStartedAt = $derived<string | null>(
|
||||||
|
live?.gameStartedAt ?? server?.game_started_at ?? null
|
||||||
|
);
|
||||||
|
let effectiveGameEndedAt = $derived<string | null>(
|
||||||
|
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 {
|
function ago(iso: string | null): string {
|
||||||
if (!iso) return 'never';
|
if (!iso) return 'never';
|
||||||
const d = Date.now() - new Date(iso).getTime();
|
const d = Date.now() - new Date(iso).getTime();
|
||||||
@@ -321,6 +349,27 @@
|
|||||||
<span class="pair"><span class="k">last seen</span> <span class="v">{ago(effectiveSeen)}</span></span>
|
<span class="pair"><span class="k">last seen</span> <span class="v">{ago(effectiveSeen)}</span></span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if showTiming}
|
||||||
|
<div class="hero-timing">
|
||||||
|
{#if activeElapsed}
|
||||||
|
<div class="tblock active">
|
||||||
|
<div class="tlabel">Active</div>
|
||||||
|
<div class="tvalue">{activeElapsed}</div>
|
||||||
|
</div>
|
||||||
|
{:else if inactiveElapsed}
|
||||||
|
<div class="tblock inactive">
|
||||||
|
<div class="tlabel">Inactive</div>
|
||||||
|
<div class="tvalue">{inactiveElapsed}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if gameElapsed}
|
||||||
|
<div class="tblock game">
|
||||||
|
<div class="tlabel">Game</div>
|
||||||
|
<div class="tvalue">{gameElapsed}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if effectiveHealth !== 'online' && integrations}
|
{#if effectiveHealth !== 'online' && integrations}
|
||||||
@@ -677,6 +726,32 @@
|
|||||||
.v.health-stale { color: var(--amber-500); }
|
.v.health-stale { color: var(--amber-500); }
|
||||||
.v.health-offline { color: var(--danger-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 {
|
.panel {
|
||||||
background: var(--bg-nav);
|
background: var(--bg-nav);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
Reference in New Issue
Block a user