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

196 lines
13 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 { 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 StatusBadge from '$ui/components/primitives/StatusBadge.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 { 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 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 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 = params.get('status') === 'attention' ? 'attention' : '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, 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(ids: string[]) {
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} queued removal${cancelled === 1 ? '' : 's'} cancelled.${cancelled ? ' The servers covered by those cancelled requests and their files were kept.' : ''}${unavailable.length ? ` ${unavailable.length} could not be cancelled; refresh their operations to see whether removal has begun.` : ''}`;
cancelling = false; await load();
}
const tabs = $derived.by(() => [{id:'active',label:'Active',badge:active.length},{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;
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,
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));
function fmt(value: string) { return $timeFormat.dateTime(value); }
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
</script>
<svelte:head><title>Provisioning | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Provisioning" subtitle="Create, start, restart, rebuild, stop, and destroy server instances.">
{#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}
</SectionHeader>
<Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" />
<LifecycleDialog bind:open={lifecycleOpen} />
<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(); }} />
{#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}
{#if tab === 'active'}
{#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}
<section>
<h2>In flight <span>{active.length}</span></h2>
{#if loading}<p>Loading provisioning activity...</p>
{:else if active.length === 0}<EmptyState title="No server operations in flight" description="Server lifecycle operations appear here automatically." />
{: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>
</nav>
</section>
{/if}
</div>
</div>
{#snippet row(d: Deployment)}
<article class="deployment-row"><a class="row-link" href={`/provisioning/${encodeURIComponent(d.id)}?returnTo=${encodeURIComponent($page.url.pathname + $page.url.search)}`}>
<div class="row-head">
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{actionLabels[d.action]}</Badge>
<strong class="server"><HistoricalServerIdentity name={d.server} deletedAt={d.server_deleted_at} /></strong>
</div>
<StatusBadge state={d.state} />
</div>
<p>{d.state === 'done' ? actionCompleted[d.action] : d.stage}</p>
<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>
{#if d.error}<p class="error">{d.error}</p>{/if}
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each}
</a>{#if d.canCancelRemoval && !d.server_deleted_at}<div class="row-actions"><Button size="sm" disabled={cancelling} onclick={() => cancelRequests([d.id])}>Cancel queued removal</Button></div>{/if}</article>
{/snippet}
<style>
.row-link { color: inherit; text-decoration: none; display: block; }.row-actions { margin-top: 12px; }
.page { display: flex; flex-direction: column; gap: 28px; }
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.row-head { justify-content: space-between; }
section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
p { margin: 8px 0; } a { color: var(--accent); }
.rows { display: grid; gap: 12px; }
.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; }
.deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
.deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
.server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; }
.meta { font-size: 13px; color: var(--text-secondary); }
.error { color: var(--state-danger); }
label { display: flex; align-items: center; gap: 8px; font-size: 13px; }
input, select { background: var(--surface); color: inherit; border: 1px solid var(--border-subtle); border-radius: 6px; padding: 8px; max-width: 100%; }
@media (max-width: 640px) { .row-head { align-items: flex-start; } .server { display: block; margin: 10px 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>