380 lines
14 KiB
Svelte
380 lines
14 KiB
Svelte
<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>
|