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(); 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() ); }