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} +