Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user