257 lines
22 KiB
Svelte
257 lines
22 KiB
Svelte
<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>
|