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

242 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 Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js';
import { actionLabels, runtimeDescriptions, type RuntimeAction } from '$lib/provisioning.js';
import { applicableRuntimeActions, 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 Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
let {
open = $bindable(false),
initialServer = '',
initialAction = 'start',
serverContext = null,
players = null,
round = null
}: {
open?: boolean;
initialServer?: string;
initialAction?: RuntimeAction;
serverContext?: FleetServer | null;
players?: number | null;
round?: number | null;
} = $props();
let servers = $state<FleetServer[]>([]);
let server = $state(untrack(() => initialServer));
let action = $state<RuntimeAction | 'destroy'>(untrack(() => initialAction));
let busy = $state(false);
let loading = $state(false);
let error = $state('');
let confirmed = $state(false);
const selected = $derived(
serverContext?.name === server
? serverContext
: servers.find((item) => item.name === server)
);
const available = $derived(selected ? applicableRuntimeActions(selected, $uiNow) : []);
const applicable = $derived(action === 'destroy' || available.includes(action));
const destructive = $derived(action === 'rebuild' || action === 'destroy');
const health = $derived(selected ? serverHealth(selected, $uiNow) : null);
const impactPlayers = $derived(
initialServer
? players
: selected && health?.state === 'online' && freshServer(selected, $uiNow)
? selected.last_players
: null
);
const impactRound = $derived(
initialServer ? round : selected ? currentRound(selected, selected.telemetry, $uiNow) : null
);
const title = $derived(
initialServer ? `${actionLabels[action]} ${serverTitle(initialServer)}?` : 'Manage server'
);
const submitLabel = $derived(
action === 'start'
? 'Start server'
: action === 'destroy'
? 'Destroy server when empty'
: `${actionLabels[action]} when empty`
);
$effect(() => {
if (!open) return;
const fixedServer = initialServer;
const requestedAction = initialAction;
const controller = new AbortController();
// Live context and match reports update the copy and eligibility without
// reopening the dialog or clearing a deliberate confirmation.
untrack(() => {
server = fixedServer;
action = requestedAction;
confirmed = false;
error = '';
servers = [];
loading = false;
if (!fixedServer || serverContext?.name !== fixedServer)
void load(fixedServer, controller.signal);
});
return () => controller.abort();
});
async function load(fixedServer: string, signal: AbortSignal) {
loading = true;
try {
const response = await fetch(
fixedServer ? `/api/servers/${encodeURIComponent(fixedServer)}` : '/api/servers',
{ signal }
);
if (!response.ok) throw new Error('Could not load servers.');
const body = await response.json();
if (signal.aborted) return;
servers = body.servers;
syncServerTime(body.serverNow);
} catch (err) {
if (!signal.aborted) error = (err as Error).message;
} finally {
if (!signal.aborted) loading = false;
}
}
function chooseServer(event: Event & { currentTarget: EventTarget & HTMLSelectElement }) {
server = event.currentTarget.value;
confirmed = false;
error = '';
const item = servers.find((item) => item.name === server);
const actions = item ? applicableRuntimeActions(item, serverTimeNow()) : [];
action = actions[0] ?? 'destroy';
}
async function submit() {
if (busy || !server || (destructive && !confirmed)) return;
if (
action !== 'destroy' &&
(!selected || !applicableRuntimeActions(selected, serverTimeNow()).includes(action))
) {
error =
'The server status has changed. Close this dialog and choose an action from its current status.';
return;
}
busy = true;
error = '';
try {
const response =
action === 'destroy'
? await fetch(`/api/servers/${encodeURIComponent(server)}`, {
method: 'DELETE'
})
: await fetch('/api/provisioning', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server, action })
});
const body = await response.json();
if (!response.ok || !body.deploymentId)
throw new Error(body.error ?? 'Could not queue this operation.');
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
open = false;
} catch (err) {
error = (err as Error).message;
} finally {
busy = false;
}
}
</script>
<Dialog bind:open {title} {busy}>
<div class="fields">
{#if !initialServer}
<Field label="Server">
<Select bind:value={server} disabled={busy || loading} onchange={chooseServer}>
<option value="">{loading ? 'Loading servers…' : 'Choose a server'}</option>
{#each servers as item (item.name)}<option value={item.name}
>{serverTitle(item.name)}</option
>{/each}
</Select></Field
>
<Field label="Action">
<Select
bind:value={action}
disabled={busy || !server || loading}
onchange={() => {
confirmed = false;
error = '';
}}
>
{#if !server}<option value="start">Choose a server first</option>{/if}
{#if server && !applicable}<option value={action} disabled
>{actionLabels[action]} unavailable</option
>{/if}
{#each available as value}<option {value}>{actionLabels[value]}</option>{/each}
{#if server}<option value="destroy">Destroy server</option>{/if}
</Select></Field
>
{/if}
{#if loading}<p role="status">Checking the latest server status…</p>
{:else if server}
{#if applicable}
<p class="impact">{lifecycleImpact(action, impactPlayers, impactRound)}</p>
<p>
{action === 'destroy'
? 'Remove the container, its writable server files, and registration after the server is empty. Shared game libraries, recorded history, and the key saved separately in Keys are retained.'
: runtimeDescriptions[action]}
</p>
{#if action !== 'start'}<p class="help">
Follow progress or cancel the waiting request on the provisioning page.
</p>{/if}
{:else}<p role="status">
{actionLabels[action]} is unavailable. {health?.reason ??
'The current server status could not be verified.'} Close this dialog and choose
an action from its current status.
</p>{/if}
{#if applicable && destructive}<label class="confirmation"
><input type="checkbox" bind:checked={confirmed} disabled={busy} /> I understand that
this instance’s writable files and data will be permanently deleted.</label
>{/if}
{/if}
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
</div>
{#snippet actions()}
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button>
<Button
variant={destructive ? 'danger' : 'primary'}
disabled={busy || loading || !server || !applicable || (destructive && !confirmed)}
loading={busy}
onclick={submit}>{submitLabel}</Button
>
{/snippet}
</Dialog>
<style>
.fields {
display: grid;
gap: 16px;
}
label {
display: grid;
gap: 8px;
}
p {
margin: 0;
line-height: 1.6;
color: var(--text-secondary);
}
.confirmation {
display: flex;
align-items: flex-start;
gap: 12px;
line-height: 1.5;
}
.impact {
padding: var(--space-4);
border-radius: var(--radius-md);
background: var(--surface-raised);
color: var(--text-primary);
}
.help {
font-size: var(--text-sm);
}
</style>