Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+308 -62
View File
@@ -13,133 +13,379 @@
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 });
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 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);
const timer = setTimeout(() => (notice = ''), 6000);
return () => clearTimeout(timer);
});
async function refresh(signal?: AbortSignal) {
const version = ++generation;
pendingRefreshes++; refreshing = true;
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.');
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; }
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;
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 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;
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; }
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;
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) };
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; }
} catch (error) {
actionError = (error as Error).message;
} finally {
busy = false;
}
}
async function togglePolicy(policy: ScalingPolicy) {
busy = true; actionError = ''; notice = ''; ++generation;
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 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; }
} 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; }
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
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}
<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(); }}>
<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>
<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></label>
<label>Autoscaling<select bind:value={settings.mode}><option value="off">Off</option>{#if report.removalAvailable}<option value="create-destroy">On</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
>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
>
<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
></label
>
<label
>Autoscaling<select bind:value={settings.mode}
><option value="off">Off</option
>{#if report.removalAvailable}<option value="create-destroy"
>On</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
>
</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>
{#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
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} />
<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; }
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; }
.banner { padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
@media (max-width: 500px) { .panel { padding: 16px; } }
.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;
}
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;
}
.banner {
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
@media (max-width: 500px) {
.panel {
padding: 16px;
}
}
</style>