Show provisioning timestamps and step offsets (Refs #89)

This commit is contained in:
2026-10-09 10:05:12 -04:00
parent 6128a137cc
commit dbbe3749e2
6 changed files with 73 additions and 23 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
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
language, including verbs: `plural(count, 'needs', 'need')`. The helper uses
`Intl.PluralRules`; supply irregular plural forms explicitly. Keep separators
@@ -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">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import OperationTime from '$ui/components/OperationTime.svelte';
import { onDestroy } from 'svelte';
import type { ProvisioningDetail, ProvisioningEvent } from '$lib/provisioning-detail.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>
<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">
{#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}
<details class="history" ontoggle={toggle}>
@@ -98,7 +98,7 @@
{#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}<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}
{#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>
@@ -1,5 +1,5 @@
<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 { provisioningCheckValue, provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js';
import Badge from './primitives/Badge.svelte';
@@ -37,7 +37,7 @@
{#each group.steps as step (step.key)}
<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>
<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>
{/each}
</ol>
@@ -49,17 +49,17 @@
<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>
<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>
{/if}
{#if progress.checkpoints.some(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key)))}
<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)}
<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}
</ul></details>
{/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}
<div class="checks">
{#each progress.checks as check (check.key)}
@@ -70,7 +70,7 @@
<div><dt>Observed</dt><dd>{provisioningCheckValue(check.key, check.actual)}</dd></div>
</dl>{/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>
{/each}
</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:
* < 60s -> "12s"
+28 -14
View File
@@ -23,6 +23,9 @@
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import Freshness from '$ui/components/Freshness.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();
const initial = untrack(() => data.detail);
@@ -36,6 +39,8 @@
const controller = createProvisioningRefresh<ProvisioningDetail>({ validate: isProvisioningDetail, onChange: value => { view = value; } });
const detail = $derived(view.detail);
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 outcome = $derived(detail ? provisioningOutcome(detail) : null);
const returnTo = $derived.by(() => {
@@ -101,6 +106,16 @@
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Activity', href: '/activity' }, { label: 'Operation' }]} />{/snippet}
{#snippet actions()}{#if serverHref}<Button href={serverHref}>View server</Button>{/if}{/snippet}
</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">
{#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>
@@ -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-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 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.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}
<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}
@@ -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.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?.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}
<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>
{#if outcome.active}<div><span>Time in phase</span><strong>{elapsedTime(detail.progress?.phaseStartedAt, now)}</strong></div>{/if}
<div><span>Last evaluated</span><RelativeTime value={detail.progress?.observedAt} missing="Not recorded" /></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(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>
</section>
@@ -151,8 +166,8 @@
<h2>Host agent execution</h2>
<dl class="metadata">
<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>Latest agent report</dt><dd><RelativeTime value={detail.agentOperation.updatedAt} 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><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>
</dl>
{#if detail.agentOperation.error}<p>{detail.agentOperation.error}</p>{/if}
@@ -171,11 +186,8 @@
{/if}
<Card as="section" padding="md" ariaLabel="Operation details">
<div class="context-section"><h2>Operation details</h2><dl class="metadata">
<div><dt>Requester</dt><dd>{provisioningRequester(deployment)}</dd></div>
<div><dt>Requested at</dt><dd><RelativeTime value={deployment.started_at} /></dd></div>
<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}
<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">
@@ -193,7 +205,7 @@
{#if detail.recipe}
{#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 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} />
<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}
@@ -212,6 +224,8 @@
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; }
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 { 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); }