Show provisioning status during server creation
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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) => ({
|
||||
|
||||
@@ -107,7 +107,9 @@
|
||||
<StatusPip {health} />
|
||||
<strong class="name"><ServerIdentity {name} mapLabel={mapLabelOverride} /></strong>
|
||||
</div>
|
||||
{#if health === 'starting'}
|
||||
{#if health === 'provisioning'}
|
||||
<Badge variant="info" uppercase>Provisioning</Badge>
|
||||
{:else if health === 'starting'}
|
||||
<Badge variant="info" uppercase>Starting</Badge>
|
||||
{:else if health === 'offline'}
|
||||
<Badge variant="danger" uppercase>offline</Badge>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type StatusTone = 'neutral' | 'online' | 'warning' | 'danger' | 'info';
|
||||
const states: Record<string, [string, StatusTone]> = {
|
||||
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'],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 @@
|
||||
<div class="setup-note"><span><strong>Finish system setup</strong> · {!$fleetIntegrations.komodo ? 'Komodo' : ''}{!$fleetIntegrations.komodo && !$fleetIntegrations.rcon ? ' and ' : ''}{!$fleetIntegrations.rcon ? 'RCON' : ''} not configured.</span><a href="/settings">Review setup →</a></div>
|
||||
{/if}
|
||||
|
||||
{#if problems.length || attentionOperations.length || starting}
|
||||
{#if problems.length || attentionOperations.length || starting || provisioning}
|
||||
<div class="attention" class:has-issues={problems.length > 0 || attentionOperations.length > 0}>
|
||||
<span class="attention-mark" aria-hidden="true">{problems.length || attentionOperations.length ? '!' : '✓'}</span>
|
||||
<div class="attention-copy">
|
||||
{#if problems.length}
|
||||
<button onclick={() => { resetFilters(); filter = 'attention'; }}>{problems.length} {problems.length === 1 ? 'server needs' : 'servers need'} attention →</button>
|
||||
{:else}<strong>{servers.length ? starting ? `${starting} ${starting === 1 ? 'server is' : 'servers are'} starting` : 'All servers healthy' : 'Preparing your fleet'}</strong>{/if}
|
||||
{:else}<strong>{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'}</strong>{/if}
|
||||
|
||||
</div>
|
||||
{#if attentionOperations.length}<a href="#fleet-operations" onclick={() => operationsOpen = true}>{attentionOperations.length} operation {attentionOperations.length === 1 ? 'needs' : 'need'} attention →</a>{/if}
|
||||
|
||||
@@ -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<T>(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<RconPlayer[]>(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.
|
||||
|
||||
@@ -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 @@
|
||||
<span class="health-label">
|
||||
{effectiveHealth === 'online' ? 'Online' :
|
||||
effectiveHealth === 'starting' ? 'Starting' :
|
||||
effectiveHealth === 'provisioning' ? 'Provisioning' :
|
||||
effectiveHealth === 'stale' ? 'Stale' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -286,7 +287,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if effectiveHealth !== 'starting'}<RoundTelemetry {name} />{/if}
|
||||
{#if effectiveHealth !== 'starting' && effectiveHealth !== 'provisioning'}<RoundTelemetry {name} />{/if}
|
||||
|
||||
</Card>
|
||||
|
||||
@@ -348,7 +349,9 @@
|
||||
<Card padding="lg">
|
||||
<div class="panel-head">
|
||||
<h2>RCON diagnostic</h2>
|
||||
{#if effectiveHealth === 'starting'}
|
||||
{#if effectiveHealth === 'provisioning'}
|
||||
<Badge variant="info" uppercase>Provisioning</Badge>
|
||||
{:else if effectiveHealth === 'starting'}
|
||||
<Badge variant="info" uppercase>Waiting for container</Badge>
|
||||
{:else if hasDiagnosticIssue}
|
||||
<Badge variant="warning" uppercase>Issue</Badge>
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user