514 lines
21 KiB
Svelte
514 lines
21 KiB
Svelte
<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>
|