Bound provisioning history and poll only active operations
This commit is contained in:
@@ -1,3 +1 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listDeployments } from '$lib/server/provisioning/index.js';
|
||||
export function GET() { return json({ deployments: listDeployments() }); }
|
||||
export { GET } from '../provisioning/+server.js';
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
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 { 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';
|
||||
|
||||
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 }) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import Tabs from '$ui/components/primitives/Tabs.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 { serverTitle } from '$ui/server-labels.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
@@ -17,17 +15,17 @@
|
||||
import HousePartyDialog from '$ui/components/HousePartyDialog.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);
|
||||
interface Deployment {
|
||||
id: string; server: string; server_deleted_at?: string | null; action: ProvisioningAction;
|
||||
state: 'queued' | 'awaiting-health' | 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted' | 'cancelled';
|
||||
stage: string; started_at: string; finished_at: string | null;
|
||||
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
||||
canCancelRemoval: boolean;
|
||||
}
|
||||
let deployments = $state<Deployment[]>([]);
|
||||
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);
|
||||
@@ -35,6 +33,7 @@
|
||||
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('');
|
||||
@@ -44,12 +43,13 @@
|
||||
action = provisioningActions.some(value => value === params.get('action')) ? params.get('action')! : 'all';
|
||||
historyState = params.get('status') === 'attention' ? 'attention' : 'all';
|
||||
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) {
|
||||
if (reset) limit = 25;
|
||||
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) : '' })) {
|
||||
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 });
|
||||
@@ -67,23 +67,41 @@
|
||||
let action = $state('all');
|
||||
let search = $state('');
|
||||
let refreshing = false;
|
||||
let active = $derived(deployments.filter(d => !d.server_deleted_at && ['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)));
|
||||
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() {
|
||||
async function load(signal?: AbortSignal) {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
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.');
|
||||
deployments = (await res.json()).deployments;
|
||||
const result: ProvisioningListPage = await res.json();
|
||||
active = result.deployments;
|
||||
needsReview = result.needsReview;
|
||||
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; }
|
||||
}
|
||||
// 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 []; } }
|
||||
@@ -97,13 +115,13 @@
|
||||
<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'; 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 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={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 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}
|
||||
<section>
|
||||
<h2>In flight <span>{active.length}</span></h2>
|
||||
@@ -113,15 +131,23 @@
|
||||
</section>
|
||||
{:else}
|
||||
<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">
|
||||
<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 !loading && history.length === 0}<EmptyState title="No matching operations" description="Try another search or operation type." />
|
||||
{:else}<div class="rows">{#each history.slice(0,limit) as d (d.id)}{@render row(d)}{/each}</div>{/if}
|
||||
<ListFooter total={history.length} bind:limit onchange={() => saveContext()} label="operations" />
|
||||
{#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>
|
||||
|
||||
Reference in New Issue
Block a user