Align provisioning UI with lifecycle states
CI / checks (push) Successful in 5m37s

This commit is contained in:
2026-10-12 15:52:34 -04:00
parent 6059ebf2a5
commit c296769b33
15 changed files with 294 additions and 237 deletions
+5 -9
View File
@@ -1,17 +1,12 @@
import type { ProvisioningAction } from './provisioning.js';
import { provisioningStates, type ProvisioningAction } from './provisioning.js';
import type { ProvisioningDeployment } from './provisioning-detail.js';
import type { ProvisioningSource } from './provisioning-detail.js';
export const provisioningListStatuses = [
...provisioningStates,
// Retain the combined filter for existing links and API clients.
'pending',
'running',
'awaiting-health',
'done',
'attention',
'failed',
'interrupted',
'warning',
'cancelled'
'attention'
] as const;
export const provisioningListSources = [
'manual',
@@ -94,6 +89,7 @@ export type ProvisioningListRow = Pick<
phase?: string | null;
phaseStartedAt?: string | null;
waitCode?: string | null;
waitMessage?: string | null;
};
export interface ProvisioningListOptions {
+13
View File
@@ -1,3 +1,16 @@
export const activeProvisioningStates = ['queued', 'waiting', 'running', 'awaiting-health'];
export const provisioningStates = [
'queued',
'waiting',
'running',
'awaiting-health',
'done',
'warning',
'failed',
'interrupted',
'cancelled'
] as const;
export const provisioningActions = [
'create',
'start',
+5 -24
View File
@@ -25,7 +25,6 @@ import { committedRebalanceStartup } from './concurrency.js';
import { nativeDeployment } from '../backends/native.js';
import { safetyOverride, safetyOverrideAvailability } from './safety-override.js';
import { serverTitle } from '../../ui/server-labels.js';
import { provisioningQueueStatus } from '../../ui/provisioning-queue.js';
import {
provisioningPageSize,
provisioningPageOffset,
@@ -617,6 +616,7 @@ const deploymentListColumns = `d.id, d.server, d.server_deleted_at, d.action, d.
d.started_at, d.finished_at, d.triggered_by, d.requested_by, d.error, d.warnings,
d.phase_key AS phase, d.phase_started_at AS phaseStartedAt,
CASE WHEN json_valid(d.observation_json) THEN json_extract(d.observation_json,'$.blocker.code') END AS waitCode,
CASE WHEN json_valid(d.observation_json) THEN json_extract(d.observation_json,'$.blocker.message') END AS waitMessage,
(d.server_deleted_at IS NULL AND EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server AND s.destroyed_at IS NULL)) AS server_exists,
(d.server_deleted_at IS NULL AND d.action = 'destroy' AND d.state = 'waiting' AND d.stage != ?) AS canCancelRemoval,
(d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health','interrupted')
@@ -674,28 +674,9 @@ function deploymentListQuery(options: ProvisioningListOptions) {
}
function activeDeploymentRank(row: ProvisioningListRow): number {
const { group } = provisioningQueueStatus(row);
if (group === 'verifying') return 0;
if (group === 'running') return 1;
// Launch preparation keeps its queued/waiting state until command submission.
if (row.phase === 'prepare' && !row.waitCode) return 1;
// Remote file staging currently records its progress only in the stage message.
if (
row.waitCode === 'native-host' &&
[
'Staging and verifying files on the host.',
'Waiting for verified remote file staging.'
].includes(row.stage)
)
return 1;
if (group === 'queued') return 3;
// A submitted command remains in progress while its agent result is pending.
if (
row.state === 'running' &&
row.phase === 'container-action' &&
row.waitCode === 'native-host'
)
return 1;
if (row.state === 'awaiting-health') return 0;
if (row.state === 'running') return 1;
if (row.state === 'queued') return 3;
return 2;
}
@@ -714,7 +695,7 @@ function deploymentListRows(
...(active ? [] : [query.limit, query.offset])
) as ProvisioningListRow[];
if (!rows.length) return rows;
// Stable sorting keeps request/insertion order within each progress group.
// Stable sorting keeps request/insertion order within each lifecycle state.
if (active)
rows.sort((left, right) => activeDeploymentRank(left) - activeDeploymentRank(right));
const links = db()
@@ -0,0 +1,10 @@
<script lang="ts">
import Badge from './primitives/Badge.svelte';
import StatusPip from './primitives/StatusPip.svelte';
import { provisioningStatus } from '$ui/provisioning-queue.js';
let { state, size = 'sm' }: { state: string; size?: 'sm' | 'md' } = $props();
const status = $derived(provisioningStatus(state));
</script>
<Badge variant={status.tone} {size}><StatusPip tone={status.tone} size="sm" />{status.label}</Badge>
@@ -13,7 +13,7 @@
import Badge from '../primitives/Badge.svelte';
import Button from '../primitives/Button.svelte';
import Stat from '../primitives/Stat.svelte';
import StatusBadge from '../primitives/StatusBadge.svelte';
import ProvisioningStatusBadge from '../ProvisioningStatusBadge.svelte';
import Tabs from '../primitives/Tabs.svelte';
import ObservationTime from './ObservationTime.svelte';
@@ -207,7 +207,7 @@
{member ? lobbyLabel(member.name) : 'lobby'}{needsAttention
? ' job'
: ''}</strong
><StatusBadge state={operation.state} />
><ProvisioningStatusBadge state={operation.state} />
</div>
<p>{operation.stage || 'Waiting for the next provisioning update.'}</p>
</div>
@@ -339,7 +339,7 @@
: 'Scaling evaluated'}{item.server
? ` · ${lobbyLabel(item.server)}`
: ''}</strong
>{#if item.operationState}<StatusBadge
>{#if item.operationState}<ProvisioningStatusBadge
state={item.operationState}
/>{/if}
</div>
+32 -1
View File
@@ -12,6 +12,7 @@ import {
import {
actionCompleted,
actionLabels,
activeProvisioningStates,
provisioningActions,
type ProvisioningAction
} from '$lib/provisioning.js';
@@ -37,7 +38,7 @@ export function provisioningRequester(
);
}
export const activeProvisioningStates = ['queued', 'waiting', 'running', 'awaiting-health'];
export { activeProvisioningStates } from '$lib/provisioning.js';
/** A finished restart can still release its recovery slot or hand it to a rebuild. */
export function provisioningNeedsRefresh(detail: ProvisioningDetail): boolean {
@@ -79,6 +80,33 @@ const phaseLabels: Record<ProvisioningPhase, string> = {
'verify-plutonium': 'Verify Plutonium revision',
'verify-stopped': 'Confirm container is stopped'
};
export function provisioningPhaseLabel(
phase: ProvisioningPhase,
action: ProvisioningAction
): string {
if (phase === 'container-action')
return action === 'destroy'
? 'Remove server instance'
: `${actionLabels[action]} container`;
return phaseLabels[phase];
}
/** Keep the current step and recorded explanation separate from the lifecycle badge. */
export function provisioningContext(row: {
state: string;
action: ProvisioningAction;
stage: string;
phase?: string | null;
waitMessage?: string | null;
}): { step: string; detail: string | null } | null {
if (!activeProvisioningStates.includes(row.state)) return null;
const step = provisioningPhases.includes(row.phase as ProvisioningPhase)
? provisioningPhaseLabel(row.phase as ProvisioningPhase, row.action)
: row.stage;
const detail = row.waitMessage || row.stage;
return { step, detail: detail && detail !== step ? detail : null };
}
type GroupDefinition = { key: string; label: string; phases: ProvisioningPhase[] };
const request: GroupDefinition = {
key: 'request',
@@ -356,6 +384,9 @@ export function provisioningOutcome(detail: ProvisioningDetail) {
description = progress.blocker.automatic
? 'The worker will recheck this condition and continue automatically when eligible.'
: 'Review the blocking condition below before this operation can continue.';
else if (progress?.phase === 'prepare' && ['queued', 'waiting'].includes(operation.state))
description =
'Preparing the files needed for the host command. The command has not been submitted yet.';
else if (operation.state === 'queued')
description =
'The request is saved and awaiting provisioning evaluation. No capacity blocker has been recorded.';
+7 -60
View File
@@ -1,64 +1,11 @@
import type { ProvisioningListRow } from '../provisioning-list.js';
import { statusPresentation, type StatusTone } from './status.js';
import { statusPresentation, type StatusPresentation } from './status.js';
export function provisioningQueueStatus(
row: Pick<ProvisioningListRow, 'state' | 'action' | 'stage' | 'waitCode' | 'phase'>
): {
label: string;
tone: StatusTone;
group: 'queued' | 'waiting' | 'verifying' | 'running' | 'complete' | 'attention' | 'cancelled';
} {
if (['failed', 'interrupted', 'warning'].includes(row.state))
return { ...statusPresentation(row.state), group: 'attention' };
if (row.state === 'done') return { label: 'Complete', tone: 'online', group: 'complete' };
if (row.state === 'cancelled')
return { label: 'Cancelled', tone: 'neutral', group: 'cancelled' };
if (row.state === 'awaiting-health')
return { label: 'Verifying health', tone: 'info', group: 'verifying' };
if (row.phase === 'cleanup') return { label: 'Cleaning up', tone: 'info', group: 'running' };
const waits: Record<string, string> = {
'startup-capacity': 'Waiting for capacity',
'restart-capacity':
row.action === 'restart' ? 'Queued for restart' : 'Waiting for recovery capacity',
'operation-conflict': 'Waiting for another operation',
'previous-recovery': 'Waiting for recovery'
};
if (row.waitCode && waits[row.waitCode])
return {
label: waits[row.waitCode],
tone: 'neutral',
group: row.waitCode.endsWith('capacity') ? 'queued' : 'waiting'
};
if (['queued', 'waiting'].includes(row.state) || row.waitCode) {
// Older records may have only a stage message; never infer a failure from it.
if (/waiting for another server to recover/i.test(row.stage))
return { label: 'Queued for restart', tone: 'neutral', group: 'queued' };
if (
/waiting.*(?:empty|players)|players.*remaining|waiting for \d+ player.*to leave/i.test(
row.stage
)
)
return { label: 'Waiting for empty lobby', tone: 'neutral', group: 'waiting' };
if (/waiting for .*player count/i.test(row.stage))
return { label: 'Checking lobby occupancy', tone: 'neutral', group: 'waiting' };
if (/capacity/i.test(row.stage))
return { label: 'Waiting for capacity', tone: 'neutral', group: 'queued' };
return {
label:
row.state === 'queued'
? 'Queued'
: row.waitCode === 'eligibility'
? 'Waiting for eligibility'
: 'Waiting',
tone: 'neutral',
group: row.state === 'queued' ? 'queued' : 'waiting'
};
}
return {
label: row.phase === 'cleanup' ? 'Cleaning up' : 'In progress',
tone: 'info',
group: 'running'
};
/** Lifecycle badges depend only on state; phases and blockers are supporting text. */
export function provisioningStatus(state: string): StatusPresentation {
const status = statusPresentation(state);
if (state === 'running') return { ...status, label: 'Running' };
if (state === 'awaiting-health') return { ...status, label: 'Validating' };
return status;
}
export function provisioningWarnings(raw: string): string[] {
@@ -9,7 +9,7 @@
import { page } from '$app/stores';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import ProvisioningStatusBadge from '$ui/components/ProvisioningStatusBadge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import type { CollectionDetail } from '$lib/deployment-plan.js';
@@ -64,28 +64,10 @@
const firstQueued = $derived(
detail?.servers.find((server) => !server.server_deleted_at && server.state === 'queued')
);
const labels: Record<string, string> = {
queued: 'Queued',
running: 'Launching',
'awaiting-health': 'Checking readiness',
done: 'Ready',
warning: 'Needs review',
failed: 'Failed',
interrupted: 'Interrupted',
waiting: 'Waiting',
cancelled: 'Cancelled'
};
const waitingForPort = (server: CollectionServer) =>
server.state === 'queued' &&
(server.stage.startsWith('Waiting for removal') ||
server.stage.startsWith('Waiting for port'));
function stateLabel(server: CollectionServer) {
if (waitingForPort(server)) return 'Waiting for removal';
if (server.action === 'destroy' && server.state === 'done') return 'Removed';
if (server.action === 'destroy' && server.state === 'running') return 'Removing';
return labels[server.state] ?? server.state;
}
function queueHint(server: CollectionServer) {
const reason = server.stage || 'Waiting for a startup slot and available host capacity.';
return server.queuePosition && server.queuePosition > 1
@@ -382,15 +364,7 @@
name={server.server}
deletedAt={server.server_deleted_at}
/></strong
><Badge
variant={needsAttention(server)
? 'danger'
: server.state === 'done'
? 'online'
: launching(server)
? 'info'
: 'neutral'}>{stateLabel(server)}</Badge
>
><ProvisioningStatusBadge state={server.state} />
</div>
<p class:error={!!server.error}>
{server.error ??
+2 -2
View File
@@ -15,7 +15,7 @@
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ProvisioningStatusBadge from '$ui/components/ProvisioningStatusBadge.svelte';
let detail = $state<WatchdogDetail | null>(null);
let loadError = $state('');
@@ -270,7 +270,7 @@
? 'Restart server'
: 'Rebuild server'}
</h3>
{#if action.operation_state}<StatusBadge
{#if action.operation_state}<ProvisioningStatusBadge
state={action.operation_state}
/>{/if}
</div>
+42 -28
View File
@@ -13,7 +13,7 @@
import Tabs from '$ui/components/primitives/Tabs.svelte';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import ProvisioningStatusBadge from '$ui/components/ProvisioningStatusBadge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
@@ -34,13 +34,22 @@
type ProvisioningListRow as Deployment,
type ProvisioningListPage
} from '$lib/provisioning-list.js';
import { provisioningActions, actionLabels } from '$lib/provisioning.js';
import {
provisioningActions,
provisioningStates,
activeProvisioningStates,
actionLabels
} from '$lib/provisioning.js';
import { contextHref } from '$lib/navigation-context.js';
import { confirmAction } from '$ui/confirm.js';
import { serverTitle } from '$ui/server-labels.js';
import { elapsedTime, provisioningRequester } from '$ui/provisioning-presentation.js';
import { provisioningQueueStatus, provisioningWarnings } from '$ui/provisioning-queue.js';
import {
elapsedTime,
provisioningContext,
provisioningRequester
} from '$ui/provisioning-presentation.js';
import { provisioningStatus, provisioningWarnings } from '$ui/provisioning-queue.js';
let lifecycleOpen = $state(false);
let filtersOpen = $state(false);
@@ -257,17 +266,13 @@
})
);
const queueSummary = $derived.by(() => {
const groups = { queued: 0, waiting: 0, verifying: 0, running: 0 };
const counts = new Map(activeProvisioningStates.map((state) => [state, 0]));
for (const row of visibleActive) {
const group = provisioningQueueStatus(row).group;
if (group in groups) groups[group as keyof typeof groups]++;
if (counts.has(row.state)) counts.set(row.state, counts.get(row.state)! + 1);
}
return Object.entries(groups)
return [...counts]
.filter(([, count]) => count)
.map(
([label, count]) =>
`${count} ${label === 'verifying' ? 'verifying health' : label === 'running' ? 'in progress' : label}`
)
.map(([state, count]) => `${count} ${provisioningStatus(state).label.toLowerCase()}`)
.join(' · ');
});
function clearFilters() {
@@ -336,18 +341,14 @@
>
<Field label="Status">
<Select bind:value={historyState} onchange={() => saveContext(true)}
><option value="all">All statuses</option><option value="pending"
>Waiting / queued</option
><option value="running">In progress</option><option value="awaiting-health"
>Verifying health</option
><option value="done">Complete</option><option value="attention"
>Needs attention</option
><option value="failed">Failed</option><option value="interrupted"
>Interrupted</option
><option value="warning">Warnings</option><option value="cancelled"
>Cancelled</option
></Select
></Field
><option value="all">All statuses</option>
{#each provisioningStates as state}<option value={state}
>{provisioningStatus(state).label}</option
>{/each}
<option value="attention">Needs attention</option>
{#if historyState === 'pending'}<option value="pending">Waiting / queued</option
>{/if}
</Select></Field
>
<Field label="Source">
<Select bind:value={source} onchange={() => saveContext(true)}
@@ -491,7 +492,7 @@
>
<tbody
>{#each rows as row (row.id)}
{@const status = provisioningQueueStatus(row)}
{@const context = provisioningContext(row)}
{@const primarySource = row.sources?.[0]}
{@const operationHref = contextHref(
`/provisioning/${encodeURIComponent(row.id)}`,
@@ -512,9 +513,14 @@
/></td
>
<td><a href={operationHref}>{actionLabels[row.action]}</a></td>
<td
><Badge variant={status.tone} size="md">{status.label}</Badge
>{#if ['queued', 'waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}<small
<td class="status-cell"
><ProvisioningStatusBadge state={row.state} size="md" />
{#if context}<small>Current step: {context.step}</small>
{#if context.detail}<small class="status-detail"
>{context.detail}</small
>{/if}
{/if}
{#if ['queued', 'waiting'].includes(row.state) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}<small
>In this stage {elapsedTime(row.phaseStartedAt, now)}</small
>{/if}</td
>
@@ -716,6 +722,14 @@
max-width: 240px;
overflow-wrap: anywhere;
}
.status-cell {
min-width: 200px;
max-width: 320px;
overflow-wrap: anywhere;
}
.status-detail {
color: var(--text-secondary);
}
.requester-cell {
max-width: 180px;
overflow-wrap: anywhere;
+18 -4
View File
@@ -16,13 +16,14 @@
import {
elapsedTime,
isProvisioningDetail,
provisioningContext,
provisioningNeedsRefresh,
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';
import ProvisioningStatusBadge from '$ui/components/ProvisioningStatusBadge.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Card from '$ui/components/primitives/Card.svelte';
@@ -65,6 +66,15 @@
const phaseStartedAt = $derived(timestampISO(detail?.progress?.phaseStartedAt));
const canOverride = $derived(data.user?.role === 'admin' && detail?.canOverrideSafety);
const outcome = $derived(detail ? provisioningOutcome(detail) : null);
const context = $derived(
deployment
? provisioningContext({
...deployment,
phase: detail?.progress?.phase,
waitMessage: detail?.progress?.blocker?.message
})
: null
);
const active = $derived(outcome?.active ?? false);
const needsRefresh = $derived(detail ? provisioningNeedsRefresh(detail) : false);
const duration = $derived(
@@ -268,14 +278,18 @@
aria-labelledby="outcome-title"
>
<div class="badges">
<StatusBadge state={deployment.state} />{#if deployment.server_deleted_at}<Badge
>Deleted identity</Badge
<ProvisioningStatusBadge
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>
<h2 id="outcome-title">
{context ? `Current step: ${context.step}` : outcome.headline}
</h2>
{#if context?.detail}<p>{context.detail}</p>{/if}
{#if outcome.description}<p>{outcome.description}</p>{/if}
{#if outcome.checksSummary}<p>
<span aria-hidden="true">✓</span>{' '}{outcome.checksSummary}