Simplify provisioning operation details (Refs #86)

This commit is contained in:
2026-10-09 12:17:35 -04:00
parent c443ffa0dc
commit 56b4043b6e
4 changed files with 60 additions and 41 deletions
+4 -2
View File
@@ -98,8 +98,10 @@ timestamps without an offset are UTC; never parse them as browser-local time.
Receipt freshness uses the browser clock and shows Paused or a retry reason Receipt freshness uses the browser clock and shows Paused or a retry reason
when updates are unavailable. These clocks are not live regions. when updates are unavailable. These clocks are not live regions.
Provisioning detail pages show absolute start and finish times once in the Provisioning detail pages show the requester, absolute start time and duration
summary. Use `OperationTime` for their steps, checks and activity: offsets from in the summary. Keep the finish time, raw IDs and copy controls in collapsed
Technical details. Show freshness and poll only while the operation is active.
Use `OperationTime` for steps, checks and activity: offsets from
the operation start (`+m:ss` or `+h:mm:ss`), with negative offsets for earlier the operation start (`+m:ss` or `+h:mm:ss`), with negative offsets for earlier
evidence. Missing timestamps stay **Not recorded**. evidence. Missing timestamps stay **Not recorded**.
@@ -71,7 +71,6 @@
<div><dt>Agent state</dt><dd>{step.agentOperation.state}</dd></div> <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>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>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> </dl>
{#if step.agentOperation.error}<p class="agent-error">{step.agentOperation.error}</p>{/if} {#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> <a href={hostHistoryHref ?? `/hosts/operations?server=${encodeURIComponent(detail.deployment.server)}`}>View host operation history</a>
@@ -136,7 +135,7 @@
.evidence-meta { display: flex; gap: var(--space-1) var(--space-4); flex-wrap: wrap; color: var(--text-tertiary); line-height: 1.6; } .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 { 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); } .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; } .map-note { margin-top: var(--space-2); }
a { color: var(--accent-primary-hover); text-underline-offset: 3px; } 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; } 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); } } @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); } }
+47 -28
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { onDestroy, onMount, untrack } from 'svelte'; import { onDestroy, untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { ProvisioningDetail } from '$lib/provisioning-detail.js'; import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
@@ -43,6 +43,8 @@
const finishedAt = $derived(timestampISO(deployment?.finished_at)); const finishedAt = $derived(timestampISO(deployment?.finished_at));
const canOverride = $derived(data.user?.role === 'admin' && detail?.canOverrideSafety); const canOverride = $derived(data.user?.role === 'admin' && detail?.canOverrideSafety);
const outcome = $derived(detail ? provisioningOutcome(detail) : null); const outcome = $derived(detail ? provisioningOutcome(detail) : null);
const active = $derived(outcome?.active ?? false);
const duration = $derived(finishedAt || active ? elapsedTime(startedAt, finishedAt ? Date.parse(finishedAt) : now) : 'Not recorded');
const serverHref = $derived(deployment && deployment.server_exists && !deployment.server_deleted_at ? `/servers/${encodeURIComponent(deployment.server)}` : null); const serverHref = $derived(deployment && deployment.server_exists && !deployment.server_deleted_at ? `/servers/${encodeURIComponent(deployment.server)}` : null);
const recipeStatus = $derived(detail?.progress?.recipeStatus ?? null); const recipeStatus = $derived(detail?.progress?.recipeStatus ?? null);
const recipeHeading = $derived(recipeStatus === 'queued' ? 'Setup captured when queued' : recipeStatus === 'attempted' ? 'Files selected for this attempt' : 'Recorded launch files'); const recipeHeading = $derived(recipeStatus === 'queued' ? 'Setup captured when queued' : recipeStatus === 'attempted' ? 'Files selected for this attempt' : 'Recorded launch files');
@@ -55,7 +57,10 @@
controller.setOperation(snapshot.deployment.id, snapshot, snapshot.generatedAt); controller.setOperation(snapshot.deployment.id, snapshot, snapshot.generatedAt);
}); });
}); });
onMount(() => pollVisible(() => { now = Date.now(); return controller.refresh(); }, 5000, false)); $effect(() => {
if (!active) return;
return pollVisible(() => { now = Date.now(); return controller.refresh(); }, 5000, false);
});
onDestroy(() => controller.dispose()); onDestroy(() => controller.dispose());
async function retryWithRebuild() { async function retryWithRebuild() {
if (!deployment || !detail?.canRetryWithRebuild || view.busy) return; if (!deployment || !detail?.canRetryWithRebuild || view.busy) return;
@@ -95,31 +100,28 @@
<svelte:head><title>{deployment ? `${actionLabels[deployment.action]} ${deployment.server}` : 'Provisioning'} | Electric Cherry</title></svelte:head> <svelte:head><title>{deployment ? `${actionLabels[deployment.action]} ${deployment.server}` : 'Provisioning'} | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<div class="operation-header">
<SectionHeader title={deployment ? `${actionLabels[deployment.action]} · ${serverTitle(deployment.server)}` : 'Provisioning operation'} subtitle={deployment?.server}> <SectionHeader title={deployment ? `${actionLabels[deployment.action]} · ${serverTitle(deployment.server)}` : 'Provisioning operation'} subtitle={deployment?.server}>
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: 'Operation' }]} />{/snippet} {#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: 'Operation' }]} />{/snippet}
{#snippet actions()}{#if serverHref}<Button href={serverHref}>View server</Button>{/if}{/snippet} {#snippet actions()}{#if serverHref}<Button href={serverHref}>View server</Button>{/if}{/snippet}
</SectionHeader> </SectionHeader>
{#if deployment && outcome} {#if deployment && outcome}
<div class="operation-summary"> <p class="operation-summary">
<p>
<span>Requested by {provisioningRequester(deployment)}</span>{' · '} <span>Requested by {provisioningRequester(deployment)}</span>{' · '}
<span>Started {#if startedAt}<time datetime={startedAt}>{$timeFormat.dateTime(startedAt)}</time>{:else}Not recorded{/if}</span>{' · '} <span>{#if startedAt}<time datetime={startedAt} title="Operation started">{$timeFormat.dateTime(startedAt)}</time>{:else}Start: Not recorded{/if}</span>{' · '}
<span>{#if finishedAt}Finished <time datetime={finishedAt}>{$timeFormat.dateTime(finishedAt)}</time>{:else}{outcome.active ? 'In progress' : 'Finished: Not recorded'}{/if}</span> <span title={finishedAt ? `Finished ${$timeFormat.dateTime(finishedAt)}` : undefined}>{finishedAt ? `took ${duration}` : active ? `${duration} elapsed` : 'Duration: Not recorded'}</span>
</p> </p>
<p class="timing-note">Offsets below show time from the operation start (minutes:seconds or hours:minutes:seconds).</p>
</div>
{/if} {/if}
<div class="identity-row">
{#if deployment}<div class="operation-id"><span>Operation</span><CopyValue value={deployment.id} label="operation ID" /></div>{/if}
<div class="identity-links"><Button size="sm" variant="ghost" onclick={copyLink}>Copy link</Button>{#if copyStatus}<span role="status">{copyStatus}</span>{/if}</div>
</div> </div>
{#if active}
<Freshness updatedAt={view.refreshedAt ? Date.parse(view.refreshedAt) : null} error={view.loadError ? 'Operation updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void controller.forceRefresh()} retryDisabled={view.refreshing || !!view.busy} /> <Freshness updatedAt={view.refreshedAt ? Date.parse(view.refreshedAt) : null} error={view.loadError ? 'Operation updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void controller.forceRefresh()} retryDisabled={view.refreshing || !!view.busy} />
{#if view.loadError}<div class="refresh-error" role="alert"><div><strong>Updates unavailable</strong><p>{view.loadError} {detail ? 'Showing the last successfully loaded record.' : ''}</p></div></div>{/if} {/if}
{#if view.loadError}<div class="refresh-error" role="alert"><div><strong>Updates unavailable</strong><p>{view.loadError} {detail ? 'Showing the last successfully loaded record.' : ''}</p></div>{#if !active}<Button size="sm" onclick={() => void controller.forceRefresh()} disabled={view.refreshing || !!view.busy}>Retry update</Button>{/if}</div>{/if}
{#if deployment && detail && outcome} {#if deployment && detail && outcome}
<section class="outcome" class:attention={['failed', 'interrupted', 'warning'].includes(deployment.state)} aria-labelledby="outcome-title"> <section class="outcome" class:attention={['failed', 'interrupted', 'warning'].includes(deployment.state)} aria-labelledby="outcome-title">
<div class="outcome-top"><div class="badges"><StatusBadge state={deployment.state} />{#if deployment.server_deleted_at}<Badge>Deleted identity</Badge>{/if}{#if detail.progress?.recovery?.state === 'held'}<Badge variant="warning">Recovery capacity held</Badge>{/if}</div><span class="record-label">Operation outcome</span></div> <div class="badges"><StatusBadge state={deployment.state} />{#if deployment.server_deleted_at}<Badge>Deleted identity</Badge>{/if}{#if detail.progress?.recovery?.state === 'held'}<Badge variant="warning">Recovery capacity held</Badge>{/if}</div>
<div class="outcome-message" aria-live="polite" aria-atomic="true"> <div class="outcome-message" aria-live="polite" aria-atomic="true">
<h2 id="outcome-title">{outcome.headline}</h2><p>{outcome.description}</p> <h2 id="outcome-title">{outcome.headline}</h2><p>{outcome.description}</p>
{#if outcome.checksSummary}<p><span aria-hidden="true">✓</span>{' '}{outcome.checksSummary}</p>{/if} {#if outcome.checksSummary}<p><span aria-hidden="true">✓</span>{' '}{outcome.checksSummary}</p>{/if}
@@ -147,7 +149,7 @@
{#if detail.progress?.recovery}<div class="recovery-note"><strong>{detail.progress.recovery.state === 'held' ? 'Recovery remains in progress' : detail.progress.recovery.state === 'released' ? 'Recovery capacity released' : detail.progress.recovery.state === 'transferred' ? 'Recovery transferred' : 'Recovery state not confirmed'}</strong><p>{detail.progress.recovery.message}</p><span>Recorded <OperationTime start={deployment.started_at} value={detail.progress.recovery.at} missing="Not recorded" /></span>{#if detail.progress.recovery.operationId && detail.progress.recovery.operationId !== deployment.id}<a href={`/provisioning/${encodeURIComponent(detail.progress.recovery.operationId)}`}>View recovery operation →</a>{/if}</div>{/if} {#if detail.progress?.recovery}<div class="recovery-note"><strong>{detail.progress.recovery.state === 'held' ? 'Recovery remains in progress' : detail.progress.recovery.state === 'released' ? 'Recovery capacity released' : detail.progress.recovery.state === 'transferred' ? 'Recovery transferred' : 'Recovery state not confirmed'}</strong><p>{detail.progress.recovery.message}</p><span>Recorded <OperationTime start={deployment.started_at} value={detail.progress.recovery.at} missing="Not recorded" /></span>{#if detail.progress.recovery.operationId && detail.progress.recovery.operationId !== deployment.id}<a href={`/provisioning/${encodeURIComponent(detail.progress.recovery.operationId)}`}>View recovery operation →</a>{/if}</div>{/if}
{#if serverHref && !['done', 'cancelled'].includes(deployment.state)}<div class="diagnostics"><a href={`${serverHref}?tab=logs`}>Open server logs</a><a href={`${serverHref}?tab=diagnostics`}>Diagnose connection</a>{#if detail.progress?.phase === 'verify-map' || detail.progress?.checks.some(check => check.key === 'map' && check.result !== 'pass')}<a href={`${serverHref}?tab=config`}>Review map configuration</a>{/if}</div>{/if} {#if serverHref && !['done', 'cancelled'].includes(deployment.state)}<div class="diagnostics"><a href={`${serverHref}?tab=logs`}>Open server logs</a><a href={`${serverHref}?tab=diagnostics`}>Diagnose connection</a>{#if detail.progress?.phase === 'verify-map' || detail.progress?.checks.some(check => check.key === 'map' && check.result !== 'pass')}<a href={`${serverHref}?tab=config`}>Review map configuration</a>{/if}</div>{/if}
<div class="timing"> <div class="timing">
<div><span>{outcome.active ? 'Total elapsed' : 'Recorded duration'}</span><strong>{finishedAt || outcome.active ? elapsedTime(startedAt, finishedAt ? Date.parse(finishedAt) : now) : 'Not recorded'}</strong></div> <div><span>{outcome.active ? 'Total elapsed' : 'Recorded duration'}</span><strong>{duration}</strong></div>
{#if outcome.active}<div><span>Time in phase</span><strong>{elapsedTime(timestampISO(detail.progress?.phaseStartedAt), now)}</strong></div>{/if} {#if outcome.active}<div><span>Time in phase</span><strong>{elapsedTime(timestampISO(detail.progress?.phaseStartedAt), now)}</strong></div>{/if}
<div><span>Last evaluated</span><OperationTime start={deployment.started_at} value={detail.progress?.observedAt} missing="Not recorded" /></div> <div><span>Last evaluated</span><OperationTime start={deployment.started_at} value={detail.progress?.observedAt} missing="Not recorded" /></div>
</div> </div>
@@ -155,7 +157,10 @@
{#key deployment.id} {#key deployment.id}
<div class="detail-grid"> <div class="detail-grid">
<div class="execution-column"><ProvisioningProgress {detail} hostHistoryHref={contextHref(`/hosts/operations?server=${encodeURIComponent(deployment.server)}`, $page.url)} /><ProvisioningActivity {detail} /></div> <div class="execution-column">
<div class="execution-progress"><p class="timing-note">Offsets below show time from the operation start (minutes:seconds or hours:minutes:seconds).</p><ProvisioningProgress {detail} hostHistoryHref={contextHref(`/hosts/operations?server=${encodeURIComponent(deployment.server)}`, $page.url)} /></div>
<ProvisioningActivity {detail} />
</div>
<aside class="context-column" aria-label="Operation context"> <aside class="context-column" aria-label="Operation context">
{#if outcome.active && detail.workerActivity} {#if outcome.active && detail.workerActivity}
<Card as="section" padding="md" ariaLabel="Controller activity"><div class="context-section"> <Card as="section" padding="md" ariaLabel="Controller activity"><div class="context-section">
@@ -166,15 +171,9 @@
</ul>{:else}<p>Waiting for the next provisioning pass.</p>{/if} </ul>{:else}<p>Waiting for the next provisioning pass.</p>{/if}
</div></Card> </div></Card>
{/if} {/if}
<Card as="section" padding="md" ariaLabel="Operation details">
<div class="context-section"><h2>Operation details</h2><dl class="metadata">
<div><dt>Submission attempted</dt><dd><OperationTime start={deployment.started_at} value={deployment.submitted_at} missing="Not recorded" /></dd></div>
{#if deployment.server_deleted_at}<div><dt>Identity deleted</dt><dd><OperationTime start={deployment.started_at} value={deployment.server_deleted_at} /></dd></div>{/if}
</dl></div>
</Card>
{#if detail.sources.length || detail.collection}<Card as="section" padding="md" ariaLabel="Related work"><div class="context-section"><h2>Related work</h2><p>The requesting work may have further checks after this operation finishes.</p><ul class="related"> {#if detail.sources.length || detail.collection}<Card as="section" padding="md" ariaLabel="Related work"><div class="context-section"><h2>Related work</h2><p>The requesting work may have further checks after this operation finishes.</p><ul class="related">
{#if detail.collection}<li><a href={`/collections/launches/${encodeURIComponent(detail.collection.id)}`}>{detail.collection.name}</a><span>Collection launch</span></li>{/if} {#if detail.collection}<li><a href={`/collections/launches/${encodeURIComponent(detail.collection.id)}`}>{detail.collection.name}</a><span>Collection launch</span></li>{/if}
{#each detail.sources as source (`${source.kind}:${source.source_id}`)}<li>{#if source.href}<a href={source.href}>{source.label}</a>{:else}<strong>{source.label}</strong><span>Source record no longer available</span>{/if}<code>{source.source_id}</code></li>{/each} {#each detail.sources as source (`${source.kind}:${source.source_id}`)}<li>{#if source.href}<a href={source.href}>{source.label}</a>{:else}<strong>{source.label}</strong><span>Source record no longer available</span>{/if}</li>{/each}
</ul></div></Card>{/if} </ul></div></Card>{/if}
{#if detail.capacity.pools.length}<Card as="section" padding="md" ariaLabel="Capacity"><div class="context-section"><h2>Capacity</h2> {#if detail.capacity.pools.length}<Card as="section" padding="md" ariaLabel="Capacity"><div class="context-section"><h2>Capacity</h2>
{#each detail.capacity.pools as pool (pool.kind)}<div class="capacity-pool"><div class="capacity-heading"><strong>{pool.kind === 'startup' ? 'Startup slots' : 'Restart recovery slots'}</strong><span>{pool.used} / {pool.limit} in use</span></div> {#each detail.capacity.pools as pool (pool.kind)}<div class="capacity-pool"><div class="capacity-heading"><strong>{pool.kind === 'startup' ? 'Startup slots' : 'Restart recovery slots'}</strong><span>{pool.used} / {pool.limit} in use</span></div>
@@ -192,6 +191,21 @@
<div class="launch-another"><Button variant="ghost" onclick={() => launchOpen = true}>Launch another server →</Button><p>Creates a separate server with a fresh ID and port using the latest promoted files. It does not repair this instance.</p></div> <div class="launch-another"><Button variant="ghost" onclick={() => launchOpen = true}>Launch another server →</Button><p>Creates a separate server with a fresh ID and port using the latest promoted files. It does not repair this instance.</p></div>
{:else}<p>{['create', 'rebuild'].includes(deployment.action) ? 'No launch recipe was recorded for this operation.' : 'This operation uses the existing instance. No new launch files were selected.'}</p>{/if} {:else}<p>{['create', 'rebuild'].includes(deployment.action) ? 'No launch recipe was recorded for this operation.' : 'This operation uses the existing instance. No new launch files were selected.'}</p>{/if}
</div></Card> </div></Card>
<details class="technical-details">
<summary>Technical details</summary>
<div class="technical-content">
<dl class="metadata">
<div><dt>Operation ID</dt><dd><CopyValue value={deployment.id} label="operation ID" /></dd></div>
{#if !active || finishedAt}<div><dt>Finished</dt><dd>{#if finishedAt}<time datetime={finishedAt}>{$timeFormat.dateTime(finishedAt)}</time>{:else}Not recorded{/if}</dd></div>{/if}
<div><dt>Submission attempted</dt><dd><OperationTime start={deployment.started_at} value={deployment.submitted_at} missing="Not recorded" /></dd></div>
{#if deployment.server_deleted_at}<div><dt>Identity deleted</dt><dd><OperationTime start={deployment.started_at} value={deployment.server_deleted_at} /></dd></div>{/if}
{#if detail.agentOperation}<div><dt>Agent command ID</dt><dd><CopyValue value={detail.agentOperation.id} label="agent command ID" /></dd></div>{/if}
{#if detail.collection}<div><dt>Collection launch ID</dt><dd><CopyValue value={detail.collection.id} label="collection launch ID" /></dd></div>{/if}
{#each detail.sources as source (`${source.kind}:${source.source_id}`)}<div><dt>{source.label} · Source ID</dt><dd><CopyValue value={source.source_id} label={`${source.label} source ID`} /></dd></div>{/each}
</dl>
<div class="copy-link"><Button size="sm" variant="ghost" onclick={copyLink}>Copy link</Button>{#if copyStatus}<span role="status">{copyStatus}</span>{/if}</div>
</div>
</details>
</aside> </aside>
</div> </div>
{#if detail.recipe}<DeployServerDialog bind:open={launchOpen} sourceDeploymentId={deployment.id} />{/if} {#if detail.recipe}<DeployServerDialog bind:open={launchOpen} sourceDeploymentId={deployment.id} />{/if}
@@ -206,12 +220,13 @@
h2, h3, p { margin: 0; }a { color: var(--accent-primary-hover); text-underline-offset: 3px; } h2, h3, p { margin: 0; }a { color: var(--accent-primary-hover); text-underline-offset: 3px; }
a:focus-visible, summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; border-radius: 2px; } a:focus-visible, summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; border-radius: 2px; }
p { line-height: 1.65; overflow-wrap: anywhere; } p { line-height: 1.65; overflow-wrap: anywhere; }
.operation-header { display: grid; gap: var(--space-3); }
.operation-summary { color: var(--text-secondary); font-size: var(--text-sm); font-variant-numeric: tabular-nums; } .operation-summary { color: var(--text-secondary); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
.operation-summary .timing-note { margin-top: var(--space-2); font-size: var(--text-xs); } .execution-progress { display: grid; gap: var(--space-4); }
.identity-row, .operation-id, .identity-links, .badges, .outcome-top, .diagnostics, .recovery-actions { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; } .timing-note { font-size: var(--text-xs); color: var(--text-tertiary); }
.identity-row { justify-content: space-between; margin-top: calc(-1 * var(--space-3)); gap: var(--space-2) var(--space-5); }.operation-id { gap: var(--space-2); min-width: 0; }.operation-id > span, .identity-links { font-size: var(--text-xs); color: var(--text-tertiary); }.identity-links { gap: var(--space-4); } .badges, .diagnostics, .recovery-actions { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.outcome { display: grid; gap: var(--space-5); padding: var(--space-6); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); } .outcome { display: grid; gap: var(--space-5); padding: var(--space-6); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }
.outcome.attention { border-color: color-mix(in srgb, var(--state-warning) 35%, var(--border-strong)); }.outcome-top { justify-content: space-between; }.record-label { color: var(--text-tertiary); font-size: var(--text-xs); }.outcome h2 { font-size: var(--text-xl); line-height: 1.35; letter-spacing: var(--tracking-tight); overflow-wrap: anywhere; } .outcome.attention { border-color: color-mix(in srgb, var(--state-warning) 35%, var(--border-strong)); }.outcome h2 { font-size: var(--text-xl); line-height: 1.35; letter-spacing: var(--tracking-tight); overflow-wrap: anywhere; }
.outcome-message { display: grid; gap: var(--space-2); }.outcome-message p { color: var(--text-secondary); font-size: var(--text-base); max-width: 85ch; } .outcome-message { display: grid; gap: var(--space-2); }.outcome-message p { color: var(--text-secondary); font-size: var(--text-base); max-width: 85ch; }
.failure, .action-error, .refresh-error { border: 1px solid color-mix(in srgb, var(--state-danger) 35%, var(--border-subtle)); border-radius: var(--radius-md); padding: var(--space-4); color: var(--state-danger); font-size: var(--text-sm); }.failure p, .action-error p { margin-top: var(--space-2); color: var(--text-primary); }.refresh-error { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); flex-wrap: wrap; }.refresh-error p { color: var(--text-secondary); margin-top: var(--space-1); } .failure, .action-error, .refresh-error { border: 1px solid color-mix(in srgb, var(--state-danger) 35%, var(--border-subtle)); border-radius: var(--radius-md); padding: var(--space-4); color: var(--state-danger); font-size: var(--text-sm); }.failure p, .action-error p { margin-top: var(--space-2); color: var(--text-primary); }.refresh-error { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); flex-wrap: wrap; }.refresh-error p { color: var(--text-secondary); margin-top: var(--space-1); }
.failure-excerpt { display: grid; gap: var(--space-1); font-size: var(--text-sm); color: var(--text-secondary); }.failure-excerpt span { font-size: var(--text-xs); color: var(--text-tertiary); } .failure-excerpt { display: grid; gap: var(--space-1); font-size: var(--text-sm); color: var(--text-secondary); }.failure-excerpt span { font-size: var(--text-xs); color: var(--text-tertiary); }
@@ -221,10 +236,14 @@
.timing { border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-4) var(--space-8); }.timing > div { display: grid; gap: var(--space-2); }.timing span { font-size: var(--text-xs); color: var(--text-tertiary); }.timing strong { font-size: var(--text-md); font-variant-numeric: tabular-nums; font-weight: var(--weight-medium); }.timing :global(time) { font-size: var(--text-sm); color: var(--text-secondary); } .timing { border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-4) var(--space-8); }.timing > div { display: grid; gap: var(--space-2); }.timing span { font-size: var(--text-xs); color: var(--text-tertiary); }.timing strong { font-size: var(--text-md); font-variant-numeric: tabular-nums; font-weight: var(--weight-medium); }.timing :global(time) { font-size: var(--text-sm); color: var(--text-secondary); }
.detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 340px); gap: var(--space-8); align-items: start; }.execution-column { display: grid; gap: var(--space-8); min-width: 0; }.context-column { display: grid; gap: var(--space-5); min-width: 0; }.context-section { display: grid; gap: var(--space-4); min-width: 0; }.context-section h2 { font-size: var(--text-md); }.context-section p { font-size: var(--text-sm); color: var(--text-secondary); }.context-section p.small { font-size: var(--text-xs); } .detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 340px); gap: var(--space-8); align-items: start; }.execution-column { display: grid; gap: var(--space-8); min-width: 0; }.context-column { display: grid; gap: var(--space-5); min-width: 0; }.context-section { display: grid; gap: var(--space-4); min-width: 0; }.context-section h2 { font-size: var(--text-md); }.context-section p { font-size: var(--text-sm); color: var(--text-secondary); }.context-section p.small { font-size: var(--text-xs); }
.metadata { display: grid; gap: var(--space-4); margin: 0; }.metadata dt { color: var(--text-tertiary); font-size: var(--text-xs); }.metadata dd { margin: var(--space-1) 0 0; font-size: var(--text-sm); line-height: 1.6; overflow-wrap: anywhere; } .metadata { display: grid; gap: var(--space-4); margin: 0; }.metadata dt { color: var(--text-tertiary); font-size: var(--text-xs); }.metadata dd { margin: var(--space-1) 0 0; font-size: var(--text-sm); line-height: 1.6; overflow-wrap: anywhere; }
.related, .capacity-pool ul { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--space-4); }.related li { display: grid; gap: var(--space-1); font-size: var(--text-sm); overflow-wrap: anywhere; }.related span, .related code { color: var(--text-tertiary); font-size: var(--text-xs); }.related strong { font-weight: var(--weight-medium); }code { font-family: var(--font-mono); } .technical-details { border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); min-width: 0; }
.technical-details > summary { padding: var(--space-5); cursor: pointer; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
.technical-content { display: grid; gap: var(--space-4); padding: 0 var(--space-5) var(--space-5); }
.copy-link { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); }
.related, .capacity-pool ul { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--space-4); }.related li { display: grid; gap: var(--space-1); font-size: var(--text-sm); overflow-wrap: anywhere; }.related span { color: var(--text-tertiary); font-size: var(--text-xs); }.related strong { font-weight: var(--weight-medium); }
.capacity-pool { display: grid; gap: var(--space-3); font-size: var(--text-xs); }.capacity-pool + .capacity-pool { border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }.capacity-heading { display: flex; justify-content: space-between; gap: var(--space-2); flex-wrap: wrap; }.capacity-heading strong { font-weight: var(--weight-medium); }.capacity-heading span { color: var(--text-secondary); }.capacity-pool summary { cursor: pointer; color: var(--text-secondary); }.capacity-pool ul { gap: var(--space-2); margin-top: var(--space-3); line-height: 1.6; } .capacity-pool { display: grid; gap: var(--space-3); font-size: var(--text-xs); }.capacity-pool + .capacity-pool { border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }.capacity-heading { display: flex; justify-content: space-between; gap: var(--space-2); flex-wrap: wrap; }.capacity-heading strong { font-weight: var(--weight-medium); }.capacity-heading span { color: var(--text-secondary); }.capacity-pool summary { cursor: pointer; color: var(--text-secondary); }.capacity-pool ul { gap: var(--space-2); margin-top: var(--space-3); line-height: 1.6; }
.launch-another { display: grid; gap: var(--space-2); justify-items: start; border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }.launch-another p { font-size: var(--text-xs); } .launch-another { display: grid; gap: var(--space-2); justify-items: start; border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }.launch-another p { font-size: var(--text-xs); }
.loading { min-height: 240px; padding: var(--space-6); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); color: var(--text-secondary); } .loading { min-height: 240px; padding: var(--space-6); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); color: var(--text-secondary); }
@media (max-width: 1000px) { .detail-grid { grid-template-columns: minmax(0, 1fr); }.context-column { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 1000px) { .detail-grid { grid-template-columns: minmax(0, 1fr); }.context-column { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px) { .page { gap: var(--space-5); }.outcome { padding: var(--space-4); gap: var(--space-4); }.outcome h2 { font-size: var(--text-lg); }.outcome-message p { font-size: var(--text-sm); }.context-column { grid-template-columns: minmax(0, 1fr); }.identity-row, .identity-links { align-items: flex-start; }.operation-id { display: block; }.operation-id > span { display: block; margin-bottom: var(--space-1); }.outcome-top { align-items: flex-start; }.record-label { display: none; }.timing { gap: var(--space-4); }.timing > div:last-child { flex-basis: 100%; }.detail-grid { gap: var(--space-6); } } @media (max-width: 600px) { .page { gap: var(--space-5); }.outcome { padding: var(--space-4); gap: var(--space-4); }.outcome h2 { font-size: var(--text-lg); }.outcome-message p { font-size: var(--text-sm); }.context-column { grid-template-columns: minmax(0, 1fr); }.technical-details > summary { padding: var(--space-4); }.technical-content { padding: 0 var(--space-4) var(--space-4); }.timing { gap: var(--space-4); }.timing > div:last-child { flex-basis: 100%; }.detail-grid { gap: var(--space-6); } }
</style> </style>
+1 -2
View File
@@ -330,7 +330,7 @@ describe('provisioning outcome and evidence presentation', () => {
}); });
const body = render(ProvisioningProgressView, { props: { detail, hostHistoryHref: '/hosts/operations?server=test&returnTo=%2Fprovisioning%2Foperation-a' } }).body; const body = render(ProvisioningProgressView, { props: { detail, hostHistoryHref: '/hosts/operations?server=test&returnTo=%2Fprovisioning%2Foperation-a' } }).body;
const commandStep = body.slice(body.indexOf('Create instance through host agent'), body.indexOf('Verify readiness')); const commandStep = body.slice(body.indexOf('Create instance through host agent'), body.indexOf('Verify readiness'));
for (const value of ['Remote response uncertain', detail.progress.command!.message, 'agent-command-a', 'Agent disconnected', 'View host operation history']) { for (const value of ['Remote response uncertain', detail.progress.command!.message, 'Agent disconnected', 'View host operation history']) {
expect(commandStep).toContain(value); expect(commandStep).toContain(value);
expect(body.split(value)).toHaveLength(2); expect(body.split(value)).toHaveLength(2);
} }
@@ -357,7 +357,6 @@ describe('provisioning outcome and evidence presentation', () => {
detail.agentOperation = { id: 'legacy-agent-command', hostId: 'host-a', state: 'failed', createdAt: at, updatedAt: at, error: 'Command failed on host' }; detail.agentOperation = { id: 'legacy-agent-command', hostId: 'host-a', state: 'failed', createdAt: at, updatedAt: at, error: 'Command failed on host' };
const body = render(ProvisioningProgressView, { props: { detail } }).body; const body = render(ProvisioningProgressView, { props: { detail } }).body;
expect(body).toContain('Detailed checkpoints were not recorded'); expect(body).toContain('Detailed checkpoints were not recorded');
expect(body).toContain('legacy-agent-command');
expect(body).toContain('Command failed on host'); expect(body).toContain('Command failed on host');
expect(body).toContain('Stopped here'); expect(body).toContain('Stopped here');
expect(body).not.toContain('Verify readiness'); expect(body).not.toContain('Verify readiness');