import type { FleetServer } from '../fleet-overview.js'; import { freshStatusTimestamp } from '../server-freshness.js'; import { decodeRconError } from './rconError.js'; import { statusPresentation } from './status.js'; export type ServerHealthState = | 'online' | 'host-offline' | 'not-responding' | 'stopped' | 'starting' | 'provisioning' | 'unknown'; export interface ServerHealthPresentation { state: ServerHealthState; label: string; reason: string; shortReason: string; actionLabel: string; actionHref: string; /** Last relevant evidence, not an inferred time when the state began. */ since: string | null; } function observedAt(value: string | null | undefined, now: number): string | null { const time = Date.parse(value ?? ''); return Number.isFinite(time) && time <= now + 1_000 ? value! : null; } /** Explain the available evidence without treating a missing report as a stopped game. */ export function serverHealth(server: FleetServer, now: number): ServerHealthPresentation { const evidence = healthEvidence(server, now); const error = decodeRconError(server.last_error); const connectionReason = error ? { 'no-config': 'Connection not configured.', 'no-port': 'Connection port not configured.', 'send-error': 'Game connection failed.', timeout: 'No reply to the status check.', 'bad-reply': 'Unreadable game response.', 'no-players': 'Player count not reported.' }[error.reason] : null; const shortReason = evidence.state === 'host-offline' ? `${server.host?.name ?? 'Host'} unavailable; game may still run.` : evidence.state === 'stopped' ? 'Container confirmed stopped.' : evidence.state === 'not-responding' ? (connectionReason ?? 'Game report out of date; players unknown.') : evidence.state === 'unknown' ? (connectionReason ?? (freshStatusTimestamp(server.last_seen_at, now) ? 'Game replied; container unverified.' : 'Game and container unverified.')) : evidence.reason; return { ...evidence, shortReason, label: statusPresentation(evidence.state).label }; } function healthEvidence( server: FleetServer, now: number ): Omit { const href = `/servers/${encodeURIComponent(server.name)}`; const diagnostics = `${href}?tab=diagnostics`; const host = server.host; const hostId = host?.id || server.host_id; const hostOffline = !!host && (host.connectionState === 'offline' || !host.connected || !freshStatusTimestamp(host.lastHeartbeat, now)); if (hostOffline) return { state: 'host-offline', reason: `The host agent${host.name ? ` on ${host.name}` : ''} is unavailable. The game may still be running.`, actionLabel: hostId ? 'Check host' : 'Open diagnostics', actionHref: hostId ? `/hosts/${encodeURIComponent(hostId)}` : diagnostics, since: observedAt(host.lastHeartbeat, now) }; if (server.health === 'provisioning') return { state: 'provisioning', reason: 'Server setup is in progress. It will be ready after its health checks pass.', actionLabel: 'View progress', actionHref: `${href}?tab=history`, since: observedAt(server.telemetry_after, now) }; if (server.health === 'starting') return { state: 'starting', reason: 'The game is starting. Waiting for a successful status report.', actionLabel: 'View progress', actionHref: `${href}?tab=history`, since: observedAt(server.telemetry_after, now) ?? observedAt(server.container_checked_at, now) }; const containerAt = observedAt(server.container_checked_at, now); const gameAt = observedAt(server.last_seen_at, now); // A last game check cannot renew a container observation. Reports predating // the most recent lifecycle submission describe the previous process. const containerCurrent = freshStatusTimestamp(containerAt, now) && (!server.telemetry_after || Date.parse(containerAt!) > Date.parse(server.telemetry_after)); const gameCurrent = freshStatusTimestamp(gameAt, now) && (!server.telemetry_after || Date.parse(gameAt!) > Date.parse(server.telemetry_after)); const hostMissing = !!server.host_id && !host; if ( !hostMissing && containerCurrent && ['stopped', 'exited', 'created', 'dead'].includes(server.container_state ?? '') && (!gameAt || Date.parse(gameAt) < Date.parse(containerAt!)) ) return { state: 'stopped', reason: 'The latest container check confirmed that the game is stopped.', actionLabel: 'Start server', actionHref: `${href}?action=start`, since: containerAt }; const containerAllowsOnline = !server.container_state || server.container_state === 'unknown' || (server.container_state === 'running' && (!server.container_checked_at || containerCurrent)); if ( !hostMissing && server.health === 'online' && !server.last_error && gameCurrent && containerAllowsOnline ) return { state: 'online', reason: 'The game is responding.', actionLabel: 'View server', actionHref: href, since: gameAt }; if (gameCurrent && !server.last_error) return { state: 'unknown', reason: 'The game recently responded, but its current container state has not been verified.', actionLabel: hostMissing ? 'Check host' : 'Open diagnostics', actionHref: hostMissing ? `/hosts/${encodeURIComponent(server.host_id!)}` : diagnostics, since: gameAt }; const error = decodeRconError(server.last_error); const errorReason = error ? { 'no-config': 'The game connection is not configured.', 'no-port': 'The game connection has no port configured.', 'send-error': 'The latest game connection attempt could not be completed.', timeout: 'The game did not answer the latest status check.', 'bad-reply': 'The latest game response could not be read.', 'no-players': 'The latest game report did not include a player count.' }[error.reason] : null; if (!hostMissing && server.container_state === 'running' && containerCurrent) return { state: 'not-responding', reason: errorReason ?? 'The container is running, but the game has no current status report.', actionLabel: 'Test connection', actionHref: diagnostics, since: gameAt ?? observedAt(server.last_checked_at, now) }; if ( !hostMissing && (server.health === 'stale' || server.health === 'online') && gameAt && (!server.telemetry_after || Date.parse(gameAt) > Date.parse(server.telemetry_after)) ) return { state: 'not-responding', reason: errorReason ?? 'The last game report is no longer current. Player count is unknown.', actionLabel: 'Test connection', actionHref: diagnostics, since: gameAt }; return { state: 'unknown', reason: errorReason ?? 'There is no current evidence of the game or container state.', actionLabel: hostMissing ? 'Check host' : 'Open diagnostics', actionHref: hostMissing ? `/hosts/${encodeURIComponent(server.host_id!)}` : diagnostics, since: gameAt ?? containerAt }; }