Save autoscaling policies without a preview step
This commit is contained in:
@@ -25,9 +25,9 @@ export interface ScalingDecision {
|
||||
action: 'create' | 'destroy' | 'wait'; reason: string; server: string | null;
|
||||
desired: number; deficit: number; occupied: number; empty: number; pending: number; unknown: number;
|
||||
}
|
||||
export interface ScalingPreview { policy: ScalingPolicy; snapshot: ScalingSnapshot; decision: ScalingDecision; token: string; blockers: string[] }
|
||||
export interface ScalingEvaluation { policy: ScalingPolicy; snapshot: ScalingSnapshot; decision: ScalingDecision; blockers: string[] }
|
||||
export interface ScalingHistory { id: string; at: number; lastSeenAt: number; decision: ScalingDecision; operationId: string | null; operationState: string | null }
|
||||
export interface ScalingView extends ScalingPreview {
|
||||
export interface ScalingView extends ScalingEvaluation {
|
||||
history: ScalingHistory[]; lastEvaluatedAt: number | null;
|
||||
totals: { observedMinutes: number; emptyLobbyHours: number; deficitMinutes: number; unknownMinutes: number };
|
||||
operations: { creates: number; removals: number; cancelled: number; failed: number; readinessSamples: number; readinessMeanSeconds: number | null; readinessMaxSeconds: number | null };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { liveScaling, scalingSettings, type ScalingPolicy, type ScalingPreview, type ScalingReport, type ScalingView } from '../../autoscaling.js';
|
||||
import { liveScaling, scalingSettings, type ScalingPolicy, type ScalingEvaluation, type ScalingReport, type ScalingView } from '../../autoscaling.js';
|
||||
import { emptyObservation, planScaling } from './planner.js';
|
||||
import { parseServerName } from '../../map-aliases.js';
|
||||
import { scalingSnapshot } from './snapshot.js';
|
||||
@@ -21,12 +21,12 @@ export const listScalingPolicies = (): ScalingPolicy[] => (db().prepare('SELECT
|
||||
export function getScalingPolicy(id: string): ScalingPolicy | undefined { const row = db().prepare('SELECT * FROM autoscaling_policies WHERE id=?').get(id) as PolicyRow | undefined; return row && present(row); }
|
||||
export const scalingGloballyPaused = (): boolean => (db().prepare("SELECT value FROM settings WHERE key='autoscaling_paused'").get() as { value: string } | undefined)?.value === '1';
|
||||
|
||||
export function previewScaling(input: unknown, id?: string, revision?: number): ScalingPreview {
|
||||
export function evaluateScaling(input: unknown, id?: string, revision?: number): ScalingEvaluation {
|
||||
const settings = scalingSettings(input), existing = id ? getScalingPolicy(id) : undefined;
|
||||
if (id && !existing) throw new ScalingError('Policy not found.', 404);
|
||||
if (existing && existing.revision !== revision) throw new ScalingError('Policy changed. Refresh before editing.', 409);
|
||||
if (existing && (existing.game !== settings.game || existing.mapSlug !== settings.mapSlug)) throw new ScalingError('A policy cannot change its game or map.');
|
||||
const policy: ScalingPolicy = { ...settings, id: existing?.id ?? 'preview', revision: existing?.revision ?? 0,
|
||||
const policy: ScalingPolicy = { ...settings, id: existing?.id ?? 'new-policy', revision: existing?.revision ?? 0,
|
||||
pausedReason: null, updatedAt: existing?.updatedAt ?? '' };
|
||||
const snapshot = scalingSnapshot(policy);
|
||||
const blockers: string[] = [];
|
||||
@@ -43,17 +43,14 @@ export function previewScaling(input: unknown, id?: string, revision?: number):
|
||||
try { nativeLaunchSelection({ automatic: true }, policy.game); } catch (error) { blockers.push((error as Error).message); }
|
||||
}
|
||||
const decision = planScaling(policy, snapshot, Date.now());
|
||||
const token = createHash('sha256').update(JSON.stringify({ settings, revision: policy.revision, globalPaused: scalingGloballyPaused(),
|
||||
members: snapshot.members.map(m => [m.name, m.identity, m.state]), keyPool: snapshot.keyPool, reservations: snapshot.reservedNames.sort(), blockers: snapshot.blockers, decision })).digest('hex');
|
||||
return { policy, snapshot, decision, token, blockers };
|
||||
return { policy, snapshot, decision, blockers };
|
||||
}
|
||||
|
||||
export function saveScaling(input: unknown, id?: string, revision?: number, token?: string): ScalingPolicy {
|
||||
export function saveScaling(input: unknown, id?: string, revision?: number): ScalingPolicy {
|
||||
return db().transaction(() => {
|
||||
const preview = previewScaling(input, id, revision);
|
||||
if (!token || preview.token !== token) throw new ScalingError('The policy preview changed. Review it again before saving.', 409);
|
||||
if (preview.blockers.length) throw new ScalingError(preview.blockers[0]);
|
||||
const policy = preview.policy, settings = scalingSettings(policy), nextId = id ?? randomUUID(), at = new Date().toISOString();
|
||||
const evaluation = evaluateScaling(input, id, revision);
|
||||
if (evaluation.blockers.length) throw new ScalingError(evaluation.blockers[0]);
|
||||
const policy = evaluation.policy, settings = scalingSettings(policy), nextId = id ?? randomUUID(), at = new Date().toISOString();
|
||||
if (id) revokePolicyRemovals(id);
|
||||
if (!id && db().prepare('SELECT 1 FROM autoscaling_policies WHERE game=? AND map_slug=?').get(policy.game, policy.mapSlug)) throw new ScalingError('A policy already exists for this scope.', 409);
|
||||
db().prepare(`INSERT INTO autoscaling_policies(id,game,map_slug,settings,mode,updated_at) VALUES(?,?,?,?,?,?)
|
||||
@@ -61,7 +58,7 @@ export function saveScaling(input: unknown, id?: string, revision?: number, toke
|
||||
// An edit starts a new observation interval; earlier inactivity is not authorization.
|
||||
db().prepare('DELETE FROM autoscaling_members WHERE policy_id=?').run(nextId);
|
||||
db().prepare('UPDATE autoscaling_policies SET owns_map=CASE WHEN ? THEN 1 ELSE owns_map END WHERE id=?').run(liveScaling(policy.mode) ? 1 : 0, nextId);
|
||||
if (liveScaling(policy.mode)) for (const member of preview.snapshot.members) db().prepare('INSERT INTO autoscaling_members(policy_id,server,identity,observation,adopted) VALUES(?,?,?,?,1)')
|
||||
if (liveScaling(policy.mode)) for (const member of evaluation.snapshot.members) db().prepare('INSERT INTO autoscaling_members(policy_id,server,identity,observation,adopted) VALUES(?,?,?,?,1)')
|
||||
.run(nextId, member.name, member.identity, JSON.stringify({ identity: member.identity, observedAt: member.observedAt, emptySince: null, evaluatedAt: Date.now() }));
|
||||
reconcileScaling(getScalingPolicy(nextId)!);
|
||||
return getScalingPolicy(nextId)!;
|
||||
@@ -213,7 +210,7 @@ export function scalingReport(): ScalingReport {
|
||||
MAX(CASE WHEN d.action='create' AND d.state IN ('done','warning') THEN (julianday(d.finished_at)-julianday(d.started_at))*86400 END) AS readinessMaxSeconds
|
||||
FROM server_deployments d JOIN provisioning_sources s ON s.operation_id=d.id
|
||||
WHERE s.kind='autoscaling' AND s.source_id=? AND d.started_at>=?`).get(policy.id, new Date(Date.now() - 31 * 86400_000).toISOString()) as ScalingView['operations'];
|
||||
return { policy, snapshot, decision, token: '', blockers: [], history, lastEvaluatedAt: history[0]?.lastSeenAt ?? null, totals, operations, unsettled, ownsMap };
|
||||
return { policy, snapshot, decision, blockers: [], history, lastEvaluatedAt: history[0]?.lastSeenAt ?? null, totals, operations, unsettled, ownsMap };
|
||||
});
|
||||
return { policies, globalPaused: scalingGloballyPaused(), liveAvailable: true, removalAvailable: true };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { previewScaling, saveScaling, deleteScaling, scalingReport, setScalingGlobalPause, ScalingError } from '$lib/server/autoscaling/index.js';
|
||||
import { saveScaling, deleteScaling, scalingReport, setScalingGlobalPause, ScalingError } from '$lib/server/autoscaling/index.js';
|
||||
|
||||
export function GET() { return json(scalingReport()); }
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
@@ -7,8 +7,7 @@ export async function POST({ request }: { request: Request }) {
|
||||
const body = await request.json();
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new ScalingError('Provide a policy action.');
|
||||
if (body.id !== undefined && typeof body.id !== 'string') throw new ScalingError('Invalid policy identity.');
|
||||
if (body.action === 'preview') return json(previewScaling(body.settings, body.id, body.revision));
|
||||
if (body.action === 'save') return json({ policy: saveScaling(body.settings, body.id, body.revision, body.token) });
|
||||
if (body.action === 'save') return json({ policy: saveScaling(body.settings, body.id, body.revision) });
|
||||
if (body.action === 'delete') {
|
||||
if (typeof body.id !== 'string' || !body.id || !Number.isInteger(body.revision) || body.revision < 1) throw new ScalingError('Provide the policy identity and current revision.');
|
||||
deleteScaling(body.id, body.revision);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user