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
+15 -6
View File
@@ -12,12 +12,21 @@ are shown. Confirmations name the server and its current players and round;
disruptive actions still wait for a fresh empty-lobby check. **Manage server** disruptive actions still wait for a fresh empty-lobby check. **Manage server**
on the operations page offers the same actions for a selected server. on the operations page offers the same actions for a selected server.
The **Active** tab shows jobs verifying health first, then jobs executing work, Provisioning badges show the recorded lifecycle state consistently on the list
waiting jobs, and queued jobs. File preparation, cleanup, and submitted commands and job details: **Queued**, **Waiting**, **Running**, **Validating**, **Completed**,
awaiting host confirmation count as executing work. Within each group, older **Completed with warnings**, **Failed**, **Interrupted**, or **Cancelled**.
requests appear first, with recording order breaking timestamp ties. **History** **Validating** is the display name for `awaiting-health`. The current step and
shows the newest requests first. This display order does not change execution waiting reason appear separately beneath the state. File preparation can occur
priority or capacity admission. while a job is Queued or Waiting; Running begins when the host command is submitted
and includes waiting for its acknowledgment. These progress details do not change
the badge, status counts, or filters.
The **Active** tab shows Validating jobs first, then Running, Waiting, and Queued.
Within each state, older requests appear first, with recording order breaking
timestamp ties. **History** shows the newest requests first. **Needs attention**
filters unresolved Failed, Interrupted, and Completed with warnings outcomes;
their individual state badges remain visible. This display order does not change
execution priority or capacity admission.
**Create server** opens one dialog with game and map choices followed by automatic **Create server** opens one dialog with game and map choices followed by automatic
readiness checks. Blockers explain what needs attention before launch. Runtime readiness checks. Blockers explain what needs attention before launch. Runtime
+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 { ProvisioningDeployment } from './provisioning-detail.js';
import type { ProvisioningSource } from './provisioning-detail.js'; import type { ProvisioningSource } from './provisioning-detail.js';
export const provisioningListStatuses = [ export const provisioningListStatuses = [
...provisioningStates,
// Retain the combined filter for existing links and API clients.
'pending', 'pending',
'running', 'attention'
'awaiting-health',
'done',
'attention',
'failed',
'interrupted',
'warning',
'cancelled'
] as const; ] as const;
export const provisioningListSources = [ export const provisioningListSources = [
'manual', 'manual',
@@ -94,6 +89,7 @@ export type ProvisioningListRow = Pick<
phase?: string | null; phase?: string | null;
phaseStartedAt?: string | null; phaseStartedAt?: string | null;
waitCode?: string | null; waitCode?: string | null;
waitMessage?: string | null;
}; };
export interface ProvisioningListOptions { 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 = [ export const provisioningActions = [
'create', 'create',
'start', 'start',
+5 -24
View File
@@ -25,7 +25,6 @@ import { committedRebalanceStartup } from './concurrency.js';
import { nativeDeployment } from '../backends/native.js'; import { nativeDeployment } from '../backends/native.js';
import { safetyOverride, safetyOverrideAvailability } from './safety-override.js'; import { safetyOverride, safetyOverrideAvailability } from './safety-override.js';
import { serverTitle } from '../../ui/server-labels.js'; import { serverTitle } from '../../ui/server-labels.js';
import { provisioningQueueStatus } from '../../ui/provisioning-queue.js';
import { import {
provisioningPageSize, provisioningPageSize,
provisioningPageOffset, 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.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, 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.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 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.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') (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 { function activeDeploymentRank(row: ProvisioningListRow): number {
const { group } = provisioningQueueStatus(row); if (row.state === 'awaiting-health') return 0;
if (group === 'verifying') return 0; if (row.state === 'running') return 1;
if (group === 'running') return 1; if (row.state === 'queued') return 3;
// 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;
return 2; return 2;
} }
@@ -714,7 +695,7 @@ function deploymentListRows(
...(active ? [] : [query.limit, query.offset]) ...(active ? [] : [query.limit, query.offset])
) as ProvisioningListRow[]; ) as ProvisioningListRow[];
if (!rows.length) return rows; 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) if (active)
rows.sort((left, right) => activeDeploymentRank(left) - activeDeploymentRank(right)); rows.sort((left, right) => activeDeploymentRank(left) - activeDeploymentRank(right));
const links = db() 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 Badge from '../primitives/Badge.svelte';
import Button from '../primitives/Button.svelte'; import Button from '../primitives/Button.svelte';
import Stat from '../primitives/Stat.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 Tabs from '../primitives/Tabs.svelte';
import ObservationTime from './ObservationTime.svelte'; import ObservationTime from './ObservationTime.svelte';
@@ -207,7 +207,7 @@
{member ? lobbyLabel(member.name) : 'lobby'}{needsAttention {member ? lobbyLabel(member.name) : 'lobby'}{needsAttention
? ' job' ? ' job'
: ''}</strong : ''}</strong
><StatusBadge state={operation.state} /> ><ProvisioningStatusBadge state={operation.state} />
</div> </div>
<p>{operation.stage || 'Waiting for the next provisioning update.'}</p> <p>{operation.stage || 'Waiting for the next provisioning update.'}</p>
</div> </div>
@@ -339,7 +339,7 @@
: 'Scaling evaluated'}{item.server : 'Scaling evaluated'}{item.server
? ` · ${lobbyLabel(item.server)}` ? ` · ${lobbyLabel(item.server)}`
: ''}</strong : ''}</strong
>{#if item.operationState}<StatusBadge >{#if item.operationState}<ProvisioningStatusBadge
state={item.operationState} state={item.operationState}
/>{/if} />{/if}
</div> </div>
+32 -1
View File
@@ -12,6 +12,7 @@ import {
import { import {
actionCompleted, actionCompleted,
actionLabels, actionLabels,
activeProvisioningStates,
provisioningActions, provisioningActions,
type ProvisioningAction type ProvisioningAction
} from '$lib/provisioning.js'; } 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. */ /** A finished restart can still release its recovery slot or hand it to a rebuild. */
export function provisioningNeedsRefresh(detail: ProvisioningDetail): boolean { export function provisioningNeedsRefresh(detail: ProvisioningDetail): boolean {
@@ -79,6 +80,33 @@ const phaseLabels: Record<ProvisioningPhase, string> = {
'verify-plutonium': 'Verify Plutonium revision', 'verify-plutonium': 'Verify Plutonium revision',
'verify-stopped': 'Confirm container is stopped' '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[] }; type GroupDefinition = { key: string; label: string; phases: ProvisioningPhase[] };
const request: GroupDefinition = { const request: GroupDefinition = {
key: 'request', key: 'request',
@@ -356,6 +384,9 @@ export function provisioningOutcome(detail: ProvisioningDetail) {
description = progress.blocker.automatic description = progress.blocker.automatic
? 'The worker will recheck this condition and continue automatically when eligible.' ? 'The worker will recheck this condition and continue automatically when eligible.'
: 'Review the blocking condition below before this operation can continue.'; : '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') else if (operation.state === 'queued')
description = description =
'The request is saved and awaiting provisioning evaluation. No capacity blocker has been recorded.'; '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 StatusPresentation } from './status.js';
import { statusPresentation, type StatusTone } from './status.js';
export function provisioningQueueStatus( /** Lifecycle badges depend only on state; phases and blockers are supporting text. */
row: Pick<ProvisioningListRow, 'state' | 'action' | 'stage' | 'waitCode' | 'phase'> export function provisioningStatus(state: string): StatusPresentation {
): { const status = statusPresentation(state);
label: string; if (state === 'running') return { ...status, label: 'Running' };
tone: StatusTone; if (state === 'awaiting-health') return { ...status, label: 'Validating' };
group: 'queued' | 'waiting' | 'verifying' | 'running' | 'complete' | 'attention' | 'cancelled'; return status;
} {
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'
};
} }
export function provisioningWarnings(raw: string): string[] { export function provisioningWarnings(raw: string): string[] {
@@ -9,7 +9,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.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 Button from '$ui/components/primitives/Button.svelte';
import type { CollectionDetail } from '$lib/deployment-plan.js'; import type { CollectionDetail } from '$lib/deployment-plan.js';
@@ -64,28 +64,10 @@
const firstQueued = $derived( const firstQueued = $derived(
detail?.servers.find((server) => !server.server_deleted_at && server.state === 'queued') 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) => const waitingForPort = (server: CollectionServer) =>
server.state === 'queued' && server.state === 'queued' &&
(server.stage.startsWith('Waiting for removal') || (server.stage.startsWith('Waiting for removal') ||
server.stage.startsWith('Waiting for port')); 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) { function queueHint(server: CollectionServer) {
const reason = server.stage || 'Waiting for a startup slot and available host capacity.'; const reason = server.stage || 'Waiting for a startup slot and available host capacity.';
return server.queuePosition && server.queuePosition > 1 return server.queuePosition && server.queuePosition > 1
@@ -382,15 +364,7 @@
name={server.server} name={server.server}
deletedAt={server.server_deleted_at} deletedAt={server.server_deleted_at}
/></strong /></strong
><Badge ><ProvisioningStatusBadge state={server.state} />
variant={needsAttention(server)
? 'danger'
: server.state === 'done'
? 'online'
: launching(server)
? 'info'
: 'neutral'}>{stateLabel(server)}</Badge
>
</div> </div>
<p class:error={!!server.error}> <p class:error={!!server.error}>
{server.error ?? {server.error ??
+2 -2
View File
@@ -15,7 +15,7 @@
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte'; import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.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 detail = $state<WatchdogDetail | null>(null);
let loadError = $state(''); let loadError = $state('');
@@ -270,7 +270,7 @@
? 'Restart server' ? 'Restart server'
: 'Rebuild server'} : 'Rebuild server'}
</h3> </h3>
{#if action.operation_state}<StatusBadge {#if action.operation_state}<ProvisioningStatusBadge
state={action.operation_state} state={action.operation_state}
/>{/if} />{/if}
</div> </div>
+42 -28
View File
@@ -13,7 +13,7 @@
import Tabs from '$ui/components/primitives/Tabs.svelte'; import Tabs from '$ui/components/primitives/Tabs.svelte';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte'; import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.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 Button from '$ui/components/primitives/Button.svelte';
import Dialog from '$ui/components/primitives/Dialog.svelte'; import Dialog from '$ui/components/primitives/Dialog.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
@@ -34,13 +34,22 @@
type ProvisioningListRow as Deployment, type ProvisioningListRow as Deployment,
type ProvisioningListPage type ProvisioningListPage
} from '$lib/provisioning-list.js'; } 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 { contextHref } from '$lib/navigation-context.js';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import { elapsedTime, provisioningRequester } from '$ui/provisioning-presentation.js'; import {
import { provisioningQueueStatus, provisioningWarnings } from '$ui/provisioning-queue.js'; elapsedTime,
provisioningContext,
provisioningRequester
} from '$ui/provisioning-presentation.js';
import { provisioningStatus, provisioningWarnings } from '$ui/provisioning-queue.js';
let lifecycleOpen = $state(false); let lifecycleOpen = $state(false);
let filtersOpen = $state(false); let filtersOpen = $state(false);
@@ -257,17 +266,13 @@
}) })
); );
const queueSummary = $derived.by(() => { 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) { for (const row of visibleActive) {
const group = provisioningQueueStatus(row).group; if (counts.has(row.state)) counts.set(row.state, counts.get(row.state)! + 1);
if (group in groups) groups[group as keyof typeof groups]++;
} }
return Object.entries(groups) return [...counts]
.filter(([, count]) => count) .filter(([, count]) => count)
.map( .map(([state, count]) => `${count} ${provisioningStatus(state).label.toLowerCase()}`)
([label, count]) =>
`${count} ${label === 'verifying' ? 'verifying health' : label === 'running' ? 'in progress' : label}`
)
.join(' · '); .join(' · ');
}); });
function clearFilters() { function clearFilters() {
@@ -336,18 +341,14 @@
> >
<Field label="Status"> <Field label="Status">
<Select bind:value={historyState} onchange={() => saveContext(true)} <Select bind:value={historyState} onchange={() => saveContext(true)}
><option value="all">All statuses</option><option value="pending" ><option value="all">All statuses</option>
>Waiting / queued</option {#each provisioningStates as state}<option value={state}
><option value="running">In progress</option><option value="awaiting-health" >{provisioningStatus(state).label}</option
>Verifying health</option >{/each}
><option value="done">Complete</option><option value="attention" <option value="attention">Needs attention</option>
>Needs attention</option {#if historyState === 'pending'}<option value="pending">Waiting / queued</option
><option value="failed">Failed</option><option value="interrupted" >{/if}
>Interrupted</option </Select></Field
><option value="warning">Warnings</option><option value="cancelled"
>Cancelled</option
></Select
></Field
> >
<Field label="Source"> <Field label="Source">
<Select bind:value={source} onchange={() => saveContext(true)} <Select bind:value={source} onchange={() => saveContext(true)}
@@ -491,7 +492,7 @@
> >
<tbody <tbody
>{#each rows as row (row.id)} >{#each rows as row (row.id)}
{@const status = provisioningQueueStatus(row)} {@const context = provisioningContext(row)}
{@const primarySource = row.sources?.[0]} {@const primarySource = row.sources?.[0]}
{@const operationHref = contextHref( {@const operationHref = contextHref(
`/provisioning/${encodeURIComponent(row.id)}`, `/provisioning/${encodeURIComponent(row.id)}`,
@@ -512,9 +513,14 @@
/></td /></td
> >
<td><a href={operationHref}>{actionLabels[row.action]}</a></td> <td><a href={operationHref}>{actionLabels[row.action]}</a></td>
<td <td class="status-cell"
><Badge variant={status.tone} size="md">{status.label}</Badge ><ProvisioningStatusBadge state={row.state} size="md" />
>{#if ['queued', 'waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}<small {#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 >In this stage {elapsedTime(row.phaseStartedAt, now)}</small
>{/if}</td >{/if}</td
> >
@@ -716,6 +722,14 @@
max-width: 240px; max-width: 240px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.status-cell {
min-width: 200px;
max-width: 320px;
overflow-wrap: anywhere;
}
.status-detail {
color: var(--text-secondary);
}
.requester-cell { .requester-cell {
max-width: 180px; max-width: 180px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
+18 -4
View File
@@ -16,13 +16,14 @@
import { import {
elapsedTime, elapsedTime,
isProvisioningDetail, isProvisioningDetail,
provisioningContext,
provisioningNeedsRefresh, provisioningNeedsRefresh,
provisioningOutcome, provisioningOutcome,
provisioningRequester provisioningRequester
} from '$ui/provisioning-presentation.js'; } from '$ui/provisioning-presentation.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.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 Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import Card from '$ui/components/primitives/Card.svelte'; import Card from '$ui/components/primitives/Card.svelte';
@@ -65,6 +66,15 @@
const phaseStartedAt = $derived(timestampISO(detail?.progress?.phaseStartedAt)); const phaseStartedAt = $derived(timestampISO(detail?.progress?.phaseStartedAt));
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 context = $derived(
deployment
? provisioningContext({
...deployment,
phase: detail?.progress?.phase,
waitMessage: detail?.progress?.blocker?.message
})
: null
);
const active = $derived(outcome?.active ?? false); const active = $derived(outcome?.active ?? false);
const needsRefresh = $derived(detail ? provisioningNeedsRefresh(detail) : false); const needsRefresh = $derived(detail ? provisioningNeedsRefresh(detail) : false);
const duration = $derived( const duration = $derived(
@@ -268,14 +278,18 @@
aria-labelledby="outcome-title" aria-labelledby="outcome-title"
> >
<div class="badges"> <div class="badges">
<StatusBadge state={deployment.state} />{#if deployment.server_deleted_at}<Badge <ProvisioningStatusBadge
>Deleted identity</Badge state={deployment.state}
/>{#if deployment.server_deleted_at}<Badge>Deleted identity</Badge
>{/if}{#if detail.progress?.recovery?.state === 'held'}<Badge variant="warning" >{/if}{#if detail.progress?.recovery?.state === 'held'}<Badge variant="warning"
>Recovery capacity held</Badge >Recovery capacity held</Badge
>{/if} >{/if}
</div> </div>
<div class="outcome-message" aria-live="polite" aria-atomic="true"> <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.description}<p>{outcome.description}</p>{/if}
{#if outcome.checksSummary}<p> {#if outcome.checksSummary}<p>
<span aria-hidden="true">✓</span>{' '}{outcome.checksSummary} <span aria-hidden="true">✓</span>{' '}{outcome.checksSummary}
+35 -11
View File
@@ -47,6 +47,26 @@ async function setup() {
} }
describe('bounded provisioning lists', () => { describe('bounded provisioning lists', () => {
it('filters Queued and Waiting independently while preserving combined pending links', async () => {
const { add, handle } = await setup();
add('queued', 'queued');
add('waiting', 'waiting', 'restart');
add('running', 'running', 'restart');
handle.prepare("UPDATE server_deployments SET phase_key='prepare' WHERE id='queued'").run();
const { GET } = await import('../../src/routes/api/provisioning/+server.js');
for (const [status, expected] of [
['queued', ['queued']],
['waiting', ['waiting']],
['running', ['running']],
['pending', ['waiting', 'queued']]
] as const) {
const result = await GET({
url: new URL(`http://localhost/api/provisioning?view=active&status=${status}`)
}).json();
expect(result.deployments.map((row: { id: string }) => row.id)).toEqual(expected);
}
});
it('keeps recovery sources concise as incident summaries change, including after retention', async () => { it('keeps recovery sources concise as incident summaries change, including after retention', async () => {
const { add, handle, deploymentListPage } = await setup(); const { add, handle, deploymentListPage } = await setup();
add('recovery', 'waiting', 'rebuild'); add('recovery', 'waiting', 'rebuild');
@@ -293,13 +313,17 @@ describe('bounded provisioning lists', () => {
) )
.run( .run(
JSON.stringify({ JSON.stringify({
blocker: { code: 'restart-capacity' }, blocker: {
code: 'restart-capacity',
message: 'Waiting for another server to recover'
},
checks: ['private snapshot'], checks: ['private snapshot'],
checkpoints: [] checkpoints: []
}) })
); );
expect(deploymentListPage({ view: 'active' }).deployments[0]).toMatchObject({ expect(deploymentListPage({ view: 'active' }).deployments[0]).toMatchObject({
waitCode: 'restart-capacity', waitCode: 'restart-capacity',
waitMessage: 'Waiting for another server to recover',
phase: 'eligibility', phase: 'eligibility',
phaseStartedAt: '2026-01-01' phaseStartedAt: '2026-01-01'
}); });
@@ -308,7 +332,7 @@ describe('bounded provisioning lists', () => {
); );
}); });
it('puts verification and executing work ahead of preflight waits and capacity queues', async () => { it('orders active jobs by lifecycle state regardless of phases and blockers', async () => {
const { add, handle, listDeployments, deploymentListPage } = await setup(); const { add, handle, listDeployments, deploymentListPage } = await setup();
const addProgress = ( const addProgress = (
id: string, id: string,
@@ -403,18 +427,18 @@ describe('bounded provisioning lists', () => {
const expected = [ const expected = [
'verify-older', 'verify-older',
'verify-newer', 'verify-newer',
'staging', 'capacity',
'staging-wait', 'eligibility',
'preparing-queued', 'operation-conflict',
'executing', 'executing',
'agent-wait', 'agent-wait',
'cleanup', 'legacy-capacity',
'eligibility',
'host-wait', 'host-wait',
'operation-conflict', 'staging',
'staging-wait',
'cleanup',
'queued', 'queued',
'capacity', 'preparing-queued'
'legacy-capacity'
]; ];
expect(listDeployments({ view: 'active' }).map((row) => row.id)).toEqual(expected); expect(listDeployments({ view: 'active' }).map((row) => row.id)).toEqual(expected);
const { GET } = await import('../../src/routes/api/provisioning/+server.js'); const { GET } = await import('../../src/routes/api/provisioning/+server.js');
@@ -424,7 +448,7 @@ describe('bounded provisioning lists', () => {
expect(active.deployments.map((row: { id: string }) => row.id)).toEqual(expected); expect(active.deployments.map((row: { id: string }) => row.id)).toEqual(expected);
expect(active).toMatchObject({ total: expected.length, needsReview: 2 }); expect(active).toMatchObject({ total: expected.length, needsReview: 2 });
// Progress priority applies only to Active; retained outcomes and mixed reads stay newest first. // State priority applies only to Active; retained outcomes and mixed reads stay newest first.
expect( expect(
deploymentListPage({ view: 'history', limit: 1 }).deployments.map((row) => row.id) deploymentListPage({ view: 'history', limit: 1 }).deployments.map((row) => row.id)
).toEqual(['failed']); ).toEqual(['failed']);
+62 -61
View File
@@ -1,74 +1,75 @@
import { expect, it } from 'vitest'; import { expect, it } from 'vitest';
import { import { render } from 'svelte/server';
provisioningQueueStatus, import { provisioningContext } from '../../src/lib/ui/provisioning-presentation.js';
provisioningWarnings import { provisioningStatus, provisioningWarnings } from '../../src/lib/ui/provisioning-queue.js';
} from '../../src/lib/ui/provisioning-queue.js'; import ProvisioningStatusBadge from '../../src/lib/ui/components/ProvisioningStatusBadge.svelte';
it('distinguishes capacity, occupancy, recovery and verification without inventing errors', () => { it.each([
const row = { action: 'restart' as const, state: 'waiting', stage: 'Waiting' }; ['queued', 'Queued', 'neutral'],
expect(provisioningQueueStatus({ ...row, waitCode: 'restart-capacity' })).toMatchObject({ ['waiting', 'Waiting', 'neutral'],
label: 'Queued for restart', ['running', 'Running', 'info'],
tone: 'neutral', ['awaiting-health', 'Validating', 'info'],
group: 'queued' ['done', 'Completed', 'online'],
}); ['warning', 'Completed with warnings', 'warning'],
expect( ['failed', 'Failed', 'danger'],
provisioningQueueStatus({ ['interrupted', 'Interrupted', 'danger'],
...row, ['cancelled', 'Cancelled', 'neutral']
stage: 'Queued to restart; waiting for another server to recover' ])('renders the recorded %s lifecycle state consistently', (state, label, tone) => {
}) expect(provisioningStatus(state)).toMatchObject({ label, tone });
).toMatchObject({ group: 'queued' }); const body = render(ProvisioningStatusBadge, { props: { state } }).body;
expect( expect(body).toContain(label);
provisioningQueueStatus({ ...row, stage: 'Waiting for 3 player(s) to leave.' }) expect(body).toContain(`v-${tone}`);
).toMatchObject({ label: 'Waiting for empty lobby', group: 'waiting' }); expect(body).toContain('aria-hidden="true"');
expect(
provisioningQueueStatus({ ...row, stage: 'Waiting for a fresh local player count.' })
).toMatchObject({ label: 'Checking lobby occupancy' });
expect(provisioningQueueStatus({ ...row, waitCode: 'operation-conflict' })).toMatchObject({
label: 'Waiting for another operation'
});
expect(
provisioningQueueStatus({
...row,
state: 'awaiting-health',
waitCode: 'native-host',
stage: 'Waiting for container readiness: starting'
})
).toMatchObject({ label: 'Verifying health', group: 'verifying' });
expect(
provisioningQueueStatus({
...row,
state: 'running',
waitCode: 'native-host',
stage: 'Waiting for startup capacity.'
})
).toMatchObject({ label: 'Waiting for capacity', group: 'queued' });
expect(provisioningQueueStatus({ ...row, phase: 'cleanup' })).toMatchObject({
label: 'Cleaning up',
group: 'running'
});
}); });
it('keeps recorded terminal outcomes ahead of leftover progress and blockers', () => { it('keeps waiting reasons and preparation separate from the recorded state', () => {
const row = { const row = {
state: 'running',
action: 'restart' as const, action: 'restart' as const,
state: 'done', phase: 'container-action',
stage: 'Old waiting text', stage: 'Waiting for host acknowledgment',
waitCode: 'restart-capacity', waitMessage: 'Waiting for another operation to finish'
phase: 'cleanup'
}; };
expect(provisioningQueueStatus(row)).toMatchObject({ label: 'Complete', group: 'complete' }); expect(provisioningContext(row)).toEqual({
expect(provisioningQueueStatus({ ...row, state: 'failed' })).toMatchObject({ step: 'Restart container',
label: 'Failed', detail: 'Waiting for another operation to finish'
tone: 'danger',
group: 'attention'
});
expect(provisioningQueueStatus({ ...row, state: 'cancelled' })).toMatchObject({
label: 'Cancelled',
group: 'cancelled',
tone: 'neutral'
}); });
expect(provisioningStatus(row.state).label).toBe('Running');
const preparing = { ...row, state: 'queued', phase: 'prepare', waitMessage: null };
expect(provisioningContext(preparing)?.step).toBe('Prepare server files');
expect(provisioningStatus(preparing.state).label).toBe('Queued');
}); });
it('preserves legacy stage text without parsing it into a lifecycle label', () => {
const row = {
state: 'waiting',
action: 'rebuild' as const,
stage: 'Waiting for 3 player(s) to leave.'
};
expect(provisioningContext(row)).toEqual({ step: row.stage, detail: null });
expect(provisioningContext({ ...row, phase: 'future-phase' })).toEqual({
step: row.stage,
detail: null
});
expect(provisioningStatus(row.state).label).toBe('Waiting');
});
it.each(['done', 'warning', 'failed', 'interrupted', 'cancelled'])(
'does not describe leftover progress as current for %s jobs',
(state) => {
expect(
provisioningContext({
state,
action: 'restart',
stage: 'Waiting for startup capacity',
waitMessage: 'Waiting for recovery',
phase: 'cleanup'
})
).toBeNull();
}
);
it('only displays strings from recorded warning arrays', () => { it('only displays strings from recorded warning arrays', () => {
expect(provisioningWarnings('["Warning",null,3]')).toEqual(['Warning']); expect(provisioningWarnings('["Warning",null,3]')).toEqual(['Warning']);
expect(provisioningWarnings('null')).toEqual([]); expect(provisioningWarnings('null')).toEqual([]);
+43
View File
@@ -123,6 +123,49 @@ function recovery(state: RecoveryObservation['state']): RecoveryObservation {
} }
describe('provisioning detail summary and technical details', () => { describe('provisioning detail summary and technical details', () => {
it('explains file preparation while preserving the Queued lifecycle state', () => {
const detail = fixture('queued');
detail.deployment.finished_at = null;
detail.deployment.stage = 'Preparing launch files for the host agent';
detail.progress!.phase = 'prepare';
const body = pageBody(detail);
const badges = body.match(/<div\b[^>]*class="badges[^>]*>[\s\S]*?<\/div>/)?.[0];
expect(badges).toContain('Queued');
expect(body).toContain('Current step: Prepare server files');
expect(body).toContain('The command has not been submitted yet.');
expect(body).not.toContain('awaiting provisioning evaluation');
});
it('shows Validating with the current step and blocker as separate supporting text', () => {
const detail = fixture('awaiting-health');
detail.deployment.finished_at = null;
detail.deployment.stage = 'Checking game status';
detail.progress!.blocker = {
code: 'native-host',
message: 'Waiting for fresh game telemetry',
automatic: true
};
const body = pageBody(detail);
const badges = body.match(/<div\b[^>]*class="badges[^>]*>[\s\S]*?<\/div>/)?.[0];
expect(badges).toContain('Validating');
expect(badges).not.toContain('Waiting for fresh game telemetry');
expect(body).toContain('Current step: Verify game status');
expect(body).toContain('Waiting for fresh game telemetry');
expect(body).not.toContain('Verifying health');
});
it('keeps failed outcomes visible even when a prior blocker remains recorded', () => {
const detail = fixture('failed');
detail.progress!.blocker = {
code: 'startup-capacity',
message: 'Waiting for startup capacity',
automatic: true
};
const body = pageBody(detail);
expect(body).toContain('Restart failed');
expect(body).not.toContain('Current step:');
});
it('puts requester, absolute start and one duration above the outcome', () => { it('puts requester, absolute start and one duration above the outcome', () => {
const body = pageBody(fixture()); const body = pageBody(fixture());
const summary = body.match(/<p\b[^>]*class="operation-summary[^>]*>[\s\S]*?<\/p>/)?.[0]; const summary = body.match(/<p\b[^>]*class="operation-summary[^>]*>[\s\S]*?<\/p>/)?.[0];