242 lines
9.6 KiB
Svelte
242 lines
9.6 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 { 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>
|