diff --git a/docs/guides/provisioning.md b/docs/guides/provisioning.md
index ec43bb2..b908f76 100644
--- a/docs/guides/provisioning.md
+++ b/docs/guides/provisioning.md
@@ -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
diff --git a/src/lib/provisioning-list.ts b/src/lib/provisioning-list.ts
index b1d2ba1..d5fb8d4 100644
--- a/src/lib/provisioning-list.ts
+++ b/src/lib/provisioning-list.ts
@@ -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 {
diff --git a/src/lib/provisioning.ts b/src/lib/provisioning.ts
index 4aaf29b..f2fe118 100644
--- a/src/lib/provisioning.ts
+++ b/src/lib/provisioning.ts
@@ -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',
diff --git a/src/lib/server/provisioning/history.ts b/src/lib/server/provisioning/history.ts
index 6a959c5..b978db5 100644
--- a/src/lib/server/provisioning/history.ts
+++ b/src/lib/server/provisioning/history.ts
@@ -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()
diff --git a/src/lib/ui/components/ProvisioningStatusBadge.svelte b/src/lib/ui/components/ProvisioningStatusBadge.svelte
new file mode 100644
index 0000000..4eaf709
--- /dev/null
+++ b/src/lib/ui/components/ProvisioningStatusBadge.svelte
@@ -0,0 +1,10 @@
+
+
+{status.label}
diff --git a/src/lib/ui/components/autoscaling/PolicyCard.svelte b/src/lib/ui/components/autoscaling/PolicyCard.svelte
index e871168..1fe8d9f 100644
--- a/src/lib/ui/components/autoscaling/PolicyCard.svelte
+++ b/src/lib/ui/components/autoscaling/PolicyCard.svelte
@@ -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'
: ''}
+ >
{operation.stage || 'Waiting for the next provisioning update.'}
@@ -339,7 +339,7 @@
: 'Scaling evaluated'}{item.server
? ` · ${lobbyLabel(item.server)}`
: ''}{#if item.operationState}{#if item.operationState}{/if}
diff --git a/src/lib/ui/provisioning-presentation.ts b/src/lib/ui/provisioning-presentation.ts
index b91b558..b8b71d1 100644
--- a/src/lib/ui/provisioning-presentation.ts
+++ b/src/lib/ui/provisioning-presentation.ts
@@ -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 = {
'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.';
diff --git a/src/lib/ui/provisioning-queue.ts b/src/lib/ui/provisioning-queue.ts
index 1e09812..8548a11 100644
--- a/src/lib/ui/provisioning-queue.ts
+++ b/src/lib/ui/provisioning-queue.ts
@@ -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
-): {
- 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 = {
- '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[] {
diff --git a/src/routes/collections/launches/[id]/+page.svelte b/src/routes/collections/launches/[id]/+page.svelte
index a8ed504..e1c4d4f 100644
--- a/src/routes/collections/launches/[id]/+page.svelte
+++ b/src/routes/collections/launches/[id]/+page.svelte
@@ -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 = {
- 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}
/>{stateLabel(server)}
+ >
{server.error ??
diff --git a/src/routes/maintenance/[id]/+page.svelte b/src/routes/maintenance/[id]/+page.svelte
index 6fc27d5..0c835b5 100644
--- a/src/routes/maintenance/[id]/+page.svelte
+++ b/src/routes/maintenance/[id]/+page.svelte
@@ -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(null);
let loadError = $state('');
@@ -270,7 +270,7 @@
? 'Restart server'
: 'Rebuild server'}
- {#if action.operation_state}{/if}
diff --git a/src/routes/provisioning/+page.svelte b/src/routes/provisioning/+page.svelte
index 1daaee1..6ecd190 100644
--- a/src/routes/provisioning/+page.svelte
+++ b/src/routes/provisioning/+page.svelte
@@ -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 @@
>
+ {#each provisioningStates as state}{/each}
+
+ {#if historyState === 'pending'}{/if}
+