Trim provisioning job details (Refs #88)

This commit is contained in:
2026-10-09 14:55:24 -04:00
parent 15ab41d4a2
commit 967b2a1c8a
4 changed files with 61 additions and 25 deletions
+13
View File
@@ -170,6 +170,13 @@ Related work links back to the requesting campaign, beta, maintenance item, or
collection. A completed provisioning job does not imply that its requester has
finished its own verification.
Optional timestamps appear only when recorded. **Launch files** appears for Create
and Rebuild, or when an older operation has a recorded recipe. Start, Restart,
and Stop use the existing instance without selecting new launch files.
While a job is active, **Controller activity** lists current work and elapsed
time. Background scans run independently of provisioning.
### Verification evidence
New operations record structured verification evidence alongside their activity:
@@ -201,6 +208,12 @@ the replacement in its reserved slot. Resuming the same operation preserves its
earlier failure and resume events. Uncertain commands have no generic Retry action.
Cancelling an operation does not stop a command already delivered to the host.
The confirmation explains that remaining work and health checks stop, while
changes already made remain. **Cancel creation and remove server** also deletes
the launch's container and instance files, even if players have joined or their
status is unknown. Shared game files are kept, and the key returns to its game
pool after confirmed removal.
A later rebuild waits for that command to settle. Its waiting message includes
the host command's state and last reported error; **View cancelled operation**
opens the original command details. If an interrupted rebuild removed its old
+2 -6
View File
@@ -138,7 +138,7 @@ export function provisioningOutcome(detail: ProvisioningDetail) {
: failure ? `${actionLabels[operation.action]} ${operation.state === 'interrupted' ? 'interrupted' : 'failed'}`
: operation.state === 'warning' ? `${actionLabels[operation.action]} completed with warnings`
: progress?.blocker?.message ?? operation.stage;
let description: string;
let description: string | null;
if (detail.canResumeCleanup) description = 'The remote deployment was deleted. Resume the remaining local cleanup without repeating container removal.';
else if (detail.canResumeRebuild) description = 'The remote deployment was deleted. Resume local cleanup, then create a fresh instance in the reserved server slot.';
else if (detail.canResumeVerification) description = 'Resume readiness checks for the existing instance. An unhealthy container may continue through this operation’s remaining restart and rebuild recovery steps.';
@@ -152,11 +152,7 @@ export function provisioningOutcome(detail: ProvisioningDetail) {
? 'The remote response was uncertain. Inspect the recorded result and server before requesting another operation.'
: 'Review the recorded failure and execution evidence before submitting another operation.';
else if (operation.state === 'warning') description = 'The operation finished. Review the recorded warnings below for any remaining issue.';
else if (operation.state === 'done') description = !progress
? 'This operation was recorded as completed. Detailed verification evidence was not recorded.'
: operation.action === 'destroy' ? 'The instance removal completed. This record preserves its results after deletion.'
: operation.action === 'stop' ? 'The container was confirmed stopped. Its files and registration were retained.'
: 'The operation completed its readiness checks. These recorded results describe that attempt; current health is available on the server page.';
else if (operation.state === 'done') description = null;
else if (active && progress?.phase === 'cleanup' && !progress.blocker) description = operation.action === 'rebuild'
? 'Remote deployment deletion was confirmed. Local cleanup must finish before a fresh instance is created in the reserved slot.'
: 'Remote deployment deletion was confirmed. The remaining local files and registration cleanup will continue without repeating container removal.';
+41 -15
View File
@@ -38,6 +38,9 @@
const deployment = $derived(detail?.deployment);
const startedAt = $derived(timestampISO(deployment?.started_at));
const finishedAt = $derived(timestampISO(deployment?.finished_at));
const submittedAt = $derived(timestampISO(deployment?.submitted_at));
const observedAt = $derived(timestampISO(detail?.progress?.observedAt));
const phaseStartedAt = $derived(timestampISO(detail?.progress?.phaseStartedAt));
const canOverride = $derived(data.user?.role === 'admin' && detail?.canOverrideSafety);
const outcome = $derived(detail ? provisioningOutcome(detail) : null);
const active = $derived(outcome?.active ?? false);
@@ -83,6 +86,28 @@
if (confirmed && view.id === id && canOverride) await controller.act('override',
'Admin override enabled for this operation. Provisioning will proceed without player safety checks when the remaining requirements are met.', { confirmed: true });
}
async function cancelOperation() {
if (!deployment || !detail?.canCancel || view.busy) return;
const id = deployment.id;
const confirmed = await confirmAction({
title: 'Cancel operation?',
message: `Stop remaining work and health checks for ${serverTitle(deployment.server)} (${deployment.server}). Changes already made are not undone. Commands already sent to the host may still finish.`,
confirmLabel: 'Cancel operation', danger: true
});
if (confirmed && view.id === id && detail?.canCancel) await controller.act('cancel',
'Operation cancelled. Already-submitted commands may finish; no further steps will be started.');
}
async function cancelCreation() {
if (!deployment || !detail?.canCancelCreation || view.busy) return;
const id = deployment.id;
const confirmed = await confirmAction({
title: 'Cancel creation and remove server?',
message: `Stop this launch and delete the container and instance files for ${serverTitle(deployment.server)} (${deployment.server}), even if player status is unknown or players have joined. Shared game files are kept. The key returns to its game pool after removal is confirmed.`,
confirmLabel: 'Cancel creation and remove server', danger: true
});
if (confirmed && view.id === id && detail?.canCancelCreation) await controller.act('cancel-creation',
'Creation cancelled. Follow cleanup below until removal is confirmed.');
}
async function copyLink() {
copyStatus = '';
try {
@@ -125,7 +150,7 @@
<section class="outcome" class:attention={['failed', 'interrupted', 'warning'].includes(deployment.state)} aria-labelledby="outcome-title">
<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">
<h2 id="outcome-title">{outcome.headline}</h2><p>{outcome.description}</p>
<h2 id="outcome-title">{outcome.headline}</h2>{#if outcome.description}<p>{outcome.description}</p>{/if}
{#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}
@@ -135,13 +160,13 @@
{#if detail.safetyOverride}<div class="warnings"><h3>Admin override enabled</h3><p>Player safety checks bypassed for this operation. Confirmed by {detail.safetyOverride.actorName} · {@render dateTime(detail.safetyOverride.confirmedAt)}.</p></div>{/if}
{#if detail.canResumeVerification || detail.canResumeCleanup || detail.canResumeRebuild || detail.canRetryWithRebuild || detail.canCancel || detail.canCancelCreation || canOverride}
<div class="recovery-actions">
{#if canOverride}<div><Button variant="danger" disabled={!!view.busy || !!detail.safetyOverrideUnavailableReason} loading={view.busy === 'override'} onclick={overrideSafety}>Admin override…</Button><p class="action-help">{detail.safetyOverrideUnavailableReason ?? 'Confirm to bypass player safety checks for this operation.'}</p></div>{/if}
{#if canOverride}<div><Button variant="danger" disabled={!!view.busy || !!detail.safetyOverrideUnavailableReason} loading={view.busy === 'override'} onclick={overrideSafety}>Admin override…</Button>{#if detail.safetyOverrideUnavailableReason}<p class="action-help">{detail.safetyOverrideUnavailableReason}</p>{/if}</div>{/if}
{#if detail.canResumeVerification}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Health verification requested. An unhealthy container may continue through this operation’s remaining recovery steps.')}>Resume health verification</Button>{/if}
{#if detail.canRetryWithRebuild}<div><Button variant="primary" loading={view.busy === 'retry-rebuild'} disabled={!!view.busy} onclick={retryWithRebuild}>Retry with rebuild</Button><p class="action-help">Starts a new rebuild for the same lobby and port. Deletes instance files and uses the latest promoted setup.</p></div>{/if}
{#if detail.canRetryWithRebuild}<Button variant="primary" loading={view.busy === 'retry-rebuild'} disabled={!!view.busy} onclick={retryWithRebuild}>Retry with rebuild</Button>{/if}
{#if detail.canResumeRebuild}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Rebuild cleanup requested. Fresh creation waits for local cleanup to finish.')}>Resume rebuild cleanup</Button>{/if}
{#if detail.canResumeCleanup}<Button variant="primary" loading={view.busy === 'resume'} disabled={!!view.busy} onclick={() => controller.act('resume', 'Local cleanup requested. Remote container deletion will not be repeated.')}>Resume cleanup</Button>{/if}
{#if detail.canCancel}<div><Button disabled={!!view.busy} loading={view.busy === 'cancel'} onclick={() => controller.act('cancel', 'Operation cancelled. Already-submitted commands may finish; no further steps will be started.')}>Cancel operation</Button><p class="action-help">Stops remaining work and health checks. Changes already made are not undone.</p></div>{/if}
{#if detail.canCancelCreation}<div><Button variant="danger" disabled={!!view.busy} loading={view.busy === 'cancel-creation'} onclick={() => controller.act('cancel-creation', 'Creation cancelled. Follow cleanup below until removal is confirmed.')}>Cancel creation and remove server</Button><p class="action-help">Stops this launch and deletes its container and instance files, even if player status is unknown or players have joined. Shared game files are kept. The key returns to its game pool after removal is confirmed.</p></div>{/if}
{#if detail.canCancel}<Button disabled={!!view.busy} loading={view.busy === 'cancel'} onclick={cancelOperation}>Cancel operation</Button>{/if}
{#if detail.canCancelCreation}<Button variant="danger" disabled={!!view.busy} loading={view.busy === 'cancel-creation'} onclick={cancelCreation}>Cancel creation and remove server</Button>{/if}
</div>
{/if}
{#if detail.cancellation}<div class="recovery-note"><strong>{detail.cancellation.state === 'done' ? 'Cancellation cleanup completed' : 'Cancellation cleanup'}</strong><p>{detail.cancellation.error ?? detail.cancellation.stage}</p><a href={`/provisioning/${encodeURIComponent(detail.cancellation.cleanupId)}`}>View cleanup progress →</a></div>{/if}
@@ -150,10 +175,12 @@
{#if detail.progress?.blocker?.href}<a class="dependency" href={detail.progress.blocker.href}>{detail.progress.blocker.label ?? 'View blocking work'} →</a>{/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 {@render dateTime(detail.progress.recovery.at)}</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 (active && phaseStartedAt) || observedAt}
<div class="timing">
{#if outcome.active}<div><span>Time in phase</span><strong>{elapsedTime(timestampISO(detail.progress?.phaseStartedAt), now)}</strong></div>{/if}
<div><span>Last evaluated</span>{@render dateTime(detail.progress?.observedAt)}</div>
{#if active && phaseStartedAt}<div><span>Time in phase</span><strong>{elapsedTime(phaseStartedAt, now)}</strong></div>{/if}
{#if observedAt}<div><span>Last evaluated</span>{@render dateTime(observedAt)}</div>{/if}
</div>
{/if}
</section>
{#key deployment.id}
@@ -166,37 +193,36 @@
{#if outcome.active && detail.workerActivity}
<Card as="section" padding="md" ariaLabel="Controller activity"><div class="context-section">
<h2>Controller activity</h2>
<p>Current work and elapsed time. Background scans run independently of provisioning.</p>
{#if detail.workerActivity.length}<ul class="related">
{#each detail.workerActivity as work (work.key)}<li><strong>{work.label}</strong><span>{elapsedTime(work.startedAt, Date.parse(detail.generatedAt))}</span></li>{/each}
</ul>{:else}<p>Waiting for the next provisioning pass.</p>{/if}
</div></Card>
{/if}
{#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><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}
{#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}
{#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>
{#if pool.ownsSlot}<p>This operation holds a slot.</p>{/if}
{#if pool.requestOrder !== null}<p>Request order: {pool.requestOrder}. Eligible requests may pass blocked work; this is not a guaranteed start order.</p>{/if}
{#if pool.requestOrder !== null}<p title="Eligible requests may pass blocked work; this is not a guaranteed start order.">Request order: {pool.requestOrder}</p>{/if}
{#if pool.holders.length}<details><summary>Current slot holders</summary><ul>{#each pool.holders as holder}<li>{#if holder.id}<a href={`/provisioning/${encodeURIComponent(holder.id)}`}>{serverTitle(holder.server)}</a>{:else}<span>{serverTitle(holder.server)} · operation not recorded</span>{/if}</li>{/each}</ul></details>{/if}
</div>{/each}<p class="small">Capacity snapshot <RelativeTime value={detail.capacity.evaluatedAt} missing="Not recorded" /></p>
</div></Card>{/if}
<Card as="section" padding="md" ariaLabel="Launch files"><div class="context-section recipe"><h2>Launch files</h2>
{#if detail.recipe || ['create', 'rebuild'].includes(deployment.action)}<Card as="section" padding="md" ariaLabel="Launch files"><div class="context-section recipe"><h2>Launch files</h2>
{#if detail.recipe}
{#if detail.blueprintName}<p>From {detail.blueprintName}</p>{/if}
<p class="small">{recipeSelection} · <RelativeTime value={detail.recipe.capturedAt} missing="Capture time not recorded" /></p>
<LaunchRecipe recipe={detail.recipe} historical />
{: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>
{:else}<p>No launch recipe was recorded for this operation.</p>{/if}
</div></Card>{/if}
<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>{@render dateTime(deployment.submitted_at)}</dd></div>
{#if finishedAt}<div><dt>Finished</dt><dd>{@render dateTime(finishedAt)}</dd></div>{/if}
{#if submittedAt}<div><dt>Submission attempted</dt><dd>{@render dateTime(submittedAt)}</dd></div>{/if}
{#if deployment.server_deleted_at}<div><dt>Identity deleted</dt><dd>{@render dateTime(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}
+3 -2
View File
@@ -145,7 +145,8 @@ describe('provisioning outcome and evidence presentation', () => {
expect(provisioningGroups(detail).flatMap(group => group.steps).every(step => step.state === 'unknown')).toBe(true);
detail.progress = null;
expect(provisioningGroups(detail)).toEqual([]);
expect(provisioningOutcome(detail).description).toContain('evidence was not recorded');
expect(provisioningOutcome(detail).description).toBeNull();
expect(render(ProvisioningProgressView, { props: { detail } }).body).toContain('Detailed checkpoints were not recorded');
});
it('shows passed container and telemetry game checks while waiting on the engine map', () => {
@@ -161,7 +162,7 @@ describe('provisioning outcome and evidence presentation', () => {
expect(steps.find(step => step.key === 'verify-map')?.state).toBe('waiting');
expect(steps.find(step => step.key === 'verify-game')?.label).not.toContain('RCON');
detail.deployment.state = 'done';
expect(provisioningOutcome(detail).description).not.toContain('RCON');
expect(provisioningOutcome(detail).description).toBeNull();
});
it('newer unknown readiness evidence supersedes an old completion checkpoint', () => {