131 lines
5.1 KiB
TypeScript
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()
|
|
);
|
|
}
|