From ad5609cd236d77fff62d0cb2547c94a715176ffa Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Tue, 6 Oct 2026 16:56:41 -0400 Subject: [PATCH] Simplify provisioning queue and add shared filters --- src/lib/provisioning-list.ts | 12 +- src/lib/server/provisioning/history.ts | 44 +++- src/lib/ui/provisioning-presentation.ts | 2 +- src/lib/ui/provisioning-queue.ts | 31 +++ src/routes/api/provisioning/+server.ts | 6 +- src/routes/provisioning/+page.svelte | 225 ++++++++++++------- tests/unit/provisioning-detail.test.ts | 2 +- tests/unit/provisioning-list.test.ts | 53 ++++- tests/unit/provisioning-presentation.test.ts | 2 + tests/unit/provisioning-queue.test.ts | 27 +++ 10 files changed, 310 insertions(+), 94 deletions(-) create mode 100644 src/lib/ui/provisioning-queue.ts create mode 100644 tests/unit/provisioning-queue.test.ts diff --git a/src/lib/provisioning-list.ts b/src/lib/provisioning-list.ts index 1bb822f..167c43e 100644 --- a/src/lib/provisioning-list.ts +++ b/src/lib/provisioning-list.ts @@ -1,14 +1,22 @@ import type { ProvisioningAction } from './provisioning.js'; import type { ProvisioningDeployment } from './provisioning-detail.js'; +import type { ProvisioningSource } from './provisioning-detail.js'; + +export const provisioningListStatuses = ['pending', 'running', 'awaiting-health', 'done', 'attention', 'failed', 'interrupted', 'warning', 'cancelled'] as const; +export const provisioningListSources = ['manual', 'campaign', 'beta', 'maintenance', 'system'] as const; export type ProvisioningListRow = Pick & { canCancelRemoval: number; canCancel?: number }; + 'finished_at' | 'triggered_by' | 'error' | 'warnings' | 'server_exists'> & { + canCancelRemoval: number; canCancel?: number; sources?: ProvisioningSource[]; + phase?: string | null; phaseStartedAt?: string | null; waitCode?: string | null; + }; export interface ProvisioningListOptions { view?: 'active' | 'history' | 'all'; action?: ProvisioningAction; - status?: 'attention'; + status?: typeof provisioningListStatuses[number]; + source?: typeof provisioningListSources[number]; server?: string; limit?: number; offset?: number; diff --git a/src/lib/server/provisioning/history.ts b/src/lib/server/provisioning/history.ts index 98a94a9..d9a3894 100644 --- a/src/lib/server/provisioning/history.ts +++ b/src/lib/server/provisioning/history.ts @@ -60,9 +60,11 @@ function warningMessages(raw: string): string[] { function sourceDetails(source: Omit): ProvisioningSource { const id = source.source_id; if (source.kind === 'campaign') { - const row = db().prepare('SELECT action FROM deployments WHERE id = ?').get(id) as { action: string } | undefined; - const action = row?.action === 'promote' ? 'Promotion' : row?.action === 'rollback' ? 'Rollback' : row?.action === 'drift' ? 'Reconciliation' : 'Campaign'; - return { ...source, label: `${action} ${id.slice(0, 8)}`, href: row ? `/campaigns/${encodeURIComponent(id)}` : null }; + const row = db().prepare(`SELECT d.action, d.beta_id, s.name FROM deployments d + LEFT JOIN scripts s ON s.id=COALESCE(d.script_id, (SELECT script_id FROM deployment_scripts WHERE deployment_id=d.id ORDER BY script_id LIMIT 1)) + WHERE d.id=?`).get(id) as { action: string; beta_id: number | null; name: string | null } | undefined; + const action = row?.action === 'promote' ? 'Promotion' : row?.action === 'rollback' ? 'Rollback' : row?.action === 'drift' ? 'Maintenance repair' : 'Campaign'; + return { ...source, label: row ? `${action}${row.beta_id ? ` · Beta #${row.beta_id}` : row.name ? ` · ${row.name}` : action === 'Campaign' ? '' : ' campaign'}` : 'Campaign unavailable', href: row ? `/campaigns/${encodeURIComponent(id)}` : null }; } if (source.kind === 'beta') { const row = db().prepare('SELECT notes FROM betas WHERE id = ?').get(id) as { notes: string | null } | undefined; @@ -251,9 +253,13 @@ function preserveOutcome(id: string): void { } const activeDeployment = "d.server_deleted_at IS NULL AND d.state IN ('queued', 'awaiting-health', 'running', 'waiting')"; -const reviewDeployment = "d.state IN ('failed', 'interrupted', 'warning')"; +const reviewDeployment = `d.state IN ('failed', 'interrupted', 'warning') AND NOT EXISTS ( + SELECT 1 FROM retention_controls c WHERE c.category='provisioning' AND c.record_id=d.id + AND c.resolved_at >= COALESCE(d.finished_at,d.started_at))`; const deploymentListColumns = `d.id, d.server, d.server_deleted_at, d.action, d.state, d.stage, d.started_at, d.finished_at, d.triggered_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, (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')) AS canCancel @@ -265,6 +271,16 @@ function deploymentListQuery(options: ProvisioningListOptions) { const parameters: string[] = []; if (options.action) { conditions.push('d.action = ?'); parameters.push(options.action); } if (options.status === 'attention') conditions.push(reviewDeployment); + else if (options.status === 'pending') conditions.push("d.state IN ('queued','waiting')"); + else if (options.status) { conditions.push('d.state=?'); parameters.push(options.status); } + const linked = (kinds: string[]) => `(EXISTS (SELECT 1 FROM provisioning_sources src WHERE src.operation_id=d.id AND src.kind IN (${kinds.map(kind => `'${kind}'`).join(',')})) + OR ${kinds.map(kind => `d.triggered_by LIKE '${kind}:%'`).join(' OR ')})`; + if (options.source === 'campaign' || options.source === 'beta') conditions.push(linked([options.source])); + else if (options.source === 'maintenance') conditions.push(linked(['maintenance', 'watchdog'])); + else if (options.source === 'manual' || options.source === 'system') { + conditions.push(`NOT ${linked(['campaign', 'beta', 'maintenance', 'watchdog'])}`); + conditions.push(options.source === 'manual' ? "d.triggered_by='ui'" : "d.triggered_by!='ui'"); + } const search = (options.server ?? '').slice(0, 100).toLowerCase(); if (search) { // Read only distinct names to preserve friendly map/lobby searches, including deleted servers. @@ -278,9 +294,27 @@ function deploymentListQuery(options: ProvisioningListOptions) { } function deploymentListRows(query: ReturnType, active: boolean): ProvisioningListRow[] { - return db().prepare(`SELECT ${deploymentListColumns} FROM server_deployments d WHERE ${query.where} + const rows = db().prepare(`SELECT ${deploymentListColumns} FROM server_deployments d WHERE ${query.where} ORDER BY d.started_at DESC, d.rowid DESC ${active ? '' : 'LIMIT ? OFFSET ?'}`) .all(REMOVING_SERVER, ...query.parameters, ...(active ? [] : [query.limit, query.offset])) as ProvisioningListRow[]; + if (!rows.length) return rows; + const links = db().prepare(`SELECT operation_id,kind,source_id FROM provisioning_sources + WHERE operation_id IN (SELECT value FROM json_each(?)) ORDER BY kind,source_id`).all(JSON.stringify(rows.map(row => row.id))) as + (Omit & { operation_id: string })[]; + const byOperation = new Map[]>(); + for (const { operation_id, ...link } of links) byOperation.set(operation_id, [...(byOperation.get(operation_id) ?? []), link]); + const cache = new Map(); + for (const row of rows) { + const sources = byOperation.get(row.id) ?? []; + const fallback = row.triggered_by.match(/^(campaign|beta|maintenance|watchdog):(.+)$/); + if (fallback && !sources.some(source => source.kind === fallback[1] && source.source_id === fallback[2])) sources.unshift({ kind: fallback[1] as ProvisioningSource['kind'], source_id: fallback[2] }); + row.sources = sources.map(source => { + const key = `${source.kind}:${source.source_id}`; + if (!cache.has(key)) cache.set(key, sourceDetails(source)); + return cache.get(key)!; + }); + } + return rows; } /** All list reads omit observation snapshots; retained history is always bounded. */ diff --git a/src/lib/ui/provisioning-presentation.ts b/src/lib/ui/provisioning-presentation.ts index 5c31fe8..cbc6fa1 100644 --- a/src/lib/ui/provisioning-presentation.ts +++ b/src/lib/ui/provisioning-presentation.ts @@ -113,7 +113,7 @@ export function provisioningReturn(value: string | null | undefined): { href: st if (url.origin !== 'https://electric-cherry.invalid') return fallback; if (url.pathname === '/provisioning') { const params = new URLSearchParams(); - for (const key of ['view', 'action', 'status', 'server', 'limit', 'offset']) { + for (const key of ['view', 'action', 'status', 'source', 'server', 'limit', 'offset']) { const item = url.searchParams.get(key); if (item) params.set(key, item); } diff --git a/src/lib/ui/provisioning-queue.ts b/src/lib/ui/provisioning-queue.ts new file mode 100644 index 0000000..ecad91b --- /dev/null +++ b/src/lib/ui/provisioning-queue.ts @@ -0,0 +1,31 @@ +import type { ProvisioningListRow } from '../provisioning-list.js'; +import { statusPresentation, type StatusTone } 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' }; +} + +export function provisioningWarnings(raw: string): string[] { + try { const value: unknown = JSON.parse(raw); return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; } + catch { return []; } +} diff --git a/src/routes/api/provisioning/+server.ts b/src/routes/api/provisioning/+server.ts index d35814f..911f185 100644 --- a/src/routes/api/provisioning/+server.ts +++ b/src/routes/api/provisioning/+server.ts @@ -3,14 +3,18 @@ import { deploymentListPage } from '$lib/server/provisioning/history.js'; import { requestLifecycle } from '$lib/server/provisioning/index.js'; import { provisioningActions, runtimeActions, type ProvisioningAction, type RuntimeAction } from '$lib/provisioning.js'; import { kickProvisioning } from '$lib/server/pipeline/worker.js'; +import { provisioningListSources, provisioningListStatuses } from '$lib/provisioning-list.js'; export function GET({ url }: { url: URL }) { const params = url.searchParams; const action = params.get('action'); + const status = params.get('status') as typeof provisioningListStatuses[number]; + const source = params.get('source') as typeof provisioningListSources[number]; return json(deploymentListPage({ view: params.get('view') === 'history' ? 'history' : 'active', action: provisioningActions.includes(action as ProvisioningAction) ? action as ProvisioningAction : undefined, - status: params.get('status') === 'attention' ? 'attention' : undefined, + status: provisioningListStatuses.includes(status) ? status : undefined, + source: provisioningListSources.includes(source) ? source : undefined, server: params.get('server') ?? '', limit: Number(params.get('limit') ?? 25), offset: Number(params.get('offset') ?? 0) diff --git a/src/routes/provisioning/+page.svelte b/src/routes/provisioning/+page.svelte index 997b077..5bbaa32 100644 --- a/src/routes/provisioning/+page.svelte +++ b/src/routes/provisioning/+page.svelte @@ -6,7 +6,6 @@ import { goto } from '$app/navigation'; import { pollVisible } from '$ui/poll.js'; import Tabs from '$ui/components/primitives/Tabs.svelte'; - import StatusBadge from '$ui/components/primitives/StatusBadge.svelte'; import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import Badge from '$ui/components/primitives/Badge.svelte'; @@ -16,8 +15,14 @@ import HousePartyDialog from '$ui/components/HousePartyDialog.svelte'; import LifecycleDialog from '$ui/components/LifecycleDialog.svelte'; - import { provisioningPageSize, provisioningPageOffset, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js'; - import { provisioningActions, actionLabels, actionCompleted } from '$lib/provisioning.js'; + import { provisioningListStatuses, provisioningListSources, provisioningPageSize, provisioningPageOffset, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js'; + import { provisioningActions, 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 } from '$ui/provisioning-presentation.js'; + import { provisioningQueueStatus, provisioningWarnings } from '$ui/provisioning-queue.js'; let lifecycleOpen = $state(false); let active = $state([]); @@ -36,13 +41,19 @@ let limit = $state(25); let offset = $state(0); let historyState = $state('all'); + let source = $state('all'); + let expanded = $state>({}); + let fleetActionsOpen = $state(false); + let now = $state(Date.now()); + let historyUpdatedAt = $state(null); let cancelling = $state(false); let cancellationNotice = $state(''); $effect(() => { const params = $page.url.searchParams; tab = ['active', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'active'; action = provisioningActions.some(value => value === params.get('action')) ? params.get('action')! : 'all'; - historyState = params.get('status') === 'attention' ? 'attention' : 'all'; + historyState = provisioningListStatuses.some(value => value === params.get('status')) ? params.get('status')! : 'all'; + source = provisioningListSources.some(value => value === params.get('source')) ? params.get('source')! : 'all'; search = params.get('server') ?? ''; limit = provisioningPageSize(Number(params.get('limit') ?? 25)); offset = provisioningPageOffset(Number(params.get('offset') ?? 0)); @@ -50,21 +61,27 @@ function saveContext(reset = false) { if (reset) offset = 0; const url = new URL($page.url); - for (const [key, value] of Object.entries({ view: tab === 'active' ? '' : tab, action: action === 'all' ? '' : action, status: historyState === 'all' ? '' : historyState, server: search, limit: limit !== 25 ? String(limit) : '', offset: offset ? String(offset) : '' })) { + for (const [key, value] of Object.entries({ view: tab === 'active' ? '' : tab, action: action === 'all' ? '' : action, status: historyState === 'all' ? '' : historyState, source: source === 'all' ? '' : source, server: search, limit: limit !== 25 ? String(limit) : '', offset: offset ? String(offset) : '' })) { if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); } void goto(url, { replaceState: true, noScroll: true, keepFocus: true }); } - async function cancelRequests(ids: string[]) { + async function cancelRequests(rows: Deployment[], bulk = false) { + if (cancelling || !rows.length) return; + const ids = rows.map(row => row.id); + if (!await confirmAction({ title: bulk ? `Cancel all ${ids.length} active operations?` : 'Cancel operation?', + message: `${bulk ? `This affects all ${ids.length} cancellable active operations across the fleet, regardless of the current filters.` : `Cancel ${actionLabels[rows[0].action].toLowerCase()} on ${serverTitle(rows[0].server)}.`} Cancelling stops remaining work and health checks. Changes already made are not undone; commands already delivered to a host may still finish.`, + confirmLabel: bulk ? 'Cancel all operations' : 'Cancel operation', danger: true })) return; + fleetActionsOpen = false; cancelling = true; cancellationNotice = ''; let cancelled = 0; const unavailable: string[] = []; for (const id of ids) { try { const response = await fetch(`/api/provisioning/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); if (response.ok) cancelled++; else unavailable.push(id); } catch { unavailable.push(id); } } cancellationNotice = `${cancelled} operation${cancelled === 1 ? '' : 's'} cancelled.${unavailable.length ? ` ${unavailable.length} could not be cancelled; refresh to see their current state.` : ''}`; - cancelling = false; await load(); + cancelling = false; await load(); historyRefresh++; } - const tabs = $derived.by(() => [{id:'active',label:'Active',badge:active.length},{id:'history',label:'History'}]); + const tabs = [{id:'active',label:'Active'},{id:'history',label:'History'}]; let action = $state('all'); let search = $state(''); let refreshing = false; @@ -75,7 +92,7 @@ const res = await fetch('/api/provisioning?view=active', { signal, cache: 'no-store' }); if (!res.ok) throw new Error('Could not load provisioning activity.'); const result: ProvisioningListPage = await res.json(); - active = result.deployments; + active = result.deployments; now = Date.now(); needsReview = result.needsReview; error = ''; } catch (err) { if (!signal?.aborted) error = (err as Error).message; return false; } @@ -84,7 +101,7 @@ // History is fetched on navigation/filter changes or explicit refresh, never by the active poll. $effect(() => { if (tab !== 'history') return; - const params = new URLSearchParams({ view: 'history', action, status: historyState, + const params = new URLSearchParams({ view: 'history', action, status: historyState, source, server: search, limit: String(limit), offset: String(offset) }); void historyRefresh; const controller = new AbortController(); @@ -97,101 +114,143 @@ const response = await fetch(`/api/provisioning?${params}`, { signal: controller.signal, cache: 'no-store' }); if (!response.ok) throw new Error('Could not load provisioning history.'); const result: ProvisioningListPage = await response.json(); - if (!controller.signal.aborted) { history = result.deployments; historyTotal = result.total; } + if (!controller.signal.aborted) { history = result.deployments; historyTotal = result.total; historyUpdatedAt = new Date().toISOString(); now = Date.now(); } } catch (err) { if (!controller.signal.aborted) historyError = (err as Error).message; } finally { if (!controller.signal.aborted) historyLoading = false; } })(); return () => controller.abort(); }); - onMount(() => pollVisible(load, 5000)); + onMount(() => { const stop = pollVisible(load, 5000); const clock = setInterval(() => now = Date.now(), 30000); return () => { stop(); clearInterval(clock); }; }); function fmt(value: string) { return $timeFormat.dateTime(value); } - function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } } + function age(value: string) { + const minutes = Math.max(0, Math.floor((now - Date.parse(value)) / 60000)); + return minutes < 1 ? 'Just now' : minutes < 60 ? `${minutes} min ago` : minutes < 1440 ? `${Math.floor(minutes / 60)} hr ago` : `${Math.floor(minutes / 1440)}d ago`; + } + const filtered = $derived(action !== 'all' || source !== 'all' || historyState !== 'all' || Boolean(search)); + const visibleActive = $derived(active.filter(row => { + if (action !== 'all' && row.action !== action) return false; + if (historyState !== 'all' && (historyState === 'pending' ? !['queued','waiting'].includes(row.state) : row.state !== historyState)) return false; + if (search && !`${row.server} ${serverTitle(row.server)}`.toLowerCase().includes(search.toLowerCase())) return false; + if (source !== 'all') { + const sources = row.sources ?? []; + if (source === 'manual' && (sources.length || row.triggered_by !== 'ui')) return false; + if (source === 'system' && (sources.length || row.triggered_by === 'ui')) return false; + if (['campaign','beta','maintenance'].includes(source) && !sources.some(item => source === 'maintenance' ? ['maintenance','watchdog'].includes(item.kind) : item.kind === source)) return false; + } + return true; + })); + const queueSummary = $derived.by(() => { + const groups = { queued: 0, waiting: 0, verifying: 0, running: 0 }; + for (const row of visibleActive) { + const group = provisioningQueueStatus(row).group; + if (group in groups) groups[group as keyof typeof groups]++; + } + return Object.entries(groups).filter(([, count]) => count).map(([label, count]) => `${count} ${label === 'verifying' ? 'verifying health' : label === 'running' ? 'in progress' : label}`).join(' · '); + }); + function clearFilters() { action = 'all'; source = 'all'; historyState = 'all'; search = ''; saveContext(true); } + function reviewAttention() { tab = 'history'; action = 'all'; source = 'all'; search = ''; historyState = 'attention'; saveContext(true); } Provisioning | Electric Cherry
- - {#snippet actions()}
Fleet actions
{/snippet} + + {#snippet actions()} + +
Fleet actions
+ {#if active.some(row => row.canCancel)}{/if} + +
+ {/snippet}
{#if $page.url.searchParams.has('returnTo')}{/if} - saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" /> +
+
saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" />
+ + + + + {#if filtered}{/if} +
- { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in the Active tab.`; tab = 'active'; saveContext(true); void load(); }} /> + { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}.`; tab = 'active'; clearFilters(); void load(); }} /> {#if housePartyNotice}

{housePartyNotice}

{/if} {#if cancellationNotice}

{cancellationNotice}

{/if}
- {#if error}{/if} - {#if tab === 'active'} - {#if needsReview}
{needsReview} recorded operations need review
{/if} - {#if active.some(d => d.canCancel)}
Cancelling stops remaining work and health checks. Changes already made are not undone.
{/if} -
-

In flight {active.length}

- {#if loading}

Loading provisioning activity...

- {:else if active.length === 0} - {:else}
{#each active as d (d.id)}{@render row(d)}{/each}
{/if} -
- {:else} -
-

History

-
- - - -
- {#if historyError}{/if} - {#if historyLoading}

Loading provisioning history...

- {:else if !historyError && history.length === 0} - {:else}
{#each history as d (d.id)}{@render row(d)}{/each}
{/if} - -
- {/if} +
+ {#if tab === 'active'} + {#if loading}

Loading provisioning activity…

+ {:else if !visibleActive.length} + {:else}{@render operationTable(visibleActive)}{/if} + {:else} + {#if historyError}{/if} + {#if historyLoading}

Loading provisioning history…

+ {:else if !historyError && !history.length} + {:else}{@render operationTable(history)}{/if} + + {/if}
-{#snippet row(d: Deployment)} - +{#snippet operationTable(rows: Deployment[])} +
+ + {#each rows as row (row.id)} + {@const status = provisioningQueueStatus(row)} + {@const primarySource = row.sources?.[0]} + {@const operationHref = contextHref(`/provisioning/${encodeURIComponent(row.id)}`, $page.url)} + {@const isExpanded = expanded[row.id] ?? false} + + + + + + + + + + {/each} +
ServerOperationStatusSourceStartedDetails
{actionLabels[row.action]}{status.label}{#if ['queued','waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}In this stage {elapsedTime(row.phaseStartedAt, now)}{/if}{#if primarySource?.href}{primarySource.label}{:else}{primarySource?.label ?? (row.triggered_by === 'ui' ? 'Manual' : 'System')}{/if}{#if (row.sources?.length ?? 0) > 1}+{row.sources!.length - 1} more{/if}
{/snippet} diff --git a/tests/unit/provisioning-detail.test.ts b/tests/unit/provisioning-detail.test.ts index c9dd8d7..9ceb403 100644 --- a/tests/unit/provisioning-detail.test.ts +++ b/tests/unit/provisioning-detail.test.ts @@ -63,7 +63,7 @@ describe('provisioning detail projection', () => { expect(detail.activity).toEqual({ total: 0, latestId: null, failureExcerpt: null }); expect(detail.warningMessages).toEqual(['Check files', 'Some recorded warnings could not be read.']); expect(detail.sources).toEqual([ - { kind: 'campaign', source_id: 'missing', server: 'old-server', label: 'Campaign missing', href: null }, + { kind: 'campaign', source_id: 'missing', server: 'old-server', label: 'Campaign unavailable', href: null }, { kind: 'watchdog', source_id: 'incident', server: 'old-server', label: 'Recovered game service', href: '/maintenance/incident' } ]); db().prepare("UPDATE server_deployments SET warnings = 'broken json' WHERE id = 'legacy'").run(); diff --git a/tests/unit/provisioning-list.test.ts b/tests/unit/provisioning-list.test.ts index b69501e..e421748 100644 --- a/tests/unit/provisioning-list.test.ts +++ b/tests/unit/provisioning-list.test.ts @@ -19,6 +19,57 @@ async function setup() { } describe('bounded provisioning lists', () => { + it('filters readable sources before pagination and retains expired source references without broken links', async () => { + const { add, handle, deploymentListPage } = await setup(); + handle.exec(`INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'Common scripts','common.gsc'); + INSERT INTO script_versions(id,script_id,version,sha256,size) VALUES(1,1,1,'hash',0); + INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state,notes) VALUES(75,1,1,'ui','promoted','Gameplay checks'); + INSERT INTO deployments(id,action,started_at,beta_id) VALUES('campaign','promote','2026-01-01',75);`); + add('old-campaign', 'done', 'restart'); + handle.prepare("UPDATE server_deployments SET triggered_by='campaign:campaign' WHERE id='old-campaign'").run(); + add('linked-beta', 'waiting', 'restart'); + handle.prepare("INSERT INTO provisioning_sources(operation_id,kind,source_id,server) VALUES('linked-beta','beta','75','t5_zombie_theater_1')").run(); + add('expired', 'done', 'restart'); + handle.prepare("UPDATE server_deployments SET triggered_by='campaign:expired' WHERE id='expired'").run(); + add('manual', 'done', 'restart'); + handle.prepare("UPDATE server_deployments SET triggered_by='ui' WHERE id='manual'").run(); + for (let i = 0; i < 30; i++) add(`system-${i}`, 'done', 'restart'); + const first = deploymentListPage({ view: 'history', source: 'campaign', action: 'restart', status: 'done', server: 'Kino', limit: 1 }); + expect(first.total).toBe(2); + expect(first.deployments[0]).toMatchObject({ id: 'expired', sources: [{ label: 'Campaign unavailable', href: null }] }); + const second = deploymentListPage({ view: 'history', source: 'campaign', limit: 1, offset: 1 }); + expect(second.deployments[0]).toMatchObject({ id: 'old-campaign', sources: [{ label: 'Promotion · Beta #75', href: '/campaigns/campaign' }] }); + const beta = deploymentListPage({ view: 'active', source: 'beta', status: 'pending' }); + expect(beta.deployments).toEqual([expect.objectContaining({ id: 'linked-beta', sources: [{ kind: 'beta', source_id: '75', label: 'Beta #75: Gameplay checks', href: '/betas/75' }] })]); + expect(deploymentListPage({ view: 'history', source: 'manual' }).deployments.map(row => row.id)).toEqual(['manual']); + expect(deploymentListPage({ view: 'history', source: 'system' }).total).toBe(30); + const { GET } = await import('../../src/routes/api/provisioning/+server.js'); + const result = await GET({ url: new URL('http://localhost/api/provisioning?view=active&status=pending&source=beta') }).json(); + expect(result.deployments.map((row: { id: string }) => row.id)).toEqual(['linked-beta']); + }); + + it('counts unresolved outcomes while retaining resolved failures and routine cancellations in history', async () => { + const { add, handle, deploymentListPage } = await setup(); + add('failed', 'failed'); add('resolved', 'failed'); add('cancelled', 'cancelled'); add('warning', 'warning'); + handle.prepare("INSERT INTO retention_controls(category,record_id,resolved_at) VALUES('provisioning','resolved','2026-01-02')").run(); + expect(deploymentListPage({ view: 'history' }).total).toBe(4); + const attention = deploymentListPage({ view: 'history', status: 'attention' }); + expect(attention.needsReview).toBe(2); + expect(attention.deployments.map(row => row.id)).toEqual(['warning', 'failed']); + expect(deploymentListPage({ view: 'history', status: 'failed' }).total).toBe(2); + handle.prepare("UPDATE server_deployments SET finished_at='2026-01-03' WHERE id='resolved'").run(); + expect(deploymentListPage({ view: 'history', status: 'attention' }).needsReview).toBe(3); + }); + + it('exposes compact waiting facts without transferring observation snapshots', async () => { + const { add, handle, deploymentListPage } = await setup(); + add('waiting', 'waiting', 'restart'); + handle.prepare("UPDATE server_deployments SET phase_key='eligibility',phase_started_at='2026-01-01',observation_json=? WHERE id='waiting'") + .run(JSON.stringify({ blocker: { code: 'restart-capacity' }, checks: ['private snapshot'], checkpoints: [] })); + expect(deploymentListPage({ view: 'active' }).deployments[0]).toMatchObject({ waitCode: 'restart-capacity', phase: 'eligibility', phaseStartedAt: '2026-01-01' }); + expect(JSON.stringify(deploymentListPage({ view: 'active' }))).not.toContain('private snapshot'); + }); + it('polls every active row and counts review outcomes without transferring retained history', async () => { const { handle, add, REMOVING_SERVER } = await setup(); handle.transaction(() => { @@ -111,7 +162,7 @@ describe('bounded provisioning lists', () => { const history = queries.find(sql => sql.includes('AS canCancelRemoval') && sql.includes('LIMIT'))!; const review = queries.find(sql => sql.includes('COUNT(*) AS needsReview'))!; expect(explain(active, [REMOVING_SERVER])).toContain('idx_server_deployments_state'); - expect(explain(review, [])).toContain('COVERING INDEX idx_server_deployments_state'); + expect(explain(review, [])).toContain('idx_server_deployments_state'); const historyPlan = explain(history, [REMOVING_SERVER, 100, 0]); expect(historyPlan).toContain('idx_server_deployments_started_at'); expect(historyPlan).not.toContain('TEMP B-TREE'); diff --git a/tests/unit/provisioning-presentation.test.ts b/tests/unit/provisioning-presentation.test.ts index 957b2dc..bd7b53c 100644 --- a/tests/unit/provisioning-presentation.test.ts +++ b/tests/unit/provisioning-presentation.test.ts @@ -147,6 +147,8 @@ describe('provisioning response and return-context boundaries', () => { .toBe('/provisioning?view=history&action=restart&server=Lobby+1&limit=50'); expect(provisioningReturn('/provisioning?view=history&limit=25&offset=100').href) .toBe('/provisioning?view=history&limit=25&offset=100'); + expect(provisioningReturn('/provisioning?view=history&source=campaign&status=failed&offset=25').href) + .toBe('/provisioning?view=history&status=failed&source=campaign&offset=25'); for (const value of ['https://evil.test', '//evil.test', '/\\evil.test', '/\n/evil.test', '/settings', '/provisioning/other']) { expect(provisioningReturn(value).href).toBe('/provisioning'); } diff --git a/tests/unit/provisioning-queue.test.ts b/tests/unit/provisioning-queue.test.ts new file mode 100644 index 0000000..abc2c86 --- /dev/null +++ b/tests/unit/provisioning-queue.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest'; +import { provisioningQueueStatus, provisioningWarnings } from '../../src/lib/ui/provisioning-queue.js'; + +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('keeps recorded terminal outcomes ahead of leftover progress and blockers', () => { + const row = { action: 'restart' as const, state: 'done', stage: 'Old waiting text', waitCode: 'restart-capacity', phase: 'cleanup' }; + expect(provisioningQueueStatus(row)).toMatchObject({ label: 'Complete', group: 'complete' }); + expect(provisioningQueueStatus({ ...row, state: 'failed' })).toMatchObject({ label: 'Failed', tone: 'danger', group: 'attention' }); + expect(provisioningQueueStatus({ ...row, state: 'cancelled' })).toMatchObject({ label: 'Cancelled', group: 'cancelled', tone: 'neutral' }); +}); + +it('only displays strings from recorded warning arrays', () => { + expect(provisioningWarnings('["Warning",null,3]')).toEqual(['Warning']); + expect(provisioningWarnings('null')).toEqual([]); + expect(provisioningWarnings('invalid')).toEqual([]); +});