This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.';
|
||||||
|
|||||||
@@ -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 ??
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
@@ -1,73 +1,74 @@
|
|||||||
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({
|
expect(provisioningStatus(row.state).label).toBe('Running');
|
||||||
label: 'Cancelled',
|
|
||||||
group: 'cancelled',
|
const preparing = { ...row, state: 'queued', phase: 'prepare', waitMessage: null };
|
||||||
tone: 'neutral'
|
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']);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user