Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c443ffa0dc | ||
|
|
43c1a32065 |
@@ -20,14 +20,15 @@
|
||||
{#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] ?? true}>
|
||||
<details open={expanded[group.key] ?? defaultOpen}>
|
||||
<summary
|
||||
onclick={event => { event.preventDefault(); expanded[group.key] = !(expanded[group.key] ?? true); }}
|
||||
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] ?? true);
|
||||
if (!event.repeat) expanded[group.key] = !(expanded[group.key] ?? defaultOpen);
|
||||
}}
|
||||
>
|
||||
<span class="phase-icon state-{group.state}" aria-hidden="true">{progressIcons[group.state]}</span>
|
||||
|
||||
@@ -125,6 +125,8 @@ export function provisioningOutcome(detail: ProvisioningDetail) {
|
||||
const { deployment: operation, progress } = detail;
|
||||
const active = activeProvisioningStates.includes(operation.state);
|
||||
const failure = ['failed', 'interrupted'].includes(operation.state);
|
||||
const checksSummary = operation.state === 'done' && progress?.checks.length && progress.checks.every(check => check.result === 'pass')
|
||||
? `All ${countLabel(progress.checks.length, 'check')} passed` : null;
|
||||
const headline = operation.state === 'done' ? actionCompleted[operation.action]
|
||||
: operation.state === 'cancelled' ? `${actionLabels[operation.action]} request cancelled`
|
||||
: failure ? `${actionLabels[operation.action]} ${operation.state === 'interrupted' ? 'interrupted' : 'failed'}`
|
||||
@@ -160,7 +162,7 @@ export function provisioningOutcome(detail: ProvisioningDetail) {
|
||||
else description = operation.action === 'destroy' ? 'Removal is in progress. Confirmed checkpoints and remaining cleanup are recorded below.'
|
||||
: operation.action === 'rebuild' ? 'The old instance is removed before a fresh instance is created in its reserved slot. Follow each checkpoint below.'
|
||||
: 'The worker is executing this request and checking its resulting state.';
|
||||
return { active, headline, description };
|
||||
return { active, headline, description, checksSummary };
|
||||
}
|
||||
|
||||
/** Accept return context only from the local operation surfaces we expose. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { safeReturnHref, returnLabel, contextHref } from '$lib/navigation-context.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { PageData } from './$types';
|
||||
@@ -9,7 +9,7 @@
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { createProvisioningRefresh, type ProvisioningRefreshState } from '$ui/provisioning-refresh.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { elapsedTime, isProvisioningDetail, provisioningOutcome, provisioningRequester, provisioningReturn } from '$ui/provisioning-presentation.js';
|
||||
import { elapsedTime, isProvisioningDetail, provisioningOutcome, provisioningRequester } from '$ui/provisioning-presentation.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
@@ -43,13 +43,6 @@
|
||||
const finishedAt = $derived(timestampISO(deployment?.finished_at));
|
||||
const canOverride = $derived(data.user?.role === 'admin' && detail?.canOverrideSafety);
|
||||
const outcome = $derived(detail ? provisioningOutcome(detail) : null);
|
||||
const returnTo = $derived.by(() => {
|
||||
const requested = $page.url.searchParams.get('returnTo');
|
||||
if (requested) { const href = safeReturnHref(requested, '/provisioning'); return { href, label: returnLabel(href) }; }
|
||||
if (detail?.collection) return { href: `/collections/launches/${encodeURIComponent(detail.collection.id)}`, label: `Back to ${detail.collection.name}` };
|
||||
if (detail?.sources.length === 1 && detail.sources[0].href) return { href: detail.sources[0].href, label: `Back to ${detail.sources[0].label}` };
|
||||
return provisioningReturn(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 recipeHeading = $derived(recipeStatus === 'queued' ? 'Setup captured when queued' : recipeStatus === 'attempted' ? 'Files selected for this attempt' : 'Recorded launch files');
|
||||
@@ -103,7 +96,7 @@
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title={deployment ? `${actionLabels[deployment.action]} · ${serverTitle(deployment.server)}` : 'Provisioning operation'} subtitle={deployment?.server}>
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Activity', href: '/activity' }, { 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}
|
||||
</SectionHeader>
|
||||
{#if deployment && outcome}
|
||||
@@ -118,7 +111,7 @@
|
||||
{/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"><a href={returnTo.href}>← {returnTo.label}</a><Button size="sm" variant="ghost" onclick={copyLink}>Copy link</Button>{#if copyStatus}<span role="status">{copyStatus}</span>{/if}</div>
|
||||
<div class="identity-links"><Button size="sm" variant="ghost" onclick={copyLink}>Copy link</Button>{#if copyStatus}<span role="status">{copyStatus}</span>{/if}</div>
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
@@ -127,7 +120,10 @@
|
||||
{#if deployment && detail && outcome}
|
||||
<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="outcome-message" aria-live="polite" aria-atomic="true"><h2 id="outcome-title">{outcome.headline}</h2><p>{outcome.description}</p></div>
|
||||
<div class="outcome-message" aria-live="polite" aria-atomic="true">
|
||||
<h2 id="outcome-title">{outcome.headline}</h2><p>{outcome.description}</p>
|
||||
{#if outcome.checksSummary}<p><span aria-hidden="true">✓</span>{' '}{outcome.checksSummary}</p>{/if}
|
||||
</div>
|
||||
{#if deployment.error}<div class="failure" role="alert"><strong>Recorded error</strong><p>{deployment.error}</p></div>{/if}
|
||||
{#if detail.activity.failureExcerpt && detail.activity.failureExcerpt.message !== deployment.error && ['failed', 'interrupted'].includes(deployment.state)}<p class="failure-excerpt"><span>Last failure event · <OperationTime start={deployment.started_at} value={detail.activity.failureExcerpt.created_at} /></span>{detail.activity.failureExcerpt.message}</p>{/if}
|
||||
{#if detail.warningMessages.length}<div class="warnings"><h3>Recorded warnings</h3><ul>{#each detail.warningMessages as warning}<li>{warning}</li>{/each}</ul></div>{/if}
|
||||
|
||||
@@ -20,8 +20,57 @@ function fixture(): ProvisioningDetail {
|
||||
collection: null, progress: progress(), capacity: { evaluatedAt: at, pools: [] }, warningMessages: []
|
||||
};
|
||||
}
|
||||
function completedFixture(): ProvisioningDetail {
|
||||
const detail = fixture();
|
||||
detail.deployment.state = 'done';
|
||||
detail.deployment.finished_at = at;
|
||||
detail.progress = progress({ checks: (['container', 'game', 'map', 'plutonium-release', 'plutonium-version'] as const).map(key => ({
|
||||
key, label: key, result: 'pass', source: 'agent', checkedAt: at, receivedAt: at,
|
||||
expected: `Expected ${key}`, actual: `Observed ${key}`, message: null
|
||||
})) });
|
||||
return detail;
|
||||
}
|
||||
|
||||
describe('provisioning outcome and evidence presentation', () => {
|
||||
it('summarizes recorded passed checks and retains their details in closed timeline groups', () => {
|
||||
const detail = completedFixture();
|
||||
expect(provisioningOutcome(detail).checksSummary).toBe('All 5 checks passed');
|
||||
const body = render(ProvisioningProgressView, { props: { detail } }).body;
|
||||
expect(body.match(/<details\b/g)).toHaveLength(3);
|
||||
expect(body).not.toMatch(/<details\b[^>]*\bopen\b/);
|
||||
for (const check of detail.progress!.checks) {
|
||||
expect(body).toContain(check.expected);
|
||||
expect(body).toContain(check.actual);
|
||||
}
|
||||
expect(body).toContain('Host agent');
|
||||
expect(body).toContain('Evidence received');
|
||||
detail.progress!.checks = detail.progress!.checks.slice(0, 1);
|
||||
expect(provisioningOutcome(detail).checksSummary).toBe('All 1 check passed');
|
||||
detail.progress!.checks = [];
|
||||
expect(provisioningOutcome(detail).checksSummary).toBeNull();
|
||||
detail.progress = null;
|
||||
expect(provisioningOutcome(detail).checksSummary).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['fail', 'waiting', 'unknown'] as const)('keeps %s evidence visible even with a recorded completed outcome', result => {
|
||||
const detail = completedFixture();
|
||||
detail.progress!.checks[2].result = result;
|
||||
expect(provisioningOutcome(detail).checksSummary).toBeNull();
|
||||
const body = render(ProvisioningProgressView, { props: { detail } }).body;
|
||||
const openGroups = body.match(/<details\b[^>]*\bopen\b[^>]*>[\s\S]*?<\/details>/g);
|
||||
expect(openGroups).toHaveLength(1);
|
||||
expect(openGroups![0]).toContain('Observed map');
|
||||
});
|
||||
|
||||
it.each(['queued', 'waiting', 'running', 'awaiting-health', 'warning', 'failed', 'interrupted', 'cancelled'])(
|
||||
'keeps %s evidence expanded without claiming the operation passed all checks', state => {
|
||||
const detail = completedFixture();
|
||||
detail.deployment.state = state;
|
||||
expect(provisioningOutcome(detail).checksSummary).toBeNull();
|
||||
const body = render(ProvisioningProgressView, { props: { detail } }).body;
|
||||
expect(body.match(/<details\b[^>]*\bopen\b/g)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('shows recorded usernames and preserves unknown manual requesters', () => {
|
||||
expect(provisioningRequester({ triggered_by: 'ui', requested_by: 'alice' })).toBe('alice');
|
||||
expect(provisioningRequester({ triggered_by: 'rebalance:run', requested_by: 'bob' })).toBe('bob');
|
||||
|
||||
Reference in New Issue
Block a user