Files
electric-cherry/src/routes/provisioning/+page.svelte
T

257 lines
22 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import BackLink from '$ui/components/BackLink.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { pollVisible } from '$ui/poll.js';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte';
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
import { provisioningListStatuses, provisioningListSources, provisioningPageSize, provisioningPageOffset, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js';
import { provisioningActions, actionLabels } from '$lib/provisioning.js';
import { contextHref } from '$lib/navigation-context.js';
import { confirmAction } from '$ui/confirm.js';
import { serverTitle } from '$ui/server-labels.js';
import { elapsedTime } from '$ui/provisioning-presentation.js';
import { provisioningQueueStatus, provisioningWarnings } from '$ui/provisioning-queue.js';
let lifecycleOpen = $state(false);
let active = $state<Deployment[]>([]);
let history = $state<Deployment[]>([]);
let needsReview = $state(0);
let historyTotal = $state(0);
let historyLoading = $state(false);
let historyError = $state('');
let historyRefresh = $state(0);
let loading = $state(true);
let error = $state('');
let open = $state(false);
let housePartyOpen = $state(false);
let housePartyNotice = $state('');
let tab = $state('active');
let limit = $state(25);
let offset = $state(0);
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 cancellationNotice = $state('');
$effect(() => {
const params = $page.url.searchParams;
tab = ['active', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'active';
action = provisioningActions.some(value => value === params.get('action')) ? params.get('action')! : 'all';
historyState = provisioningListStatuses.some(value => value === params.get('status')) ? params.get('status')! : 'all';
source = provisioningListSources.some(value => value === params.get('source')) ? params.get('source')! : 'all';
search = params.get('server') ?? '';
limit = provisioningPageSize(Number(params.get('limit') ?? 25));
offset = provisioningPageOffset(Number(params.get('offset') ?? 0));
});
function saveContext(reset = false) {
if (reset) offset = 0;
const url = new URL($page.url);
for (const [key, value] of Object.entries({ view: tab === 'active' ? '' : tab, action: action === 'all' ? '' : action, status: historyState === 'all' ? '' : historyState, source: source === 'all' ? '' : source, server: search, limit: limit !== 25 ? String(limit) : '', offset: offset ? String(offset) : '' })) {
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
}
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
async function cancelRequests(rows: Deployment[], bulk = false) {
if (cancelling || !rows.length) return;
const ids = rows.map(row => row.id);
if (!await confirmAction({ title: bulk ? `Cancel all ${ids.length} active operations?` : 'Cancel operation?',
message: `${bulk ? `This affects all ${ids.length} cancellable active operations across the fleet, regardless of the current filters.` : `Cancel ${actionLabels[rows[0].action].toLowerCase()} on ${serverTitle(rows[0].server)}.`} Cancelling stops remaining work and health checks. Changes already made are not undone; commands already delivered to a host may still finish.`,
confirmLabel: bulk ? 'Cancel all operations' : 'Cancel operation', danger: true })) return;
fleetActionsOpen = false;
cancelling = true; cancellationNotice = ''; let cancelled = 0; const unavailable: string[] = [];
for (const id of ids) {
try { const response = await fetch(`/api/provisioning/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); if (response.ok) cancelled++; else unavailable.push(id); }
catch { unavailable.push(id); }
}
cancellationNotice = `${cancelled} operation${cancelled === 1 ? '' : 's'} cancelled.${unavailable.length ? ` ${unavailable.length} could not be cancelled; refresh to see their current state.` : ''}`;
cancelling = false; await load(); historyRefresh++;
}
const tabs = [{id:'active',label:'Active'},{id:'history',label:'History'}];
let action = $state('all');
let search = $state('');
let refreshing = false;
async function load(signal?: AbortSignal) {
if (refreshing) return;
refreshing = true;
try {
const res = await fetch('/api/provisioning?view=active', { signal, cache: 'no-store' });
if (!res.ok) throw new Error('Could not load provisioning activity.');
const result: ProvisioningListPage = await res.json();
active = result.deployments; now = Date.now();
needsReview = result.needsReview;
error = '';
} catch (err) { if (!signal?.aborted) error = (err as Error).message; return 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, source,
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; historyUpdatedAt = new Date().toISOString(); now = Date.now(); }
} catch (err) { if (!controller.signal.aborted) historyError = (err as Error).message; }
finally { if (!controller.signal.aborted) historyLoading = false; }
})();
return () => controller.abort();
});
onMount(() => { 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 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>
<svelte:head><title>Provisioning | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Provisioning">
{#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>
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/" />{/if}
<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} />
<DeployServerDialog bind:open />
<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 cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
<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}
<div class="queue-heading">
{#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>
{:else}<h2>History <span>{historyTotal}</span></h2>{/if}
<div class="queue-tools">
{#if needsReview}<Button size="sm" variant="ghost" onclick={reviewAttention}>Needs attention ({needsReview})</Button>{/if}
{#if tab === 'active'}<span class="muted">{error ? 'Updates interrupted' : 'Updates every 5s'}</span>
{:else}<span class="muted">{historyUpdatedAt ? `Updated ${age(historyUpdatedAt).toLowerCase()}` : ''}</span><Button size="sm" variant="ghost" disabled={historyLoading} onclick={() => historyRefresh++}>Refresh</Button>{/if}
</div>
</div>
{#if tab === 'active'}
{#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>
{#snippet operationTable(rows: Deployment[])}
<div class="table-scroll"><table>
<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>
<tbody>{#each rows as row (row.id)}
{@const status = provisioningQueueStatus(row)}
{@const primarySource = row.sources?.[0]}
{@const operationHref = contextHref(`/provisioning/${encodeURIComponent(row.id)}`, $page.url)}
{@const isExpanded = expanded[row.id] ?? false}
<tr>
<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>
<td><a href={operationHref}>{actionLabels[row.action]}</a></td>
<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>
<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}
<style>
.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%; }
label { display:grid; gap:6px; font-size:var(--text-xs); color:var(--text-secondary); }.search { flex:1; min-width:220px; }
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); }
.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; }
p { margin:0; }.queue-summary { font-size:var(--text-sm); }.queue-summary span,.muted,.pagination { color:var(--text-secondary); font-size:var(--text-sm); }
.table-scroll { overflow-x:auto; }table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
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); }
td { padding:16px 12px; border-bottom:1px solid var(--border-subtle); vertical-align:middle; }tbody tr:hover { background:var(--surface); }
a { color:var(--accent-primary-hover); }.server-cell { min-width:160px; font-weight:var(--weight-semibold); }.source-cell { max-width:240px; overflow-wrap:anywhere; }
small { display:block; margin-top:6px; color:var(--text-tertiary); font-size:var(--text-xs); }.align-right { text-align:right; }
.text-button,.timestamp { border:0; background:transparent; color:var(--text-secondary); font:inherit; padding:6px 0; cursor:pointer; white-space:nowrap; }
.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; }
.detail-row { background:var(--surface); }.operation-details { display:grid; gap:16px; line-height:1.6; overflow-wrap:anywhere; }
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); }
.detail-links { display:flex; align-items:center; flex-wrap:wrap; gap:16px; }.error { color:var(--state-danger); }.warning { color:var(--state-warning); }
.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:720px) { table { min-width:820px; }.search { flex-basis:100%; }th,td { padding:12px 8px; }.fleet-menu { right:auto; left:0; } }
</style>