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**
on the operations page offers the same actions for a selected server.
The **Active** tab shows jobs verifying health first, then jobs executing work,
waiting jobs, and queued jobs. File preparation, cleanup, and submitted commands
awaiting host confirmation count as executing work. Within each group, older
requests appear first, with recording order breaking timestamp ties. **History**
shows the newest requests first. This display order does not change execution
priority or capacity admission.
Provisioning badges show the recorded lifecycle state consistently on the list
and job details: **Queued**, **Waiting**, **Running**, **Validating**, **Completed**,
**Completed with warnings**, **Failed**, **Interrupted**, or **Cancelled**.
**Validating** is the display name for `awaiting-health`. The current step and
waiting reason appear separately beneath the state. File preparation can occur
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
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 { 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}
+35 -11
View File
@@ -47,6 +47,26 @@ async function setup() {
}
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 () => {
const { add, handle, deploymentListPage } = await setup();
add('recovery', 'waiting', 'rebuild');
@@ -293,13 +313,17 @@ describe('bounded provisioning lists', () => {
)
.run(
JSON.stringify({
blocker: { code: 'restart-capacity' },
blocker: {
code: 'restart-capacity',
message: 'Waiting for another server to recover'
},
checks: ['private snapshot'],
checkpoints: []
})
);
expect(deploymentListPage({ view: 'active' }).deployments[0]).toMatchObject({
waitCode: 'restart-capacity',
waitMessage: 'Waiting for another server to recover',
phase: 'eligibility',
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 addProgress = (
id: string,
@@ -403,18 +427,18 @@ describe('bounded provisioning lists', () => {
const expected = [
'verify-older',
'verify-newer',
'staging',
'staging-wait',
'preparing-queued',
'capacity',
'eligibility',
'operation-conflict',
'executing',
'agent-wait',
'cleanup',
'eligibility',
'legacy-capacity',
'host-wait',
'operation-conflict',
'staging',
'staging-wait',
'cleanup',
'queued',
'capacity',
'legacy-capacity'
'preparing-queued'
];
expect(listDeployments({ view: 'active' }).map((row) => row.id)).toEqual(expected);
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).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(
deploymentListPage({ view: 'history', limit: 1 }).deployments.map((row) => row.id)
).toEqual(['failed']);
+61 -60
View File
@@ -1,73 +1,74 @@
import { expect, it } from 'vitest';
import {
provisioningQueueStatus,
provisioningWarnings
} from '../../src/lib/ui/provisioning-queue.js';
import { render } from 'svelte/server';
import { provisioningContext } from '../../src/lib/ui/provisioning-presentation.js';
import { provisioningStatus, provisioningWarnings } 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', () => {
const row = { action: 'restart' as const, state: 'waiting', stage: 'Waiting' };
expect(provisioningQueueStatus({ ...row, waitCode: 'restart-capacity' })).toMatchObject({
label: 'Queued for restart',
tone: 'neutral',
group: 'queued'
});
expect(
provisioningQueueStatus({
...row,
stage: 'Queued to restart; waiting for another server to recover'
})
).toMatchObject({ group: 'queued' });
expect(
provisioningQueueStatus({ ...row, stage: 'Waiting for 3 player(s) to leave.' })
).toMatchObject({ label: 'Waiting for empty lobby', group: 'waiting' });
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.each([
['queued', 'Queued', 'neutral'],
['waiting', 'Waiting', 'neutral'],
['running', 'Running', 'info'],
['awaiting-health', 'Validating', 'info'],
['done', 'Completed', 'online'],
['warning', 'Completed with warnings', 'warning'],
['failed', 'Failed', 'danger'],
['interrupted', 'Interrupted', 'danger'],
['cancelled', 'Cancelled', 'neutral']
])('renders the recorded %s lifecycle state consistently', (state, label, tone) => {
expect(provisioningStatus(state)).toMatchObject({ label, tone });
const body = render(ProvisioningStatusBadge, { props: { state } }).body;
expect(body).toContain(label);
expect(body).toContain(`v-${tone}`);
expect(body).toContain('aria-hidden="true"');
});
it('keeps recorded terminal outcomes ahead of leftover progress and blockers', () => {
it('keeps waiting reasons and preparation separate from the recorded state', () => {
const row = {
state: 'running',
action: 'restart' as const,
state: 'done',
stage: 'Old waiting text',
waitCode: 'restart-capacity',
phase: 'cleanup'
phase: 'container-action',
stage: 'Waiting for host acknowledgment',
waitMessage: 'Waiting for another operation to finish'
};
expect(provisioningQueueStatus(row)).toMatchObject({ label: 'Complete', group: 'complete' });
expect(provisioningQueueStatus({ ...row, state: 'failed' })).toMatchObject({
label: 'Failed',
tone: 'danger',
group: 'attention'
expect(provisioningContext(row)).toEqual({
step: 'Restart container',
detail: 'Waiting for another operation to finish'
});
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', () => {
expect(provisioningWarnings('["Warning",null,3]')).toEqual(['Warning']);
+43
View File
@@ -123,6 +123,49 @@ function recovery(state: RecoveryObservation['state']): RecoveryObservation {
}
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', () => {
const body = pageBody(fixture());
const summary = body.match(/<p\b[^>]*class="operation-summary[^>]*>[\s\S]*?<\/p>/)?.[0];