diff --git a/src/lib/server/fleet/health.ts b/src/lib/server/fleet/health.ts index 9936822..4633181 100644 --- a/src/lib/server/fleet/health.ts +++ b/src/lib/server/fleet/health.ts @@ -1,5 +1,5 @@ import { freshStatusTimestamp } from '../../server-freshness.js'; -export type Health = 'online' | 'stale' | 'offline' | 'starting'; +export type Health = 'online' | 'stale' | 'offline' | 'starting' | 'provisioning'; const STALE_MAX_MS = 120_000; diff --git a/src/lib/server/fleet/provisioning.ts b/src/lib/server/fleet/provisioning.ts new file mode 100644 index 0000000..58c4409 --- /dev/null +++ b/src/lib/server/fleet/provisioning.ts @@ -0,0 +1,9 @@ +import { db } from '../db/index.js'; + +/** Only the latest lifecycle operation can describe the current server. */ +export function isProvisioning(server: string): boolean { + const operation = db().prepare(`SELECT action, state FROM server_deployments + WHERE server = ? ORDER BY rowid DESC LIMIT 1`).get(server) as + { action: string; state: string } | undefined; + return operation?.action === 'create' && ['queued', 'running', 'awaiting-health'].includes(operation.state); +} diff --git a/src/lib/server/pipeline/worker.ts b/src/lib/server/pipeline/worker.ts index 2e54b56..2a7642b 100644 --- a/src/lib/server/pipeline/worker.ts +++ b/src/lib/server/pipeline/worker.ts @@ -9,6 +9,7 @@ import { getServer, listServers, setServerField } from '../fleet/servers.js'; import { komodoInspectHealthy } from '../integrations/komodo.js'; import { restartSettling } from './restart-rollout.js'; import { classifyHealth } from '../fleet/health.js'; +import { isProvisioning } from '../fleet/provisioning.js'; import { STATUS_REFRESH_MS } from '../../server-freshness.js'; const TICK_MS = 15_000; @@ -74,6 +75,18 @@ async function refreshStatus(): Promise { const i = cursor++; if (i >= servers.length) return; const srv = servers[i]; + // Creation performs its own container, RCON, and map verification. + if (isProvisioning(srv.name)) { + setServerField(srv.name, 'last_error', null); + setServerField(srv.name, 'last_reply_snippet', null); + entries[i] = { + server: srv.name, players: null, map: srv.map, gametype: null, + roster: null, health: 'provisioning', lastSeenAt: srv.last_seen_at, + lastError: null, endpoint: null, reason: null, replySnippet: null, + activeSince: null, inactiveSince: null, gameStartedAt: null, gameEndedAt: null + }; + continue; + } const container = restartSettling(srv.name) ? { ok: false as const, state: 'restarting', reason: 'Restart requested' } : await komodoInspectHealthy(srv.name).catch((err) => ({ diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index 5bd48f6..a803785 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -107,7 +107,9 @@ - {#if health === 'starting'} + {#if health === 'provisioning'} + Provisioning + {:else if health === 'starting'} Starting {:else if health === 'offline'} offline diff --git a/src/lib/ui/components/StatusDot.svelte b/src/lib/ui/components/StatusDot.svelte index 2025338..1a1c10d 100644 --- a/src/lib/ui/components/StatusDot.svelte +++ b/src/lib/ui/components/StatusDot.svelte @@ -26,7 +26,7 @@ background: var(--spark-500); animation: electric-pulse 1.6s ease-in-out infinite; } - .dot-stale, .dot-starting { + .dot-stale, .dot-starting, .dot-provisioning { background: var(--amber-500); box-shadow: var(--amber-glow); } diff --git a/src/lib/ui/components/primitives/StatusPip.svelte b/src/lib/ui/components/primitives/StatusPip.svelte index 2dc65cf..25ad062 100644 --- a/src/lib/ui/components/primitives/StatusPip.svelte +++ b/src/lib/ui/components/primitives/StatusPip.svelte @@ -11,7 +11,7 @@ let toneClass = $derived( health === 'online' ? 't-online' : - health === 'stale' || health === 'starting' ? 't-warning' : + health === 'stale' || health === 'starting' || health === 'provisioning' ? 't-warning' : 't-danger' ); @@ -19,6 +19,7 @@ label ?? ( health === 'online' ? 'online' : health === 'starting' ? 'starting' : + health === 'provisioning' ? 'provisioning' : health === 'stale' ? 'stale' : 'offline' ) diff --git a/src/lib/ui/status.ts b/src/lib/ui/status.ts index f828847..695adad 100644 --- a/src/lib/ui/status.ts +++ b/src/lib/ui/status.ts @@ -1,5 +1,6 @@ export type StatusTone = 'neutral' | 'online' | 'warning' | 'danger' | 'info'; const states: Record = { + provisioning: ['Provisioning', 'info'], online: ['Online', 'online'], offline: ['Offline', 'danger'], stale: ['Report outdated', 'warning'], starting: ['Starting', 'info'], queued: ['Queued', 'neutral'], pending: ['Queued for restart', 'neutral'], running: ['In progress', 'info'], active: ['In progress', 'info'], deploying: ['Installing files', 'info'], restarting: ['Restarting', 'info'], diff --git a/src/lib/ui/stores/servers.ts b/src/lib/ui/stores/servers.ts index d55d33a..bb1ae23 100644 --- a/src/lib/ui/stores/servers.ts +++ b/src/lib/ui/stores/servers.ts @@ -13,7 +13,7 @@ export interface RosterPlayer { name: string; } -export type Health = 'online' | 'stale' | 'offline' | 'starting'; +export type Health = 'online' | 'stale' | 'offline' | 'starting' | 'provisioning'; export type RconFailure = 'no-config' | 'no-port' | 'send-error' | 'timeout' | 'bad-reply' | 'no-players'; export interface RconEndpoint { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0214c39..2e2219b 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -86,6 +86,7 @@ let empty = $derived(healthy.filter(server => server.last_players === 0).length); let problems = $derived(servers.filter(server => server.health === 'offline' || server.health === 'stale')); let starting = $derived(servers.filter(server => server.health === 'starting').length); + let provisioning = $derived(servers.filter(server => server.health === 'provisioning').length); let filtered = $derived(servers.filter(server => matchesFleet(server, query, filter, game, pinned, activeOperations, $uiNow)) .sort((a, b) => { const pinOrder = Number(pinned.includes(b.name)) - Number(pinned.includes(a.name)); @@ -161,13 +162,13 @@
Finish system setup · {!$fleetIntegrations.komodo ? 'Komodo' : ''}{!$fleetIntegrations.komodo && !$fleetIntegrations.rcon ? ' and ' : ''}{!$fleetIntegrations.rcon ? 'RCON' : ''} not configured.Review setup →
{/if} - {#if problems.length || attentionOperations.length || starting} + {#if problems.length || attentionOperations.length || starting || provisioning}
0 || attentionOperations.length > 0}>
{#if problems.length} - {:else}{servers.length ? starting ? `${starting} ${starting === 1 ? 'server is' : 'servers are'} starting` : 'All servers healthy' : 'Preparing your fleet'}{/if} + {:else}{provisioning ? `${provisioning} ${provisioning === 1 ? 'server is' : 'servers are'} provisioning` : servers.length ? starting ? `${starting} ${starting === 1 ? 'server is' : 'servers are'} starting` : 'All servers healthy' : 'Preparing your fleet'}{/if}
{#if attentionOperations.length} operationsOpen = true}>{attentionOperations.length} operation {attentionOperations.length === 1 ? 'needs' : 'need'} attention →{/if} diff --git a/src/routes/api/servers/+server.ts b/src/routes/api/servers/+server.ts index d89d322..58544c6 100644 --- a/src/routes/api/servers/+server.ts +++ b/src/routes/api/servers/+server.ts @@ -3,6 +3,7 @@ import { kick } from '$lib/server/pipeline/worker.js'; import { json } from '@sveltejs/kit'; import { listServers, heldByBeta } from '$lib/server/fleet/servers.js'; import { classifyHealth } from '$lib/server/fleet/health.js'; +import { isProvisioning } from '$lib/server/fleet/provisioning.js'; import { queueCreateServer } from '$lib/server/fleet/create.js'; import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js'; import type { RconPlayer } from '$lib/server/integrations/rcon.js'; @@ -20,7 +21,8 @@ function safeJson(raw: string | null): T | null { export async function GET() { const rconHost = config.rcon.host; const servers = listServers().map((s) => { - const ready = !s.container_state || s.container_state === 'running'; + const provisioning = isProvisioning(s.name); + const ready = !provisioning && (!s.container_state || s.container_state === 'running'); return { ...s, last_players: ready ? s.last_players : null, @@ -30,7 +32,7 @@ export async function GET() { game_started_at: ready ? s.game_started_at : null, game_ended_at: ready ? s.game_ended_at : null, roster: ready ? safeJson(s.last_roster) : null, - health: classifyHealth(s.last_seen_at, s.last_error, s.container_state), + health: provisioning ? 'provisioning' : classifyHealth(s.last_seen_at, s.last_error, s.container_state), replySnippet: ready ? s.last_reply_snippet : null, // Derived endpoint — same logic as endpointFor() but without asking // Komodo. If we don't have a port yet, endpoint is null. diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index fb41e4d..db29a6b 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -160,7 +160,7 @@ let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince); let gameElapsed = $derived(fresh && gameInProgress ? elapsedSince(effectiveGameStartedAt, $uiNow) : null); - let hasDiagnosticIssue = $derived(effectiveHealth !== 'online' && effectiveHealth !== 'starting'); + let hasDiagnosticIssue = $derived(effectiveHealth !== 'online' && effectiveHealth !== 'starting' && effectiveHealth !== 'provisioning'); function ago(iso: string | null, now: number): string { if (!iso) return 'never'; @@ -243,6 +243,7 @@ {effectiveHealth === 'online' ? 'Online' : effectiveHealth === 'starting' ? 'Starting' : + effectiveHealth === 'provisioning' ? 'Provisioning' : effectiveHealth === 'stale' ? 'Stale' : 'Offline'}
@@ -286,7 +287,7 @@ {/if} - {#if effectiveHealth !== 'starting'}{/if} + {#if effectiveHealth !== 'starting' && effectiveHealth !== 'provisioning'}{/if} @@ -348,7 +349,9 @@

RCON diagnostic

- {#if effectiveHealth === 'starting'} + {#if effectiveHealth === 'provisioning'} + Provisioning + {:else if effectiveHealth === 'starting'} Waiting for container {:else if hasDiagnosticIssue} Issue diff --git a/tests/unit/server-status.test.ts b/tests/unit/server-status.test.ts index 5927bb4..d8bcc89 100644 --- a/tests/unit/server-status.test.ts +++ b/tests/unit/server-status.test.ts @@ -30,6 +30,34 @@ async function setup() { } describe('database fleet and container readiness', () => { + it.each(['queued', 'running', 'awaiting-health'])('shows provisioning during %s creation and resumes health reporting afterward', async (state) => { + const { db, inspect, rcon, fanoutStatus } = await setup(); + const { GET } = await import('../../src/routes/api/servers/+server.js'); + const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); + db.prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by) + VALUES ('creation', 'managed', 'create', ?, 'Creating', ?, 'test')`).run(state, new Date().toISOString()); + const snapshot = async () => (await (await GET()).json()).servers[0]; + expect(await snapshot()).toMatchObject({ health: 'provisioning', last_error: null, last_players: null }); + const events: unknown[] = []; + const unsubscribe = subscribe(event => events.push(event)); + try { await fanoutStatus(); } finally { unsubscribe(); } + expect(inspect).not.toHaveBeenCalled(); + expect(rcon).not.toHaveBeenCalled(); + expect(events).toContainEqual(expect.objectContaining({ type: 'server-status', entries: [expect.objectContaining({ + health: 'provisioning', lastError: null, reason: null, replySnippet: null + })] })); + db.prepare("UPDATE server_deployments SET state = 'failed' WHERE id = 'creation'").run(); + inspect.mockResolvedValue({ ok: true, state: 'running' }); + rcon.mockResolvedValue({ ok: false, endpoint: null, reason: 'timeout', detail: 'no reply' }); + await fanoutStatus(); + expect(await snapshot()).toMatchObject({ health: 'offline', last_error: 'timeout:no reply' }); + db.prepare("UPDATE server_deployments SET state = 'done' WHERE id = 'creation'").run(); + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } }); + await fanoutStatus(); + expect(await snapshot()).toMatchObject({ health: 'online', last_error: null }); + }); + it('keeps game_end through occupied polls until game_start or an empty lobby', async () => { const { inspect, rcon, fanoutStatus, getServer } = await setup(); inspect.mockResolvedValue({ ok: true, state: 'running' });