From 485a73c4229fbc2d6a312d4e1f1e0e1fa68ad88e Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Fri, 9 Oct 2026 11:18:33 -0400 Subject: [PATCH] Consolidate provisioning progress and evidence (Refs #82) --- .../ui/components/ProvisioningActivity.svelte | 26 +--- .../ui/components/ProvisioningProgress.svelte | 124 ++++++++++-------- src/lib/ui/provisioning-presentation.ts | 65 +++++++-- src/routes/provisioning/[id]/+page.svelte | 16 +-- tests/unit/provisioning-presentation.test.ts | 105 ++++++++++++++- 5 files changed, 235 insertions(+), 101 deletions(-) diff --git a/src/lib/ui/components/ProvisioningActivity.svelte b/src/lib/ui/components/ProvisioningActivity.svelte index 2e40bdc..68e0049 100644 --- a/src/lib/ui/components/ProvisioningActivity.svelte +++ b/src/lib/ui/components/ProvisioningActivity.svelte @@ -2,17 +2,16 @@ import OperationTime from '$ui/components/OperationTime.svelte'; import { onDestroy } from 'svelte'; import type { ProvisioningDetail, ProvisioningEvent } from '$lib/provisioning-detail.js'; + import { countLabel } from '$ui/plural.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([]); - let frozenRecent = $state(null); let total = $state(0); let baselineTotal = $state(null); let watermark = $state(null); @@ -23,7 +22,6 @@ 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) { @@ -63,29 +61,19 @@ } 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; + if (expanded && !loaded && !loading) void load(); } function jumpToLatest() { query = appliedQuery; order = 'newest'; - frozenRecent = detail.events.slice(); void load(); } onDestroy(() => { disposed = true; requestNumber++; controller?.abort(); });
-
Activity

Recent updates

{detail.activity.total} event{detail.activity.total === 1 ? '' : 's'}
-

{open ? 'Recent updates stay in place while you read the full history.' : 'Newest first · updates automatically'}

- {#if recent.length}
    - {#each recent as event (event.id)}
  1. {event.message}

  2. {/each} -
{:else}

No detailed activity was recorded. The recorded outcome and timestamps are preserved above.

{/if} - +

Activity log

- Full activity history {detail.activity.total} events + Full activity history {countLabel(detail.activity.total, 'event')}
{#if newEvents > 0}
{newEvents} new event{newEvents === 1 ? '' : 's'} since this view was loaded.
{/if}
{ event.preventDefault(); void load(); }}> @@ -107,16 +95,14 @@ diff --git a/src/lib/ui/provisioning-presentation.ts b/src/lib/ui/provisioning-presentation.ts index 0ae7392..ac6ba75 100644 --- a/src/lib/ui/provisioning-presentation.ts +++ b/src/lib/ui/provisioning-presentation.ts @@ -1,4 +1,4 @@ -import { provisioningPhases, type ProvisioningCheck, type ProvisioningDeployment, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js'; +import { provisioningPhases, type ProvisioningCheck, type ProvisioningCheckpoint, type ProvisioningDeployment, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase, type ProvisioningProgress } from '$lib/provisioning-detail.js'; import { actionCompleted, actionLabels, provisioningActions, type ProvisioningAction } from '$lib/provisioning.js'; import { systemUsername } from '$lib/auth.js'; import { countLabel } from './plural.js'; @@ -16,7 +16,13 @@ export function provisioningRequester(operation: Pick = { @@ -38,26 +44,58 @@ function definitions(action: ProvisioningAction): GroupDefinition[] { return [request, command, { ...readiness, phases: action === 'stop' ? ['verify-stopped'] : ['verify-container', 'verify-game'] }]; } +const checkPhases: Record = { + occupancy: 'eligibility', container: 'verify-container', game: 'verify-game', map: 'verify-map', + 'plutonium-release': 'verify-plutonium', 'plutonium-version': 'verify-plutonium', stopped: 'verify-stopped' +}; + +function checkpointPhase(checkpoint: ProvisioningCheckpoint): ProvisioningPhase { + if (provisioningPhases.includes(checkpoint.key as ProvisioningPhase)) return checkpoint.key as ProvisioningPhase; + // Receipts can arrive after the worker has moved to another phase. + if (checkpoint.key.startsWith('staging-')) return 'prepare'; + if (/^(agent|recovery)-/.test(checkpoint.key)) return 'container-action'; + return checkpoint.phase ?? 'container-action'; +} + export function provisioningGroups(detail: ProvisioningDetail): ProgressGroup[] { const progress = detail.progress; - if (!progress) return []; - const groups = definitions(detail.deployment.action); + if (!progress && !detail.agentOperation) return []; + const commandGroup: GroupDefinition = { key: 'command', label: `${actionLabels[detail.deployment.action]} on host agent`, phases: ['container-action'] }; + const groups = (progress ? definitions(detail.deployment.action) : [commandGroup]).map(group => ({ ...group, phases: [...group.phases] })); + // Keep recorded evidence even when an older operation used a different phase layout. + const recordedPhases = [...(progress?.checks.map(check => checkPhases[check.key]) ?? []), + ...(progress?.checkpoints.map(checkpointPhase) ?? []), ...(progress?.phase ? [progress.phase] : [])]; + for (const phase of recordedPhases) { + if (groups.some(group => group.phases.includes(phase))) continue; + let group = groups.find(group => group.key === (phase.startsWith('verify-') ? 'verify' : phase === 'cleanup' ? 'cleanup' : 'command')); + if (!group) { + group = { key: phase === 'cleanup' ? 'cleanup' : 'verify', label: phase === 'cleanup' ? 'Retire instance' : 'Verify readiness', phases: [] }; + const verifyIndex = groups.findIndex(group => group.key === 'verify'); + groups.splice(verifyIndex < 0 ? groups.length : verifyIndex, 0, group); + } + group.phases.push(phase); + group.phases.sort((a, b) => provisioningPhases.indexOf(a) - provisioningPhases.indexOf(b)); + } const phases = groups.flatMap(group => group.phases); - const current = progress.phase ? phases.indexOf(progress.phase) : -1; + const current = progress?.phase ? phases.indexOf(progress.phase) : -1; const active = activeProvisioningStates.includes(detail.deployment.state); const failure = ['failed', 'interrupted'].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-plutonium' ? 'plutonium-version' : key === 'verify-stopped' ? 'stopped' : null; - const check = checkKey ? progress.checks.find(item => item.key === checkKey) : null; + const checkpoint = progress?.checkpoints.find(item => item.key === key); + const checks = progress?.checks.filter(item => checkPhases[item.key] === key) ?? []; + // A fetched release manifest does not prove the running revision was verified. + const check = checks.find(item => item.key !== 'plutonium-release'); + const command = key === 'container-action' ? progress?.command ?? null : null; + const agentOperation = key === 'container-action' ? detail.agentOperation : null; const index = phases.indexOf(key); - const isCurrent = key === progress.phase; + const isCurrent = key === progress?.phase; let state: ProgressState = check ? ({ pass: 'done', fail: 'failed', waiting: 'waiting', unknown: 'unknown' } as const)[check.result] : checkpoint ? 'done' + : agentOperation?.state === 'completed' ? 'done' + : agentOperation?.state === 'failed' || command?.state === 'rejected' ? 'failed' : isCurrent && failure ? 'failed' - : isCurrent && active ? progress.blocker || ['waiting', 'queued'].includes(detail.deployment.state) ? 'waiting' : 'current' + : isCurrent && active ? progress?.blocker || ['waiting', 'queued'].includes(detail.deployment.state) ? 'waiting' : 'current' : current >= 0 && index >= current && (failure || detail.deployment.state === 'cancelled') ? 'cancelled' : active && current >= 0 && index > current ? 'pending' : 'unknown'; // Preserve completed evidence, but outstanding work cannot keep waiting after failure. @@ -66,7 +104,10 @@ export function provisioningGroups(detail: ProvisioningDetail): ProgressGroup[] : key === 'prepare' ? 'Prepare launch payload' : key === 'container-action' ? `${actionLabels[detail.deployment.action]} instance through host agent` : phaseLabels[key]; - return { key, label, state, at: check?.checkedAt ?? checkpoint?.at ?? (isCurrent ? progress.phaseStartedAt : null) }; + return { key, label: checkpoint && !check && state === 'done' ? checkpoint.label : label, state, + at: check?.checkedAt ?? checkpoint?.at ?? agentOperation?.updatedAt ?? (isCurrent ? progress?.phaseStartedAt ?? null : null), + checks, checkpoints: progress?.checkpoints.filter(item => item.key !== key && checkpointPhase(item) === key) ?? [], + command, agentOperation }; }); const states = steps.map(step => step.state); const state = (['failed', 'current', 'waiting'] as const).find(value => states.includes(value)) diff --git a/src/routes/provisioning/[id]/+page.svelte b/src/routes/provisioning/[id]/+page.svelte index 9c1e353..5b7dd5c 100644 --- a/src/routes/provisioning/[id]/+page.svelte +++ b/src/routes/provisioning/[id]/+page.svelte @@ -159,22 +159,8 @@ {#key deployment.id}
-
+