314 lines
12 KiB
Svelte
314 lines
12 KiB
Svelte
<script lang="ts">
|
||
import Select from '$ui/components/primitives/Select.svelte';
|
||
import Field from '$ui/components/primitives/Field.svelte';
|
||
import { untrack } from 'svelte';
|
||
import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js';
|
||
import { actionLabels } from '$lib/provisioning.js';
|
||
import {
|
||
fleetActions,
|
||
fleetActionAvailable,
|
||
queueFleetAction,
|
||
type FleetAction,
|
||
type FleetActionFailure
|
||
} from '$ui/fleet-actions.js';
|
||
import { lifecycleImpact } from '$ui/lifecycle-actions.js';
|
||
import { serverHealth } from '$ui/server-health.js';
|
||
import { serverTitle } from '$ui/server-labels.js';
|
||
import { serverTimeNow, syncServerTime, uiNow } from '$ui/stores/servers.js';
|
||
import Button from './primitives/Button.svelte';
|
||
import Dialog from './primitives/Dialog.svelte';
|
||
|
||
let {
|
||
open = $bindable(false),
|
||
onqueued
|
||
}: { open?: boolean; onqueued: (count: number, action: FleetAction) => void } = $props();
|
||
let servers = $state<FleetServer[]>([]);
|
||
let action = $state<FleetAction>('restart');
|
||
let selected = $state<string[]>([]);
|
||
let queued = $state<string[]>([]);
|
||
let failures = $state<FleetActionFailure[]>([]);
|
||
let loading = $state(false);
|
||
let busy = $state(false);
|
||
let error = $state('');
|
||
let confirmed = $state(false);
|
||
let refresh = $state(0);
|
||
const descriptions: Record<FleetAction, string> = {
|
||
start: 'Start the selected servers and verify their health.',
|
||
restart: 'Restart the selected servers once they are empty, then verify their health.',
|
||
stop: 'Stop the selected servers once they are empty. Their files, keys, and registrations are kept.',
|
||
rebuild:
|
||
'Rebuild the selected servers from the latest promoted setup once they are empty. Their writable files are permanently deleted.',
|
||
destroy:
|
||
'Destroy the selected servers once they are empty. Their containers, writable files, and registrations are permanently deleted.'
|
||
};
|
||
const destructive = $derived(action === 'rebuild' || action === 'destroy');
|
||
const eligible = $derived(
|
||
servers.filter(
|
||
(server) =>
|
||
!queued.includes(server.name) && fleetActionAvailable(server, action, $uiNow)
|
||
)
|
||
);
|
||
const targets = $derived(eligible.filter((server) => selected.includes(server.name)));
|
||
const allSelected = $derived(eligible.length > 0 && targets.length === eligible.length);
|
||
|
||
$effect(() => {
|
||
if (!open) return;
|
||
untrack(() => {
|
||
action = 'restart';
|
||
selected = [];
|
||
queued = [];
|
||
failures = [];
|
||
confirmed = false;
|
||
});
|
||
});
|
||
$effect(() => {
|
||
if (!open) return;
|
||
void refresh;
|
||
const controller = new AbortController();
|
||
untrack(() => {
|
||
void load(controller.signal);
|
||
});
|
||
return () => controller.abort();
|
||
});
|
||
async function load(signal: AbortSignal) {
|
||
loading = true;
|
||
error = '';
|
||
try {
|
||
const response = await fetch('/api/servers', { signal, cache: 'no-store' });
|
||
if (!response.ok) throw new Error('Could not load servers.');
|
||
const body = await response.json();
|
||
if (signal.aborted) return;
|
||
servers = body.servers.sort((a: FleetServer, b: FleetServer) =>
|
||
serverTitle(a.name).localeCompare(serverTitle(b.name))
|
||
);
|
||
syncServerTime(body.serverNow);
|
||
} catch (cause) {
|
||
if (!signal.aborted)
|
||
error = cause instanceof Error ? cause.message : 'Could not load servers.';
|
||
} finally {
|
||
if (!signal.aborted) loading = false;
|
||
}
|
||
}
|
||
function chooseAction() {
|
||
selected = [];
|
||
confirmed = false;
|
||
failures = [];
|
||
}
|
||
function toggle(name: string, checked: boolean) {
|
||
selected = checked ? [...selected, name] : selected.filter((value) => value !== name);
|
||
confirmed = false;
|
||
}
|
||
function selectAll(checked: boolean) {
|
||
selected = checked ? eligible.map((server) => server.name) : [];
|
||
confirmed = false;
|
||
}
|
||
async function submit() {
|
||
if (busy || loading || error || (destructive && !confirmed)) return;
|
||
const names = targets
|
||
.filter((server) => fleetActionAvailable(server, action, serverTimeNow()))
|
||
.map((server) => server.name);
|
||
if (!names.length) return;
|
||
busy = true;
|
||
failures = [];
|
||
try {
|
||
const result = await queueFleetAction(action, names);
|
||
queued = [...queued, ...result.queued.map((item) => item.server)];
|
||
selected = selected.filter((name) => !queued.includes(name));
|
||
failures = result.failed;
|
||
confirmed = false;
|
||
if (result.queued.length) onqueued(result.queued.length, action);
|
||
if (!result.failed.length) open = false;
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<Dialog
|
||
bind:open
|
||
title="Fleet actions"
|
||
description="Choose an action, then select the servers to apply it to."
|
||
{busy}
|
||
wide
|
||
>
|
||
<div class="fields">
|
||
<Field class="action" label="Action">
|
||
<Select bind:value={action} disabled={busy} onchange={chooseAction}>
|
||
{#each fleetActions as value}<option {value}>{actionLabels[value]}</option>{/each}
|
||
</Select></Field
|
||
>
|
||
<p>{descriptions[action]}</p>
|
||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||
{#if loading}<p role="status">Loading the latest server status…</p>
|
||
{:else}
|
||
<div class="selection-tools">
|
||
<label class="check"
|
||
><input
|
||
type="checkbox"
|
||
checked={allSelected}
|
||
indeterminate={targets.length > 0 && !allSelected}
|
||
disabled={busy || !!error || !eligible.length}
|
||
onchange={(event) => selectAll(event.currentTarget.checked)}
|
||
/>Select all eligible</label
|
||
>
|
||
<span>{targets.length} selected · {eligible.length} eligible</span>
|
||
<Button size="sm" disabled={busy} onclick={() => refresh++}>Refresh servers</Button>
|
||
</div>
|
||
{#if servers.length}
|
||
<fieldset disabled={busy || !!error} class="servers">
|
||
<legend>Servers</legend>
|
||
{#each servers as server (server.name)}
|
||
{@const available = eligible.some((item) => item.name === server.name)}
|
||
{@const health = serverHealth(server, $uiNow)}
|
||
<label class="server" class:unavailable={!available}>
|
||
<input
|
||
type="checkbox"
|
||
checked={available && selected.includes(server.name)}
|
||
disabled={!available}
|
||
onchange={(event) =>
|
||
toggle(server.name, event.currentTarget.checked)}
|
||
/>
|
||
<span
|
||
><strong>{serverTitle(server.name)}</strong><span class="metadata"
|
||
>{server.host?.name ?? 'Host unavailable'} · {health.label}</span
|
||
>
|
||
{#if queued.includes(server.name)}<span class="metadata"
|
||
>Job queued</span
|
||
>
|
||
{:else if !available}<span class="metadata"
|
||
>{actionLabels[action]} unavailable for the current server status.</span
|
||
>
|
||
{:else}<span class="metadata"
|
||
>{lifecycleImpact(
|
||
action,
|
||
health.state === 'online' && freshServer(server, $uiNow)
|
||
? server.last_players
|
||
: null,
|
||
currentRound(server, server.telemetry, $uiNow)
|
||
)}</span
|
||
>{/if}
|
||
</span>
|
||
</label>
|
||
{/each}
|
||
</fieldset>
|
||
{:else if !error}<p>No servers are available.</p>{/if}
|
||
{/if}
|
||
{#if action !== 'start'}<p>
|
||
Each server waits for a live check confirming it is empty. Follow progress or cancel
|
||
waiting jobs in Provisioning.
|
||
</p>{/if}
|
||
{#if destructive}<p>
|
||
Shared game libraries, recorded history, saved keys, and collections are retained.
|
||
</p>{/if}
|
||
{#if destructive}<label class="check"
|
||
><input
|
||
type="checkbox"
|
||
bind:checked={confirmed}
|
||
disabled={busy || !targets.length}
|
||
/>I understand that the selected servers’ writable files and data will be
|
||
permanently deleted.</label
|
||
>{/if}
|
||
{#if failures.length}<div role="alert">
|
||
<p class="ui-error">Some requests could not be queued:</p>
|
||
<ul>
|
||
{#each failures as failure}<li>
|
||
<strong>{serverTitle(failure.server)}:</strong>
|
||
{failure.error}
|
||
</li>{/each}
|
||
</ul>
|
||
</div>{/if}
|
||
{#if busy}<p role="status">
|
||
Queuing {actionLabels[action].toLowerCase()} for the selected servers…
|
||
</p>{/if}
|
||
</div>
|
||
{#snippet actions()}
|
||
<Button disabled={busy} onclick={() => (open = false)}>Close</Button>
|
||
<Button
|
||
variant={destructive ? 'danger' : 'primary'}
|
||
disabled={busy || loading || !!error || !targets.length || (destructive && !confirmed)}
|
||
loading={busy}
|
||
onclick={submit}
|
||
>{actionLabels[action]}
|
||
{targets.length} server{targets.length === 1 ? '' : 's'}{action !== 'start'
|
||
? ' when empty'
|
||
: ''}</Button
|
||
>
|
||
{/snippet}
|
||
</Dialog>
|
||
|
||
<style>
|
||
.fields,
|
||
.fields :global(.action) {
|
||
display: grid;
|
||
gap: var(--space-4);
|
||
}
|
||
.fields :global(.action) {
|
||
gap: var(--space-2);
|
||
}
|
||
|
||
p,
|
||
ul {
|
||
margin: 0;
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-sm);
|
||
line-height: 1.6;
|
||
}
|
||
.check,
|
||
.selection-tools {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-3);
|
||
}
|
||
.check {
|
||
line-height: 1.5;
|
||
}
|
||
input[type='checkbox'] {
|
||
flex: 0 0 auto;
|
||
width: 18px;
|
||
height: 18px;
|
||
accent-color: var(--accent-primary);
|
||
}
|
||
.selection-tools {
|
||
flex-wrap: wrap;
|
||
justify-content: space-between;
|
||
font-size: var(--text-sm);
|
||
}
|
||
.servers {
|
||
min-width: 0;
|
||
margin: 0;
|
||
padding: 0;
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-md);
|
||
max-height: 360px;
|
||
overflow-y: auto;
|
||
}
|
||
legend {
|
||
margin-left: var(--space-3);
|
||
padding: 0 var(--space-2);
|
||
}
|
||
.server {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--space-3);
|
||
padding: var(--space-3);
|
||
cursor: pointer;
|
||
}
|
||
.server + .server {
|
||
border-top: 1px solid var(--border-subtle);
|
||
}
|
||
.server > span {
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.metadata {
|
||
display: block;
|
||
margin-top: var(--space-1);
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-sm);
|
||
line-height: 1.5;
|
||
}
|
||
.unavailable {
|
||
cursor: default;
|
||
}
|
||
</style>
|