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

314 lines
12 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 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>