Bound provisioning history and poll only active operations
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ProvisioningAction } from './provisioning.js';
|
||||||
|
import type { ProvisioningDeployment } from './provisioning-detail.js';
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|
||||||
|
export interface ProvisioningListOptions {
|
||||||
|
view?: 'active' | 'history' | 'all';
|
||||||
|
action?: ProvisioningAction;
|
||||||
|
status?: 'attention';
|
||||||
|
server?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProvisioningListPage {
|
||||||
|
deployments: ProvisioningListRow[];
|
||||||
|
total: number;
|
||||||
|
needsReview: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function provisioningPageSize(value: number): number {
|
||||||
|
return Number.isFinite(value) ? Math.min(100, Math.max(1, Math.floor(value) || 25)) : 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function provisioningPageOffset(value: number): number {
|
||||||
|
return Number.isFinite(value) ? Math.min(1_000_000, Math.max(0, Math.floor(value))) : 0;
|
||||||
|
}
|
||||||
@@ -378,7 +378,9 @@ export function db(): Database.Database {
|
|||||||
handle.exec(`CREATE INDEX IF NOT EXISTS idx_server_deployments_server
|
handle.exec(`CREATE INDEX IF NOT EXISTS idx_server_deployments_server
|
||||||
ON server_deployments(server, server_deleted_at);
|
ON server_deployments(server, server_deleted_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_server_deployments_state
|
CREATE INDEX IF NOT EXISTS idx_server_deployments_state
|
||||||
ON server_deployments(state, action, server_deleted_at);`);
|
ON server_deployments(state, action, server_deleted_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_deployments_started_at
|
||||||
|
ON server_deployments(started_at);`);
|
||||||
installReportRevisions(handle);
|
installReportRevisions(handle);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handle.close();
|
handle.close();
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { bindNativeOperation } from './native.js';
|
|||||||
import { workerActivity } from '../pipeline/activity.js';
|
import { workerActivity } from '../pipeline/activity.js';
|
||||||
import { canCancelCreation } from './cancel-creation.js';
|
import { canCancelCreation } from './cancel-creation.js';
|
||||||
import { nativeDeployment } from '../backends/native.js';
|
import { nativeDeployment } from '../backends/native.js';
|
||||||
|
import { serverTitle } from '../../ui/server-labels.js';
|
||||||
|
import { provisioningPageSize, provisioningPageOffset, type ProvisioningListOptions, type ProvisioningListPage, type ProvisioningListRow } from '../../provisioning-list.js';
|
||||||
|
|
||||||
export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string {
|
export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
@@ -250,10 +252,54 @@ function preserveOutcome(id: string): void {
|
|||||||
details: { state: row.state, finishedAt: row.finished_at, error: row.error, warnings: warningMessages(row.warnings) } } });
|
details: { state: row.state, finishedAt: row.finished_at, error: row.error, warnings: warningMessages(row.warnings) } } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listDeployments() {
|
const activeDeployment = "d.server_deleted_at IS NULL AND d.state IN ('queued', 'awaiting-health', 'running', 'waiting')";
|
||||||
return db().prepare(`SELECT d.*, (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,
|
const reviewDeployment = "d.state IN ('failed', 'interrupted', 'warning')";
|
||||||
|
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.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
|
||||||
FROM server_deployments d ORDER BY started_at DESC, rowid DESC`).all(REMOVING_SERVER);
|
`;
|
||||||
|
|
||||||
|
function deploymentListQuery(options: ProvisioningListOptions) {
|
||||||
|
const conditions = [options.view === 'active' ? `(${activeDeployment})`
|
||||||
|
: options.view === 'history' ? `NOT (${activeDeployment})` : '1'];
|
||||||
|
const parameters: string[] = [];
|
||||||
|
if (options.action) { conditions.push('d.action = ?'); parameters.push(options.action); }
|
||||||
|
if (options.status === 'attention') conditions.push(reviewDeployment);
|
||||||
|
const search = (options.server ?? '').slice(0, 100).toLowerCase();
|
||||||
|
if (search) {
|
||||||
|
// Read only distinct names to preserve friendly map/lobby searches, including deleted servers.
|
||||||
|
const names = (db().prepare('SELECT DISTINCT server FROM server_deployments').all() as { server: string }[])
|
||||||
|
.map(row => row.server).filter(name => `${name} ${serverTitle(name)}`.toLowerCase().includes(search));
|
||||||
|
conditions.push('d.server IN (SELECT value FROM json_each(?))');
|
||||||
|
parameters.push(JSON.stringify(names));
|
||||||
|
}
|
||||||
|
return { where: conditions.join(' AND '), parameters,
|
||||||
|
limit: provisioningPageSize(options.limit ?? 25), offset: provisioningPageOffset(options.offset ?? 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deploymentListRows(query: ReturnType<typeof deploymentListQuery>, active: boolean): ProvisioningListRow[] {
|
||||||
|
return 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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All list reads omit observation snapshots; retained history is always bounded. */
|
||||||
|
export function listDeployments(options: ProvisioningListOptions = {}): ProvisioningListRow[] {
|
||||||
|
return deploymentListRows(deploymentListQuery(options), options.view === 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deploymentListPage(options: ProvisioningListOptions = {}): ProvisioningListPage {
|
||||||
|
return db().transaction(() => {
|
||||||
|
const query = deploymentListQuery(options);
|
||||||
|
const deployments = deploymentListRows(query, options.view === 'active');
|
||||||
|
const total = options.view === 'active' ? deployments.length : (db().prepare(
|
||||||
|
`SELECT COUNT(*) AS total FROM server_deployments d WHERE ${query.where}`
|
||||||
|
).get(...query.parameters) as { total: number }).total;
|
||||||
|
const { needsReview } = db().prepare(`SELECT COUNT(*) AS needsReview FROM server_deployments d WHERE ${reviewDeployment}`)
|
||||||
|
.get() as { needsReview: number };
|
||||||
|
return { deployments, total, needsReview, limit: query.limit, offset: query.offset };
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function interruptDeployments(): void {
|
export function interruptDeployments(): void {
|
||||||
|
|||||||
@@ -122,7 +122,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']) {
|
for (const key of ['view', 'action', 'status', '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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
export { GET } from '../provisioning/+server.js';
|
||||||
import { listDeployments } from '$lib/server/provisioning/index.js';
|
|
||||||
export function GET() { return json({ deployments: listDeployments() }); }
|
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { listDeployments } from '$lib/server/provisioning/index.js';
|
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 { runtimeActions, type RuntimeAction } from '$lib/provisioning.js';
|
import { provisioningActions, runtimeActions, type ProvisioningAction, type RuntimeAction } from '$lib/provisioning.js';
|
||||||
import { kick } from '$lib/server/pipeline/worker.js';
|
import { kick } from '$lib/server/pipeline/worker.js';
|
||||||
|
|
||||||
export function GET() { return json({ deployments: listDeployments() }); }
|
export function GET({ url }: { url: URL }) {
|
||||||
|
const params = url.searchParams;
|
||||||
|
const action = params.get('action');
|
||||||
|
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,
|
||||||
|
server: params.get('server') ?? '',
|
||||||
|
limit: Number(params.get('limit') ?? 25),
|
||||||
|
offset: Number(params.get('offset') ?? 0)
|
||||||
|
}), { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST({ request }: { request: Request }) {
|
export async function POST({ request }: { request: Request }) {
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
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 StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
|
||||||
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
|
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
|
||||||
import { serverTitle } from '$ui/server-labels.js';
|
|
||||||
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';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
@@ -17,17 +15,17 @@
|
|||||||
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 { provisioningActions, actionLabels, actionCompleted, type ProvisioningAction } from '$lib/provisioning.js';
|
import { provisioningPageSize, provisioningPageOffset, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js';
|
||||||
|
import { provisioningActions, actionLabels, actionCompleted } from '$lib/provisioning.js';
|
||||||
|
|
||||||
let lifecycleOpen = $state(false);
|
let lifecycleOpen = $state(false);
|
||||||
interface Deployment {
|
let active = $state<Deployment[]>([]);
|
||||||
id: string; server: string; server_deleted_at?: string | null; action: ProvisioningAction;
|
let history = $state<Deployment[]>([]);
|
||||||
state: 'queued' | 'awaiting-health' | 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted' | 'cancelled';
|
let needsReview = $state(0);
|
||||||
stage: string; started_at: string; finished_at: string | null;
|
let historyTotal = $state(0);
|
||||||
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
let historyLoading = $state(false);
|
||||||
canCancelRemoval: boolean;
|
let historyError = $state('');
|
||||||
}
|
let historyRefresh = $state(0);
|
||||||
let deployments = $state<Deployment[]>([]);
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -35,6 +33,7 @@
|
|||||||
let housePartyNotice = $state('');
|
let housePartyNotice = $state('');
|
||||||
let tab = $state('active');
|
let tab = $state('active');
|
||||||
let limit = $state(25);
|
let limit = $state(25);
|
||||||
|
let offset = $state(0);
|
||||||
let historyState = $state('all');
|
let historyState = $state('all');
|
||||||
let cancelling = $state(false);
|
let cancelling = $state(false);
|
||||||
let cancellationNotice = $state('');
|
let cancellationNotice = $state('');
|
||||||
@@ -44,12 +43,13 @@
|
|||||||
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 = params.get('status') === 'attention' ? 'attention' : 'all';
|
||||||
search = params.get('server') ?? '';
|
search = params.get('server') ?? '';
|
||||||
limit = Math.max(25, Number(params.get('limit')) || 25);
|
limit = provisioningPageSize(Number(params.get('limit') ?? 25));
|
||||||
|
offset = provisioningPageOffset(Number(params.get('offset') ?? 0));
|
||||||
});
|
});
|
||||||
function saveContext(reset = false) {
|
function saveContext(reset = false) {
|
||||||
if (reset) limit = 25;
|
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) : '' })) {
|
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) : '' })) {
|
||||||
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 });
|
||||||
@@ -67,23 +67,41 @@
|
|||||||
let action = $state('all');
|
let action = $state('all');
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let refreshing = false;
|
let refreshing = false;
|
||||||
let active = $derived(deployments.filter(d => !d.server_deleted_at && ['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)));
|
async function load(signal?: AbortSignal) {
|
||||||
const needsAttention = (deployment: Deployment) => ['failed', 'interrupted', 'warning'].includes(deployment.state);
|
|
||||||
const attention = $derived(deployments.filter(needsAttention));
|
|
||||||
let history = $derived(deployments.filter(d => (d.server_deleted_at || !['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)) &&
|
|
||||||
(historyState === 'all' || needsAttention(d)) &&
|
|
||||||
(action === 'all' || d.action === action) && (d.server + ' ' + serverTitle(d.server)).toLowerCase().includes(search.toLowerCase())));
|
|
||||||
async function load() {
|
|
||||||
if (refreshing) return;
|
if (refreshing) return;
|
||||||
refreshing = true;
|
refreshing = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/provisioning');
|
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.');
|
||||||
deployments = (await res.json()).deployments;
|
const result: ProvisioningListPage = await res.json();
|
||||||
|
active = result.deployments;
|
||||||
|
needsReview = result.needsReview;
|
||||||
error = '';
|
error = '';
|
||||||
} catch (err) { error = (err as Error).message; }
|
} catch (err) { if (!signal?.aborted) error = (err as Error).message; return false; }
|
||||||
finally { loading = false; refreshing = false; }
|
finally { loading = false; refreshing = false; }
|
||||||
}
|
}
|
||||||
|
// 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,
|
||||||
|
server: search, limit: String(limit), offset: String(offset) });
|
||||||
|
void historyRefresh;
|
||||||
|
const controller = new AbortController();
|
||||||
|
historyLoading = true;
|
||||||
|
historyError = '';
|
||||||
|
history = [];
|
||||||
|
historyTotal = 0;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
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; }
|
||||||
|
} 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(() => pollVisible(load, 5000));
|
||||||
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 warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
|
||||||
@@ -97,13 +115,13 @@
|
|||||||
<Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" />
|
<Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" />
|
||||||
<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'; void load(); }} />
|
<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(); }} />
|
||||||
{#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={load}>Retry</button></p>{/if}
|
{#if error}<p role="alert" class="error">{error} <button onclick={() => void load()}>Retry</button></p>{/if}
|
||||||
{#if tab === 'active'}
|
{#if tab === 'active'}
|
||||||
{#if attention.length}<div class="ui-summary"><span>{attention.length} recorded operations need review</span><Button size="sm" onclick={() => { historyState = 'attention'; tab = 'history'; saveContext(true); }}>Review history</Button></div>{/if}
|
{#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 active.some(d => d.canCancelRemoval)}<div class="ui-summary"><span>Waiting removals can be withdrawn until removal begins. Running and completed removals cannot be reversed.</span><Button disabled={cancelling} onclick={() => cancelRequests(active.filter(d => d.canCancelRemoval).map(d => d.id))}>Cancel all waiting removals</Button></div>{/if}
|
{#if active.some(d => d.canCancelRemoval)}<div class="ui-summary"><span>Waiting removals can be withdrawn until removal begins. Running and completed removals cannot be reversed.</span><Button disabled={cancelling} onclick={() => cancelRequests(active.filter(d => d.canCancelRemoval).map(d => d.id))}>Cancel all waiting removals</Button></div>{/if}
|
||||||
<section>
|
<section>
|
||||||
<h2>In flight <span>{active.length}</span></h2>
|
<h2>In flight <span>{active.length}</span></h2>
|
||||||
@@ -113,15 +131,23 @@
|
|||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<section>
|
<section>
|
||||||
<h2>History</h2>
|
<div class="row-head"><h2>History</h2><Button size="sm" disabled={historyLoading} onclick={() => historyRefresh++}>Refresh history</Button></div>
|
||||||
<div class="filters">
|
<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>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>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>
|
<label>Server <input bind:value={search} oninput={event => { search = event.currentTarget.value; saveContext(true); }} placeholder="Filter by server name" /></label>
|
||||||
</div>
|
</div>
|
||||||
{#if !loading && history.length === 0}<EmptyState title="No matching operations" description="Try another search or operation type." />
|
{#if historyError}<p role="alert" class="error">{historyError} <button onclick={() => historyRefresh++}>Retry</button></p>{/if}
|
||||||
{:else}<div class="rows">{#each history.slice(0,limit) as d (d.id)}{@render row(d)}{/each}</div>{/if}
|
{#if historyLoading}<p>Loading provisioning history...</p>
|
||||||
<ListFooter total={history.length} bind:limit onchange={() => saveContext()} label="operations" />
|
{: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>
|
||||||
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ describe('provisioning reads with retained lifecycle history', () => {
|
|||||||
expect(handle.prepare('SELECT message FROM server_deployment_events').all()).toEqual([{ message: 'Creation complete' }]);
|
expect(handle.prepare('SELECT message FROM server_deployment_events').all()).toEqual([{ message: 'Creation complete' }]);
|
||||||
const indexes = handle.pragma('index_list(server_deployments)') as { name: string }[];
|
const indexes = handle.pragma('index_list(server_deployments)') as { name: string }[];
|
||||||
expect(indexes.map(index => index.name)).toEqual(expect.arrayContaining([
|
expect(indexes.map(index => index.name)).toEqual(expect.arrayContaining([
|
||||||
'idx_server_deployments_server', 'idx_server_deployments_state'
|
'idx_server_deployments_server', 'idx_server_deployments_state', 'idx_server_deployments_started_at'
|
||||||
]));
|
]));
|
||||||
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||||
closeDb();
|
closeDb();
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||||
|
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const history = await import('../../src/lib/server/provisioning/history.js');
|
||||||
|
const handle = db();
|
||||||
|
const insert = handle.prepare(`INSERT INTO server_deployments
|
||||||
|
(id,server,action,state,stage,started_at,triggered_by,server_deleted_at,observation_json)
|
||||||
|
VALUES(?,?,?,?,?,?,'fixture',?,?)`);
|
||||||
|
const add = (id: string, state = 'done', action = 'create', server = 't5_zombie_theater_1',
|
||||||
|
deleted: string | null = null, stage = 'Recorded', at = '2026-01-01') =>
|
||||||
|
insert.run(id, server, action, state, stage, at, deleted, JSON.stringify({ snapshot: 'x'.repeat(4096) }));
|
||||||
|
return { ...history, handle, add };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('bounded provisioning lists', () => {
|
||||||
|
it('polls every active row and counts review outcomes without transferring retained history', async () => {
|
||||||
|
const { handle, add, REMOVING_SERVER } = await setup();
|
||||||
|
handle.transaction(() => {
|
||||||
|
for (let i = 0; i < 6_000; i++) add(`history-${i}`, i % 2 ? 'done' : 'failed');
|
||||||
|
for (const state of ['queued', 'awaiting-health', 'running', 'waiting']) add(state, state);
|
||||||
|
for (let i = 0; i < 110; i++) add(`active-${i}`, 'waiting', 'destroy');
|
||||||
|
add('cleanup', 'waiting', 'destroy', 'cleanup', null, REMOVING_SERVER);
|
||||||
|
add('deleted-active', 'running', 'create', 'retired', '2026-01-02');
|
||||||
|
add('deleted-warning', 'warning', 'create', 'retired', '2026-01-02');
|
||||||
|
add('interrupted', 'interrupted');
|
||||||
|
})();
|
||||||
|
const { GET } = await import('../../src/routes/api/provisioning/+server.js');
|
||||||
|
const response = GET({ url: new URL('http://localhost/api/provisioning?view=active&limit=1') });
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.total).toBe(115);
|
||||||
|
expect(body.deployments).toHaveLength(115);
|
||||||
|
expect(body.needsReview).toBe(3_002);
|
||||||
|
expect(body.deployments.map((row: { id: string }) => row.id)).not.toContain('deleted-active');
|
||||||
|
expect(body.deployments.find((row: { id: string }) => row.id === 'active-0').canCancelRemoval).toBe(1);
|
||||||
|
expect(body.deployments.find((row: { id: string }) => row.id === 'cleanup').canCancelRemoval).toBe(0);
|
||||||
|
expect(JSON.stringify(body)).not.toContain('observation_json');
|
||||||
|
expect(Buffer.byteLength(JSON.stringify(body))).toBeLessThan(60_000);
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||||
|
const history = await GET({ url: new URL('http://localhost/api/provisioning?view=history&limit=100') }).json();
|
||||||
|
expect(history.deployments).toHaveLength(100);
|
||||||
|
expect(history.total).toBe(6_003);
|
||||||
|
expect(Buffer.byteLength(JSON.stringify(history))).toBeLessThan(50_000);
|
||||||
|
|
||||||
|
// The legacy endpoint must not reintroduce the unbounded response.
|
||||||
|
const { GET: legacyGET } = await import('../../src/routes/api/deployments/+server.js');
|
||||||
|
expect(await legacyGET({ url: new URL('http://localhost/api/deployments') }).json()).toEqual({ ...body, limit: 25 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates in a stable order and applies combined filters before the limit', async () => {
|
||||||
|
const { add, listDeployments, deploymentListPage } = await setup();
|
||||||
|
for (let i = 0; i < 130; i++) add(`done-${i}`);
|
||||||
|
add('failed', 'failed', 'restart');
|
||||||
|
add('warning', 'warning', 'restart', 't5_zombie_theater_1', '2026-01-02');
|
||||||
|
add('other-map', 'failed', 'restart', 't5_zombie_moon_1');
|
||||||
|
add('active', 'running');
|
||||||
|
add('deleted-active', 'running', 'create', 'retired', '2026-01-02');
|
||||||
|
add('old-but-inserted-last', 'done', 'create', 'old', null, 'Recorded', '2025-01-01');
|
||||||
|
expect(listDeployments()).toHaveLength(25);
|
||||||
|
const page = deploymentListPage({ view: 'history', limit: 100 });
|
||||||
|
expect(page.total).toBe(135);
|
||||||
|
expect(page.deployments).toHaveLength(100);
|
||||||
|
expect(page.deployments[0].id).toBe('deleted-active');
|
||||||
|
const next = deploymentListPage({ view: 'history', limit: 100, offset: 100 });
|
||||||
|
expect(next.deployments).toHaveLength(35);
|
||||||
|
expect(new Set([...page.deployments, ...next.deployments].map(row => row.id)).size).toBe(135);
|
||||||
|
expect(next.deployments.at(-1)?.id).toBe('old-but-inserted-last');
|
||||||
|
expect(page.deployments[0]).not.toHaveProperty('observation_json');
|
||||||
|
expect(page.deployments[0]).not.toHaveProperty('phase_key');
|
||||||
|
const { GET } = await import('../../src/routes/api/provisioning/+server.js');
|
||||||
|
const filtered = await GET({ url: new URL('http://localhost/api/provisioning?view=history&action=restart&status=attention&server=KINO&limit=1') }).json();
|
||||||
|
expect(filtered.total).toBe(2);
|
||||||
|
expect(filtered.needsReview).toBe(3);
|
||||||
|
expect(filtered.deployments).toEqual([expect.objectContaining({ id: 'warning', server_exists: 0 })]);
|
||||||
|
expect(deploymentListPage({ view: 'history', server: 'Lobby 1', action: 'restart' }).total).toBe(3);
|
||||||
|
expect(deploymentListPage({ view: 'history', server: "%' OR 1=1 --" }).total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds malformed and oversized pagination parameters at the API', async () => {
|
||||||
|
const { add } = await setup();
|
||||||
|
for (let i = 0; i < 120; i++) add(String(i));
|
||||||
|
const { GET } = await import('../../src/routes/api/provisioning/+server.js');
|
||||||
|
for (const [query, limit, offset] of [
|
||||||
|
['limit=999999&offset=-5', 100, 0],
|
||||||
|
['limit=Infinity&offset=NaN', 25, 0],
|
||||||
|
['limit=-1&offset=1.9', 1, 1],
|
||||||
|
['limit=0&offset=Infinity', 25, 0]
|
||||||
|
] as const) {
|
||||||
|
const body = await GET({ url: new URL(`http://localhost/api/provisioning?view=history&${query}`) }).json();
|
||||||
|
expect(body).toMatchObject({ limit, offset, total: 120 });
|
||||||
|
expect(body.deployments).toHaveLength(limit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses indexes for active reads, SQL review counts, and history ordering', async () => {
|
||||||
|
const { handle, add, deploymentListPage, REMOVING_SERVER } = await setup();
|
||||||
|
for (let i = 0; i < 200; i++) add(String(i));
|
||||||
|
const prepare = vi.spyOn(handle, 'prepare');
|
||||||
|
deploymentListPage({ view: 'active' });
|
||||||
|
deploymentListPage({ view: 'history', limit: 100 });
|
||||||
|
const queries = prepare.mock.calls.map(([sql]) => sql);
|
||||||
|
prepare.mockRestore();
|
||||||
|
const explain = (sql: string, bindings: (string | number)[]) =>
|
||||||
|
(handle.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...bindings) as { detail: string }[]).map(row => row.detail).join('\n');
|
||||||
|
const active = 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'))!;
|
||||||
|
expect(explain(active, [REMOVING_SERVER])).toContain('idx_server_deployments_state');
|
||||||
|
expect(explain(review, [])).toContain('COVERING INDEX 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -146,6 +146,8 @@ describe('provisioning response and return-context boundaries', () => {
|
|||||||
it('retains list filters without accepting an external or unrelated return destination', () => {
|
it('retains list filters without accepting an external or unrelated return destination', () => {
|
||||||
expect(provisioningReturn('/provisioning?view=history&action=restart&server=Lobby+1&limit=50&redirect=https://evil.test').href)
|
expect(provisioningReturn('/provisioning?view=history&action=restart&server=Lobby+1&limit=50&redirect=https://evil.test').href)
|
||||||
.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)
|
||||||
|
.toBe('/provisioning?view=history&limit=25&offset=100');
|
||||||
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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ describe('provisioning operations', () => {
|
|||||||
const result = await submit({ server, action: 'start' });
|
const result = await submit({ server, action: 'start' });
|
||||||
expect(result.status).toBe(202);
|
expect(result.status).toBe(202);
|
||||||
const { deploymentId } = await result.json();
|
const { deploymentId } = await result.json();
|
||||||
expect((await GET().json()).deployments).toContainEqual(expect.objectContaining({ id: deploymentId, action: 'start', state: 'waiting' }));
|
expect((await GET({ url: new URL('http://localhost/api/provisioning') }).json()).deployments).toContainEqual(expect.objectContaining({ id: deploymentId, action: 'start', state: 'waiting' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not create a replacement when teardown fails or resume verification of the old instance', async () => {
|
it('does not create a replacement when teardown fails or resume verification of the old instance', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user