Save autoscaling policies without a preview step

This commit is contained in:
2026-10-07 15:56:13 -04:00
parent 7ede98ff87
commit daf78bc11c
12 changed files with 127 additions and 124 deletions
+11 -24
View File
@@ -7,7 +7,7 @@
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';
import type { ScalingSettings, ScalingPolicy, ScalingReport } from '$lib/autoscaling.js';
let { data }: { data: import('./$types').PageData } = $props();
let snapshot = $state<ScalingReport | null>(null);
const report = $derived(snapshot ?? data.autoscaling);
@@ -16,7 +16,6 @@
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', 'create-destroy': 'On' })[mode] ?? mode;
@@ -32,7 +31,7 @@
onMount(() => pollVisible(refresh, 15_000, false));
function edit(policy: ScalingPolicy | null) {
editing = policy; settings = policy ? { ...policy } : defaults();
preview = null; actionError = ''; notice = ''; editorOpen = true;
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) });
@@ -40,13 +39,12 @@
if (!response.ok) throw new Error(result.error ?? 'Policy action failed.');
return result;
}
async function submit(action: 'preview' | 'save') {
async function submit() {
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; }
await post({ action: 'save', settings, id: editing?.id, revision: editing?.revision });
notice = 'Policy saved. Evaluation runs every 15 seconds.'; editorOpen = false; await refresh();
} catch (error) { actionError = (error as Error).message; }
finally { busy = false; }
}
async function remove(policy: ScalingPolicy) {
@@ -55,7 +53,7 @@
busy = true; actionError = ''; notice = ''; ++generation;
try {
await post({ action: 'delete', id: policy.id, revision: policy.revision });
if (editing?.id === policy.id) { editing = null; editorOpen = false; preview = null; }
if (editing?.id === policy.id) { editing = null; editorOpen = false; }
snapshot = { ...report, policies: report.policies.filter(view => view.policy.id !== policy.id) };
notice = 'Policy deleted.';
await refresh();
@@ -82,7 +80,7 @@
{#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}>
<form onsubmit={event => { event.preventDefault(); void submit(); }}>
<fieldset disabled={busy}>
<div class="fields">
<label>Game<select bind:value={settings.game} disabled={!!editing} onchange={() => { if (settings.mapSlug !== '*') settings.mapSlug = zombieMaps(settings.game)[0].slug; }}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
@@ -95,20 +93,9 @@
</div>
{#if settings.mapSlug === '*'}<p>Targets apply separately to every map, including all variants. All maps share this game’s key pool. Actions run one at a time across the game.</p>{/if}
<p>Each new lobby needs an available key from the game pool. Creation waits when the pool is exhausted; keys return after confirmed removal.</p>
<div class="actions"><Button type="submit" disabled={busy}>Preview changes</Button><Button onclick={() => editorOpen = false}>Close editor</Button></div>
<div class="actions"><Button type="submit" variant="primary" disabled={busy}>Save policy</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.policy.mapSlug === '*' ? ' across all maps' : ''}. {preview.snapshot.keyPool.available} of {preview.snapshot.keyPool.total} game keys available. {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-destroy'}<p>Saving gives this policy exclusive control of the selected maps' lobby counts. 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 manage empty lobbies for a map or a whole game.</p>{/if}
@@ -136,13 +123,13 @@
.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); }
h2 { font-size: var(--text-lg); margin: 0; }
.heading h2 { flex: 1 1 200px; } p, li { line-height: 1.6; } .muted { 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); }
.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>