Compare commits

..
3 Commits
9 changed files with 105 additions and 36 deletions
+5
View File
@@ -98,6 +98,11 @@ 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
summary. Use `OperationTime` for their steps, checks and activity: offsets from
the operation start (`+m:ss` or `+h:mm:ss`), with negative offsets for earlier
evidence. Missing timestamps stay **Not recorded**.
Use `plural` or `countLabel` from `src/lib/ui/plural.ts` for count-dependent Use `plural` or `countLabel` from `src/lib/ui/plural.ts` for count-dependent
language, including verbs: `plural(count, 'needs', 'need')`. The helper uses language, including verbs: `plural(count, 'needs', 'need')`. The helper uses
`Intl.PluralRules`; supply irregular plural forms explicitly. Keep separators `Intl.PluralRules`; supply irregular plural forms explicitly. Keep separators
+2 -1
View File
@@ -95,8 +95,9 @@ function sourceDetails(source: Omit<ProvisioningSource, 'label' | 'href'>): Prov
href: row ? `/maintenance?view=activity#job-${encodeURIComponent(id)}` : null }; href: row ? `/maintenance?view=activity#job-${encodeURIComponent(id)}` : null };
} }
/** These are current pool snapshots, not a second admission/eligibility engine. */ /** Active operations get current pool snapshots, not a second admission/eligibility engine. */
function capacityFor(deployment: ProvisioningDeployment, evaluatedAt: string): ProvisioningCapacity { function capacityFor(deployment: ProvisioningDeployment, evaluatedAt: string): ProvisioningCapacity {
if (!['queued', 'waiting', 'running', 'awaiting-health'].includes(deployment.state)) return { evaluatedAt, pools: [] };
const settings = fleetSettings(); const settings = fleetSettings();
const pools: ProvisioningCapacity['pools'] = []; const pools: ProvisioningCapacity['pools'] = [];
if (deployment.action === 'create' || deployment.action === 'rebuild') { if (deployment.action === 'create' || deployment.action === 'rebuild') {
@@ -0,0 +1,16 @@
<script lang="ts">
import { timestampISO, type Timestamp } from '$lib/time-zone.js';
import { formatTimeOffset } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
let { value, start, missing = 'Not recorded' }: { value: Timestamp; start: Timestamp; missing?: string } = $props();
const iso = $derived(timestampISO(value));
const offset = $derived(formatTimeOffset(start, value));
const absolute = $derived($timeFormat.dateTime(value));
</script>
{#if iso}<time datetime={iso} title={absolute} aria-label={offset ? `${offset} from operation start · ${absolute}` : undefined}>{offset ?? absolute}</time>{:else}<span>{missing}</span>{/if}
<style>
time { font-variant-numeric: tabular-nums; }
</style>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte'; import OperationTime from '$ui/components/OperationTime.svelte';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import type { ProvisioningDetail, ProvisioningEvent } from '$lib/provisioning-detail.js'; import type { ProvisioningDetail, ProvisioningEvent } from '$lib/provisioning-detail.js';
import { isProvisioningEventPage } from '$ui/provisioning-presentation.js'; import { isProvisioningEventPage } from '$ui/provisioning-presentation.js';
@@ -81,7 +81,7 @@
<div class="heading"><div><span class="eyebrow">Activity</span><h2 id="activity-title">Recent updates</h2></div><span class="count">{detail.activity.total} event{detail.activity.total === 1 ? '' : 's'}</span></div> <div class="heading"><div><span class="eyebrow">Activity</span><h2 id="activity-title">Recent updates</h2></div><span class="count">{detail.activity.total} event{detail.activity.total === 1 ? '' : 's'}</span></div>
<p class="muted">{open ? 'Recent updates stay in place while you read the full history.' : 'Newest first · updates automatically'}</p> <p class="muted">{open ? 'Recent updates stay in place while you read the full history.' : 'Newest first · updates automatically'}</p>
{#if recent.length}<ol class="timeline recent"> {#if recent.length}<ol class="timeline recent">
{#each recent as event (event.id)}<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>{/each} {#each recent as event (event.id)}<li><span class="time"><OperationTime start={detail.deployment.started_at} value={event.created_at} /></span><p>{event.message}</p></li>{/each}
</ol>{:else}<p class="empty">No detailed activity was recorded. The recorded outcome and timestamps are preserved above.</p>{/if} </ol>{:else}<p class="empty">No detailed activity was recorded. The recorded outcome and timestamps are preserved above.</p>{/if}
<details class="history" ontoggle={toggle}> <details class="history" ontoggle={toggle}>
@@ -98,7 +98,7 @@
{#if loading && !loaded}<p class="muted" role="status">Loading activity…</p> {#if loading && !loaded}<p class="muted" role="status">Loading activity…</p>
{:else if loaded && !entries.length}<p class="empty">{appliedQuery ? 'No activity matches this search.' : 'No detailed activity recorded.'}</p> {:else if loaded && !entries.length}<p class="empty">{appliedQuery ? 'No activity matches this search.' : 'No detailed activity recorded.'}</p>
{:else}<ol class="timeline"> {:else}<ol class="timeline">
{#each entries as event (event.id)}<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>{/each} {#each entries as event (event.id)}<li><span class="time"><OperationTime start={detail.deployment.started_at} value={event.created_at} /></span><p>{event.message}</p></li>{/each}
</ol>{/if} </ol>{/if}
{#if loaded}<div class="footer"><span>Showing {entries.length} of {total} matching events</span>{#if cursor !== null}<Button size="sm" loading={loading} disabled={loading} onclick={() => load(true)}>Show more activity</Button>{/if}</div>{/if} {#if loaded}<div class="footer"><span>Showing {entries.length} of {total} matching events</span>{#if cursor !== null}<Button size="sm" loading={loading} disabled={loading} onclick={() => load(true)}>Show more activity</Button>{/if}</div>{/if}
</div> </div>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte'; import OperationTime from '$ui/components/OperationTime.svelte';
import type { ProvisioningDetail } from '$lib/provisioning-detail.js'; import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
import { provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js'; import { provisioningCheckValue, provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js';
import Badge from './primitives/Badge.svelte'; import Badge from './primitives/Badge.svelte';
let { detail }: { detail: ProvisioningDetail } = $props(); let { detail }: { detail: ProvisioningDetail } = $props();
@@ -37,7 +37,7 @@
{#each group.steps as step (step.key)} {#each group.steps as step (step.key)}
<li aria-current={step.state === 'current' || step.state === 'waiting' ? 'step' : undefined}> <li aria-current={step.state === 'current' || step.state === 'waiting' ? 'step' : undefined}>
<span class="step-icon state-{step.state}" aria-hidden="true">{progressIcons[step.state]}</span> <span class="step-icon state-{step.state}" aria-hidden="true">{progressIcons[step.state]}</span>
<div><strong>{step.label}</strong><span class="step-meta state-{step.state}">{progressStateLabels[step.state]}{#if step.at} · <span class="time"><RelativeTime value={step.at} /></span>{/if}</span></div> <div><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></div>
</li> </li>
{/each} {/each}
</ol> </ol>
@@ -49,28 +49,28 @@
<div class="command" class:uncertain={progress.command.state === 'uncertain'}> <div class="command" class:uncertain={progress.command.state === 'uncertain'}>
<h3>{progress.command.state === 'attempted' ? 'Command submission attempted' : progress.command.state === 'accepted' ? 'Remote request accepted' : progress.command.state === 'rejected' ? 'Remote request rejected' : 'Remote response uncertain'}</h3> <h3>{progress.command.state === 'attempted' ? 'Command submission attempted' : progress.command.state === 'accepted' ? 'Remote request accepted' : progress.command.state === 'rejected' ? 'Remote request rejected' : 'Remote response uncertain'}</h3>
<p>{progress.command.message}</p> <p>{progress.command.message}</p>
<span class="time"><RelativeTime value={progress.command.at} /></span> <span class="time"><OperationTime start={detail.deployment.started_at} value={progress.command.at} /></span>
</div> </div>
{/if} {/if}
{#if progress.checkpoints.some(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key)))} {#if progress.checkpoints.some(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key)))}
<details class="milestones"><summary>Recorded checkpoints</summary><ul> <details class="milestones"><summary>Recorded checkpoints</summary><ul>
{#each progress.checkpoints.filter(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key))) as checkpoint (checkpoint.key)} {#each progress.checkpoints.filter(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key))) as checkpoint (checkpoint.key)}
<li><strong>{checkpoint.label}</strong><span class="time"><RelativeTime value={checkpoint.at} /></span></li> <li><strong>{checkpoint.label}</strong><span class="time"><OperationTime start={detail.deployment.started_at} value={checkpoint.at} /></span></li>
{/each} {/each}
</ul></details> </ul></details>
{/if} {/if}
<div class="evidence-heading"><h3>Latest recorded checks</h3>{#if progress.observedAt}<span>Evaluated <span class="time"><RelativeTime value={progress.observedAt} /></span></span>{/if}</div> <div class="evidence-heading"><h3>Latest recorded checks</h3>{#if progress.observedAt}<span>Evaluated <span class="time"><OperationTime start={detail.deployment.started_at} value={progress.observedAt} /></span></span>{/if}</div>
{#if progress.checks.length} {#if progress.checks.length}
<div class="checks"> <div class="checks">
{#each progress.checks as check (check.key)} {#each progress.checks as check (check.key)}
<article class="check"> <article class="check">
<div class="check-heading"><strong>{check.label}</strong><Badge variant={check.result === 'pass' ? 'online' : check.result === 'fail' ? 'danger' : check.result === 'waiting' ? 'warning' : 'neutral'}>{evidenceLabels[check.result]}</Badge></div> <div class="check-heading"><strong>{check.label}</strong><Badge variant={check.result === 'pass' ? 'online' : check.result === 'fail' ? 'danger' : check.result === 'waiting' ? 'warning' : 'neutral'}>{evidenceLabels[check.result]}</Badge></div>
{#if check.expected !== null || check.actual !== null}<dl> {#if check.expected !== null || check.actual !== null}<dl>
{#if check.expected !== null}<div><dt>Expected</dt><dd>{check.expected}</dd></div>{/if} {#if check.expected !== null}<div><dt>Expected</dt><dd>{provisioningCheckValue(check.key, check.expected)}</dd></div>{/if}
<div><dt>Observed</dt><dd>{check.actual ?? 'Unknown'}</dd></div> <div><dt>Observed</dt><dd>{provisioningCheckValue(check.key, check.actual)}</dd></div>
</dl>{/if} </dl>{/if}
{#if check.message}<p>{check.message}</p>{/if} {#if check.message}<p>{check.message}</p>{/if}
<div class="evidence-meta"><span>{check.source ? sources[check.source] : 'Source not recorded'}</span><span>Checked <span class="time"><RelativeTime value={check.checkedAt} /></span></span>{#if check.receivedAt}<span>Evidence received <span class="time"><RelativeTime value={check.receivedAt} /></span></span>{/if}</div> <div class="evidence-meta"><span>{check.source ? sources[check.source] : 'Source not recorded'}</span><span>Checked <span class="time"><OperationTime start={detail.deployment.started_at} value={check.checkedAt} /></span></span>{#if check.receivedAt}<span>Evidence received <span class="time"><OperationTime start={detail.deployment.started_at} value={check.receivedAt} /></span></span>{/if}</div>
</article> </article>
{/each} {/each}
</div> </div>
+15
View File
@@ -1,3 +1,18 @@
import { timestampISO, type Timestamp } from '$lib/time-zone.js';
/** Signed time from an operation's start, preserving seconds even after an hour. */
export function formatTimeOffset(start: Timestamp, value: Timestamp): string | null {
const startISO = timestampISO(start);
const valueISO = timestampISO(value);
if (!startISO || !valueISO) return null;
const difference = Date.parse(valueISO) - Date.parse(startISO);
const seconds = Math.floor(Math.abs(difference) / 1000);
const minutes = Math.floor(seconds / 60);
const clock = minutes < 60 ? `${minutes}:${String(seconds % 60).padStart(2, '0')}`
: `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
return `${difference < 0 && seconds > 0 ? '−' : '+'}${clock}`;
}
/** /**
* Format an elapsed millisecond count as a compact human string: * Format an elapsed millisecond count as a compact human string:
* < 60s -> "12s" * < 60s -> "12s"
+9 -1
View File
@@ -1,6 +1,14 @@
import { provisioningPhases, type ProvisioningDeployment, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js'; import { provisioningPhases, type ProvisioningCheck, type ProvisioningDeployment, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js';
import { actionCompleted, actionLabels, provisioningActions, type ProvisioningAction } from '$lib/provisioning.js'; import { actionCompleted, actionLabels, provisioningActions, type ProvisioningAction } from '$lib/provisioning.js';
import { systemUsername } from '$lib/auth.js'; import { systemUsername } from '$lib/auth.js';
import { countLabel } from './plural.js';
export function provisioningCheckValue(key: ProvisioningCheck['key'], value: ProvisioningCheck['actual']): string {
if (typeof value !== 'number') return value ?? 'Unknown';
if (key === 'game') return countLabel(value, 'player');
if (key === 'plutonium-release' || key === 'plutonium-version') return `r${value}`;
return String(value);
}
export function provisioningRequester(operation: Pick<ProvisioningDeployment, 'requested_by' | 'triggered_by'>): string { export function provisioningRequester(operation: Pick<ProvisioningDeployment, 'requested_by' | 'triggered_by'>): string {
return operation.requested_by ?? (operation.triggered_by === 'ui' ? 'Not recorded' : systemUsername); return operation.requested_by ?? (operation.triggered_by === 'ui' ? 'Not recorded' : systemUsername);
+28 -14
View File
@@ -23,6 +23,9 @@
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte'; import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import Freshness from '$ui/components/Freshness.svelte'; import Freshness from '$ui/components/Freshness.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import OperationTime from '$ui/components/OperationTime.svelte';
import { timestampISO } from '$lib/time-zone.js';
import { timeFormat } from '$ui/stores/time-zone.js';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const initial = untrack(() => data.detail); const initial = untrack(() => data.detail);
@@ -36,6 +39,8 @@
const controller = createProvisioningRefresh<ProvisioningDetail>({ validate: isProvisioningDetail, onChange: value => { view = value; } }); const controller = createProvisioningRefresh<ProvisioningDetail>({ validate: isProvisioningDetail, onChange: value => { view = value; } });
const detail = $derived(view.detail); const detail = $derived(view.detail);
const deployment = $derived(detail?.deployment); const deployment = $derived(detail?.deployment);
const startedAt = $derived(timestampISO(deployment?.started_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 returnTo = $derived.by(() => { const returnTo = $derived.by(() => {
@@ -101,6 +106,16 @@
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Activity', href: '/activity' }, { label: 'Operation' }]} />{/snippet} {#snippet crumbs()}<Breadcrumbs items={[{ label: 'Activity', href: '/activity' }, { 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}
<div class="operation-summary">
<p>
<span>Requested by {provisioningRequester(deployment)}</span>{' · '}
<span>Started {#if startedAt}<time datetime={startedAt}>{$timeFormat.dateTime(startedAt)}</time>{:else}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>
</p>
<p class="timing-note">Offsets below show time from the operation start (minutes:seconds or hours:minutes:seconds).</p>
</div>
{/if}
<div class="identity-row"> <div class="identity-row">
{#if deployment}<div class="operation-id"><span>Operation</span><CopyValue value={deployment.id} label="operation ID" /></div>{/if} {#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"><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>
@@ -114,10 +129,10 @@
<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-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></div>
{#if deployment.error}<div class="failure" role="alert"><strong>Recorded error</strong><p>{deployment.error}</p></div>{/if} {#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 · <RelativeTime value={detail.activity.failureExcerpt.created_at} /></span>{detail.activity.failureExcerpt.message}</p>{/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} {#if detail.warningMessages.length}<div class="warnings"><h3>Recorded warnings</h3><ul>{#each detail.warningMessages as warning}<li>{warning}</li>{/each}</ul></div>{/if}
{#if detail.safetyOverride}<div class="warnings"><h3>Admin override enabled</h3><p>Player safety checks bypassed for this operation. Confirmed by {detail.safetyOverride.actorName} · <RelativeTime value={detail.safetyOverride.confirmedAt} />.</p></div>{/if} {#if detail.safetyOverride}<div class="warnings"><h3>Admin override enabled</h3><p>Player safety checks bypassed for this operation. Confirmed by {detail.safetyOverride.actorName} · <OperationTime start={deployment.started_at} value={detail.safetyOverride.confirmedAt} />.</p></div>{/if}
{#if detail.canResumeVerification || detail.canResumeCleanup || detail.canResumeRebuild || detail.canRetryWithRebuild || detail.canCancel || detail.canCancelCreation || canOverride} {#if detail.canResumeVerification || detail.canResumeCleanup || detail.canResumeRebuild || detail.canRetryWithRebuild || detail.canCancel || detail.canCancelCreation || canOverride}
<div class="recovery-actions"> <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><p class="action-help">{detail.safetyOverrideUnavailableReason ?? 'Confirm to bypass player safety checks for this operation.'}</p></div>{/if}
@@ -133,12 +148,12 @@
{#if view.actionError}<div class="action-error" role="alert"><strong>Request not confirmed</strong><p>{view.actionError}</p></div>{/if} {#if view.actionError}<div class="action-error" role="alert"><strong>Request not confirmed</strong><p>{view.actionError}</p></div>{/if}
{#if view.notice}<p class="notice" role="status">{view.notice}</p>{/if} {#if view.notice}<p class="notice" role="status">{view.notice}</p>{/if}
{#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?.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 <RelativeTime 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>{elapsedTime(deployment.started_at, deployment.finished_at ? Date.parse(deployment.finished_at) : now)}</strong></div> <div><span>{outcome.active ? 'Total elapsed' : 'Recorded duration'}</span><strong>{finishedAt || outcome.active ? elapsedTime(startedAt, finishedAt ? Date.parse(finishedAt) : now) : 'Not recorded'}</strong></div>
{#if outcome.active}<div><span>Time in phase</span><strong>{elapsedTime(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><RelativeTime 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>
</section> </section>
@@ -151,8 +166,8 @@
<h2>Host agent execution</h2> <h2>Host agent execution</h2>
<dl class="metadata"> <dl class="metadata">
<div><dt>Agent state</dt><dd>{detail.agentOperation.state}</dd></div> <div><dt>Agent state</dt><dd>{detail.agentOperation.state}</dd></div>
<div><dt>Command queued</dt><dd><RelativeTime value={detail.agentOperation.createdAt} missing="Not recorded" /></dd></div> <div><dt>Command queued</dt><dd><OperationTime start={deployment.started_at} value={detail.agentOperation.createdAt} missing="Not recorded" /></dd></div>
<div><dt>Latest agent report</dt><dd><RelativeTime value={detail.agentOperation.updatedAt} missing="Not recorded" /></dd></div> <div><dt>Latest agent report</dt><dd><OperationTime start={deployment.started_at} value={detail.agentOperation.updatedAt} missing="Not recorded" /></dd></div>
<div><dt>Command ID</dt><dd><code>{detail.agentOperation.id}</code></dd></div> <div><dt>Command ID</dt><dd><code>{detail.agentOperation.id}</code></dd></div>
</dl> </dl>
{#if detail.agentOperation.error}<p>{detail.agentOperation.error}</p>{/if} {#if detail.agentOperation.error}<p>{detail.agentOperation.error}</p>{/if}
@@ -171,11 +186,8 @@
{/if} {/if}
<Card as="section" padding="md" ariaLabel="Operation details"> <Card as="section" padding="md" ariaLabel="Operation details">
<div class="context-section"><h2>Operation details</h2><dl class="metadata"> <div class="context-section"><h2>Operation details</h2><dl class="metadata">
<div><dt>Requester</dt><dd>{provisioningRequester(deployment)}</dd></div> <div><dt>Submission attempted</dt><dd><OperationTime start={deployment.started_at} value={deployment.submitted_at} missing="Not recorded" /></dd></div>
<div><dt>Requested at</dt><dd><RelativeTime value={deployment.started_at} /></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}
<div><dt>Submission attempted</dt><dd><RelativeTime value={deployment.submitted_at} missing="Not recorded" /></dd></div>
<div><dt>Finished</dt><dd><RelativeTime value={deployment.finished_at} missing={outcome.active ? 'In progress' : 'Not recorded'} /></dd></div>
{#if deployment.server_deleted_at}<div><dt>Identity deleted</dt><dd><RelativeTime value={deployment.server_deleted_at} /></dd></div>{/if}
</dl></div> </dl></div>
</Card> </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">
@@ -193,7 +205,7 @@
{#if detail.recipe} {#if detail.recipe}
{#if detail.blueprintName}<p>From {detail.blueprintName}</p>{/if} {#if detail.blueprintName}<p>From {detail.blueprintName}</p>{/if}
<p>{recipeStatus === 'queued' ? 'This capture does not pin versions. The latest promoted setup is selected again when creation begins.' : recipeStatus === 'attempted' ? 'These files were selected when creation began. Selection alone does not confirm successful installation.' : 'This is the saved recipe for this operation. The point at which it was selected was not recorded.'}</p> <p>{recipeStatus === 'queued' ? 'This capture does not pin versions. The latest promoted setup is selected again when creation begins.' : recipeStatus === 'attempted' ? 'These files were selected when creation began. Selection alone does not confirm successful installation.' : 'This is the saved recipe for this operation. The point at which it was selected was not recorded.'}</p>
<p class="small">Captured <RelativeTime value={detail.recipe.capturedAt} /></p> <p class="small">Captured <OperationTime start={deployment.started_at} value={detail.recipe.capturedAt} /></p>
<LaunchRecipe recipe={detail.recipe} historical selectionStatus={recipeStatus} /> <LaunchRecipe recipe={detail.recipe} historical selectionStatus={recipeStatus} />
<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}
@@ -212,6 +224,8 @@
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-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); }
.identity-row, .operation-id, .identity-links, .badges, .outcome-top, .diagnostics, .recovery-actions { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; } .identity-row, .operation-id, .identity-links, .badges, .outcome-top, .diagnostics, .recovery-actions { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.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); } .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); }
.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); }
+18 -8
View File
@@ -70,21 +70,29 @@ describe('provisioning detail projection', () => {
expect(getDeployment('legacy')!.warningMessages).toEqual(['Recorded warnings could not be read.']); expect(getDeployment('legacy')!.warningMessages).toEqual(['Recorded warnings could not be read.']);
}); });
it('correlates reservations by operation ID without assigning a later same-name slot to historical work', async () => { it.each(['create', 'rebuild', 'start', 'restart'] as const)('shows live capacity only for active %s operations, excluding historical work on the same server', async action => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const { beginDeployment, getDeployment, finishDeployment } = await import('../../src/lib/server/provisioning/history.js'); const { beginDeployment, getDeployment, finishDeployment } = await import('../../src/lib/server/provisioning/history.js');
db().prepare("INSERT INTO servers (name) VALUES ('server')").run(); db().prepare("INSERT INTO servers (name) VALUES ('server')").run();
const old = beginDeployment('server', 'restart'); const old = beginDeployment('server', action);
finishDeployment(old, { ok: false, message: 'Timeout' }); finishDeployment(old, { ok: false, message: 'Timeout' });
const current = beginDeployment('server', 'restart'); const current = beginDeployment('server', action);
db().prepare("INSERT INTO restart_rollout (server,requested_at,operation_id) VALUES ('server','2026-01-01',?)").run(current); db().prepare("INSERT INTO restart_rollout (server,requested_at,operation_id) VALUES ('server','2026-01-01',?)").run(current);
expect(getDeployment(old)!.capacity.pools[0]).toMatchObject({ kind: 'restart', used: 1, ownsSlot: false, const kinds = action === 'create' ? ['startup'] : action === 'rebuild' ? ['startup', 'restart'] : ['restart'];
holders: [{ id: current, server: 'server' }] }); expect(getDeployment(current)!.capacity.pools).toEqual(kinds.map(kind => expect.objectContaining({
kind, used: 1, ownsSlot: true, holders: [{ id: current, server: 'server' }]
})));
for (const state of ['done', 'warning', 'failed', 'interrupted', 'cancelled']) {
db().prepare('UPDATE server_deployments SET state = ? WHERE id = ?').run(state, old);
expect(getDeployment(old)!.capacity.pools, state).toEqual([]);
}
expect(getDeployment(old)!.progress!.recovery).toBeNull(); expect(getDeployment(old)!.progress!.recovery).toBeNull();
expect(getDeployment(current)!.capacity.pools[0].ownsSlot).toBe(true);
db().prepare('UPDATE restart_rollout SET healthy_at = ?').run(new Date().toISOString()); db().prepare('UPDATE restart_rollout SET healthy_at = ?').run(new Date().toISOString());
expect(getDeployment(old)!.capacity.pools[0].used).toBe(0); expect(getDeployment(old)!.capacity.pools).toEqual([]);
expect(getDeployment(old)!.deployment.state).toBe('failed'); for (const state of ['queued', 'waiting', 'running', 'awaiting-health']) {
db().prepare('UPDATE server_deployments SET state = ? WHERE id = ?').run(state, current);
expect(getDeployment(current)!.capacity.pools.map(pool => pool.kind), state).toEqual(kinds);
}
}); });
it('labels submission order independently of startup reservations and retains queued recipe status', async () => { it('labels submission order independently of startup reservations and retains queued recipe status', async () => {
@@ -113,10 +121,12 @@ describe('provisioning detail projection', () => {
history.finishDeployment(id, { ok: false, message: 'Observation interrupted' }); history.finishDeployment(id, { ok: false, message: 'Observation interrupted' });
const failed = history.getDeployment(id)!; const failed = history.getDeployment(id)!;
expect(failed.progress?.phase).toBe('verify-game'); expect(failed.progress?.phase).toBe('verify-game');
expect(failed.capacity.pools).toEqual([]);
expect(history.resumeVerification(id)).toBe(true); expect(history.resumeVerification(id)).toBe(true);
const resumed = history.getDeployment(id)!; const resumed = history.getDeployment(id)!;
expect(resumed.deployment).toMatchObject({ id, state: 'awaiting-health', finished_at: null, error: null }); expect(resumed.deployment).toMatchObject({ id, state: 'awaiting-health', finished_at: null, error: null });
expect(resumed.progress).toMatchObject({ phase: 'verify-container', checks: [], observedAt: null }); expect(resumed.progress).toMatchObject({ phase: 'verify-container', checks: [], observedAt: null });
expect(resumed.capacity.pools).toEqual([expect.objectContaining({ kind: 'startup', ownsSlot: true })]);
const outcomes = resumed.events.filter(event => event.kind === 'outcome'); const outcomes = resumed.events.filter(event => event.kind === 'outcome');
expect(outcomes).toHaveLength(1); expect(outcomes).toHaveLength(1);
expect(JSON.parse(outcomes[0].details_json!)).toMatchObject({ state: 'failed', finishedAt: failed.deployment.finished_at, expect(JSON.parse(outcomes[0].details_json!)).toMatchObject({ state: 'failed', finishedAt: failed.deployment.finished_at,