492 lines
18 KiB
Svelte
492 lines
18 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, 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>
|