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
@@ -1,6 +1,11 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import type { RebalancePreview, RebalanceReport, RebalanceRun, RebalanceSettings } from '$lib/rebalancing.js';
import type {
RebalancePreview,
RebalanceReport,
RebalanceRun,
RebalanceSettings
} from '$lib/rebalancing.js';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { rebalanceRunLabels } from '$ui/rebalancing-presentation.js';
@@ -9,8 +14,14 @@
import RebalancePreviewView from './RebalancePreviewView.svelte';
import RebalanceRunView from './RebalanceRunView.svelte';
let { canManage, preferredHost = null, preferredRun = null }: {
canManage: boolean; preferredHost?: string | null; preferredRun?: string | null;
let {
canManage,
preferredHost = null,
preferredRun = null
}: {
canManage: boolean;
preferredHost?: string | null;
preferredRun?: string | null;
} = $props();
let report = $state<RebalanceReport | null>(null);
let selectedHosts = $state<string[]>([]);
@@ -29,132 +40,472 @@
let sequence = 0;
let loadRequest: AbortController | null = null;
let actionRequest: AbortController | null = null;
const activeRun = $derived(report?.runs.find(run => run.state === 'running' || run.state === 'stopping') ?? null);
const run = $derived(report?.runs.find(item => item.id === selectedRun) ?? activeRun ?? report?.runs[0] ?? null);
const validSettings = $derived(selectedHosts.length >= 2 && selectedHosts.length <= 100 && Number.isInteger(maxMoves) && maxMoves! >= 1 && maxMoves! <= 20);
const activeRun = $derived(
report?.runs.find((run) => run.state === 'running' || run.state === 'stopping') ?? null
);
const run = $derived(
report?.runs.find((item) => item.id === selectedRun) ?? activeRun ?? report?.runs[0] ?? null
);
const validSettings = $derived(
selectedHosts.length >= 2 &&
selectedHosts.length <= 100 &&
Number.isInteger(maxMoves) &&
maxMoves! >= 1 &&
maxMoves! <= 20
);
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
function editSettings() { preview = null; actionError = ''; notice = ''; }
function editSettings() {
preview = null;
actionError = '';
notice = '';
}
function toggleHost(id: string, checked: boolean) {
selectedHosts = checked ? [...selectedHosts, id] : selectedHosts.filter(value => value !== id);
selectedHosts = checked
? [...selectedHosts, id]
: selectedHosts.filter((value) => value !== id);
editSettings();
}
function cancelLoad() { sequence++; loadRequest?.abort(); loadRequest = null; loading = false; }
function cancelLoad() {
sequence++;
loadRequest?.abort();
loadRequest = null;
loading = false;
}
async function load(signal?: AbortSignal) {
if (busy || loading) return;
const current = ++sequence;
const controller = new AbortController();
loadRequest = controller; loading = true;
loadRequest = controller;
loading = true;
try {
const response = await fetch('/api/rebalancing', { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(20_000), ...(signal ? [signal] : [])]) });
const response = await fetch('/api/rebalancing', {
signal: AbortSignal.any([
controller.signal,
AbortSignal.timeout(20_000),
...(signal ? [signal] : [])
])
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not refresh rebalancing.');
if (current !== sequence) return;
report = body as RebalanceReport; refreshError = ''; checkedAt = Date.now(); now = Date.now();
report = body as RebalanceReport;
refreshError = '';
checkedAt = Date.now();
now = Date.now();
if (!initialized) {
selectedHosts = preferredHost ? report.hosts.filter(host => host.id === preferredHost && host.eligible).map(host => host.id)
: report.hosts.filter(host => host.eligible).map(host => host.id).slice(0, 100);
selectedHosts = preferredHost
? report.hosts
.filter((host) => host.id === preferredHost && host.eligible)
.map((host) => host.id)
: report.hosts
.filter((host) => host.eligible)
.map((host) => host.id)
.slice(0, 100);
initialized = true;
} else {
const eligible = new Set(report.hosts.filter(host => host.eligible).map(host => host.id));
const available = selectedHosts.filter(id => eligible.has(id));
const eligible = new Set(
report.hosts.filter((host) => host.eligible).map((host) => host.id)
);
const available = selectedHosts.filter((id) => eligible.has(id));
if (available.length !== selectedHosts.length) {
selectedHosts = available; preview = null;
notice = 'Host availability changed. Unavailable hosts were removed from your selection; preview again before starting.';
selectedHosts = available;
preview = null;
notice =
'Host availability changed. Unavailable hosts were removed from your selection; preview again before starting.';
}
}
} catch (cause) {
if (current === sequence && !controller.signal.aborted && !signal?.aborted) refreshError = (cause as Error).message;
if (current === sequence && !controller.signal.aborted && !signal?.aborted)
refreshError = (cause as Error).message;
return false;
} finally { if (current === sequence) { loadRequest = null; loading = false; } }
} finally {
if (current === sequence) {
loadRequest = null;
loading = false;
}
}
}
async function act(action: 'preview' | 'start' | 'stop') {
if (busy || !canManage) return;
const payload = action === 'preview' ? { action, settings: { hostIds: selectedHosts, game, maxMoves } }
: action === 'start' ? { action, previewId: preview?.id } : { action, id: run?.id };
cancelLoad(); busy = action; actionError = ''; notice = '';
const payload =
action === 'preview'
? { action, settings: { hostIds: selectedHosts, game, maxMoves } }
: action === 'start'
? { action, previewId: preview?.id }
: { action, id: run?.id };
cancelLoad();
busy = action;
actionError = '';
notice = '';
if (action === 'preview') preview = null;
const controller = new AbortController(); actionRequest = controller;
const controller = new AbortController();
actionRequest = controller;
try {
const response = await fetch('/api/rebalancing', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) });
const response = await fetch('/api/rebalancing', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Rebalance request failed.');
if (action === 'preview') preview = body.preview;
else {
const updated = body.run as RebalanceRun;
if (report) report = { ...report, runs: [updated, ...report.runs.filter(item => item.id !== updated.id)] };
selectedRun = updated.id; preview = null;
notice = action === 'start' ? 'Rebalance started. Moves run one at a time.' : 'Unstarted moves have been withdrawn. Any move already in progress will finish.';
if (report)
report = {
...report,
runs: [updated, ...report.runs.filter((item) => item.id !== updated.id)]
};
selectedRun = updated.id;
preview = null;
notice =
action === 'start'
? 'Rebalance started. Moves run one at a time.'
: 'Unstarted moves have been withdrawn. Any move already in progress will finish.';
}
now = Date.now();
} catch (cause) {
if (!controller.signal.aborted) actionError = `${(cause as Error).message}${action === 'preview' ? '' : ' Refresh the run status before retrying an unconfirmed request.'}`;
if (!controller.signal.aborted)
actionError = `${(cause as Error).message}${action === 'preview' ? '' : ' Refresh the run status before retrying an unconfirmed request.'}`;
} finally {
if (!controller.signal.aborted) { busy = null; actionRequest = null; await load(); }
if (!controller.signal.aborted) {
busy = null;
actionRequest = null;
await load();
}
}
}
onMount(() => {
const stop = pollVisible(load, 10_000);
const timer = setInterval(() => now = Date.now(), 5_000);
return () => { stop(); clearInterval(timer); cancelLoad(); actionRequest?.abort(); };
const timer = setInterval(() => (now = Date.now()), 5_000);
return () => {
stop();
clearInterval(timer);
cancelLoad();
actionRequest?.abort();
};
});
</script>
<section class="panel" aria-labelledby="rebalance-title">
<div class="heading"><div><h2 id="rebalance-title">Rebalance instances</h2><p>Spread existing instances across hosts according to CPU and memory use relative to each host’s capacity.</p></div>
{#if checkedAt && !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}
<div class="heading">
<div>
<h2 id="rebalance-title">Rebalance instances</h2>
<p>
Spread existing instances across hosts according to CPU and memory use relative to
each host’s capacity.
</p>
</div>
{#if checkedAt && !refreshError}<Freshness
updatedAt={checkedAt}
intervalLabel="Updates every 10 seconds"
/>{/if}
</div>
<p class="help">Only healthy, empty lobbies can move. Each move rebuilds the lobby on another host, causing a temporary outage; its address or port may change. The server identity, map, key, and recorded deployed files are retained. Local mutable files are not copied.</p>
{#if refreshError}<div class="error" role="alert"><p>{refreshError}{report ? ' Showing the last loaded rebalancing status.' : ''}</p><Button size="sm" disabled={loading || !!busy} onclick={() => void load()}>Retry</Button></div>{/if}
<p class="help">
Only healthy, empty lobbies can move. Each move rebuilds the lobby on another host, causing
a temporary outage; its address or port may change. The server identity, map, key, and
recorded deployed files are retained. Local mutable files are not copied.
</p>
{#if refreshError}<div class="error" role="alert">
<p>{refreshError}{report ? ' Showing the last loaded rebalancing status.' : ''}</p>
<Button size="sm" disabled={loading || !!busy} onclick={() => void load()}>Retry</Button
>
</div>{/if}
{#if actionError}<p class="error" role="alert">{actionError}</p>{/if}
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
{#if !report && loading}<p role="status">Loading hosts and rebalance runs…</p>{/if}
{#if report}
{#if canManage}
<form onsubmit={event => { event.preventDefault(); void act('preview'); }}>
<form
onsubmit={(event) => {
event.preventDefault();
void act('preview');
}}
>
<fieldset disabled={!!busy || !!activeRun}>
<legend>Participating hosts</legend>
<p class="help">Choose at least two hosts. Existing lobbies may move between any selected hosts.</p>
{#if preferredHost && !report.hosts.some(host => host.id === preferredHost && host.eligible)}<p class="help">The host opened from the shortcut is currently unavailable. Choose eligible hosts below.</p>{/if}
<div class="host-selection">{#each report.hosts as host (host.id)}
<label class:unavailable={!host.eligible}><input type="checkbox" checked={selectedHosts.includes(host.id)} disabled={!host.eligible} onchange={event => toggleHost(host.id, event.currentTarget.checked)} />
<span><strong>{host.name}</strong>{#if host.reason}<small>{host.reason}</small>{/if}</span></label>
{/each}</div>
<div class="settings"><label>Game<select bind:value={game} onchange={editSettings}><option value="all">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
<label>Maximum moves<input type="number" min="1" max="20" step="1" required bind:value={maxMoves} oninput={editSettings} /></label>
<Button type="submit" disabled={!validSettings || !!busy || !!activeRun || !!refreshError} loading={busy === 'preview'}>Preview rebalance</Button>
<p class="help">
Choose at least two hosts. Existing lobbies may move between any selected
hosts.
</p>
{#if preferredHost && !report.hosts.some((host) => host.id === preferredHost && host.eligible)}<p
class="help"
>
The host opened from the shortcut is currently unavailable. Choose
eligible hosts below.
</p>{/if}
<div class="host-selection">
{#each report.hosts as host (host.id)}
<label class:unavailable={!host.eligible}
><input
type="checkbox"
checked={selectedHosts.includes(host.id)}
disabled={!host.eligible}
onchange={(event) =>
toggleHost(host.id, event.currentTarget.checked)}
/>
<span
><strong>{host.name}</strong>{#if host.reason}<small
>{host.reason}</small
>{/if}</span
></label
>
{/each}
</div>
<div class="settings">
<label
>Game<select bind:value={game} onchange={editSettings}
><option value="all">All games</option><option value="t5"
>Black Ops I</option
><option value="t6">Black Ops II</option></select
></label
>
<label
>Maximum moves<input
type="number"
min="1"
max="20"
step="1"
required
bind:value={maxMoves}
oninput={editSettings}
/></label
>
<Button
type="submit"
disabled={!validSettings || !!busy || !!activeRun || !!refreshError}
loading={busy === 'preview'}>Preview rebalance</Button
>
</div>
</fieldset>
</form>
{#if activeRun}<p class="help">A rebalance is {activeRun.state === 'stopping' ? 'finishing its current move' : 'already in progress'}. A new run can start after it finishes.{#if activeRun.id !== run?.id} <button class="text-button" onclick={() => selectedRun = activeRun.id}>View active run</button>{/if}</p>{/if}
{:else}<p class="help">Operators and administrators can preview, start, and stop rebalance runs.</p>{/if}
{#if preview}<RebalancePreviewView {preview} {expired} disabled={!!busy || !!activeRun || !!refreshError} starting={busy === 'start'} onstart={() => void act('start')} />{/if}
{#if activeRun}<p class="help">
A rebalance is {activeRun.state === 'stopping'
? 'finishing its current move'
: 'already in progress'}. A new run can start after it finishes.{#if activeRun.id !== run?.id}
<button class="text-button" onclick={() => (selectedRun = activeRun.id)}
>View active run</button
>{/if}
</p>{/if}
{:else}<p class="help">
Operators and administrators can preview, start, and stop rebalance runs.
</p>{/if}
{#if preview}<RebalancePreviewView
{preview}
{expired}
disabled={!!busy || !!activeRun || !!refreshError}
starting={busy === 'start'}
onstart={() => void act('start')}
/>{/if}
<section class="runs" aria-labelledby="rebalance-runs-title">
<div class="heading"><h3 id="rebalance-runs-title">Rebalance runs</h3>{#if report.runs.length}<label class="run-picker"><span class="sr-only">Select rebalance run</span><select value={run?.id ?? ''} onchange={event => selectedRun = event.currentTarget.value}>{#each report.runs as item (item.id)}<option value={item.id}>{$timeFormat.dateTime(item.createdAt)} · {rebalanceRunLabels[item.state]}</option>{/each}</select></label>{/if}</div>
{#if preferredRun && !report.runs.some(item => item.id === preferredRun)}<p class="help">The linked run is not in the recent history.</p>{/if}
{#if run}<RebalanceRunView {run} {canManage} busy={!!busy} stopping={busy === 'stop'} onstop={() => void act('stop')} />
<div class="heading">
<h3 id="rebalance-runs-title">Rebalance runs</h3>
{#if report.runs.length}<label class="run-picker"
><span class="sr-only">Select rebalance run</span><select
value={run?.id ?? ''}
onchange={(event) => (selectedRun = event.currentTarget.value)}
>{#each report.runs as item (item.id)}<option value={item.id}
>{$timeFormat.dateTime(item.createdAt)} · {rebalanceRunLabels[
item.state
]}</option
>{/each}</select
></label
>{/if}
</div>
{#if preferredRun && !report.runs.some((item) => item.id === preferredRun)}<p
class="help"
>
The linked run is not in the recent history.
</p>{/if}
{#if run}<RebalanceRunView
{run}
{canManage}
busy={!!busy}
stopping={busy === 'stop'}
onstop={() => void act('stop')}
/>
{:else}<p class="help">No rebalance runs yet.</p>{/if}
</section>
{/if}
</section>
<style>
.panel { display:grid; gap:var(--space-5); padding:24px; border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); min-width:0; }
h2,h3,p { margin:0; } h2 { font-size:var(--text-lg); } h3 { font-size:var(--text-md); } p { font-size:var(--text-sm); line-height:1.6; } .heading>div>p { margin-top:8px; color:var(--text-secondary); }
.heading,.settings { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
.help { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.6; }
fieldset { border:0; padding:0; margin:0; display:grid; gap:12px; min-width:0; } legend { font-size:var(--text-sm); font-weight:600; padding:0 0 8px; }
.host-selection { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr)); gap:8px; }
.host-selection label { display:flex; align-items:flex-start; gap:10px; padding:12px; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; }
.host-selection input { margin:3px 0 0; accent-color:var(--accent-primary); }
.host-selection span { display:grid; gap:4px; min-width:0; } .host-selection strong { font-size:var(--text-sm); font-weight:500; overflow-wrap:anywhere; }
.host-selection small { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.5; } .host-selection .unavailable { opacity:0.65; cursor:default; }
.settings { justify-content:flex-start; align-items:end; } .settings label { display:grid; gap:8px; font-size:var(--text-xs); } .settings input { width:110px; }
select,input[type='number'] { box-sizing:border-box; padding:9px 12px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:var(--text-sm) var(--font-body); min-width:0; }
input:disabled,select:disabled { opacity:0.6; } .run-picker { max-width:100%; } .run-picker select { max-width:100%; }
.runs { display:grid; grid-template-columns:minmax(0,1fr); gap:16px; border-top:1px solid var(--border-subtle); padding-top:20px; min-width:0; }
.error { display:flex; gap:12px; align-items:center; justify-content:space-between; padding:12px 16px; color:var(--state-danger); border:1px solid var(--state-danger); border-radius:var(--radius-sm); font-size:var(--text-sm); overflow-wrap:anywhere; } .notice { color:var(--state-online); }
.text-button { color:var(--accent-primary-hover); } .text-button { border:0; padding:0; background:none; font:inherit; cursor:pointer; text-decoration:underline; }
button:focus-visible,input:focus-visible,select:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
@media(max-width:600px) { .panel { padding:16px; } }
.panel {
display: grid;
gap: var(--space-5);
padding: 24px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface);
min-width: 0;
}
h2,
h3,
p {
margin: 0;
}
h2 {
font-size: var(--text-lg);
}
h3 {
font-size: var(--text-md);
}
p {
font-size: var(--text-sm);
line-height: 1.6;
}
.heading > div > p {
margin-top: 8px;
color: var(--text-secondary);
}
.heading,
.settings {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.help {
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.6;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
display: grid;
gap: 12px;
min-width: 0;
}
legend {
font-size: var(--text-sm);
font-weight: 600;
padding: 0 0 8px;
}
.host-selection {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
gap: 8px;
}
.host-selection label {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
cursor: pointer;
}
.host-selection input {
margin: 3px 0 0;
accent-color: var(--accent-primary);
}
.host-selection span {
display: grid;
gap: 4px;
min-width: 0;
}
.host-selection strong {
font-size: var(--text-sm);
font-weight: 500;
overflow-wrap: anywhere;
}
.host-selection small {
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.5;
}
.host-selection .unavailable {
opacity: 0.65;
cursor: default;
}
.settings {
justify-content: flex-start;
align-items: end;
}
.settings label {
display: grid;
gap: 8px;
font-size: var(--text-xs);
}
.settings input {
width: 110px;
}
select,
input[type='number'] {
box-sizing: border-box;
padding: 9px 12px;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
font: var(--text-sm) var(--font-body);
min-width: 0;
}
input:disabled,
select:disabled {
opacity: 0.6;
}
.run-picker {
max-width: 100%;
}
.run-picker select {
max-width: 100%;
}
.runs {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
border-top: 1px solid var(--border-subtle);
padding-top: 20px;
min-width: 0;
}
.error {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
color: var(--state-danger);
border: 1px solid var(--state-danger);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.notice {
color: var(--state-online);
}
.text-button {
color: var(--accent-primary-hover);
}
.text-button {
border: 0;
padding: 0;
background: none;
font: inherit;
cursor: pointer;
text-decoration: underline;
}
button:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 600px) {
.panel {
padding: 16px;
}
}
</style>