Files
electric-cherry/src/lib/server/fleet/status.ts
T

131 lines
5.1 KiB
TypeScript

import {
rconHealthError,
selectServerObservation,
type GameObservation
} from '../../server-observation.js';
import { classifyHealth } from './health.js';
import { isProvisioning } from './provisioning.js';
import { restartSettling, recoverySettling } from '../provisioning/restarts.js';
import { type ServerRow } from './servers.js';
import { telemetryReport, latestGameSubmission } from './status-evidence.js';
import { latestGameSubmissions } from './lifecycle-evidence.js';
import { db } from '../db/index.js';
import { parseGameEvent, type GameTelemetry } from '../../telemetry.js';
interface Evidence {
provisioning: boolean;
settling: boolean;
telemetry: GameTelemetry | null;
submission: string | null;
}
/** One set of evidence reads per snapshot, rather than four queries per server. */
export function observationReader() {
const handle = db();
const operations = new Map(
(
handle
.prepare(
`SELECT id, server, action, state FROM server_deployments
WHERE rowid IN (SELECT MAX(rowid) FROM server_deployments WHERE server_deleted_at IS NULL GROUP BY server)`
)
.all() as { id: string; server: string; action: string; state: string }[]
).map((row) => [row.server, row])
);
const restarts = new Map(
(
handle
.prepare(
'SELECT server, requested_at, healthy_at, operation_id FROM restart_rollout'
)
.all() as {
server: string;
requested_at: string;
healthy_at: string | null;
operation_id: string | null;
}[]
).map((row) => [row.server, row])
);
const submissions = latestGameSubmissions();
const reports = new Map<string, GameTelemetry>();
for (const row of handle
.prepare(
'SELECT server, received_at, payload FROM server_telemetry WHERE payload IS NOT NULL'
)
.all() as { server: string; received_at: string; payload: string }[]) {
try {
const saved = JSON.parse(row.payload) as GameTelemetry;
const event = parseGameEvent(saved);
if (event.server === row.server)
reports.set(row.server, {
...event,
receivedAt: row.received_at,
timing: saved.timing
});
} catch {
/* Invalid retained telemetry is unavailable. */
}
}
return (server: ServerRow) => {
const operation = operations.get(server.name),
restart = restarts.get(server.name);
return serverObservation(server, {
provisioning:
!!operation &&
['create', 'rebuild', 'start'].includes(operation.action) &&
['queued', 'running', 'awaiting-health'].includes(operation.state),
settling: recoverySettling(restart, operation),
telemetry: reports.get(server.name) ?? null,
submission: submissions.get(server.name) ?? null
});
};
}
/** Shared snapshot for REST, status events, metrics, and other server-side readers. */
export function serverObservation(server: ServerRow, evidence?: Evidence) {
let roster = null;
try {
roster = server.last_roster ? JSON.parse(server.last_roster) : null;
} catch {
/* unavailable */
}
const rcon: GameObservation = {
last_players: server.last_players,
last_map: server.last_map,
last_gametype: server.last_gametype,
last_seen_at: server.last_seen_at,
last_error: server.last_error,
rcon_failures: server.rcon_failures,
roster
};
const provisioning = evidence ? evidence.provisioning : isProvisioning(server.name);
const state = (evidence ? evidence.settling : restartSettling(server.name))
? 'restarting'
: server.container_state;
const ready = !provisioning && (!state || state === 'running' || state === 'unknown');
const health = provisioning
? 'provisioning'
: classifyHealth(server.last_seen_at, rconHealthError(rcon), state);
// This projection is shared by viewer-visible snapshots. Never spread saved diagnostics.
const { last_reply_snippet: _snippet, ...publicServer } = server;
return selectServerObservation(
{
...publicServer,
...rcon,
rcon_status: rcon,
container_state: state,
health,
status_source: null as 'telemetry' | 'rcon' | null,
telemetry: (evidence ? evidence.telemetry : telemetryReport(server.name)) ?? undefined,
telemetry_after: evidence ? evidence.submission : latestGameSubmission(server.name),
last_error: ready ? server.last_error : null,
active_since: ready ? server.active_since : null,
inactive_since: ready ? server.inactive_since : null,
game_started_at: ready ? server.game_started_at : null,
game_ended_at: ready ? server.game_ended_at : null
},
undefined,
Date.now()
);
}