Files
electric-cherry/src/lib/ui/server-health.ts
T

198 lines
8.0 KiB
TypeScript

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<ServerHealthPresentation, 'label' | 'shortReason'> {
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
};
}