Complete provisioning job detail pages and lifecycle evidence

This commit is contained in:
2026-09-28 14:17:19 -04:00
parent 5b65520bbc
commit 3fb59cd598
41 changed files with 2866 additions and 346 deletions
+3 -3
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } = $props();
let { recipe, historical = false, selectionStatus = null }: { recipe: LaunchRecipeSummary; historical?: boolean; selectionStatus?: 'queued' | 'attempted' | null } = $props();
</script>
<div class="recipe">
<div class="recipe-heading"><div><span class="eyebrow">{historical ? 'Recorded launch files' : 'Current promoted setup'}</span><h3>{recipe.mapName}</h3></div><span class="game">{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}</span></div>
<div class="recipe-heading"><div><span class="eyebrow">{historical ? selectionStatus === 'queued' ? 'Queued setup capture' : selectionStatus === 'attempted' ? 'Selected for this attempt' : 'Recorded launch files' : 'Current promoted setup'}</span><h3>{recipe.mapName}</h3></div><span class="game">{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}</span></div>
<dl>
<div><dt>Configuration</dt><dd>{recipe.configSource}</dd></div>
<div><dt>Container image</dt><dd class="mono">{recipe.image}</dd></div>
@@ -14,7 +14,7 @@
<details>
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'}</summary>
<ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul>
<p>{historical ? 'Recorded' : 'Previewed'} {$timeFormat.dateTime(recipe.capturedAt)}. {historical ? 'This records the files selected for this deployment. Launch again uses the latest promoted setup for this map.' : 'This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.'}</p>
<p>{historical ? 'Recorded' : 'Previewed'} {$timeFormat.dateTime(recipe.capturedAt)}. {historical ? selectionStatus === 'queued' ? 'Versions are selected again when creation begins. This queued capture does not pin them.' : selectionStatus === 'attempted' ? 'These files were selected for this attempt; this does not confirm installation succeeded. Launch again uses the latest promoted setup.' : 'This records the saved setup for this deployment. Launch again uses the latest promoted setup for this map.' : 'This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.'}</p>
<small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
</details>
</div>
@@ -0,0 +1,132 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { ProvisioningDetail, ProvisioningEvent } from '$lib/provisioning-detail.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { isProvisioningEventPage } from '$ui/provisioning-presentation.js';
import Button from './primitives/Button.svelte';
let { detail }: { detail: ProvisioningDetail } = $props();
let open = $state(false);
let query = $state('');
let order = $state<'newest' | 'oldest'>('newest');
let appliedQuery = $state('');
let appliedOrder = $state<'newest' | 'oldest'>('newest');
let entries = $state<ProvisioningEvent[]>([]);
let frozenRecent = $state<ProvisioningEvent[] | null>(null);
let total = $state(0);
let baselineTotal = $state<number | null>(null);
let watermark = $state<number | null>(null);
let cursor = $state<number | null>(null);
let loaded = $state(false);
let loading = $state(false);
let error = $state('');
let requestNumber = 0;
let controller: AbortController | null = null;
let disposed = false;
const recent = $derived((frozenRecent ?? detail.events).slice().sort((a, b) => b.id - a.id).slice(0, 5));
const newEvents = $derived(baselineTotal === null ? 0 : Math.max(0, detail.activity.total - baselineTotal));
async function load(more = false) {
if (more && (loading || cursor === null)) return;
controller?.abort();
controller = new AbortController();
const currentController = controller;
const request = ++requestNumber;
const id = detail.deployment.id;
const current = () => !disposed && request === requestNumber && detail.deployment.id === id;
loading = true; error = '';
const requestedQuery = more ? appliedQuery : query;
const requestedOrder = more ? appliedOrder : order;
const params = new URLSearchParams({ order: requestedOrder, limit: '25' });
if (requestedQuery) params.set('query', requestedQuery);
if (more && cursor !== null && watermark !== null) {
params.set('cursor', String(cursor));
params.set('watermark', String(watermark));
}
let timedOut = false;
const timeout = setTimeout(() => { timedOut = true; currentController.abort(); }, 15_000);
try {
const response = await fetch(`/api/provisioning/${encodeURIComponent(id)}/events?${params}`, { signal: currentController.signal, cache: 'no-store' });
if (!response.ok) throw new Error('Could not load activity history.');
const body: unknown = await response.json();
if (!isProvisioningEventPage(body)) throw new Error('The activity response was incomplete.');
if (!current()) return;
entries = more ? [...entries, ...body.events.filter(event => !entries.some(previous => previous.id === event.id))] : body.events;
total = body.total; watermark = body.watermark; cursor = body.nextCursor; loaded = true;
if (!more) { baselineTotal = body.totalEvents; appliedQuery = requestedQuery; appliedOrder = requestedOrder; }
} catch (reason) {
if (current() && (!currentController.signal.aborted || timedOut)) error = timedOut ? 'Activity refresh timed out. Try again.' : reason instanceof Error ? reason.message : 'Could not load activity history.';
} finally {
clearTimeout(timeout);
if (current()) loading = false;
}
}
function toggle(event: Event) {
const expanded = (event.currentTarget as HTMLDetailsElement).open;
open = expanded;
if (expanded) {
frozenRecent = detail.events.slice();
if (!loaded && !loading) void load();
} else frozenRecent = null;
}
function jumpToLatest() {
query = appliedQuery; order = 'newest';
frozenRecent = detail.events.slice();
void load();
}
onDestroy(() => { disposed = true; requestNumber++; controller?.abort(); });
</script>
<section aria-labelledby="activity-title" class="activity">
<div class="heading"><div><span class="eyebrow">Activity</span><h2 id="activity-title">Recent updates</h2></div><span class="count">{detail.activity.total} event{detail.activity.total === 1 ? '' : 's'}</span></div>
<p class="muted">{open ? 'Recent updates stay in place while you read the full history.' : 'Newest first · updates automatically'}</p>
{#if recent.length}<ol class="timeline recent">
{#each recent as event (event.id)}<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>{/each}
</ol>{:else}<p class="empty">No detailed activity was recorded. The recorded outcome and timestamps are preserved above.</p>{/if}
<details class="history" ontoggle={toggle}>
<summary>Full activity history <span>{detail.activity.total} events</span></summary>
<div class="history-content">
{#if newEvents > 0}<div class="new-events" role="status"><span>{newEvents} new event{newEvents === 1 ? '' : 's'} since this view was loaded.</span><Button size="sm" onclick={jumpToLatest}>Jump to latest</Button></div>{/if}
<form class="tools" onsubmit={event => { event.preventDefault(); void load(); }}>
<label class="search">Search activity<input type="search" bind:value={query} placeholder="Find a stage or message" /></label>
<label>Order<select bind:value={order} onchange={() => { void load(); }}><option value="newest">Newest first</option><option value="oldest">Oldest first</option></select></label>
<Button type="submit" disabled={loading}>Search</Button>
</form>
{#if error}<div class="history-error" role="alert"><p>{error} {loaded ? 'Your previous history view is still shown.' : ''}</p><Button size="sm" onclick={() => load()}>Retry history</Button></div>{/if}
{#if loaded}<p class="muted">{appliedOrder === 'newest' ? 'Newest first' : 'Oldest first'}{appliedQuery ? ` · Matching “${appliedQuery}”` : ' · All messages'}</p>{/if}
{#if loading && !loaded}<p class="muted" role="status">Loading activity…</p>
{:else if loaded && !entries.length}<p class="empty">{appliedQuery ? 'No activity matches this search.' : 'No detailed activity recorded.'}</p>
{:else}<ol class="timeline">
{#each entries as event (event.id)}<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>{/each}
</ol>{/if}
{#if loaded}<div class="footer"><span>Showing {entries.length} of {total} matching events</span>{#if cursor !== null}<Button size="sm" loading={loading} disabled={loading} onclick={() => load(true)}>Show more activity</Button>{/if}</div>{/if}
</div>
</details>
</section>
<style>
.activity { display: grid; gap: var(--space-4); min-width: 0; }
.heading { display: flex; justify-content: space-between; gap: var(--space-4); align-items: end; flex-wrap: wrap; }
.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: var(--space-2); }
h2 { margin: 0; font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
p { margin: 0; line-height: 1.65; overflow-wrap: anywhere; font-size: var(--text-sm); }
.muted, .count, time, .footer { font-size: var(--text-xs); color: var(--text-secondary); }
.empty { padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-secondary); }
.timeline { list-style: none; margin: 0 0 0 var(--space-1); padding: 0 0 0 var(--space-5); border-left: 1px solid var(--border-strong); }
.timeline li { padding: 0 0 var(--space-5); position: relative; }.timeline li:last-child { padding-bottom: 0; }
.timeline li::before { content: ''; position: absolute; top: 6px; left: -24px; width: 7px; height: 7px; background: var(--border-control); border-radius: var(--radius-pill); }
.recent li:first-child::before { background: var(--accent-primary); }.timeline p { margin-top: var(--space-1); }
.history { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
summary { cursor: pointer; padding: var(--space-5); font-size: var(--text-sm); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
summary span { margin-left: var(--space-2); color: var(--text-tertiary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: -3px; border-radius: var(--radius-sm); }
.history-content { padding: 0 var(--space-5) var(--space-5); display: grid; gap: var(--space-5); }
.tools { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); }.tools label { display: grid; gap: var(--space-2); font-size: var(--text-xs); color: var(--text-secondary); }.search { flex: 1 1 160px; }
input, select { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 10px; font: inherit; min-height: 40px; width: 100%; }
input:focus-visible, select:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 2px; }
.new-events { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }.new-events span { flex: 1; }
.footer { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); flex-wrap: wrap; line-height: 1.6; }
.history-error { display: grid; justify-items: start; gap: var(--space-3); color: var(--state-danger); }
@media (max-width: 600px) { summary { padding: var(--space-4); }.history-content { padding: 0 var(--space-4) var(--space-4); }.tools { align-items: stretch; }.tools .search { flex-basis: 100%; } }
</style>
@@ -0,0 +1,123 @@
<script lang="ts">
import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
import { provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from './primitives/Badge.svelte';
let { detail }: { detail: ProvisioningDetail } = $props();
const groups = $derived(provisioningGroups(detail));
const progress = $derived(detail.progress);
let expanded = $state<Record<string, boolean>>({});
const evidenceLabels = { pass: 'Passed', waiting: 'Waiting', fail: 'Failed', unknown: 'Unknown' };
const sources = { komodo: 'Komodo', rcon: 'RCON', telemetry: 'Telemetry', combined: 'RCON and telemetry' };
</script>
<section class="execution" aria-labelledby="execution-title">
<div class="section-heading"><div><span class="eyebrow">Execution</span><h2 id="execution-title">Progress and evidence</h2></div></div>
{#if !progress}
<div class="legacy"><h3>{detail.deployment.stage}</h3><p>Detailed checkpoints were not recorded for this operation. Its recorded outcome and original activity remain available below.</p></div>
{:else}
<ol class="phases">
{#each groups as group (group.key)}
<li class:current={group.state === 'current' || group.state === 'waiting'} class:failed={group.state === 'failed'}>
<details open={expanded[group.key] ?? ['current', 'waiting', 'failed'].includes(group.state)}>
<summary
onclick={event => { event.preventDefault(); expanded[group.key] = !(expanded[group.key] ?? ['current', 'waiting', 'failed'].includes(group.state)); }}
onkeydown={event => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
if (!event.repeat) expanded[group.key] = !(expanded[group.key] ?? ['current', 'waiting', 'failed'].includes(group.state));
}}
>
<span class="phase-icon state-{group.state}" aria-hidden="true">{progressIcons[group.state]}</span>
<span class="phase-title">{group.label}</span>
<span class="phase-state state-{group.state}">{progressStateLabels[group.state]}</span>
</summary>
<ol class="steps">
{#each group.steps as step (step.key)}
<li aria-current={step.state === 'current' || step.state === 'waiting' ? 'step' : undefined}>
<span class="step-icon state-{step.state}" aria-hidden="true">{progressIcons[step.state]}</span>
<div><strong>{step.label}</strong><span class="step-meta state-{step.state}">{progressStateLabels[step.state]}{#if step.at} · <time datetime={step.at}>{$timeFormat.dateTime(step.at)}</time>{/if}</span></div>
</li>
{/each}
</ol>
</details>
</li>
{/each}
</ol>
{#if progress.command}
<div class="command" class:uncertain={progress.command.state === 'uncertain'}>
<h3>{progress.command.state === 'attempted' ? 'Command submission attempted' : progress.command.state === 'accepted' ? 'Remote request accepted' : progress.command.state === 'rejected' ? 'Remote request rejected' : 'Remote response uncertain'}</h3>
<p>{progress.command.message}</p>
<time datetime={progress.command.at}>{$timeFormat.dateTime(progress.command.at)}</time>
</div>
{/if}
{#if progress.checkpoints.some(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key)))}
<details class="milestones"><summary>Recorded checkpoints</summary><ul>
{#each progress.checkpoints.filter(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key))) as checkpoint (checkpoint.key)}
<li><strong>{checkpoint.label}</strong><time datetime={checkpoint.at}>{$timeFormat.dateTime(checkpoint.at)}</time></li>
{/each}
</ul></details>
{/if}
<div class="evidence-heading"><h3>Latest recorded checks</h3>{#if progress.observedAt}<span>Evaluated <time datetime={progress.observedAt}>{$timeFormat.dateTime(progress.observedAt)}</time></span>{/if}</div>
{#if progress.checks.length}
<div class="checks">
{#each progress.checks as check (check.key)}
<article class="check">
<div class="check-heading"><strong>{check.label}</strong><Badge variant={check.result === 'pass' ? 'online' : check.result === 'fail' ? 'danger' : check.result === 'waiting' ? 'warning' : 'neutral'}>{evidenceLabels[check.result]}</Badge></div>
{#if check.expected !== null || check.actual !== null}<dl>
{#if check.expected !== null}<div><dt>Expected</dt><dd>{check.expected}</dd></div>{/if}
<div><dt>Observed</dt><dd>{check.actual ?? 'Unknown'}</dd></div>
</dl>{/if}
{#if check.message}<p>{check.message}</p>{/if}
<div class="evidence-meta"><span>{check.source ? sources[check.source] : 'Source not recorded'}</span><span>Checked <time datetime={check.checkedAt}>{$timeFormat.dateTime(check.checkedAt)}</time></span>{#if check.receivedAt}<span>Evidence received <time datetime={check.receivedAt}>{$timeFormat.dateTime(check.receivedAt)}</time></span>{/if}</div>
</article>
{/each}
</div>
{:else}<p class="empty">No check evidence has been recorded for this operation yet.</p>{/if}
{#if detail.deployment.expected_map}<p class="map-note">Selected engine map: <code>{detail.deployment.expected_map}</code>. Engine-map verification does not independently verify a variant or start location.</p>{/if}
{/if}
</section>
<style>
.execution { display: grid; gap: var(--space-5); min-width: 0; }
h2, h3, p { margin: 0; }
h2 { font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
h3 { font-size: var(--text-sm); font-weight: var(--weight-semibold); }
.eyebrow { display: block; margin-bottom: var(--space-2); font-size: var(--text-xs); color: var(--text-tertiary); }
.phases, .steps, .milestones ul { list-style: none; padding: 0; margin: 0; }
.phases { display: grid; gap: var(--space-3); }
.phases > li { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
.phases > li.current { border-color: color-mix(in srgb, var(--accent-primary) 60%, var(--border-subtle)); }
.phases > li.failed { border-color: color-mix(in srgb, var(--state-danger) 60%, var(--border-subtle)); }
summary { cursor: pointer; }
.phases summary { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-5); list-style: none; }
.phases summary::-webkit-details-marker { display: none; }
.phases summary::after { content: '+'; color: var(--text-tertiary); font-size: 18px; }
.phases details[open] > summary::after { content: '−'; }
summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: -3px; border-radius: var(--radius-sm); }
.phase-icon { flex: 0 0 26px; display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid var(--border-strong); border-radius: var(--radius-pill); font-size: var(--text-sm); }
.phase-title { flex: 1; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
.phase-state { font-size: var(--text-xs); }
.steps { border-top: 1px solid var(--border-subtle); padding: var(--space-4) var(--space-5); display: grid; gap: var(--space-4); }
.steps li { display: flex; align-items: flex-start; gap: var(--space-3); }
.step-icon { flex: 0 0 26px; text-align: center; line-height: 1.5; }
.steps strong { display: block; font-size: var(--text-sm); font-weight: var(--weight-medium); line-height: 1.5; }
.step-meta { display: block; font-size: var(--text-xs); margin-top: var(--space-1); line-height: 1.6; }
.state-current { color: var(--accent-primary-hover); }.state-waiting { color: var(--state-warning); }.state-failed { color: var(--state-danger); }.state-done { color: var(--state-online); }
.state-unknown, .state-pending, .state-cancelled, time { color: var(--text-secondary); }
.command, .legacy { display: grid; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.command.uncertain { border-color: color-mix(in srgb, var(--state-warning) 45%, var(--border-subtle)); }
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.65; overflow-wrap: anywhere; }
time, .evidence-meta, .evidence-heading span, .milestones { font-size: var(--text-xs); }
.evidence-heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-2); padding-top: var(--space-2); }
.evidence-heading span { color: var(--text-secondary); }
.checks { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
.check { padding: var(--space-5); display: grid; gap: var(--space-3); }.check + .check { border-top: 1px solid var(--border-subtle); }
.check-heading { display: flex; gap: var(--space-3); align-items: center; justify-content: space-between; flex-wrap: wrap; }.check-heading strong { font-size: var(--text-sm); }
dl { display: flex; gap: var(--space-4) var(--space-8); margin: 0; flex-wrap: wrap; }dt { font-size: var(--text-xs); color: var(--text-tertiary); }dd { margin: var(--space-1) 0 0; font-size: var(--text-sm); overflow-wrap: anywhere; }
.evidence-meta { display: flex; gap: var(--space-1) var(--space-4); flex-wrap: wrap; color: var(--text-tertiary); line-height: 1.6; }
.milestones { border-block: 1px solid var(--border-subtle); padding: var(--space-3) 0; }.milestones summary { color: var(--text-secondary); }.milestones ul { display: grid; gap: var(--space-3); padding-top: var(--space-4); }.milestones li { display: grid; gap: var(--space-1); }.milestones strong { font-weight: var(--weight-medium); }
.map-note { font-size: var(--text-xs); }code { font-family: var(--font-mono); overflow-wrap: anywhere; }
@media (max-width: 600px) { .phases summary { padding: var(--space-4); gap: var(--space-2); flex-wrap: wrap; }.phase-title { flex-basis: calc(100% - 70px); }.phase-state { padding-left: 34px; order: 1; flex-basis: 100%; }.phases summary::after { order: 0; }.steps, .check { padding: var(--space-4); } }
</style>
+202
View File
@@ -0,0 +1,202 @@
import { provisioningPhases, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js';
import { actionCompleted, actionLabels, provisioningActions, type ProvisioningAction } from '$lib/provisioning.js';
export const activeProvisioningStates = ['queued', 'waiting', 'running', 'awaiting-health'];
export type ProgressState = 'done' | 'current' | 'waiting' | 'failed' | 'pending' | 'cancelled' | 'unknown';
export interface ProgressStep { key: ProvisioningPhase; label: string; state: ProgressState; at: string | null }
export interface ProgressGroup { key: string; label: string; steps: ProgressStep[]; state: ProgressState }
const phaseLabels: Record<ProvisioningPhase, string> = {
requested: 'Request recorded', eligibility: 'Check eligibility and capacity', 'startup-queue': 'Wait for a startup slot',
prepare: 'Prepare server files', 'create-deployment': 'Create Komodo deployment', 'container-action': 'Start container',
'remove-container': 'Stop and remove container', 'delete-deployment': 'Delete Komodo deployment', cleanup: 'Remove files and registration',
'verify-container': 'Confirm container readiness', 'verify-game': 'Verify game status', 'verify-map': 'Verify selected engine map',
'verify-stopped': 'Confirm container is stopped'
};
type GroupDefinition = { key: string; label: string; phases: ProvisioningPhase[] };
const request: GroupDefinition = { key: 'request', label: 'Request and eligibility', phases: ['requested', 'eligibility'] };
const creation: GroupDefinition = { key: 'create', label: 'Create and start', phases: ['startup-queue', 'prepare', 'create-deployment', 'container-action'] };
const removal: GroupDefinition = { key: 'remove', label: 'Remove the instance', phases: ['remove-container', 'delete-deployment', 'cleanup'] };
const readiness: GroupDefinition = { key: 'verify', label: 'Verify readiness', phases: ['verify-container', 'verify-game', 'verify-map'] };
function definitions(action: ProvisioningAction): GroupDefinition[] {
if (action === 'create') return [{ ...request, phases: ['requested'] }, creation, readiness];
if (action === 'rebuild') return [request, removal, { ...creation, label: 'Create a fresh instance' }, readiness];
if (action === 'destroy') return [request, removal];
return [request, { key: 'command', label: `${actionLabels[action]} the existing container`, phases: ['container-action'] },
{ ...readiness, phases: action === 'stop' ? ['verify-stopped'] : ['verify-container', 'verify-game'] }];
}
export function provisioningGroups(detail: ProvisioningDetail): ProgressGroup[] {
const progress = detail.progress;
if (!progress) return [];
const groups = definitions(detail.deployment.action);
const phases = groups.flatMap(group => group.phases);
const current = progress.phase ? phases.indexOf(progress.phase) : -1;
const active = activeProvisioningStates.includes(detail.deployment.state);
return groups.map(group => {
const steps = group.phases.map((key): ProgressStep => {
const checkpoint = progress.checkpoints.find(item => item.key === key);
const checkKey = key === 'verify-container' ? 'container' : key === 'verify-game' ? 'game' : key === 'verify-map' ? 'map' : key === 'verify-stopped' ? 'stopped' : null;
const check = checkKey ? progress.checks.find(item => item.key === checkKey) : null;
const index = phases.indexOf(key);
const isCurrent = key === progress.phase;
const state: ProgressState = check ? ({ pass: 'done', fail: 'failed', waiting: 'waiting', unknown: 'unknown' } as const)[check.result]
: checkpoint ? 'done'
: isCurrent && ['failed', 'interrupted'].includes(detail.deployment.state) ? 'failed'
: isCurrent && active ? progress.blocker || ['waiting', 'queued'].includes(detail.deployment.state) ? 'waiting' : 'current'
: current >= 0 && index >= current && detail.deployment.state === 'cancelled' ? 'cancelled'
: active && current >= 0 && index > current ? 'pending' : 'unknown';
const label = key === 'container-action' && ['start', 'restart', 'stop'].includes(detail.deployment.action)
? `${actionLabels[detail.deployment.action]} existing container` : phaseLabels[key];
return { key, label, state, at: check?.checkedAt ?? checkpoint?.at ?? (isCurrent ? progress.phaseStartedAt : null) };
});
const states = steps.map(step => step.state);
const state = (['failed', 'current', 'waiting'] as const).find(value => states.includes(value))
?? (states.every(value => value === 'done') ? 'done' : states.includes('unknown') ? 'unknown' : states.includes('cancelled') ? 'cancelled' : 'pending');
return { key: group.key, label: group.label, steps, state };
});
}
export const progressStateLabels: Record<ProgressState, string> = {
done: 'Completed', current: 'In progress', waiting: 'Waiting', failed: 'Needs attention', pending: 'Not started', cancelled: 'Not run', unknown: 'Not recorded'
};
export const progressIcons: Record<ProgressState, string> = { done: '✓', current: '•', waiting: '◷', failed: '!', pending: '○', cancelled: '–', unknown: '?' };
export function provisioningOutcome(detail: ProvisioningDetail) {
const { deployment: operation, progress } = detail;
const active = activeProvisioningStates.includes(operation.state);
const failure = ['failed', 'interrupted'].includes(operation.state);
const headline = operation.state === 'done' ? actionCompleted[operation.action]
: operation.state === 'cancelled' ? `${actionLabels[operation.action]} request cancelled`
: failure ? `${actionLabels[operation.action]} ${operation.state === 'interrupted' ? 'interrupted' : 'failed'}`
: operation.state === 'warning' ? `${actionLabels[operation.action]} completed with warnings`
: progress?.blocker?.message ?? operation.stage;
let description: string;
if (detail.canResumeCleanup) description = 'The remote deployment was deleted. Resume the remaining local cleanup without repeating container removal.';
else if (detail.canResumeRebuild) description = 'The remote deployment was deleted. Resume local cleanup, then create a fresh instance in the reserved server slot.';
else if (detail.canResumeVerification) description = 'Resume observation of the existing instance. This does not create, restart, or rebuild a container.';
else if (operation.state === 'cancelled') description = 'This request is cancelled. Recorded prior work is preserved below; remaining work will not run.';
else if (failure) description = progress?.command?.state === 'uncertain'
? 'The remote response was uncertain. Inspect the recorded result and server before requesting another operation.'
: 'Review the recorded failure and execution evidence before submitting another operation.';
else if (operation.state === 'warning') description = 'The operation finished. Review the recorded warnings below for any remaining issue.';
else if (operation.state === 'done') description = !progress
? 'This operation was recorded as completed. Detailed verification evidence was not recorded.'
: operation.action === 'destroy' ? 'The instance removal completed. This record preserves its results after deletion.'
: operation.action === 'stop' ? 'The container was confirmed stopped. Its files and registration were retained.'
: 'The operation completed its readiness checks. These recorded results describe that attempt; current health is available on the server page.';
else if (active && progress?.phase === 'cleanup' && !progress.blocker) description = operation.action === 'rebuild'
? 'Remote deployment deletion was confirmed. Local cleanup must finish before a fresh instance is created in the reserved slot.'
: 'Remote deployment deletion was confirmed. The remaining local files and registration cleanup will continue without repeating container removal.';
else if (progress?.blocker) description = progress.blocker.automatic
? 'The worker will recheck this condition and continue automatically when eligible.'
: 'Review the blocking condition below before this operation can continue.';
else if (operation.state === 'queued') description = 'The request is saved. It will start when its dependencies and startup capacity allow.';
else if (operation.state === 'waiting') description = 'Waiting for the worker’s eligibility checks. The recorded stage below describes the last known condition.';
else description = operation.action === 'destroy' ? 'Removal is in progress. Confirmed checkpoints and remaining cleanup are recorded below.'
: operation.action === 'rebuild' ? 'The old instance is removed before a fresh instance is created in its reserved slot. Follow each checkpoint below.'
: 'The worker is executing this request and checking its resulting state.';
return { active, headline, description };
}
/** Accept return context only from the local operation surfaces we expose. */
export function provisioningReturn(value: string | null | undefined): { href: string; label: string } {
const fallback = { href: '/provisioning', label: 'Back to Provisioning' };
if (!value || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) return fallback;
try {
const url = new URL(value, 'https://electric-cherry.invalid');
if (url.origin !== 'https://electric-cherry.invalid') return fallback;
if (url.pathname === '/provisioning') {
const params = new URLSearchParams();
for (const key of ['view', 'action', 'status', 'server', 'limit']) {
const item = url.searchParams.get(key);
if (item) params.set(key, item);
}
return { href: '/provisioning' + (params.size ? `?${params}` : ''), label: 'Back to Provisioning' };
}
const match = url.pathname.match(/^\/(campaigns|betas|maintenance|collections\/launches)\/[^/]+$/);
if (match) return { href: url.pathname, label: `Back to ${match[1] === 'collections/launches' ? 'collection' : match[1] === 'campaigns' ? 'campaign' : match[1] === 'betas' ? 'beta' : 'maintenance'}` };
} catch { /* Fall back to the local list. */ }
return fallback;
}
export function elapsedTime(start: string | null | undefined, end: number): string {
if (!start || !Number.isFinite(Date.parse(start))) return 'Not recorded';
const seconds = Math.max(0, Math.floor((end - Date.parse(start)) / 1000));
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`;
}
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
const text = (value: unknown): value is string => typeof value === 'string';
const number = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
const nullableText = (value: unknown) => value === null || text(value);
const timestamp = (value: unknown): value is string => text(value) && Number.isFinite(Date.parse(value));
const nullableTime = (value: unknown) => value === null || timestamp(value);
const scalar = (value: unknown) => value === null || text(value) || number(value);
const localLink = (value: unknown) => value === null || (text(value) && value.startsWith('/') && !value.startsWith('//') && !value.includes('\\'));
const optionalLink = (value: unknown) => value === undefined || localLink(value);
const phase = (value: unknown) => value === null || provisioningPhases.includes(value as ProvisioningPhase);
const event = (value: unknown): value is ProvisioningEvent => record(value) && number(value.id) && timestamp(value.created_at) && text(value.message);
export function isProvisioningEventPage(value: unknown): value is ProvisioningEventPage {
return record(value) && Array.isArray(value.events) && value.events.every(event)
&& number(value.total) && number(value.totalEvents) && (value.latestId === null || number(value.latestId))
&& number(value.watermark) && (value.nextCursor === null || number(value.nextCursor));
}
/** Validate every nested shape consumed by the page before replacing its last good snapshot. */
export function isProvisioningDetail(value: unknown, id: string): value is ProvisioningDetail {
if (!record(value) || !record(value.deployment)) return false;
const operation = value.deployment;
if (operation.id !== id || !provisioningActions.includes(operation.action as ProvisioningAction)
|| !['server', 'state', 'stage', 'triggered_by', 'warnings'].every(key => text(operation[key]))
|| !timestamp(operation.started_at) || !nullableTime(operation.finished_at) || !nullableTime(operation.server_deleted_at)
|| !nullableText(operation.error) || !number(operation.server_exists)
|| (operation.submitted_at !== undefined && !nullableTime(operation.submitted_at))
|| (operation.expected_map !== undefined && !nullableText(operation.expected_map))) return false;
if (!timestamp(value.generatedAt) || !Array.isArray(value.events) || !value.events.every(event)
|| !Array.isArray(value.warningMessages) || !value.warningMessages.every(text)
|| !['canResumeVerification', 'canResumeCleanup', 'canResumeRebuild', 'canCancelRemoval'].every(key => typeof value[key] === 'boolean')
|| !nullableText(value.blueprintName)) return false;
if (!record(value.activity) || !number(value.activity.total) || !(value.activity.latestId === null || number(value.activity.latestId))
|| !(value.activity.failureExcerpt === null || event(value.activity.failureExcerpt))) return false;
if (!Array.isArray(value.sources) || !value.sources.every(source => record(source)
&& ['campaign', 'beta', 'maintenance', 'watchdog'].includes(source.kind as string)
&& text(source.source_id) && text(source.label) && localLink(source.href))) return false;
if (!(value.collection === null || record(value.collection) && text(value.collection.id) && text(value.collection.name))) return false;
if (!record(value.capacity) || !timestamp(value.capacity.evaluatedAt) || !Array.isArray(value.capacity.pools)
|| !value.capacity.pools.every(pool => record(pool) && ['startup', 'restart'].includes(pool.kind as string)
&& number(pool.limit) && number(pool.used) && typeof pool.ownsSlot === 'boolean' && (pool.requestOrder === null || number(pool.requestOrder))
&& Array.isArray(pool.holders) && pool.holders.every(holder => record(holder) && nullableText(holder.id) && text(holder.server)))) return false;
if (value.recipe !== null) {
const recipe = value.recipe;
if (!record(recipe) || !['t5', 't6'].includes(recipe.game as string) || !timestamp(recipe.capturedAt)
|| !['mapSlug', 'mapName', 'expectedMap', 'image', 'configFile', 'configSource', 'fingerprint'].every(key => text(recipe[key]))
|| !Array.isArray(recipe.files) || !recipe.files.every(file => record(file)
&& ['name', 'kind', 'path', 'sha256'].every(key => text(file[key])) && number(file.size) && (file.version === null || number(file.version)))) return false;
}
if (value.progress !== null) {
const progress = value.progress;
if (!record(progress) || progress.version !== 1 || !phase(progress.phase) || !nullableTime(progress.phaseStartedAt) || !nullableTime(progress.observedAt)
|| ![null, 'queued', 'attempted'].includes(progress.recipeStatus as null | string)) return false;
if (!(progress.blocker === null || record(progress.blocker) && text(progress.blocker.code) && text(progress.blocker.message)
&& typeof progress.blocker.automatic === 'boolean' && optionalLink(progress.blocker.href)
&& (progress.blocker.label === undefined || nullableText(progress.blocker.label)))) return false;
if (!Array.isArray(progress.checks) || !progress.checks.every(check => record(check)
&& ['container', 'game', 'map', 'occupancy', 'stopped'].includes(check.key as string)
&& ['pass', 'waiting', 'fail', 'unknown'].includes(check.result as string)
&& [null, 'komodo', 'rcon', 'telemetry', 'combined'].includes(check.source as null | string)
&& text(check.label) && timestamp(check.checkedAt) && nullableTime(check.receivedAt)
&& scalar(check.expected) && scalar(check.actual) && nullableText(check.message))) return false;
if (!Array.isArray(progress.checkpoints) || !progress.checkpoints.every(checkpoint => record(checkpoint)
&& text(checkpoint.key) && text(checkpoint.label) && timestamp(checkpoint.at) && phase(checkpoint.phase))) return false;
if (!(progress.command === null || record(progress.command) && ['attempted', 'accepted', 'rejected', 'uncertain'].includes(progress.command.state as string)
&& timestamp(progress.command.at) && text(progress.command.message))) return false;
if (!(progress.recovery === null || record(progress.recovery) && ['held', 'released', 'transferred', 'unknown'].includes(progress.recovery.state as string)
&& timestamp(progress.recovery.at) && text(progress.recovery.message)
&& (progress.recovery.operationId === undefined || nullableText(progress.recovery.operationId)))) return false;
}
return true;
}
+131
View File
@@ -0,0 +1,131 @@
export type ProvisioningMutation = 'resume' | 'cancel';
export interface ProvisioningRefreshState<T> {
id: string;
detail: T | null;
refreshing: boolean;
busy: ProvisioningMutation | null;
loadError: string;
actionError: string;
notice: string;
refreshedAt: string | null;
}
interface Options<T> {
onChange: (state: ProvisioningRefreshState<T>) => void;
validate: (value: unknown, id: string) => value is T;
fetcher?: typeof fetch;
now?: () => string;
timeoutMs?: number;
}
/** Reads and action feedback belong to a particular visit, even when its ID is revisited. */
export function createProvisioningRefresh<T>(options: Options<T>) {
const request = options.fetcher ?? fetch;
const now = options.now ?? (() => new Date().toISOString());
let visit = 0;
let read = 0;
let disposed = false;
let pending: { controller: AbortController; promise: Promise<void>; timer?: ReturnType<typeof setTimeout> } | null = null;
let mutation: AbortController | null = null;
let state: ProvisioningRefreshState<T> = {
id: '', detail: null, refreshing: false, busy: null, loadError: '', actionError: '', notice: '', refreshedAt: null
};
const publish = (patch: Partial<ProvisioningRefreshState<T>>) => {
state = { ...state, ...patch };
if (!disposed) options.onChange(state);
};
function invalidateRead() {
read++;
if (pending) {
clearTimeout(pending.timer);
pending.controller.abort();
pending = null;
}
}
async function load(force = false, reconcile = false): Promise<void> {
if (disposed || !state.id || (state.busy && !reconcile)) return;
if (pending && !force) return pending.promise;
invalidateRead();
const currentVisit = visit;
const currentRead = read;
const id = state.id;
const current = () => !disposed && currentVisit === visit && currentRead === read && id === state.id;
const controller = new AbortController();
const entry = { controller, promise: Promise.resolve(), timer: undefined as ReturnType<typeof setTimeout> | undefined };
let timedOut = false;
pending = entry;
publish({ refreshing: true });
entry.timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? 15_000);
entry.promise = (async () => {
try {
const response = await request(`/api/provisioning/${encodeURIComponent(id)}?events=recent`, { signal: controller.signal, cache: 'no-store' });
if (!response.ok) throw new Error(response.status === 404 ? 'Provisioning operation not found.' : 'Could not refresh this operation.');
const detail: unknown = await response.json();
if (!options.validate(detail, id)) throw new Error('The operation response was incomplete. Try refreshing again.');
if (current()) publish({ detail, loadError: '', refreshedAt: now() });
} catch (error) {
if (current() && (!controller.signal.aborted || timedOut)) publish({
loadError: timedOut ? 'Operation refresh timed out. Try again.' : error instanceof Error ? error.message : 'Could not refresh this operation.'
});
} finally {
clearTimeout(entry.timer);
if (pending === entry) pending = null;
if (current()) publish({ refreshing: false });
}
})();
return entry.promise;
}
function setOperation(id: string, detail: T | null = null, refreshedAt: string | null = null) {
if (disposed) return;
visit++;
invalidateRead();
mutation?.abort();
mutation = null;
publish({ id, detail, refreshedAt, refreshing: false, busy: null, loadError: '', actionError: '', notice: '' });
if (!detail) void load();
}
async function act(action: ProvisioningMutation, successMessage: string): Promise<void> {
if (disposed || !state.id || state.busy) return;
const currentVisit = visit;
const id = state.id;
const current = () => !disposed && currentVisit === visit && id === state.id;
invalidateRead();
const controller = new AbortController();
mutation = controller;
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? 15_000);
publish({ busy: action, refreshing: false, actionError: '', notice: '' });
try {
const response = await request(`/api/provisioning/${encodeURIComponent(id)}/${action}`, { method: 'POST', signal: controller.signal });
const body = await response.json() as { error?: string };
if (!response.ok) throw new Error(body.error ?? 'The operation changed before this request could be accepted.');
if (current()) publish({ notice: successMessage });
} catch (error) {
if (current() && (!controller.signal.aborted || timedOut)) publish({ actionError: timedOut
? 'The request timed out and may have reached the server. Review the refreshed operation before trying again.'
: error instanceof Error ? error.message : 'Could not confirm the request. Review the operation before trying again.' });
} finally {
clearTimeout(timer);
if (mutation === controller) mutation = null;
// A failed reconciliation is a read error, never a failed accepted mutation.
if (current()) {
await load(true, true);
if (current()) publish({ busy: null });
}
}
}
function dispose() {
disposed = true;
visit++;
invalidateRead();
mutation?.abort();
mutation = null;
}
return { setOperation, refresh: () => load(), forceRefresh: () => load(true), act, dispose };
}