Files
electric-cherry/src/lib/ui/components/FleetActionsDialog.svelte
T

143 lines
9.6 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 { 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;
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">
<label class="action">Action<select bind:value={action} disabled={busy} onchange={chooseAction}>
{#each fleetActions as value}<option value={value}>{actionLabels[value]}</option>{/each}
</select></label>
<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, .action { display: grid; gap: var(--space-4); }
.action { gap: var(--space-2); }
select { width: 100%; min-height: 40px; padding: 10px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-primary); font: inherit; }
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>