Consolidate provisioning progress and evidence (Refs #82)
This commit is contained in:
@@ -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<ProvisioningEvent[]>([]);
|
||||
let frozenRecent = $state<ProvisioningEvent[] | null>(null);
|
||||
let total = $state(0);
|
||||
let baselineTotal = $state<number | null>(null);
|
||||
let watermark = $state<number | null>(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(); });
|
||||
</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><span class="time"><OperationTime start={detail.deployment.started_at} value={event.created_at} /></span><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}
|
||||
|
||||
<h2 id="activity-title">Activity log</h2>
|
||||
<details class="history" ontoggle={toggle}>
|
||||
<summary>Full activity history <span>{detail.activity.total} events</span></summary>
|
||||
<summary>Full activity history <span>{countLabel(detail.activity.total, 'event')}</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(); }}>
|
||||
@@ -107,16 +95,14 @@
|
||||
|
||||
<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); }
|
||||
.muted, .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); }
|
||||
.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); }
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import OperationTime from '$ui/components/OperationTime.svelte';
|
||||
import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
|
||||
import { activeProvisioningStates, provisioningCheckValue, provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
|
||||
let { detail }: { detail: ProvisioningDetail } = $props();
|
||||
let { detail, hostHistoryHref }: { detail: ProvisioningDetail; hostHistoryHref?: string } = $props();
|
||||
const groups = $derived(provisioningGroups(detail));
|
||||
const progress = $derived(detail.progress);
|
||||
const active = $derived(activeProvisioningStates.includes(detail.deployment.state));
|
||||
@@ -17,17 +16,18 @@
|
||||
<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}
|
||||
{/if}
|
||||
{#if groups.length}
|
||||
<ol class="phases">
|
||||
{#each groups as group (group.key)}
|
||||
<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] ?? true}>
|
||||
<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] ?? true); }}
|
||||
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));
|
||||
if (!event.repeat) expanded[group.key] = !(expanded[group.key] ?? true);
|
||||
}}
|
||||
>
|
||||
<span class="phase-icon state-{group.state}" aria-hidden="true">{progressIcons[group.state]}</span>
|
||||
@@ -38,7 +38,50 @@
|
||||
{#each group.steps as step (step.key)}
|
||||
<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>
|
||||
<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 class="step-content">
|
||||
<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>
|
||||
{#each step.checks as check (check.key)}
|
||||
<div class="evidence">
|
||||
{#if step.checks.length > 1 || check.key === 'plutonium-release'}<p class="check-label">{check.label}{' · '}{evidenceLabels[check.result]}</p>{/if}
|
||||
<dl class="values">
|
||||
{#if check.expected !== null}<div><dt>Expected</dt><dd>{provisioningCheckValue(check.key, check.expected)}</dd></div>{/if}
|
||||
<div><dt>Observed</dt><dd>{provisioningCheckValue(check.key, check.actual)}</dd></div>
|
||||
</dl>
|
||||
{#if check.message}<p>{check.message}</p>{/if}
|
||||
<div class="evidence-meta">
|
||||
<span>{check.source ? sources[check.source] : 'Source not recorded'}</span>
|
||||
{#if check.checkedAt !== step.at}<span>Checked <OperationTime start={detail.deployment.started_at} value={check.checkedAt} /></span>{/if}
|
||||
{#if check.receivedAt}<span>Evidence received <OperationTime start={detail.deployment.started_at} value={check.receivedAt} /></span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if step.key === 'verify-map' && detail.deployment.expected_map}<p class="map-note">Engine-map verification does not independently verify a variant or start location.</p>{/if}
|
||||
{#if step.command}
|
||||
<div class="evidence" class:uncertain={step.command.state === 'uncertain'}>
|
||||
<p class="command-label">{step.command.state === 'attempted' ? 'Command submission attempted' : step.command.state === 'accepted' ? 'Remote request accepted' : step.command.state === 'rejected' ? 'Remote request rejected' : 'Remote response uncertain'}</p>
|
||||
<p>{step.command.message}</p>
|
||||
<span class="time"><OperationTime start={detail.deployment.started_at} value={step.command.at} /></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if step.agentOperation}
|
||||
<div class="evidence">
|
||||
<dl class="agent-details">
|
||||
<div><dt>Agent state</dt><dd>{step.agentOperation.state}</dd></div>
|
||||
<div><dt>Command queued</dt><dd><OperationTime start={detail.deployment.started_at} value={step.agentOperation.createdAt} missing="Not recorded" /></dd></div>
|
||||
<div><dt>Latest agent report</dt><dd><OperationTime start={detail.deployment.started_at} value={step.agentOperation.updatedAt} missing="Not recorded" /></dd></div>
|
||||
<div><dt>Command ID</dt><dd><code>{step.agentOperation.id}</code></dd></div>
|
||||
</dl>
|
||||
{#if step.agentOperation.error}<p class="agent-error">{step.agentOperation.error}</p>{/if}
|
||||
<a href={hostHistoryHref ?? `/hosts/operations?server=${encodeURIComponent(detail.deployment.server)}`}>View host operation history</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if step.checkpoints.length}<ul class="checkpoints">
|
||||
{#each step.checkpoints as checkpoint (checkpoint.key)}
|
||||
<li><span>{checkpoint.label}</span><span class="time"><OperationTime start={detail.deployment.started_at} value={checkpoint.at} /></span></li>
|
||||
{/each}
|
||||
</ul>{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
@@ -46,37 +89,6 @@
|
||||
</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>
|
||||
<span class="time"><OperationTime start={detail.deployment.started_at} value={progress.command.at} /></span>
|
||||
</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><span class="time"><OperationTime start={detail.deployment.started_at} value={checkpoint.at} /></span></li>
|
||||
{/each}
|
||||
</ul></details>
|
||||
{/if}
|
||||
<div class="evidence-heading"><h3>Latest recorded checks</h3>{#if progress.observedAt}<span>Evaluated <span class="time"><OperationTime start={detail.deployment.started_at} value={progress.observedAt} /></span></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>{provisioningCheckValue(check.key, check.expected)}</dd></div>{/if}
|
||||
<div><dt>Observed</dt><dd>{provisioningCheckValue(check.key, check.actual)}</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 <span class="time"><OperationTime start={detail.deployment.started_at} value={check.checkedAt} /></span></span>{#if check.receivedAt}<span>Evidence received <span class="time"><OperationTime start={detail.deployment.started_at} value={check.receivedAt} /></span></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>
|
||||
|
||||
@@ -86,7 +98,7 @@
|
||||
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, .steps, .checkpoints { 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)); }
|
||||
@@ -101,24 +113,30 @@
|
||||
.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); }
|
||||
.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; }
|
||||
.legacy { display: grid; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
p { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.65; overflow-wrap: anywhere; }
|
||||
.legacy p { font-size: var(--text-sm); }
|
||||
.step-content { min-width: 0; flex: 1; overflow-wrap: anywhere; }
|
||||
.evidence { display: grid; gap: var(--space-1); margin-top: var(--space-2); font-size: var(--text-xs); }
|
||||
.check-label, .command-label { color: var(--text-primary); font-weight: var(--weight-medium); }
|
||||
.uncertain .command-label { color: var(--state-warning); }
|
||||
.agent-error { color: var(--state-danger); }
|
||||
dl { display: flex; gap: var(--space-2) var(--space-4); margin: 0; flex-wrap: wrap; line-height: 1.6; }
|
||||
dt { color: var(--text-tertiary); }dd { margin: 0; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
.values > div { display: flex; flex-wrap: wrap; gap: 0 var(--space-1); }
|
||||
.agent-details { margin-block: var(--space-1); }.agent-details > div { min-width: 0; }
|
||||
.time, .evidence-meta, .checkpoints { font-size: var(--text-xs); }
|
||||
.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); } }
|
||||
.checkpoints { display: grid; gap: var(--space-2); margin-top: var(--space-3); color: var(--text-secondary); line-height: 1.6; }
|
||||
.checkpoints li { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3); }
|
||||
.map-note { margin-top: var(--space-2); }code { font-family: var(--font-mono); overflow-wrap: anywhere; }
|
||||
a { color: var(--accent-primary-hover); text-underline-offset: 3px; }
|
||||
a:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; border-radius: 2px; }
|
||||
@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 { padding: var(--space-4); } }
|
||||
</style>
|
||||
|
||||
@@ -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<ProvisioningDeployment, 'r
|
||||
|
||||
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 ProgressStep {
|
||||
key: ProvisioningPhase; label: string; state: ProgressState; at: string | null;
|
||||
checks: ProvisioningCheck[];
|
||||
checkpoints: ProvisioningCheckpoint[];
|
||||
command: ProvisioningProgress['command'];
|
||||
agentOperation: ProvisioningDetail['agentOperation'];
|
||||
}
|
||||
export interface ProgressGroup { key: string; label: string; steps: ProgressStep[]; state: ProgressState }
|
||||
|
||||
const phaseLabels: Record<ProvisioningPhase, string> = {
|
||||
@@ -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<ProvisioningCheck['key'], ProvisioningPhase> = {
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user