Files
electric-cherry/src/lib/ui/components/ProvisioningProgress.svelte
T

514 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
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';
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));
let expanded = $state<Record<string, boolean>>({});
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'
};
</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>
{/if}
{#if groups.length}
<ol class="phases">
{#each groups as group (group.key)}
{@const defaultOpen =
detail.deployment.state !== 'done' ||
group.steps.some(
(step) =>
step.state === 'failed' ||
step.checks.some((check) => check.result !== 'pass')
)}
<li
class:current={active &&
(group.state === 'current' || group.state === 'waiting')}
class:failed={group.state === 'failed'}
>
<details open={expanded[group.key] ?? defaultOpen}>
<summary
onclick={(event) => {
event.preventDefault();
expanded[group.key] = !(expanded[group.key] ?? defaultOpen);
}}
onkeydown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
if (!event.repeat)
expanded[group.key] = !(expanded[group.key] ?? defaultOpen);
}}
>
<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={active &&
(step.state === 'current' || step.state === 'waiting')
? 'step'
: undefined}
>
<span class="step-icon state-{step.state}" aria-hidden="true"
>{progressIcons[step.state]}</span
>
<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>
</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>
</details>
</li>
{/each}
</ol>
{/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,
.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));
}
.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: var(--text-md);
}
.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);
}
.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;
}
.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);
}
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>