Add safe manual instance rebalancing
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { cancelProvisioningJob } from '$lib/server/provisioning/cancellation.js';
|
||||
import { cancelProvisioningJob, rebalanceCancellationBlocker } from '$lib/server/provisioning/cancellation.js';
|
||||
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export function POST({ params }: { params: { id: string } }) {
|
||||
if (!cancelProvisioningJob(params.id)) return json({ error: 'This operation can no longer be cancelled. Refresh to see its current state.' }, { status: 409 });
|
||||
if (!cancelProvisioningJob(params.id)) return json({ error: rebalanceCancellationBlocker(params.id)
|
||||
?? 'This operation can no longer be cancelled. Refresh to see its current state.' }, { status: 409 });
|
||||
void kickProvisioning();
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { RebalanceError, previewRebalance, rebalanceReport, startRebalance, stopRebalance } from '$lib/server/rebalancing/index.js';
|
||||
|
||||
export function GET() { return json(rebalanceReport()); }
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new RebalanceError('Provide a rebalance action.');
|
||||
if (body.action === 'preview') return json({ preview: previewRebalance(body.settings) });
|
||||
if (body.action === 'start' && typeof body.previewId === 'string' && body.previewId.length <= 100) return json({ run: startRebalance(body.previewId) });
|
||||
if (body.action === 'stop' && typeof body.id === 'string' && body.id.length <= 100) return json({ run: stopRebalance(body.id) });
|
||||
throw new RebalanceError('Choose a supported rebalance action.');
|
||||
} catch (error) { return json({ error: (error as Error).message }, { status: error instanceof RebalanceError ? error.status : 400 }); }
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import CapacityHosts from '$ui/components/capacity/CapacityHosts.svelte';
|
||||
import CapacityChart from '$ui/components/capacity/CapacityChart.svelte';
|
||||
import RebalancePanel from '$ui/components/capacity/RebalancePanel.svelte';
|
||||
import Freshness from '$ui/components/Freshness.svelte';
|
||||
import { plural } from '$ui/plural.js';
|
||||
|
||||
@@ -21,11 +22,13 @@
|
||||
let navigating = $state(false);
|
||||
let refreshError = $state('');
|
||||
let navigationError = $state('');
|
||||
let rebalanceOpen = $state(untrack(() => page.url.searchParams.get('rebalance') === '1'));
|
||||
let checkedAt = $state(untrack(() => Date.now()));
|
||||
let request: AbortController | null = null;
|
||||
let sequence = 0;
|
||||
const report = $derived(snapshot ?? data);
|
||||
const summary = $derived(report.current.summary);
|
||||
const canRebalance = $derived(['admin', 'operator'].includes(page.data?.user?.role ?? ''));
|
||||
const scopeName = $derived(report.query.hostId ? report.current.hostOptions.find(host => host.id === report.query.hostId)?.name ?? 'Selected host' : 'All hosts');
|
||||
const hostOptions = $derived([{ value: '', label: 'All hosts' }, ...report.current.hostOptions.map(host => ({ value: host.id, label: host.name })),
|
||||
...(report.query.hostId && !report.current.hostOptions.some(host => host.id === report.query.hostId) ? [{ value: report.query.hostId, label: 'Unavailable host' }] : [])]);
|
||||
@@ -43,6 +46,10 @@
|
||||
const params = queryParams({ ...report.query, ...change });
|
||||
const returnTo = page.url.searchParams.get('returnTo');
|
||||
if (returnTo) params.set('returnTo', returnTo);
|
||||
for (const key of ['rebalance', 'rebalanceHost', 'rebalanceRun']) {
|
||||
const value = page.url.searchParams.get(key);
|
||||
if (value) params.set(key, value);
|
||||
}
|
||||
return `/capacity?${params}`;
|
||||
}
|
||||
function cancel() { sequence++; request?.abort(); request = null; refreshing = false; }
|
||||
@@ -71,15 +78,17 @@
|
||||
beforeNavigate(() => { cancel(); navigating = true; });
|
||||
afterNavigate(() => { navigating = false; });
|
||||
$effect(() => { void data; untrack(() => { cancel(); snapshot = null; refreshError = ''; checkedAt = Date.now(); }); });
|
||||
$effect(() => { if (page.url.searchParams.get('rebalance') === '1') rebalanceOpen = true; });
|
||||
onMount(() => { const stop = pollVisible(refresh, 30_000, false); return () => { stop(); cancel(); }; });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Capacity · Electric Cherry</title><meta name="description" content="Available game-server capacity, resource history, and host limits." /></svelte:head>
|
||||
|
||||
<div class="page" aria-busy={navigating}>
|
||||
<SectionHeader title="Capacity" />
|
||||
<SectionHeader title="Capacity">{#snippet actions()}<Button onclick={() => rebalanceOpen = !rebalanceOpen}>{rebalanceOpen ? 'Hide rebalance' : 'Rebalance'}</Button>{/snippet}</SectionHeader>
|
||||
{#if page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/if}
|
||||
{#if refreshError || navigationError}<div class="refresh-error" role="alert"><strong>{navigationError || refreshError}</strong><span>Showing the last successfully loaded capacity report.</span>{#if refreshError}<Button size="sm" disabled={refreshing || navigating} onclick={() => void refresh()}>Retry</Button>{/if}</div>{/if}
|
||||
{#if rebalanceOpen}{#key `${page.url.searchParams.get('rebalanceHost') ?? ''}:${page.url.searchParams.get('rebalanceRun') ?? ''}`}<RebalancePanel canManage={canRebalance} preferredHost={page.url.searchParams.get('rebalanceHost')} preferredRun={page.url.searchParams.get('rebalanceRun')} />{/key}{/if}
|
||||
<div class="toolbar">
|
||||
<div class="filters"><Field label="Host"><Select value={report.query.hostId ?? ''} options={hostOptions} disabled={navigating} onchange={event => void changeScope({ hostId: (event.currentTarget as HTMLSelectElement).value || null })} /></Field>
|
||||
<Field label="Game"><Select value={report.query.game} options={gameOptions} disabled={navigating} onchange={event => void changeScope({ game: (event.currentTarget as HTMLSelectElement).value as CapacityGame })} /></Field></div>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<svelte:head><title>Hosts · Electric Cherry</title></svelte:head>
|
||||
<section class="page">
|
||||
<SectionHeader title="Hosts">
|
||||
{#snippet actions()}{#if canManage}<Button variant="primary" onclick={() => addOpen = true}>Add host</Button>{/if}{/snippet}
|
||||
{#snippet actions()}<Button href="/capacity?rebalance=1">Rebalance</Button>{#if canManage}<Button variant="primary" onclick={() => addOpen = true}>Add host</Button>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
{#if error}<p role="alert" class="ui-error">{error} Showing the last loaded hosts. <Button variant="ghost" size="sm" onclick={() => load()}>Retry</Button></p>{/if}
|
||||
{#if hosts.length}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
|
||||
<section class="page"><SectionHeader title={host?.name ?? 'Host'} subtitle="Running instances, readiness, inventory, settings, and credentials.">
|
||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Hosts', href: '/hosts' }, { label: host?.name ?? 'Host' }]} />{/snippet}
|
||||
{#snippet actions()}<Button href={contextHref(`/capacity?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Capacity</Button><Button href={contextHref(`/hosts/operations?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<details class="host-menu"><summary>More actions</summary><div><Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button></div></details>{/if}{/snippet}
|
||||
{#snippet actions()}<Button href={contextHref(`/capacity?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Capacity</Button><Button href={contextHref(`/capacity?rebalance=1&rebalanceHost=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Rebalance</Button><Button href={contextHref(`/hosts/operations?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<details class="host-menu"><summary>More actions</summary><div><Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button></div></details>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if loading}<p role="status">Loading host…</p>{:else if host}
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
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;
|
||||
if (['campaign','beta','maintenance','rebalance'].includes(source) && !sources.some(item => source === 'maintenance' ? ['maintenance','watchdog'].includes(item.kind) : item.kind === source)) return false;
|
||||
}
|
||||
return true;
|
||||
}));
|
||||
@@ -175,7 +175,7 @@
|
||||
<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>
|
||||
<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="rebalance">Rebalance</option><option value="system">System</option></select></label>
|
||||
{#if filtered}<Button variant="ghost" onclick={clearFilters}>Clear filters</Button>{/if}
|
||||
</div>
|
||||
<LifecycleDialog bind:open={lifecycleOpen} />
|
||||
|
||||
Reference in New Issue
Block a user