Files
electric-cherry/src/routes/autoscaling/+page.svelte
T
2026-10-10 00:19:48 -04:00

380 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { confirmAction } from '$ui/confirm.js';
import { pollVisible } from '$ui/poll.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import PolicyCard from '$ui/components/autoscaling/PolicyCard.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import { zombieMaps, zombieMapBySlug } from '$lib/map-aliases.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);
const canOperate = $derived(
page.data.user?.role === 'admin' || page.data.user?.role === 'operator'
);
const defaults = (): ScalingSettings => ({
game: 't5',
mapSlug: 'theater',
mode: 'off',
minimum: 1,
maximum: 3,
spares: 1,
emptyMinutes: 15
});
let settings = $state<ScalingSettings>(defaults());
let editing = $state<ScalingPolicy | null>(null);
let editorOpen = $state(false);
let busy = $state(false),
actionError = $state(''),
refreshError = $state(''),
notice = $state('');
let updatedAt = $state(Date.now());
let refreshing = $state(false);
let generation = 0;
let pendingRefreshes = 0;
$effect(() => {
if (!notice) return;
const timer = setTimeout(() => (notice = ''), 6000);
return () => clearTimeout(timer);
});
async function refresh(signal?: AbortSignal) {
const version = ++generation;
pendingRefreshes++;
refreshing = true;
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 = '';
updatedAt = Date.now();
}
} catch (error) {
if (version === generation && !signal?.aborted) refreshError = (error as Error).message;
return false;
} finally {
refreshing = --pendingRefreshes > 0;
}
}
onMount(() => pollVisible(refresh, 15_000, false));
function edit(policy: ScalingPolicy | null) {
editing = policy;
settings = policy ? { ...policy } : defaults();
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() {
busy = true;
actionError = '';
notice = '';
++generation;
try {
await post({ action: 'save', settings, id: editing?.id, revision: editing?.revision });
notice = 'Policy saved.';
editorOpen = false;
await refresh();
} catch (error) {
actionError = (error as Error).message;
} finally {
busy = false;
}
}
async function remove(policy: ScalingPolicy) {
const scope =
policy.mapSlug === '*'
? `every map in ${policy.game.toUpperCase()}`
: `${zombieMapBySlug(policy.game, policy.mapSlug)?.friendly ?? policy.mapSlug} · ${policy.game.toUpperCase()}`;
if (
!(await confirmAction({
title: 'Delete autoscaling policy?',
message: `Delete the policy for ${scope} and its scaling history? Existing lobbies, saved keys, and Provisioning history will be kept.`,
confirmLabel: 'Delete policy',
danger: true
}))
)
return;
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;
}
snapshot = {
...report,
policies: report.policies.filter((view) => view.policy.id !== policy.id)
};
notice = 'Policy deleted.';
await refresh();
} catch (error) {
actionError = (error as Error).message;
} finally {
busy = false;
}
}
async function togglePolicy(policy: ScalingPolicy) {
busy = true;
actionError = '';
notice = '';
++generation;
try {
await post({
action: 'save',
id: policy.id,
revision: policy.revision,
settings: { ...policy, mode: policy.mode === 'off' ? 'create-destroy' : 'off' }
});
notice =
policy.mode === 'off'
? 'Policy resumed.'
: 'Policy paused. Outstanding jobs are still tracked.';
if (editing?.id === policy.id) {
editing = null;
editorOpen = false;
}
await refresh();
} catch (error) {
actionError = (error as Error).message;
} 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. Outstanding jobs are still tracked.';
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()}{#if canOperate && report.policies.length}<Button
disabled={busy}
onclick={pauseAll}
>{report.globalPaused ? 'Resume autoscaling' : 'Pause all'}</Button
><Button variant="primary" disabled={busy} onclick={() => edit(null)}
>Add policy</Button
>{/if}{/snippet}
</SectionHeader>
<Freshness
{updatedAt}
error={refreshError ? 'Policy updates interrupted.' : ''}
intervalLabel="Updates every 15 seconds"
onRetry={() => void refresh()}
retryDisabled={refreshing || busy}
/>
{#if report.globalPaused}<p class="banner">
Autoscaling is paused globally. Outstanding jobs are still tracked and may finish.
</p>{/if}
{#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();
}}
>
<fieldset disabled={busy}>
<div class="fields">
<Field 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
></Field
>
<Field label="Scope">
<Select bind:value={settings.mapSlug} disabled={!!editing}
><option value="*">Every map in this game</option
>{#each zombieMaps(settings.game) as map}<option value={map.slug}
>{map.friendly}</option
>{/each}</Select
></Field
>
<Field label="Autoscaling">
<Select bind:value={settings.mode}
><option value="off">Off</option
>{#if report.removalAvailable}<option value="create-destroy"
>On</option
>{/if}</Select
></Field
>
<Field label="Minimum running lobbies">
<Input
type="number"
min="1"
max="20"
required
bind:value={settings.minimum}
/></Field
>
<Field label="Maximum lobbies">
<Input
type="number"
min="1"
max="20"
required
bind:value={settings.maximum}
/></Field
>
<Field label="Empty spare target">
<Input
type="number"
min="1"
max="20"
required
bind:value={settings.spares}
/></Field
>
<Field label="Empty duration in minutes">
<Input
type="number"
min="1"
max="1440"
required
bind:value={settings.emptyMinutes}
/></Field
>
</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" variant="primary" disabled={busy}>Save policy</Button
><Button onclick={() => (editorOpen = false)}>Close editor</Button>
</div>
</fieldset>
</form>
</section>
{/if}
{#if !report.policies.length && !editorOpen}
<EmptyState
title="No autoscaling policies"
description={canOperate
? 'Policies keep empty lobbies available for a map or a whole game. Add one to choose its limits and spare lobby target.'
: 'Policies keep empty lobbies available for a map or a whole game. An operator can add a policy.'}
>
{#snippet actions()}{#if canOperate}<Button
variant="primary"
disabled={busy}
onclick={() => edit(null)}>Add policy</Button
>{/if}{/snippet}
</EmptyState>
{/if}
{#each report.policies as view (view.policy.id)}
<PolicyCard
{view}
globallyPaused={report.globalPaused}
{canOperate}
{busy}
onedit={edit}
ontoggle={togglePolicy}
ondelete={remove}
/>
{/each}
</div>
<style>
.page {
display: grid;
gap: var(--space-5);
min-width: 0;
}
.panel {
padding: 20px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
overflow-wrap: anywhere;
}
.actions {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
h2 {
font-size: var(--text-lg);
margin: 0;
}
p {
line-height: 1.6;
}
.fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
gap: 16px;
margin-bottom: 20px;
}
fieldset {
border: 0;
padding: 0;
margin: 16px 0;
min-width: 0;
}
.banner {
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
@media (max-width: 500px) {
.panel {
padding: 16px;
}
}
</style>