Add autoscaling dry run and policy controls

This commit is contained in:
2026-10-06 22:52:50 -04:00
parent ed4d8a4802
commit 36cf2b9c05
11 changed files with 317 additions and 4 deletions
+2
View File
@@ -0,0 +1,2 @@
import { scalingReport } from '$lib/server/autoscaling/index.js';
export function load() { return { autoscaling: scalingReport() }; }
+133
View File
@@ -0,0 +1,133 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import { zombieMaps, zombieMapBySlug } from '$lib/map-aliases.js';
import type { ScalingSettings, ScalingPolicy, ScalingPreview, ScalingReport } from '$lib/autoscaling.js';
let { data }: { data: import('./$types').PageData } = $props();
let snapshot = $state<ScalingReport | null>(null);
const report = $derived(snapshot ?? data.autoscaling);
const canOperate = $derived(page.data.user?.role === 'admin' || page.data.user?.role === 'operator');
const defaults = (): ScalingSettings => ({ game: 't5', mapSlug: 'theater', mode: 'dry-run', minimum: 1, maximum: 4, spares: 1, emptyMinutes: 20, cooldownMinutes: 5 });
let settings = $state<ScalingSettings>(defaults());
let editing = $state<ScalingPolicy | null>(null);
let editorOpen = $state(false);
let preview = $state<ScalingPreview | null>(null);
let busy = $state(false), actionError = $state(''), refreshError = $state(''), notice = $state('');
let generation = 0;
const modeLabel = (mode: string) => ({ off: 'Off', 'dry-run': 'Dry run', 'create-only': 'Create only', 'create-destroy': 'Create and destroy' })[mode] ?? mode;
async function refresh(signal?: AbortSignal) {
const version = ++generation;
try {
const response = await fetch('/api/autoscaling', { signal });
if (!response.ok) throw new Error('Could not refresh policies. The last successful report is shown.');
const result = await response.json();
if (version === generation && !signal?.aborted) { snapshot = result; refreshError = ''; }
} catch (error) { if (version === generation && !signal?.aborted) refreshError = (error as Error).message; }
}
onMount(() => pollVisible(refresh, 15_000, false));
function edit(policy: ScalingPolicy | null) {
editing = policy; settings = policy ? { ...policy } : defaults();
preview = null; actionError = ''; notice = ''; editorOpen = true;
}
async function post(body: object) {
const response = await fetch('/api/autoscaling', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? 'Policy action failed.');
return result;
}
async function submit(action: 'preview' | 'save') {
busy = true; actionError = ''; notice = ''; ++generation;
try {
const result = await post({ action, settings, id: editing?.id, revision: editing?.revision, token: preview?.token });
if (action === 'preview') preview = result;
else { notice = 'Policy saved. Evaluation runs every 15 seconds.'; editorOpen = false; preview = null; await refresh(); }
} catch (error) { actionError = (error as Error).message; preview = null; }
finally { busy = false; }
}
async function pauseAll() {
busy = true; actionError = ''; ++generation;
try { await post({ action: 'global-pause', paused: !report.globalPaused }); notice = report.globalPaused ? 'Autoscaling resumed.' : 'Autoscaling paused. Review any unsettled operations below.'; await refresh(); }
catch (error) { actionError = (error as Error).message; }
finally { busy = false; }
}
</script>
<svelte:head><title>Autoscaling | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Autoscaling" subtitle="Keep empty lobbies available for new groups, within the limits you choose.">
{#snippet actions()}<Button onclick={() => refresh()}>Refresh</Button>{#if canOperate}<Button disabled={busy} onclick={pauseAll}>{report.globalPaused ? 'Resume autoscaling' : 'Pause all'}</Button><Button variant="primary" onclick={() => edit(null)}>Add policy</Button>{/if}{/snippet}
</SectionHeader>
<p class="context"><a href="/">Fleet</a> · <a href="/collections">Collections</a> · <a href="/metrics">Metrics</a></p>
{#if report.globalPaused}<p class="banner">All autoscaling decisions are paused. Delivered operations may still need to settle.</p>{/if}
<p>Start with a two-week dry run. Dry run records hypothetical actions and reserves no lobby or host capacity.</p>
{#if refreshError}<p role="alert">{refreshError}</p>{/if}
{#if actionError}<p role="alert">{actionError}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if editorOpen && canOperate}
<section class="panel" aria-labelledby="policy-editor">
<h2 id="policy-editor">{editing ? 'Edit policy' : 'New policy'}</h2>
<form onsubmit={event => { event.preventDefault(); void submit('preview'); }} oninput={() => preview = null} onchange={() => preview = null}>
<fieldset disabled={busy}>
<div class="fields">
<label>Game<select bind:value={settings.game} disabled={!!editing} onchange={() => settings.mapSlug = zombieMaps(settings.game)[0].slug}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
<label>Map variant<select bind:value={settings.mapSlug} disabled={!!editing}>{#each zombieMaps(settings.game) as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label>
<label>Mode<select bind:value={settings.mode}><option value="off">Off</option><option value="dry-run">Dry run</option>{#if report.liveAvailable}<option value="create-only">Create only</option>{/if}{#if report.removalAvailable}<option value="create-destroy">Create and destroy</option>{/if}</select></label>
<label>Minimum running lobbies<input type="number" min="1" max="20" required bind:value={settings.minimum} /></label>
<label>Maximum lobbies<input type="number" min="1" max="20" required bind:value={settings.maximum} /></label>
<label>Empty spare target<input type="number" min="1" max="20" required bind:value={settings.spares} /></label>
<label>Empty duration in minutes<input type="number" min="1" max="1440" required bind:value={settings.emptyMinutes} /></label>
<label>Action cooldown in minutes<input type="number" min="1" max="1440" required bind:value={settings.cooldownMinutes} /></label>
</div>
<div class="actions"><Button type="submit" disabled={busy}>Preview changes</Button><Button onclick={() => editorOpen = false}>Close editor</Button></div>
</fieldset>
</form>
{#if preview}
<div class="preview" aria-live="polite">
<h3>Preview · {modeLabel(preview.policy.mode)}</h3>
<p>Target: {preview.decision.desired} running lobbies. {preview.snapshot.keyedNames.length} saved lobby keys. {preview.snapshot.members.length} existing or pending lobbies.</p>
<p>{preview.decision.action === 'wait' ? 'Wait' : `${preview.decision.action === 'create' ? 'Create' : 'Remove'} ${preview.decision.server}`}: {preview.decision.reason}</p>
{#if preview.policy.mode === 'create-only' || preview.policy.mode === 'create-destroy'}<p>Saving gives this policy exclusive control of the map's lobby count. Pause it before launching a competing collection or manual lifecycle action.</p>{/if}
{#if preview.snapshot.members.length}<ul>{#each preview.snapshot.members as member}<li>{member.name} · {member.state}{member.blocker ? ` · ${member.blocker}` : ''}</li>{/each}</ul>{/if}
{#each [...preview.blockers, ...preview.snapshot.blockers] as blocker}<p>{blocker}</p>{/each}
<Button variant="primary" disabled={busy || preview.blockers.length > 0} onclick={() => submit('save')}>Save reviewed policy</Button>
</div>
{/if}
</section>
{/if}
{#if !report.policies.length}<p class="panel">No autoscaling policies. Add a policy to observe a map in dry run.</p>{/if}
{#each report.policies as view (view.policy.id)}
<section class="panel" id={view.policy.id} aria-labelledby={`title-${view.policy.id}`}>
<div class="heading"><h2 id={`title-${view.policy.id}`}>{zombieMapBySlug(view.policy.game, view.policy.mapSlug)?.friendly ?? view.policy.mapSlug} · {view.policy.game.toUpperCase()}</h2><span>{modeLabel(view.policy.mode)}</span>{#if canOperate}<Button onclick={() => edit(view.policy)}>Edit or pause</Button>{/if}</div>
<p class="counts">{view.decision.occupied} occupied · {view.decision.empty} empty and ready · {view.decision.pending} pending · {view.decision.unknown} unknown or stopped</p>
<p>Minimum {view.policy.minimum} · Maximum {view.policy.maximum} · Spare target {view.policy.spares} · {view.snapshot.keyedNames.length} saved keys</p>
<p><strong>{view.policy.mode === 'dry-run' ? 'Hypothetical decision: ' : 'Current decision: '}</strong>{view.decision.action}{view.decision.server ? ` ${view.decision.server}` : ''}. {view.decision.reason}</p>
{#if view.policy.mode === 'create-only' && view.decision.action === 'destroy'}<p>Removal is advisory in Create only mode.</p>{/if}
<p class="muted">Last evaluated: {view.lastEvaluatedAt ? $timeFormat.dateTime(new Date(view.lastEvaluatedAt).toISOString()) : 'Not evaluated yet'}{view.policy.nextActionAt > Date.now() ? ` · Next action after ${$timeFormat.dateTime(new Date(view.policy.nextActionAt).toISOString())}` : ''}</p>
<details><summary>Lobbies and evidence</summary><ul>{#each view.snapshot.members as member}<li><a href={`/servers/${encodeURIComponent(member.name)}`}>{member.name}</a> · {member.state}{member.blocker ? ` · ${member.blocker}` : ''}<br />{member.source ?? 'No current source'} · {member.observedAt ? $timeFormat.dateTime(new Date(member.observedAt).toISOString()) : 'Unknown observation time'}{#if member.operationId} · <a href={`/provisioning/${member.operationId}`}>Provisioning</a>{/if}</li>{/each}</ul>{#if !view.snapshot.members.length}<p>No registered or pending lobbies.</p>{/if}{#each view.snapshot.blockers as blocker}<p>{blocker}</p>{/each}</details>
<details><summary>Recent decisions and measurements</summary>
<p>{view.totals.observedMinutes.toFixed(1)} observed minutes · {view.totals.emptyLobbyHours.toFixed(2)} empty lobby-hours · {view.totals.deficitMinutes.toFixed(1)} minutes below spare target · {view.totals.unknownMinutes.toFixed(1)} unknown minutes</p>
<p class="muted">Up to 31 days of bounded observations; gaps are excluded. Dry run measures the actual fleet, not a simulated replacement lineup. Readiness does not certify browser visibility or successful joins.</p>
<ol>{#each view.history as item}<li>{$timeFormat.dateTime(new Date(item.at).toISOString())} · {item.decision.action} {item.decision.server ?? ''}<br />{item.decision.reason}{#if item.operationId} · <a href={`/provisioning/${item.operationId}`}>View operation</a>{/if}</li>{/each}</ol>
</details>
</section>
{/each}
</div>
<style>
.page { max-width: 1200px; margin: 0 auto; padding: 24px; }
.panel { margin: 20px 0; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow-wrap: anywhere; }
.heading, .actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
h2 { font-size: var(--text-lg); margin: 0; } h3 { font-size: var(--text-md); }
.heading h2 { flex: 1; } p, li { line-height: 1.6; } .muted, .context { color: var(--text-secondary); font-size: var(--text-sm); }
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 16px; margin-bottom: 20px; }
label { display: flex; flex-direction: column; gap: 8px; font-size: var(--text-sm); }
input, select { padding: 10px; min-width: 0; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); font: inherit; }
fieldset { border: 0; padding: 0; margin: 16px 0; min-width: 0; }
.preview, .banner { padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
details { margin-top: 16px; } summary { cursor: pointer; } li { margin: 10px 0; }
@media (max-width: 500px) { .page { padding: 16px; } .panel { padding: 16px; } }
</style>