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
@@ -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[] {