Fix failed provisioning progress states (Refs #80)

This commit is contained in:
2026-10-09 10:21:02 -04:00
parent dbbe3749e2
commit 29c2e3ba00
3 changed files with 69 additions and 7 deletions
@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import OperationTime from '$ui/components/OperationTime.svelte'; import OperationTime from '$ui/components/OperationTime.svelte';
import type { ProvisioningDetail } from '$lib/provisioning-detail.js'; import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
import { provisioningCheckValue, provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js'; import { activeProvisioningStates, provisioningCheckValue, provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js';
import Badge from './primitives/Badge.svelte'; import Badge from './primitives/Badge.svelte';
let { detail }: { detail: ProvisioningDetail } = $props(); let { detail }: { detail: ProvisioningDetail } = $props();
const groups = $derived(provisioningGroups(detail)); const groups = $derived(provisioningGroups(detail));
const progress = $derived(detail.progress); const progress = $derived(detail.progress);
const active = $derived(activeProvisioningStates.includes(detail.deployment.state));
let expanded = $state<Record<string, boolean>>({}); let expanded = $state<Record<string, boolean>>({});
const evidenceLabels = { pass: 'Passed', waiting: 'Waiting', fail: 'Failed', unknown: 'Unknown' }; const evidenceLabels = { pass: 'Passed', waiting: 'Waiting', fail: 'Failed', unknown: 'Unknown' };
const sources = { agent: 'Host agent', rcon: 'RCON', telemetry: 'Telemetry', combined: 'RCON and telemetry', manifest: 'Plutonium manifest' }; const sources = { agent: 'Host agent', rcon: 'RCON', telemetry: 'Telemetry', combined: 'RCON and telemetry', manifest: 'Plutonium manifest' };
@@ -19,7 +20,7 @@
{:else} {:else}
<ol class="phases"> <ol class="phases">
{#each groups as group (group.key)} {#each groups as group (group.key)}
<li class:current={group.state === 'current' || group.state === 'waiting'} class:failed={group.state === 'failed'}> <li class:current={active && (group.state === 'current' || group.state === 'waiting')} class:failed={group.state === 'failed'}>
<details open={expanded[group.key] ?? ['current', 'waiting', 'failed'].includes(group.state)}> <details open={expanded[group.key] ?? ['current', 'waiting', 'failed'].includes(group.state)}>
<summary <summary
onclick={event => { event.preventDefault(); expanded[group.key] = !(expanded[group.key] ?? ['current', 'waiting', 'failed'].includes(group.state)); }} onclick={event => { event.preventDefault(); expanded[group.key] = !(expanded[group.key] ?? ['current', 'waiting', 'failed'].includes(group.state)); }}
@@ -35,7 +36,7 @@
</summary> </summary>
<ol class="steps"> <ol class="steps">
{#each group.steps as step (step.key)} {#each group.steps as step (step.key)}
<li aria-current={step.state === 'current' || step.state === 'waiting' ? 'step' : undefined}> <li aria-current={active && (step.state === 'current' || step.state === 'waiting') ? 'step' : undefined}>
<span class="step-icon state-{step.state}" aria-hidden="true">{progressIcons[step.state]}</span> <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}{' · '}<span class="time"><OperationTime start={detail.deployment.started_at} value={step.at} /></span>{/if}</span></div> <div><strong>{step.label}</strong><span class="step-meta state-{step.state}">{progressStateLabels[step.state]}{#if step.at}{' · '}<span class="time"><OperationTime start={detail.deployment.started_at} value={step.at} /></span>{/if}</span></div>
</li> </li>
+7 -4
View File
@@ -45,6 +45,7 @@ export function provisioningGroups(detail: ProvisioningDetail): ProgressGroup[]
const phases = groups.flatMap(group => group.phases); 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 active = activeProvisioningStates.includes(detail.deployment.state);
const failure = ['failed', 'interrupted'].includes(detail.deployment.state);
return groups.map(group => { return groups.map(group => {
const steps = group.phases.map((key): ProgressStep => { const steps = group.phases.map((key): ProgressStep => {
const checkpoint = progress.checkpoints.find(item => item.key === key); const checkpoint = progress.checkpoints.find(item => item.key === key);
@@ -53,12 +54,14 @@ export function provisioningGroups(detail: ProvisioningDetail): ProgressGroup[]
const check = checkKey ? progress.checks.find(item => item.key === checkKey) : null; const check = checkKey ? progress.checks.find(item => item.key === checkKey) : null;
const index = phases.indexOf(key); const index = phases.indexOf(key);
const isCurrent = key === progress.phase; const isCurrent = key === progress.phase;
const state: ProgressState = check ? ({ pass: 'done', fail: 'failed', waiting: 'waiting', unknown: 'unknown' } as const)[check.result] let state: ProgressState = check ? ({ pass: 'done', fail: 'failed', waiting: 'waiting', unknown: 'unknown' } as const)[check.result]
: checkpoint ? 'done' : checkpoint ? 'done'
: isCurrent && ['failed', 'interrupted'].includes(detail.deployment.state) ? '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 && detail.deployment.state === 'cancelled' ? 'cancelled' : current >= 0 && index >= current && (failure || detail.deployment.state === 'cancelled') ? 'cancelled'
: active && current >= 0 && index > current ? 'pending' : 'unknown'; : active && current >= 0 && index > current ? 'pending' : 'unknown';
// Preserve completed evidence, but outstanding work cannot keep waiting after failure.
if (failure && (state === 'waiting' || (isCurrent && state === 'unknown'))) state = 'failed';
const label = key === 'startup-queue' ? 'Admit to startup capacity' const label = key === 'startup-queue' ? 'Admit to startup capacity'
: key === 'prepare' ? 'Prepare launch payload' : key === 'prepare' ? 'Prepare launch payload'
: key === 'container-action' ? `${actionLabels[detail.deployment.action]} instance through host agent` : key === 'container-action' ? `${actionLabels[detail.deployment.action]} instance through host agent`
@@ -73,7 +76,7 @@ export function provisioningGroups(detail: ProvisioningDetail): ProgressGroup[]
} }
export const progressStateLabels: Record<ProgressState, string> = { 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' done: 'Completed', current: 'In progress', waiting: 'Waiting', failed: 'Stopped here', pending: 'Not started', cancelled: 'Not run', unknown: 'Not recorded'
}; };
export const progressIcons: Record<ProgressState, string> = { done: '✓', current: '•', waiting: '◷', failed: '!', pending: '○', cancelled: '–', unknown: '?' }; export const progressIcons: Record<ProgressState, string> = { done: '✓', current: '•', waiting: '◷', failed: '!', pending: '○', cancelled: '–', unknown: '?' };
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { render } from 'svelte/server';
import type { ProvisioningDetail, ProvisioningProgress } from '../../src/lib/provisioning-detail.js'; import type { ProvisioningDetail, ProvisioningProgress } from '../../src/lib/provisioning-detail.js';
import { provisioningActions } from '../../src/lib/provisioning.js'; import { provisioningActions } from '../../src/lib/provisioning.js';
import { isProvisioningDetail, isProvisioningEventPage, provisioningGroups, provisioningOutcome, provisioningRequester, provisioningReturn } from '../../src/lib/ui/provisioning-presentation.js'; import { isProvisioningDetail, isProvisioningEventPage, provisioningGroups, provisioningOutcome, provisioningRequester, provisioningReturn } from '../../src/lib/ui/provisioning-presentation.js';
import ProvisioningProgressView from '../../src/lib/ui/components/ProvisioningProgress.svelte';
const at = '2026-09-28T12:00:00Z'; const at = '2026-09-28T12:00:00Z';
function progress(patch: Partial<ProvisioningProgress> = {}): ProvisioningProgress { function progress(patch: Partial<ProvisioningProgress> = {}): ProvisioningProgress {
@@ -119,6 +121,62 @@ describe('provisioning outcome and evidence presentation', () => {
expect(provisioningGroups(detail).flatMap(group => group.steps).find(step => step.key === 'verify-game')).toMatchObject({ state: 'unknown', at: '2026-09-28T12:01:00Z' }); expect(provisioningGroups(detail).flatMap(group => group.steps).find(step => step.key === 'verify-game')).toMatchObject({ state: 'unknown', at: '2026-09-28T12:01:00Z' });
}); });
it.each(['failed', 'interrupted'])('shows where an unhealthy container stopped a %s operation', state => {
const detail = fixture();
detail.deployment.state = state;
detail.deployment.finished_at = at;
detail.progress = progress({ phase: 'verify-container',
checkpoints: [{ key: 'container-action', label: 'Host command completed', at, phase: 'container-action' }],
checks: [{ key: 'container', label: 'Container readiness', result: 'waiting', source: 'agent', checkedAt: at, receivedAt: null,
expected: 'healthy', actual: 'unhealthy', message: null }] });
const groups = provisioningGroups(detail);
expect(groups.flatMap(group => group.steps).find(step => step.key === 'container-action')?.state).toBe('done');
expect(groups.find(group => group.key === 'verify')).toMatchObject({ state: 'failed', steps: [
{ key: 'verify-container', state: 'failed', at },
{ key: 'verify-game', state: 'cancelled', at: null },
{ key: 'verify-map', state: 'cancelled', at: null },
{ key: 'verify-plutonium', state: 'cancelled', at: null }
] });
const body = render(ProvisioningProgressView, { props: { detail } }).body;
const phases = body.slice(0, body.indexOf('Latest recorded checks'));
expect(phases.match(/Stopped here/g)).toHaveLength(2);
expect(phases.match(/Not run/g)).toHaveLength(3);
expect(phases).not.toContain('Waiting');
expect(phases).toMatch(/<li class="[^"]*\bfailed\b/);
expect(phases).not.toMatch(/<li class="[^"]*\bcurrent\b/);
expect(phases).not.toContain('aria-current="step"');
expect(detail.progress.checks[0].result).toBe('waiting');
});
it.each(['failed', 'interrupted'])('marks the current step of a %s operation even without conclusive check evidence', state => {
const detail = fixture();
detail.deployment.state = state;
detail.progress = progress();
expect(provisioningGroups(detail).flatMap(group => group.steps).find(step => step.key === 'verify-map')?.state).toBe('failed');
detail.progress.checks = [{ key: 'map', label: 'Engine map', result: 'unknown', source: null, checkedAt: at, receivedAt: null,
expected: 'zombie_theater', actual: null, message: 'No fresh evidence' }];
const steps = provisioningGroups(detail).flatMap(group => group.steps);
expect(steps.find(step => step.key === 'verify-map')?.state).toBe('failed');
expect(steps.find(step => step.key === 'verify-plutonium')?.state).toBe('cancelled');
expect(steps.find(step => step.key === 'prepare')?.state).toBe('unknown');
});
it.each(['done', 'warning', 'cancelled', 'failed', 'interrupted'])('does not highlight stale waiting evidence as active on a %s operation', state => {
const detail = fixture();
detail.progress = progress({ phase: 'verify-container', checks: [
{ key: 'container', label: 'Container readiness', result: 'waiting', source: 'agent', checkedAt: at, receivedAt: null,
expected: 'healthy', actual: 'starting', message: null }
] });
const activeBody = render(ProvisioningProgressView, { props: { detail } }).body;
expect(activeBody).toMatch(/<li class="[^"]*\bcurrent\b/);
expect(activeBody).toContain('aria-current="step"');
detail.deployment.state = state;
detail.deployment.finished_at = at;
const finishedBody = render(ProvisioningProgressView, { props: { detail } }).body;
expect(finishedBody).not.toMatch(/<li class="[^"]*\bcurrent\b/);
expect(finishedBody).not.toContain('aria-current="step"');
});
it.each(['create', 'rebuild'] as const)('shows %s Plutonium verification from runtime evidence, separately from the release manifest', action => { it.each(['create', 'rebuild'] as const)('shows %s Plutonium verification from runtime evidence, separately from the release manifest', action => {
const detail = fixture(); const detail = fixture();
detail.deployment.action = action; detail.deployment.action = action;