Simplify provisioning queue and add shared filters

This commit is contained in:
2026-10-06 16:56:41 -04:00
parent 36c2907602
commit ad5609cd23
10 changed files with 310 additions and 94 deletions
+10 -2
View File
@@ -1,14 +1,22 @@
import type { ProvisioningAction } from './provisioning.js'; import 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';
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<ProvisioningDeployment, export type ProvisioningListRow = Pick<ProvisioningDeployment,
'id' | 'server' | 'server_deleted_at' | 'action' | 'state' | 'stage' | 'started_at' | 'id' | 'server' | 'server_deleted_at' | 'action' | 'state' | 'stage' | 'started_at' |
'finished_at' | 'triggered_by' | 'error' | 'warnings' | 'server_exists'> & { 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 { export interface ProvisioningListOptions {
view?: 'active' | 'history' | 'all'; view?: 'active' | 'history' | 'all';
action?: ProvisioningAction; action?: ProvisioningAction;
status?: 'attention'; status?: typeof provisioningListStatuses[number];
source?: typeof provisioningListSources[number];
server?: string; server?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
+39 -5
View File
@@ -60,9 +60,11 @@ function warningMessages(raw: string): string[] {
function sourceDetails(source: Omit<ProvisioningSource, 'label' | 'href'>): ProvisioningSource { function sourceDetails(source: Omit<ProvisioningSource, 'label' | 'href'>): ProvisioningSource {
const id = source.source_id; const id = source.source_id;
if (source.kind === 'campaign') { if (source.kind === 'campaign') {
const row = db().prepare('SELECT action FROM deployments WHERE id = ?').get(id) as { action: string } | undefined; const row = db().prepare(`SELECT d.action, d.beta_id, s.name FROM deployments d
const action = row?.action === 'promote' ? 'Promotion' : row?.action === 'rollback' ? 'Rollback' : row?.action === 'drift' ? 'Reconciliation' : 'Campaign'; 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))
return { ...source, label: `${action} ${id.slice(0, 8)}`, href: row ? `/campaigns/${encodeURIComponent(id)}` : null }; 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') { if (source.kind === 'beta') {
const row = db().prepare('SELECT notes FROM betas WHERE id = ?').get(id) as { notes: string | null } | undefined; 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 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, 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.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 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')) AS canCancel (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[] = []; const parameters: string[] = [];
if (options.action) { conditions.push('d.action = ?'); parameters.push(options.action); } if (options.action) { conditions.push('d.action = ?'); parameters.push(options.action); }
if (options.status === 'attention') conditions.push(reviewDeployment); 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(); const search = (options.server ?? '').slice(0, 100).toLowerCase();
if (search) { if (search) {
// Read only distinct names to preserve friendly map/lobby searches, including deleted servers. // 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<typeof deploymentListQuery>, active: boolean): ProvisioningListRow[] { function deploymentListRows(query: ReturnType<typeof deploymentListQuery>, 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 ?'}`) ORDER BY d.started_at DESC, d.rowid DESC ${active ? '' : 'LIMIT ? OFFSET ?'}`)
.all(REMOVING_SERVER, ...query.parameters, ...(active ? [] : [query.limit, query.offset])) as ProvisioningListRow[]; .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<ProvisioningSource, 'label' | 'href'> & { operation_id: string })[];
const byOperation = new Map<string, Omit<ProvisioningSource, 'label' | 'href'>[]>();
for (const { operation_id, ...link } of links) byOperation.set(operation_id, [...(byOperation.get(operation_id) ?? []), link]);
const cache = new Map<string, ProvisioningSource>();
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. */ /** All list reads omit observation snapshots; retained history is always bounded. */
+1 -1
View File
@@ -113,7 +113,7 @@ export function provisioningReturn(value: string | null | undefined): { href: st
if (url.origin !== 'https://electric-cherry.invalid') return fallback; if (url.origin !== 'https://electric-cherry.invalid') return fallback;
if (url.pathname === '/provisioning') { if (url.pathname === '/provisioning') {
const params = new URLSearchParams(); 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); const item = url.searchParams.get(key);
if (item) params.set(key, item); if (item) params.set(key, item);
} }
+31
View File
@@ -0,0 +1,31 @@
import type { ProvisioningListRow } from '../provisioning-list.js';
import { statusPresentation, type StatusTone } from './status.js';
export function provisioningQueueStatus(row: Pick<ProvisioningListRow, 'state' | 'action' | 'stage' | 'waitCode' | 'phase'>): {
label: string; tone: StatusTone; group: 'queued' | 'waiting' | 'verifying' | 'running' | 'complete' | 'attention' | 'cancelled';
} {
if (['failed', 'interrupted', 'warning'].includes(row.state)) return { ...statusPresentation(row.state), group: 'attention' };
if (row.state === 'done') return { label: 'Complete', tone: 'online', group: 'complete' };
if (row.state === 'cancelled') return { label: 'Cancelled', tone: 'neutral', group: 'cancelled' };
if (row.state === 'awaiting-health') return { label: 'Verifying health', tone: 'info', group: 'verifying' };
if (row.phase === 'cleanup') return { label: 'Cleaning up', tone: 'info', group: 'running' };
const waits: Record<string, string> = {
'startup-capacity': 'Waiting for capacity', 'restart-capacity': row.action === 'restart' ? 'Queued for restart' : 'Waiting for recovery capacity',
'operation-conflict': 'Waiting for another operation', 'previous-recovery': 'Waiting for recovery'
};
if (row.waitCode && waits[row.waitCode]) return { label: waits[row.waitCode], tone: 'neutral', group: row.waitCode.endsWith('capacity') ? 'queued' : 'waiting' };
if (['queued', 'waiting'].includes(row.state) || row.waitCode) {
// Older records may have only a stage message; never infer a failure from it.
if (/waiting for another server to recover/i.test(row.stage)) return { label: 'Queued for restart', tone: 'neutral', group: 'queued' };
if (/waiting.*(?:empty|players)|players.*remaining|waiting for \d+ player.*to leave/i.test(row.stage)) return { label: 'Waiting for empty lobby', tone: 'neutral', group: 'waiting' };
if (/waiting for .*player count/i.test(row.stage)) return { label: 'Checking lobby occupancy', tone: 'neutral', group: 'waiting' };
if (/capacity/i.test(row.stage)) return { label: 'Waiting for capacity', tone: 'neutral', group: 'queued' };
return { label: row.state === 'queued' ? 'Queued' : row.waitCode === 'eligibility' ? 'Waiting for eligibility' : 'Waiting', tone: 'neutral', group: row.state === 'queued' ? 'queued' : 'waiting' };
}
return { label: row.phase === 'cleanup' ? 'Cleaning up' : 'In progress', tone: 'info', group: 'running' };
}
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 []; }
}
+5 -1
View File
@@ -3,14 +3,18 @@ import { deploymentListPage } from '$lib/server/provisioning/history.js';
import { requestLifecycle } from '$lib/server/provisioning/index.js'; import { requestLifecycle } from '$lib/server/provisioning/index.js';
import { provisioningActions, runtimeActions, type ProvisioningAction, type RuntimeAction } from '$lib/provisioning.js'; import { provisioningActions, runtimeActions, type ProvisioningAction, type RuntimeAction } from '$lib/provisioning.js';
import { kickProvisioning } from '$lib/server/pipeline/worker.js'; import { kickProvisioning } from '$lib/server/pipeline/worker.js';
import { provisioningListSources, provisioningListStatuses } from '$lib/provisioning-list.js';
export function GET({ url }: { url: URL }) { export function GET({ url }: { url: URL }) {
const params = url.searchParams; const params = url.searchParams;
const action = params.get('action'); 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({ return json(deploymentListPage({
view: params.get('view') === 'history' ? 'history' : 'active', view: params.get('view') === 'history' ? 'history' : 'active',
action: provisioningActions.includes(action as ProvisioningAction) ? action as ProvisioningAction : undefined, 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') ?? '', server: params.get('server') ?? '',
limit: Number(params.get('limit') ?? 25), limit: Number(params.get('limit') ?? 25),
offset: Number(params.get('offset') ?? 0) offset: Number(params.get('offset') ?? 0)
+142 -83
View File
@@ -6,7 +6,6 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import Tabs from '$ui/components/primitives/Tabs.svelte'; import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.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 Badge from '$ui/components/primitives/Badge.svelte';
@@ -16,8 +15,14 @@
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte'; import HousePartyDialog from '$ui/components/HousePartyDialog.svelte';
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte'; import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
import { provisioningPageSize, provisioningPageOffset, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js'; import { provisioningListStatuses, provisioningListSources, provisioningPageSize, provisioningPageOffset, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js';
import { provisioningActions, actionLabels, actionCompleted } from '$lib/provisioning.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 lifecycleOpen = $state(false);
let active = $state<Deployment[]>([]); let active = $state<Deployment[]>([]);
@@ -36,13 +41,19 @@
let limit = $state(25); let limit = $state(25);
let offset = $state(0); let offset = $state(0);
let historyState = $state('all'); let historyState = $state('all');
let source = $state('all');
let expanded = $state<Record<string, boolean>>({});
let fleetActionsOpen = $state(false);
let now = $state(Date.now());
let historyUpdatedAt = $state<string | null>(null);
let cancelling = $state(false); let cancelling = $state(false);
let cancellationNotice = $state(''); let cancellationNotice = $state('');
$effect(() => { $effect(() => {
const params = $page.url.searchParams; const params = $page.url.searchParams;
tab = ['active', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'active'; tab = ['active', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'active';
action = provisioningActions.some(value => value === params.get('action')) ? params.get('action')! : 'all'; 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') ?? ''; search = params.get('server') ?? '';
limit = provisioningPageSize(Number(params.get('limit') ?? 25)); limit = provisioningPageSize(Number(params.get('limit') ?? 25));
offset = provisioningPageOffset(Number(params.get('offset') ?? 0)); offset = provisioningPageOffset(Number(params.get('offset') ?? 0));
@@ -50,21 +61,27 @@
function saveContext(reset = false) { function saveContext(reset = false) {
if (reset) offset = 0; if (reset) offset = 0;
const url = new URL($page.url); 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); if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
} }
void goto(url, { replaceState: true, noScroll: true, keepFocus: true }); 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[] = []; cancelling = true; cancellationNotice = ''; let cancelled = 0; const unavailable: string[] = [];
for (const id of ids) { 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); } try { const response = await fetch(`/api/provisioning/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); if (response.ok) cancelled++; else unavailable.push(id); }
catch { 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.` : ''}`; 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 action = $state('all');
let search = $state(''); let search = $state('');
let refreshing = false; let refreshing = false;
@@ -75,7 +92,7 @@
const res = await fetch('/api/provisioning?view=active', { signal, cache: 'no-store' }); const res = await fetch('/api/provisioning?view=active', { signal, cache: 'no-store' });
if (!res.ok) throw new Error('Could not load provisioning activity.'); if (!res.ok) throw new Error('Could not load provisioning activity.');
const result: ProvisioningListPage = await res.json(); const result: ProvisioningListPage = await res.json();
active = result.deployments; active = result.deployments; now = Date.now();
needsReview = result.needsReview; needsReview = result.needsReview;
error = ''; error = '';
} catch (err) { if (!signal?.aborted) error = (err as Error).message; return false; } } 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. // History is fetched on navigation/filter changes or explicit refresh, never by the active poll.
$effect(() => { $effect(() => {
if (tab !== 'history') return; 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) }); server: search, limit: String(limit), offset: String(offset) });
void historyRefresh; void historyRefresh;
const controller = new AbortController(); const controller = new AbortController();
@@ -97,101 +114,143 @@
const response = await fetch(`/api/provisioning?${params}`, { signal: controller.signal, cache: 'no-store' }); const response = await fetch(`/api/provisioning?${params}`, { signal: controller.signal, cache: 'no-store' });
if (!response.ok) throw new Error('Could not load provisioning history.'); if (!response.ok) throw new Error('Could not load provisioning history.');
const result: ProvisioningListPage = await response.json(); 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; } } catch (err) { if (!controller.signal.aborted) historyError = (err as Error).message; }
finally { if (!controller.signal.aborted) historyLoading = false; } finally { if (!controller.signal.aborted) historyLoading = false; }
})(); })();
return () => controller.abort(); 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 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); }
</script> </script>
<svelte:head><title>Provisioning | Electric Cherry</title></svelte:head> <svelte:head><title>Provisioning | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="Provisioning" subtitle="Create, start, restart, rebuild, stop, and destroy server instances."> <SectionHeader title="Provisioning">
{#snippet actions()}<Button onclick={() => lifecycleOpen = true}>Manage server</Button><Button variant="primary" onclick={() => open = true}>Create server</Button><details class="fleet-actions"><summary>Fleet actions</summary><Button variant="danger" onclick={() => housePartyOpen = true}>Destroy fleet…</Button></details>{/snippet} {#snippet actions()}
<Button onclick={() => lifecycleOpen = true}>Server actions</Button><Button variant="primary" onclick={() => open = true}>Create server</Button>
<details class="fleet-actions" bind:open={fleetActionsOpen}><summary>Fleet actions</summary><div class="fleet-menu">
{#if active.some(row => row.canCancel)}<Button disabled={cancelling} onclick={() => cancelRequests(active.filter(row => row.canCancel && !row.server_deleted_at), true)}>Cancel all active operations</Button>{/if}
<Button variant="danger" onclick={() => { fleetActionsOpen = false; housePartyOpen = true; }}>Destroy fleet…</Button>
</div></details>
{/snippet}
</SectionHeader> </SectionHeader>
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/" />{/if} {#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/" />{/if}
<Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" /> <div class="toolbar">
<div class="view-tabs"><Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" /></div>
<label class="search">Server<input type="search" bind:value={search} oninput={() => saveContext(true)} placeholder="Search server or lobby" maxlength="100" /></label>
<label>Operation<select bind:value={action} onchange={() => saveContext(true)}><option value="all">All operations</option>{#each provisioningActions as value}<option value={value}>{actionLabels[value]}</option>{/each}</select></label>
<label>Status<select bind:value={historyState} onchange={() => saveContext(true)}><option value="all">All statuses</option><option value="pending">Waiting / queued</option><option value="running">In progress</option><option value="awaiting-health">Verifying health</option><option value="done">Complete</option><option value="attention">Needs attention</option><option value="failed">Failed</option><option value="interrupted">Interrupted</option><option value="warning">Warnings</option><option value="cancelled">Cancelled</option></select></label>
<label>Source<select bind:value={source} onchange={() => saveContext(true)}><option value="all">All sources</option><option value="manual">Manual</option><option value="campaign">Campaign</option><option value="beta">Beta</option><option value="maintenance">Maintenance</option><option value="system">System</option></select></label>
{#if filtered}<Button variant="ghost" onclick={clearFilters}>Clear filters</Button>{/if}
</div>
<LifecycleDialog bind:open={lifecycleOpen} /> <LifecycleDialog bind:open={lifecycleOpen} />
<DeployServerDialog bind:open /> <DeployServerDialog bind:open />
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in the Active tab.`; tab = 'active'; saveContext(true); void load(); }} /> <HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}.`; tab = 'active'; clearFilters(); void load(); }} />
{#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if} {#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if}
{#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if} {#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
<div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels"> <div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels">
{#if error}<p role="alert" class="error">{error} <button onclick={() => void load()}>Retry</button></p>{/if} {#if error}<p role="alert" class="error">{error} <button onclick={() => void load()}>Retry</button></p>{/if}
{#if tab === 'active'} <div class="queue-heading">
{#if needsReview}<div class="ui-summary"><span>{needsReview} recorded operations need review</span><Button size="sm" onclick={() => { historyState = 'attention'; tab = 'history'; saveContext(true); }}>Review history</Button></div>{/if} {#if tab === 'active'}<p class="queue-summary"><strong>{filtered ? `${visibleActive.length} of ${active.length}` : active.length} active</strong>{#if queueSummary}<span> · {queueSummary}</span>{/if}</p>
{#if active.some(d => d.canCancel)}<div class="ui-summary"><span>Cancelling stops remaining work and health checks. Changes already made are not undone.</span><Button disabled={cancelling} onclick={() => cancelRequests(active.filter(d => d.canCancel).map(d => d.id))}>Cancel all active operations</Button></div>{/if} {:else}<h2>History <span>{historyTotal}</span></h2>{/if}
<section> <div class="queue-tools">
<h2>In flight <span>{active.length}</span></h2> {#if needsReview}<Button size="sm" variant="ghost" onclick={reviewAttention}>Needs attention ({needsReview})</Button>{/if}
{#if loading}<p>Loading provisioning activity...</p> {#if tab === 'active'}<span class="muted">{error ? 'Updates interrupted' : 'Updates every 5s'}</span>
{:else if active.length === 0}<EmptyState title="No server operations in flight" description="Server lifecycle operations appear here automatically." /> {:else}<span class="muted">{historyUpdatedAt ? `Updated ${age(historyUpdatedAt).toLowerCase()}` : ''}</span><Button size="sm" variant="ghost" disabled={historyLoading} onclick={() => historyRefresh++}>Refresh</Button>{/if}
{:else}<div class="rows">{#each active as d (d.id)}{@render row(d)}{/each}</div>{/if}
</section>
{:else}
<section>
<div class="row-head"><h2>History</h2><Button size="sm" disabled={historyLoading} onclick={() => historyRefresh++}>Refresh history</Button></div>
<div class="filters">
<label>Operation <select bind:value={action} onchange={event => { action = event.currentTarget.value; saveContext(true); }}><option value="all">All operations</option>{#each provisioningActions as value}<option value={value}>{actionLabels[value]}</option>{/each}</select></label>
<label>Status <select bind:value={historyState} onchange={event => { historyState = event.currentTarget.value; saveContext(true); }}><option value="all">All statuses</option><option value="attention">Needs review</option></select></label>
<label>Server <input bind:value={search} oninput={event => { search = event.currentTarget.value; saveContext(true); }} placeholder="Filter by server name" /></label>
</div>
{#if historyError}<p role="alert" class="error">{historyError} <button onclick={() => historyRefresh++}>Retry</button></p>{/if}
{#if historyLoading}<p>Loading provisioning history...</p>
{:else if !historyError && history.length === 0}<EmptyState title="No matching operations" description="Try another search or operation type." />
{:else}<div class="rows">{#each history as d (d.id)}{@render row(d)}{/each}</div>{/if}
<nav class="row-head" aria-label="Provisioning history pages">
<span role="status">{history.length ? `${offset + 1}–${offset + history.length}` : '0'} of {historyTotal} operations</span>
<div class="filters">
<Button size="sm" disabled={historyLoading || offset === 0} onclick={() => { offset = Math.max(0, offset - limit); saveContext(); }}>Previous</Button>
<Button size="sm" disabled={historyLoading || offset + limit >= historyTotal} onclick={() => { offset += limit; saveContext(); }}>Next</Button>
</div> </div>
</nav> </div>
</section> {#if tab === 'active'}
{/if} {#if loading}<p class="muted">Loading provisioning activity…</p>
{:else if !visibleActive.length}<EmptyState title={filtered ? 'No matching active operations' : 'No active operations'} description={filtered ? 'Try another search or clear the filters.' : undefined} />
{:else}{@render operationTable(visibleActive)}{/if}
{:else}
{#if historyError}<p role="alert" class="error">{historyError} <button onclick={() => historyRefresh++}>Retry</button></p>{/if}
{#if historyLoading}<p class="muted">Loading provisioning history…</p>
{:else if !historyError && !history.length}<EmptyState title="No matching operations" description="Try another search or clear the filters." />
{:else}{@render operationTable(history)}{/if}
<nav class="pagination" aria-label="Provisioning history pages">
<span role="status">{history.length ? `${offset + 1}–${offset + history.length}` : '0'} of {historyTotal} operations</span>
{#if historyTotal > limit || offset > 0}<div class="queue-tools"><Button size="sm" disabled={historyLoading || offset === 0} onclick={() => { offset = Math.max(0, offset - limit); saveContext(); }}>Previous</Button><Button size="sm" disabled={historyLoading || offset + limit >= historyTotal} onclick={() => { offset += limit; saveContext(); }}>Next</Button></div>{/if}
</nav>
{/if}
</div> </div>
</div> </div>
{#snippet row(d: Deployment)} {#snippet operationTable(rows: Deployment[])}
<article class="deployment-row"><a class="row-link" href={`/provisioning/${encodeURIComponent(d.id)}?returnTo=${encodeURIComponent($page.url.pathname + $page.url.search)}`}> <div class="table-scroll"><table>
<div class="row-head"> <thead><tr><th scope="col">Server</th><th scope="col">Operation</th><th scope="col">Status</th><th scope="col">Source</th><th scope="col">Started</th><th scope="col" class="align-right">Details</th></tr></thead>
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{actionLabels[d.action]}</Badge> <tbody>{#each rows as row (row.id)}
<strong class="server"><HistoricalServerIdentity name={d.server} deletedAt={d.server_deleted_at} /></strong> {@const status = provisioningQueueStatus(row)}
</div> {@const primarySource = row.sources?.[0]}
<StatusBadge state={d.state} /> {@const operationHref = contextHref(`/provisioning/${encodeURIComponent(row.id)}`, $page.url)}
</div> {@const isExpanded = expanded[row.id] ?? false}
<p>{d.state === 'done' ? actionCompleted[d.action] : d.stage}</p> <tr>
<div class="meta"><span>Started {fmt(d.started_at)}</span>{#if d.finished_at}<span>Finished {fmt(d.finished_at)}</span>{/if}<span>By {d.triggered_by}</span></div> <td class="server-cell"><HistoricalServerIdentity name={row.server} deletedAt={row.server_deleted_at} href={row.server_exists ? contextHref(`/servers/${encodeURIComponent(row.server)}`, $page.url) : undefined} /></td>
{#if d.error}<p class="error">{d.error}</p>{/if} <td><a href={operationHref}>{actionLabels[row.action]}</a></td>
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each} <td><Badge variant={status.tone} size="md">{status.label}</Badge>{#if ['queued','waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}<small>In this stage {elapsedTime(row.phaseStartedAt, now)}</small>{/if}</td>
</a>{#if d.canCancel && !d.server_deleted_at}<div class="row-actions"><Button size="sm" disabled={cancelling} onclick={() => cancelRequests([d.id])}>Cancel operation</Button></div>{/if}</article> <td class="source-cell">{#if primarySource?.href}<a href={contextHref(primarySource.href, $page.url)}>{primarySource.label}</a>{:else}<span>{primarySource?.label ?? (row.triggered_by === 'ui' ? 'Manual' : 'System')}</span>{/if}{#if (row.sources?.length ?? 0) > 1}<small>+{row.sources!.length - 1} more</small>{/if}</td>
<td><button type="button" class="timestamp" aria-label={`Started ${fmt(row.started_at)}`}><time datetime={row.started_at}>{age(row.started_at)}</time><span class="exact-time" aria-hidden="true">{fmt(row.started_at)}</span></button></td>
<td class="align-right"><button class="text-button" aria-expanded={isExpanded} aria-controls={`operation-${row.id}`} aria-label={`${isExpanded ? 'Hide' : 'Show'} ${actionLabels[row.action].toLowerCase()} details for ${serverTitle(row.server)}`} onclick={() => expanded = { ...expanded, [row.id]: !isExpanded }}>{isExpanded ? 'Hide' : 'Details'} <span aria-hidden="true">{isExpanded ? '−' : '+'}</span></button></td>
</tr>
<tr hidden={!isExpanded} id={`operation-${row.id}`} class="detail-row"><td colspan="6">
<div class="operation-details">
<p>{row.stage}</p>{#if row.error}<p class="error">{row.error}</p>{/if}
{#each provisioningWarnings(row.warnings) as warning}<p class="warning">{warning}</p>{/each}
<dl><div><dt>Started</dt><dd>{fmt(row.started_at)}</dd></div>{#if row.finished_at}<div><dt>Finished</dt><dd>{fmt(row.finished_at)}</dd></div>{/if}<div><dt>{row.finished_at ? 'Duration' : 'Elapsed'}</dt><dd>{elapsedTime(row.started_at, row.finished_at ? Date.parse(row.finished_at) : now)}</dd></div><div><dt>Operation ID</dt><dd class="mono">{row.id}</dd></div></dl>
{#if row.sources?.length}<div class="detail-links">{#each row.sources as item}{#if item.href}<a href={contextHref(item.href, $page.url)}>{item.label}</a>{:else}<span class="muted">{item.label}</span>{/if}{/each}</div>{/if}
<div class="detail-links"><a href={operationHref}>Open operation</a>{#if row.server_exists && !row.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(row.server)}?tab=diagnostics`, $page.url)}>Diagnostics</a><a href={contextHref(`/servers/${encodeURIComponent(row.server)}?tab=logs`, $page.url)}>Open logs</a>{/if}{#if row.canCancel && !row.server_deleted_at}<Button variant="danger" size="sm" disabled={cancelling} onclick={() => cancelRequests([row])}>Cancel operation</Button>{/if}</div>
</div>
</td></tr>
{/each}</tbody>
</table></div>
{/snippet} {/snippet}
<style> <style>
.row-link { color: inherit; text-decoration: none; display: block; }.row-actions { margin-top: 12px; } .page,.panels { display:grid; gap:24px; min-width:0; }
.toolbar { display:flex; align-items:flex-end; flex-wrap:wrap; gap:12px; }.view-tabs { max-width:100%; }
.page { display: flex; flex-direction: column; gap: 28px; } label { display:grid; gap:6px; font-size:var(--text-xs); color:var(--text-secondary); }.search { flex:1; min-width:220px; }
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } input,select { padding:10px 12px; min-height:40px; background:var(--surface-sunken); color:var(--text-primary); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); }
.row-head { justify-content: space-between; } .queue-heading,.queue-tools,.pagination { display:flex; align-items:center; flex-wrap:wrap; gap:12px; }.queue-heading,.pagination { justify-content:space-between; }
h2 { font-size:var(--text-md); margin:0; }h2 span { color:var(--text-tertiary); font-size:var(--text-sm); margin-left:8px; font-weight:400; }
section { display: grid; gap: 16px; } p { margin:0; }.queue-summary { font-size:var(--text-sm); }.queue-summary span,.muted,.pagination { color:var(--text-secondary); font-size:var(--text-sm); }
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; } .table-scroll { overflow-x:auto; }table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
p { margin: 8px 0; } a { color: var(--accent); } th { padding:10px 12px; color:var(--text-tertiary); font-size:var(--text-xs); font-weight:var(--weight-medium); border-bottom:1px solid var(--border-strong); }
.rows { display: grid; gap: 12px; } td { padding:16px 12px; border-bottom:1px solid var(--border-subtle); vertical-align:middle; }tbody tr:hover { background:var(--surface); }
.deployment-row { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; } a { color:var(--accent-primary-hover); }.server-cell { min-width:160px; font-weight:var(--weight-semibold); }.source-cell { max-width:240px; overflow-wrap:anywhere; }
.deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); } small { display:block; margin-top:6px; color:var(--text-tertiary); font-size:var(--text-xs); }.align-right { text-align:right; }
.deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; } .text-button,.timestamp { border:0; background:transparent; color:var(--text-secondary); font:inherit; padding:6px 0; cursor:pointer; white-space:nowrap; }
.server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; } .timestamp { position:relative; cursor:help; }.exact-time { display:none; position:absolute; right:0; bottom:100%; padding:6px 10px; z-index:1; background:var(--surface-overlay); border:1px solid var(--border-strong); border-radius:var(--radius-sm); color:var(--text-primary); font-size:var(--text-xs); }.timestamp:hover .exact-time,.timestamp:focus .exact-time { display:block; }
.meta { font-size: 13px; color: var(--text-secondary); } .detail-row { background:var(--surface); }.operation-details { display:grid; gap:16px; line-height:1.6; overflow-wrap:anywhere; }
.error { color: var(--state-danger); } dl { display:flex; flex-wrap:wrap; gap:12px 32px; margin:0; }dt { color:var(--text-tertiary); font-size:var(--text-xs); }dd { margin:0; }.mono { font-family:var(--font-mono); }
label { display: flex; align-items: center; gap: 8px; font-size: 13px; } .detail-links { display:flex; align-items:center; flex-wrap:wrap; gap:16px; }.error { color:var(--state-danger); }.warning { color:var(--state-warning); }
input, select { background: var(--surface); color: inherit; border: 1px solid var(--border-subtle); border-radius: 6px; padding: 8px; max-width: 100%; } .fleet-actions { position:relative; font-size:var(--text-sm); }.fleet-actions summary { padding:10px; cursor:pointer; }.fleet-menu { position:absolute; right:0; top:100%; z-index:10; display:grid; gap:8px; padding:12px; background:var(--surface-overlay); border:1px solid var(--border-strong); border-radius:var(--radius-md); min-width:250px; box-shadow:var(--shadow-raised); }
@media (max-width: 640px) { .row-head { align-items: flex-start; } .server { display: block; margin: 10px 0; } } @media(max-width:720px) { table { min-width:820px; }.search { flex-basis:100%; }th,td { padding:12px 8px; }.fleet-menu { right:auto; left:0; } }
.panels { display:grid; gap:24px; }
.fleet-actions { position:relative; font-size:13px; }
.fleet-actions summary { padding:10px; cursor:pointer; }
.fleet-actions[open] :global(.btn) { position:absolute; right:0; top:100%; z-index:10; white-space:nowrap; box-shadow:var(--shadow-raised); }
</style> </style>
+1 -1
View File
@@ -63,7 +63,7 @@ describe('provisioning detail projection', () => {
expect(detail.activity).toEqual({ total: 0, latestId: null, failureExcerpt: null }); 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.warningMessages).toEqual(['Check files', 'Some recorded warnings could not be read.']);
expect(detail.sources).toEqual([ 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' } { 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(); db().prepare("UPDATE server_deployments SET warnings = 'broken json' WHERE id = 'legacy'").run();
+52 -1
View File
@@ -19,6 +19,57 @@ async function setup() {
} }
describe('bounded provisioning lists', () => { 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 () => { it('polls every active row and counts review outcomes without transferring retained history', async () => {
const { handle, add, REMOVING_SERVER } = await setup(); const { handle, add, REMOVING_SERVER } = await setup();
handle.transaction(() => { handle.transaction(() => {
@@ -111,7 +162,7 @@ describe('bounded provisioning lists', () => {
const history = queries.find(sql => sql.includes('AS canCancelRemoval') && sql.includes('LIMIT'))!; const history = queries.find(sql => sql.includes('AS canCancelRemoval') && sql.includes('LIMIT'))!;
const review = queries.find(sql => sql.includes('COUNT(*) AS needsReview'))!; const review = queries.find(sql => sql.includes('COUNT(*) AS needsReview'))!;
expect(explain(active, [REMOVING_SERVER])).toContain('idx_server_deployments_state'); 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]); const historyPlan = explain(history, [REMOVING_SERVER, 100, 0]);
expect(historyPlan).toContain('idx_server_deployments_started_at'); expect(historyPlan).toContain('idx_server_deployments_started_at');
expect(historyPlan).not.toContain('TEMP B-TREE'); expect(historyPlan).not.toContain('TEMP B-TREE');
@@ -147,6 +147,8 @@ describe('provisioning response and return-context boundaries', () => {
.toBe('/provisioning?view=history&action=restart&server=Lobby+1&limit=50'); .toBe('/provisioning?view=history&action=restart&server=Lobby+1&limit=50');
expect(provisioningReturn('/provisioning?view=history&limit=25&offset=100').href) expect(provisioningReturn('/provisioning?view=history&limit=25&offset=100').href)
.toBe('/provisioning?view=history&limit=25&offset=100'); .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']) { for (const value of ['https://evil.test', '//evil.test', '/\\evil.test', '/\n/evil.test', '/settings', '/provisioning/other']) {
expect(provisioningReturn(value).href).toBe('/provisioning'); expect(provisioningReturn(value).href).toBe('/provisioning');
} }
+27
View File
@@ -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([]);
});