Files
electric-cherry/src/lib/ui/components/capacity/RebalancePanel.svelte
T
2026-10-10 00:19:48 -04:00

492 lines
18 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, untrack } from 'svelte';
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';
import Button from '$ui/components/primitives/Button.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import RebalancePreviewView from './RebalancePreviewView.svelte';
import RebalanceRunView from './RebalanceRunView.svelte';
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[]>([]);
let game = $state<RebalanceSettings['game']>('all');
let maxMoves = $state<number | undefined>(5);
let preview = $state<RebalancePreview | null>(null);
let selectedRun = $state(untrack(() => preferredRun));
let busy = $state<'preview' | 'start' | 'stop' | null>(null);
let loading = $state(false);
let refreshError = $state('');
let actionError = $state('');
let notice = $state('');
let now = $state(untrack(() => Date.now()));
let checkedAt = $state<number | null>(null);
let initialized = false;
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 expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
function editSettings() {
preview = null;
actionError = '';
notice = '';
}
function toggleHost(id: string, checked: boolean) {
selectedHosts = checked
? [...selectedHosts, id]
: selectedHosts.filter((value) => value !== id);
editSettings();
}
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;
try {
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();
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);
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));
if (available.length !== selectedHosts.length) {
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;
return 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 = '';
if (action === 'preview') preview = null;
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 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.';
}
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.'}`;
} finally {
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();
};
});
</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>
<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');
}}
>
<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">
<Field 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
></Field
>
<Field class="max-moves" label="Maximum moves">
<Input
type="number"
min="1"
max="20"
step="1"
required
bind:value={maxMoves}
oninput={editSettings}
/></Field
>
<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}
<section class="runs" aria-labelledby="rebalance-runs-title">
<div class="heading">
<h3 id="rebalance-runs-title">Rebalance runs</h3>
{#if report.runs.length}<Field
class="run-picker"
hiddenLabel
label="Select rebalance run"
>
<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
></Field
>{/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 > :global(.max-moves) {
width: 110px;
}
.settings {
justify-content: flex-start;
align-items: end;
}
input:disabled {
opacity: 0.6;
}
.heading :global(.run-picker) {
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 {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
@media (max-width: 600px) {
.panel {
padding: 16px;
}
}
</style>