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 { 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,
'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 {
view?: 'active' | 'history' | 'all';
action?: ProvisioningAction;
status?: 'attention';
status?: typeof provisioningListStatuses[number];
source?: typeof provisioningListSources[number];
server?: string;
limit?: number;
offset?: number;
+39 -5
View File
@@ -60,9 +60,11 @@ function warningMessages(raw: string): string[] {
function sourceDetails(source: Omit<ProvisioningSource, 'label' | 'href'>): 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<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 ?'}`)
.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. */
+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.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);
}
+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 []; }
}