198 lines
13 KiB
Svelte
198 lines
13 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 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} operation${cancelled === 1 ? '' : 's'} cancelled.${unavailable.length ? ` ${unavailable.length} could not be cancelled; refresh to see their current state.` : ''}`;
|
||
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>
|
||
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/" />{/if}
|
||
<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.canCancel)}<div class="ui-summary"><span>Cancelling stops remaining work and health checks. Changes already made are not undone.</span><Button disabled={cancelling} onclick={() => cancelRequests(active.filter(d => d.canCancel).map(d => d.id))}>Cancel all active operations</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.canCancel && !d.server_deleted_at}<div class="row-actions"><Button size="sm" disabled={cancelling} onclick={() => cancelRequests([d.id])}>Cancel operation</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>
|