Add fleet action dialog with server selection

This commit is contained in:
2026-10-07 23:41:45 -04:00
parent 7e552b700a
commit 92c50e62a1
16 changed files with 284 additions and 110 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
export const activityTypes = { export const activityTypes = {
create: 'Create server', start: 'Start server', restart: 'Restart server', rebuild: 'Rebuild server', create: 'Create server', start: 'Start server', restart: 'Restart server', rebuild: 'Rebuild server',
stop: 'Stop server', remove: 'Remove server', update: 'Game update', files: 'File changes', stop: 'Stop server', remove: 'Destroy server', update: 'Game update', files: 'File changes',
health: 'Health finding', drift: 'File drift', host: 'Host operation', beta: 'Beta test' health: 'Health finding', drift: 'File drift', host: 'Host operation', beta: 'Beta test'
} as const; } as const;
export type ActivityType = keyof typeof activityTypes; export type ActivityType = keyof typeof activityTypes;
+1 -1
View File
@@ -1,7 +1,7 @@
export const hostOperationTitles: Record<string, string> = { export const hostOperationTitles: Record<string, string> = {
'instance.create': 'Create server container', 'instance.rebuild': 'Rebuild server container', 'instance.create': 'Create server container', 'instance.rebuild': 'Rebuild server container',
'instance.start': 'Start server container', 'instance.restart': 'Restart server container', 'instance.start': 'Start server container', 'instance.restart': 'Restart server container',
'instance.stop': 'Stop server container', 'instance.remove': 'Remove server container', 'instance.stop': 'Stop server container', 'instance.remove': 'Destroy server container',
'instance.cancel-create': 'Cancel server creation', 'instance.inspect': 'Check server container', 'instance.cancel-create': 'Cancel server creation', 'instance.inspect': 'Check server container',
'host.inspect': 'Check host', 'host.decommission': 'Remove host', 'runtime.pull': 'Prepare container runtime', 'host.inspect': 'Check host', 'host.decommission': 'Remove host', 'runtime.pull': 'Prepare container runtime',
'library.download': 'Download game files', 'library.import': 'Import game files', 'library.download': 'Download game files', 'library.import': 'Import game files',
+2 -2
View File
@@ -2,11 +2,11 @@ export const provisioningActions = ['create', 'start', 'restart', 'rebuild', 'st
export type ProvisioningAction = typeof provisioningActions[number]; export type ProvisioningAction = typeof provisioningActions[number];
export type RuntimeAction = Exclude<ProvisioningAction, 'create' | 'destroy'>; export type RuntimeAction = Exclude<ProvisioningAction, 'create' | 'destroy'>;
export const actionLabels: Record<ProvisioningAction, string> = { export const actionLabels: Record<ProvisioningAction, string> = {
create: 'Create', start: 'Start', restart: 'Restart', rebuild: 'Rebuild', stop: 'Stop', destroy: 'Remove' create: 'Create', start: 'Start', restart: 'Restart', rebuild: 'Rebuild', stop: 'Stop', destroy: 'Destroy'
}; };
export const actionCompleted: Record<ProvisioningAction, string> = { export const actionCompleted: Record<ProvisioningAction, string> = {
create: 'Server creation completed', start: 'Server started', restart: 'Server restarted', create: 'Server creation completed', start: 'Server started', restart: 'Server restarted',
rebuild: 'Server rebuilt', stop: 'Server stopped', destroy: 'Server removed' rebuild: 'Server rebuilt', stop: 'Server stopped', destroy: 'Server destroyed'
}; };
export const runtimeActions: RuntimeAction[] = ['start', 'restart', 'rebuild', 'stop']; export const runtimeActions: RuntimeAction[] = ['start', 'restart', 'rebuild', 'stop'];
export const runtimeDescriptions: Record<RuntimeAction, string> = { export const runtimeDescriptions: Record<RuntimeAction, string> = {
+1 -1
View File
@@ -62,7 +62,7 @@ items AS (
CASE WHEN d.state IN ('failed','interrupted','cancelled','warning') THEN d.state CASE WHEN d.state IN ('failed','interrupted','cancelled','warning') THEN d.state
WHEN m.failed=1 THEN 'failed' WHEN m.pending=1 AND d.state='done' THEN 'verifying' WHEN m.failed=1 THEN 'failed' WHEN m.pending=1 AND d.state='done' THEN 'verifying'
WHEN m.cancelled=1 THEN 'cancelled' ELSE d.state END AS rawState, WHEN m.cancelled=1 THEN 'cancelled' ELSE d.state END AS rawState,
CASE WHEN m.is_update=1 THEN 'Update game' ELSE ${labelCase('d.action', { create: 'Create server', start: 'Start server', restart: 'Restart server', rebuild: 'Rebuild server', stop: 'Stop server', destroy: 'Remove server' }, 'Server operation')} END AS title, CASE WHEN m.is_update=1 THEN 'Update game' ELSE ${labelCase('d.action', { create: 'Create server', start: 'Start server', restart: 'Restart server', rebuild: 'Rebuild server', stop: 'Stop server', destroy: 'Destroy server' }, 'Server operation')} END AS title,
CASE WHEN m.failed=1 AND d.state='done' THEN 'The restart completed, but maintenance verification failed.' CASE WHEN m.failed=1 AND d.state='done' THEN 'The restart completed, but maintenance verification failed.'
WHEN m.pending=1 AND d.state='done' THEN 'The restart completed. Maintenance verification is still in progress.' WHEN m.pending=1 AND d.state='done' THEN 'The restart completed. Maintenance verification is still in progress.'
WHEN m.revision IS NOT NULL THEN 'Target game revision r' || m.revision || '.' WHEN m.revision IS NOT NULL THEN 'Target game revision r' || m.revision || '.'
@@ -211,10 +211,10 @@
</section> </section>
{#if preview.removals.length}<section class="removals" aria-label="Servers to remove"> {#if preview.removals.length}<section class="removals" aria-label="Servers to remove">
<h3>Remove {preview.removals.length} server{preview.removals.length === 1 ? '' : 's'}</h3> <h3>Destroy {preview.removals.length} server{preview.removals.length === 1 ? '' : 's'}</h3>
<p>Containers, files, and registrations will be removed. Occupied or unreachable servers wait for a fresh empty-player check; unstarted creations are cancelled.</p> <p>Containers, files, and registrations will be removed. Occupied or unreachable servers wait for a fresh empty-player check; unstarted creations are cancelled.</p>
<ul>{#each preview.removals as server}<li><strong>{serverTitle(server.name)}</strong><code>{server.name}</code><span>{server.reason}</span></li>{/each}</ul> <ul>{#each preview.removals as server}<li><strong>{serverTitle(server.name)}</strong><code>{server.name}</code><span>{server.reason}</span></li>{/each}</ul>
<label class="confirmation"><input type="checkbox" bind:checked={confirmRemovals} disabled={launching || unconfirmedLaunch || !currentReady} />Remove the {preview.removals.length} listed servers and apply this collection.</label> <label class="confirmation"><input type="checkbox" bind:checked={confirmRemovals} disabled={launching || unconfirmedLaunch || !currentReady} />Destroy the {preview.removals.length} listed servers and apply this collection.</label>
</section>{/if} </section>{/if}
<details class="details"> <details class="details">
@@ -19,7 +19,7 @@
try { try {
const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, { method: 'DELETE' }); const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, { method: 'DELETE' });
const body = await res.json(); const body = await res.json();
if (!res.ok || !body.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue server removal. Please retry.'); if (!res.ok || !body.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue server destruction. Please retry.');
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`); await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
open = false; open = false;
} catch (err) { error = (err as Error).message; } } catch (err) { error = (err as Error).message; }
@@ -27,17 +27,17 @@
} }
</script> </script>
<Dialog bind:open title={`Remove ${serverTitle(name)}?`} busy={submitting}> <Dialog bind:open title={`Destroy ${serverTitle(name)}?`} busy={submitting}>
<div class="fields"> <div class="fields">
<p class="notice">{lifecycleImpact('destroy', players, round)}</p> <p class="notice">{lifecycleImpact('destroy', players, round)}</p>
<p>This stops and removes the container, its writable server files, and registration. Shared game libraries, recorded history, and the saved server key are retained.</p> <p>This stops and removes the container, its writable server files, and registration. Shared game libraries, recorded history, and the saved server key are retained.</p>
<p>Follow progress or cancel the waiting request on the provisioning page.</p> <p>Follow progress or cancel the waiting request on the provisioning page.</p>
<p class="irreversible">You can cancel while waiting. Once removal begins, it cannot be undone.</p> <p class="irreversible">You can cancel while waiting. Once destruction begins, it cannot be undone.</p>
{#if error}<p role="alert" class="error">{error}</p>{/if} {#if error}<p role="alert" class="error">{error}</p>{/if}
</div> </div>
{#snippet actions()} {#snippet actions()}
<Button disabled={submitting} onclick={() => open = false}>Cancel</Button> <Button disabled={submitting} onclick={() => open = false}>Cancel</Button>
<Button variant="danger" disabled={submitting} loading={submitting} onclick={destroy}>Remove server when empty</Button> <Button variant="danger" disabled={submitting} loading={submitting} onclick={destroy}>Destroy server when empty</Button>
{/snippet} {/snippet}
</Dialog> </Dialog>
@@ -0,0 +1,142 @@
<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>
@@ -1,76 +0,0 @@
<script lang="ts">
import { serverTitle } from '$ui/server-labels.js';
import { isDialogBackdrop } from '$ui/dialog.js';
import Button from './primitives/Button.svelte';
let { open = $bindable(false), onqueued }: { open?: boolean; onqueued: (count: number) => void } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let servers = $state<string[] | null>(null);
let busy = $state(false);
let error = $state('');
let wasOpen = false;
$effect(() => {
if (!dialog) return;
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void review(); }
else if (!open && wasOpen) { wasOpen = false; dialog.close(); }
});
async function review() {
busy = true; error = ''; servers = null;
try {
const res = await fetch('/api/provisioning/house-party');
if (!res.ok) throw new Error('Could not load the fleet.');
servers = (await res.json()).servers;
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
async function launch() {
if (busy || !servers?.length) return;
busy = true; error = '';
try {
const res = await fetch('/api/provisioning/house-party', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers })
});
const body = await res.json();
if (!res.ok) {
if (res.status === 409) servers = null;
throw new Error(body.error ?? 'Could not queue server removal.');
}
open = false;
onqueued(body.deployments.length);
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
</script>
<dialog bind:this={dialog} aria-label="Remove fleet servers" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<div class="content">
<header><h2>Remove fleet servers?</h2></header>
<p>Queue removal of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
<p>Removal deletes containers, server files, and fleet registrations. Saved keys and collections are kept.</p>
<p>In Provisioning’s Active tab, cancel individual waiting requests or all waiting removals. Once a server begins removal, that removal cannot be cancelled or undone.</p>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if servers}
<strong>{servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to remove` : 'No servers to remove.'}</strong>
{#if servers.length}<ul>{#each servers as server}<li>{serverTitle(server)}</li>{/each}</ul><p class="caption">Existing removal requests are reused. Servers added after this request are not included.</p>{/if}
{:else}<p>{busy ? 'Reviewing the fleet…' : 'Refresh the preview to review the current fleet.'}</p>{/if}
<div class="actions">
<Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button>
{#if !servers}<Button disabled={busy} onclick={review}>Refresh preview</Button>
{:else}<Button variant="danger" loading={busy} disabled={busy || !servers.length} onclick={launch}>Remove {servers.length} server{servers.length === 1 ? '' : 's'}</Button>{/if}
</div>
</div>
</dialog>
<style>
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: var(--text-xl); }
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: var(--text-xs); }
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: var(--radius-md); }
li { padding: 5px 0; font-family: var(--font-body); font-size: var(--text-xs); overflow-wrap: anywhere; }
.actions { display: flex; gap: 12px; justify-content: flex-end; flex-wrap: wrap; }
</style>
+2 -2
View File
@@ -27,7 +27,7 @@
const impactPlayers = $derived(initialServer ? players : selected && health?.state === 'online' && freshServer(selected, $uiNow) ? selected.last_players : 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 impactRound = $derived(initialServer ? round : selected ? currentRound(selected, selected.telemetry, $uiNow) : null);
const title = $derived(initialServer ? `${actionLabels[action]} ${serverTitle(initialServer)}?` : 'Manage server'); const title = $derived(initialServer ? `${actionLabels[action]} ${serverTitle(initialServer)}?` : 'Manage server');
const submitLabel = $derived(action === 'start' ? 'Start server' : action === 'destroy' ? 'Remove server when empty' : `${actionLabels[action]} when empty`); const submitLabel = $derived(action === 'start' ? 'Start server' : action === 'destroy' ? 'Destroy server when empty' : `${actionLabels[action]} when empty`);
$effect(() => { $effect(() => {
if (!open) return; if (!open) return;
const fixedServer = initialServer; const fixedServer = initialServer;
@@ -92,7 +92,7 @@
{#if !server}<option value="start">Choose a server first</option>{/if} {#if !server}<option value="start">Choose a server first</option>{/if}
{#if server && !applicable}<option value={action} disabled>{actionLabels[action]} unavailable</option>{/if} {#if server && !applicable}<option value={action} disabled>{actionLabels[action]} unavailable</option>{/if}
{#each available as value}<option value={value}>{actionLabels[value]}</option>{/each} {#each available as value}<option value={value}>{actionLabels[value]}</option>{/each}
{#if server}<option value="destroy">Remove server</option>{/if} {#if server}<option value="destroy">Destroy server</option>{/if}
</select></label> </select></label>
{/if} {/if}
{#if loading}<p role="status">Checking the latest server status…</p> {#if loading}<p role="status">Checking the latest server status…</p>
+34
View File
@@ -0,0 +1,34 @@
import type { FleetServer } from '../fleet-overview.js';
import type { RuntimeAction } from '../provisioning.js';
import { applicableRuntimeActions } from './lifecycle-actions.js';
export const fleetActions = ['restart', 'start', 'stop', 'rebuild', 'destroy'] as const;
export type FleetAction = RuntimeAction | 'destroy';
export interface FleetActionFailure { server: string; error: string }
export interface FleetActionResult {
queued: { server: string; deploymentId: string }[];
failed: FleetActionFailure[];
}
export function fleetActionAvailable(server: FleetServer, action: FleetAction, now: number): boolean {
return action === 'destroy' || applicableRuntimeActions(server, now).includes(action);
}
/** Submit only the reviewed selection. Each server keeps its own durable job and result. */
export async function queueFleetAction(action: FleetAction, servers: readonly string[], request: typeof fetch = fetch): Promise<FleetActionResult> {
const result: FleetActionResult = { queued: [], failed: [] };
for (const server of new Set(servers)) {
try {
const response = action === 'destroy'
? await request(`/api/servers/${encodeURIComponent(server)}`, { method: 'DELETE' })
: await request('/api/provisioning', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server, action }) });
const body = await response.json();
if (!response.ok || !body.deploymentId) {
result.failed.push({ server, error: body.error ?? 'Could not queue this operation.' });
} else result.queued.push({ server, deploymentId: body.deploymentId });
} catch {
result.failed.push({ server, error: 'Could not confirm whether the request was queued. Check Provisioning before trying again.' });
}
}
return result;
}
+1 -1
View File
@@ -17,7 +17,7 @@ export function applicableRuntimeActions(server: FleetServer, now: number): Runt
/** Counts must come from fresh observations; null must never read as an empty lobby. */ /** Counts must come from fresh observations; null must never read as an empty lobby. */
export function lifecycleImpact(action: RuntimeAction | 'destroy', players: number | null, round: number | null): string { export function lifecycleImpact(action: RuntimeAction | 'destroy', players: number | null, round: number | null): string {
if (action === 'start') return 'Start the existing container, then verify that the game is responding.'; if (action === 'start') return 'Start the existing container, then verify that the game is responding.';
const label = action === 'destroy' ? 'Removal' : actionLabels[action]; const label = action === 'destroy' ? 'Destruction' : actionLabels[action];
if (players === null || !Number.isSafeInteger(players) || players < 0) { if (players === null || !Number.isSafeInteger(players) || players < 0) {
return `Player count is unknown. ${label} waits for a live check confirming the lobby is empty.`; return `Player count is unknown. ${label} waits for a live check confirming the lobby is empty.`;
} }
@@ -174,7 +174,7 @@
<div class="server-content"> <div class="server-content">
<div class="server-heading"><strong><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><Badge variant={needsAttention(server) ? 'danger' : server.state === 'done' ? 'online' : launching(server) ? 'info' : 'neutral'}>{stateLabel(server)}</Badge></div> <div class="server-heading"><strong><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><Badge variant={needsAttention(server) ? 'danger' : server.state === 'done' ? 'online' : launching(server) ? 'info' : 'neutral'}>{stateLabel(server)}</Badge></div>
<p class:error={!!server.error}>{server.error ?? (server.state === 'queued' ? queueHint(server) : server.state === 'done' ? server.action === 'destroy' ? server.stage : 'Startup checks passed.' : server.stage)}</p> <p class:error={!!server.error}>{server.error ?? (server.state === 'queued' ? queueHint(server) : server.state === 'done' ? server.action === 'destroy' ? server.stage : 'Startup checks passed.' : server.stage)}</p>
<div class="server-meta"><span>{server.action === 'destroy' ? 'Remove server' : server.port === null ? 'Create server · Port not assigned yet' : `Create server · Port ${server.port}`}</span><span>{needsAttention(server) ? 'Review & recover' : 'View operation'} <span aria-hidden="true">→</span></span></div> <div class="server-meta"><span>{server.action === 'destroy' ? 'Destroy server' : server.port === null ? 'Create server · Port not assigned yet' : `Create server · Port ${server.port}`}</span><span>{needsAttention(server) ? 'Review & recover' : 'View operation'} <span aria-hidden="true">→</span></span></div>
</div> </div>
</a> </a>
{#if server.cancellation} {#if server.cancellation}
+7 -12
View File
@@ -11,7 +11,7 @@
import Badge from '$ui/components/primitives/Badge.svelte'; import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte'; import FleetActionsDialog from '$ui/components/FleetActionsDialog.svelte';
import Freshness from '$ui/components/Freshness.svelte'; import Freshness from '$ui/components/Freshness.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
@@ -35,8 +35,7 @@
let historyRefresh = $state(0); let historyRefresh = $state(0);
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
let housePartyOpen = $state(false); let fleetNotice = $state('');
let housePartyNotice = $state('');
let tab = $state('active'); let tab = $state('active');
let limit = $state(25); let limit = $state(25);
let offset = $state(0); let offset = $state(0);
@@ -75,7 +74,6 @@
if (!await confirmAction({ title: bulk ? `Cancel all ${ids.length} active operations?` : 'Cancel operation?', if (!await confirmAction({ title: bulk ? `Cancel all ${ids.length} active operations?` : 'Cancel operation?',
message: `${bulk ? `This affects all ${ids.length} cancellable active operations across the fleet, regardless of the current filters.` : `Cancel ${actionLabels[rows[0].action].toLowerCase()} on ${serverTitle(rows[0].server)}.`} Cancelling stops remaining work and health checks. Changes already made are not undone; commands already delivered to a host may still finish.`, message: `${bulk ? `This affects all ${ids.length} cancellable active operations across the fleet, regardless of the current filters.` : `Cancel ${actionLabels[rows[0].action].toLowerCase()} on ${serverTitle(rows[0].server)}.`} Cancelling stops remaining work and health checks. Changes already made are not undone; commands already delivered to a host may still finish.`,
confirmLabel: bulk ? 'Cancel all operations' : 'Cancel operation', danger: true })) return; confirmLabel: bulk ? 'Cancel all operations' : 'Cancel operation', danger: true })) return;
fleetActionsOpen = false;
cancelling = true; cancellationNotice = ''; let cancelled = 0; const unavailable: string[] = []; cancelling = true; cancellationNotice = ''; let cancelled = 0; const unavailable: string[] = [];
for (const id of ids) { for (const id of ids) {
try { const response = await fetch(`/api/provisioning/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); if (response.ok) cancelled++; else unavailable.push(id); } try { const response = await fetch(`/api/provisioning/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); if (response.ok) cancelled++; else unavailable.push(id); }
@@ -168,10 +166,7 @@
<SectionHeader title="Provisioning"> <SectionHeader title="Provisioning">
{#snippet actions()} {#snippet actions()}
<Button onclick={() => lifecycleOpen = true}>Server actions</Button> <Button onclick={() => lifecycleOpen = true}>Server actions</Button>
<details class="fleet-actions" bind:open={fleetActionsOpen}><summary>Fleet actions</summary><div class="fleet-menu"> <Button onclick={() => fleetActionsOpen = true}>Fleet actions</Button>
{#if active.some(row => row.canCancel)}<Button disabled={cancelling} onclick={() => cancelRequests(active.filter(row => row.canCancel && !row.server_deleted_at), true)}>Cancel all active operations</Button>{/if}
<Button variant="danger" onclick={() => { fleetActionsOpen = false; housePartyOpen = true; }}>Remove fleet servers…</Button>
</div></details>
{/snippet} {/snippet}
</SectionHeader> </SectionHeader>
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/" />{/if} {#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/" />{/if}
@@ -184,8 +179,8 @@
{#if filtered}<Button variant="ghost" onclick={clearFilters}>Clear filters</Button>{/if} {#if filtered}<Button variant="ghost" onclick={clearFilters}>Clear filters</Button>{/if}
</div> </div>
<LifecycleDialog bind:open={lifecycleOpen} /> <LifecycleDialog bind:open={lifecycleOpen} />
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Removal queued for ${count} server${count === 1 ? '' : 's'}.`; tab = 'active'; clearFilters(); void load(); }} /> <FleetActionsDialog bind:open={fleetActionsOpen} onqueued={(count, action) => { fleetNotice = `${actionLabels[action]} queued for ${count} server${count === 1 ? '' : 's'}.`; tab = 'active'; clearFilters(); void load(); }} />
{#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if} {#if fleetNotice}<p role="status">{fleetNotice}</p>{/if}
{#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if} {#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
<div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels"> <div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels">
{#if error && tab === 'active'}<p role="alert" class="error">{error} {#if updatedAt}Showing the last loaded operations.{/if}</p>{/if} {#if error && tab === 'active'}<p role="alert" class="error">{error} {#if updatedAt}Showing the last loaded operations.{/if}</p>{/if}
@@ -193,6 +188,7 @@
{#if tab === 'active'}<p class="queue-summary"><strong>{filtered ? `${visibleActive.length} of ${active.length}` : active.length} active</strong>{#if queueSummary}<span> · {queueSummary}</span>{/if}</p> {#if tab === 'active'}<p class="queue-summary"><strong>{filtered ? `${visibleActive.length} of ${active.length}` : active.length} active</strong>{#if queueSummary}<span> · {queueSummary}</span>{/if}</p>
{:else}<h2>History <span>{historyTotal}</span></h2>{/if} {:else}<h2>History <span>{historyTotal}</span></h2>{/if}
<div class="queue-tools"> <div class="queue-tools">
{#if tab === 'active' && active.some(row => row.canCancel)}<Button size="sm" disabled={cancelling} onclick={() => cancelRequests(active.filter(row => row.canCancel && !row.server_deleted_at), true)}>Cancel all active operations</Button>{/if}
{#if needsReview}<Button size="sm" variant="ghost" onclick={reviewAttention}>Needs attention ({needsReview})</Button>{/if} {#if needsReview}<Button size="sm" variant="ghost" onclick={reviewAttention}>Needs attention ({needsReview})</Button>{/if}
{#if tab === 'active'}<Freshness {updatedAt} error={error ? 'Operation updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void load()} retryDisabled={refreshing} /> {#if tab === 'active'}<Freshness {updatedAt} error={error ? 'Operation updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void load()} retryDisabled={refreshing} />
{:else}<Freshness updatedAt={historyUpdatedAt} error={historyError ? 'History updates interrupted.' : ''} intervalLabel="Updates every 15 seconds" onRetry={() => historyRefresh++} retryDisabled={historyLoading} />{/if} {:else}<Freshness updatedAt={historyUpdatedAt} error={historyError ? 'History updates interrupted.' : ''} intervalLabel="Updates every 15 seconds" onRetry={() => historyRefresh++} retryDisabled={historyLoading} />{/if}
@@ -263,6 +259,5 @@
.detail-row { background:var(--surface); }.operation-details { display:grid; gap:16px; line-height:1.6; overflow-wrap:anywhere; } .detail-row { background:var(--surface); }.operation-details { display:grid; gap:16px; line-height:1.6; overflow-wrap:anywhere; }
dl { display:flex; flex-wrap:wrap; gap:12px 32px; margin:0; }dt { color:var(--text-tertiary); font-size:var(--text-xs); }dd { margin:0; }.mono { font-family:var(--font-mono); } dl { display:flex; flex-wrap:wrap; gap:12px 32px; margin:0; }dt { color:var(--text-tertiary); font-size:var(--text-xs); }dd { margin:0; }.mono { font-family:var(--font-mono); }
.detail-links { display:flex; align-items:center; flex-wrap:wrap; gap:16px; }.error { color:var(--state-danger); }.warning { color:var(--state-warning); } .detail-links { display:flex; align-items:center; flex-wrap:wrap; gap:16px; }.error { color:var(--state-danger); }.warning { color:var(--state-warning); }
.fleet-actions { position:relative; font-size:var(--text-sm); }.fleet-actions summary { padding:10px; cursor:pointer; }.fleet-menu { position:absolute; right:0; top:100%; z-index:10; display:grid; gap:8px; padding:12px; background:var(--surface-overlay); border:1px solid var(--border-strong); border-radius:var(--radius-md); min-width:250px; box-shadow:var(--shadow-raised); } @media(max-width:720px) { table { min-width:820px; }.search { flex-basis:100%; }th,td { padding:12px 8px; }}
@media(max-width:720px) { table { min-width:820px; }.search { flex-basis:100%; }th,td { padding:12px 8px; }.fleet-menu { right:auto; left:0; } }
</style> </style>
+1 -1
View File
@@ -231,7 +231,7 @@
{#snippet actions()} {#snippet actions()}
{#if canOperate} {#if canOperate}
{#if primaryAction}<Button onclick={() => openAction(primaryAction)}>{actionLabels[primaryAction]} server</Button>{/if} {#if primaryAction}<Button onclick={() => openAction(primaryAction)}>{actionLabels[primaryAction]} server</Button>{/if}
<details class="server-actions"><summary>More actions</summary><div class="action-menu">{#each availableActions.filter(action => action !== primaryAction) as action}<Button onclick={() => openAction(action)}>{actionLabels[action]} server</Button>{/each}<Button variant="danger" onclick={() => destroyOpen = true}>Remove server</Button></div></details> <details class="server-actions"><summary>More actions</summary><div class="action-menu">{#each availableActions.filter(action => action !== primaryAction) as action}<Button onclick={() => openAction(action)}>{actionLabels[action]} server</Button>{/each}<Button variant="danger" onclick={() => destroyOpen = true}>Destroy server</Button></div></details>
{/if} {/if}
{/snippet} {/snippet}
</SectionHeader> </SectionHeader>
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import type { FleetServer } from '../../src/lib/fleet-overview.js';
import { fleetActionAvailable, queueFleetAction } from '../../src/lib/ui/fleet-actions.js';
import FleetActionsDialog from '../../src/lib/ui/components/FleetActionsDialog.svelte';
const now = Date.parse('2026-10-07T12:00:00Z');
const at = new Date(now - 1000).toISOString();
const server: FleetServer = {
name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater',
last_gametype: 'zclassic', last_players: 4, last_seen_at: new Date(now - 10_000).toISOString(), last_error: null,
container_state: 'running', container_checked_at: at, last_checked_at: at,
roster: [], health: 'online', held: [], active_since: null, inactive_since: null,
game_started_at: null, game_ended_at: null, host_id: 'host-1',
host: { id: 'host-1', name: 'Dallas 1', connected: true, connectionState: 'online', lastHeartbeat: at }
};
describe('fleet actions', () => {
it('uses the single-server eligibility rules and permits queued destruction with unknown status', () => {
expect(fleetActionAvailable(server, 'restart', now)).toBe(true);
expect(fleetActionAvailable(server, 'start', now)).toBe(false);
const stopped = { ...server, health: 'offline' as const, container_state: 'stopped' };
expect(fleetActionAvailable(stopped, 'start', now)).toBe(true);
expect(fleetActionAvailable(stopped, 'restart', now)).toBe(false);
expect(fleetActionAvailable(server, 'stop', now + 300_000)).toBe(false);
expect(fleetActionAvailable({ ...server, host: null }, 'rebuild', now)).toBe(false);
expect(fleetActionAvailable({ ...server, host: null }, 'destroy', now)).toBe(true);
});
it.each(['restart', 'start', 'stop', 'rebuild'] as const)('queues %s only for selected servers and deduplicates selections', async action => {
const request = vi.fn().mockImplementation(async () => Response.json({ deploymentId: 'job-1' }, { status: 202 }));
const result = await queueFleetAction(action, [server.name, 'second', server.name], request);
expect(request.mock.calls.map(([url, options]) => [url, options.method, JSON.parse(options.body)])).toEqual([
['/api/provisioning', 'POST', { server: server.name, action }],
['/api/provisioning', 'POST', { server: 'second', action }]
]);
expect(result.queued.map(item => item.server)).toEqual([server.name, 'second']);
expect(result.failed).toEqual([]);
});
it('destroys only the chosen servers through the existing per-server endpoint', async () => {
const request = vi.fn().mockResolvedValue(Response.json({ deploymentId: 'destroy-1' }, { status: 202 }));
await queueFleetAction('destroy', ['server/a'], request);
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith('/api/servers/server%2Fa', { method: 'DELETE' });
});
it('reports partial failures and continues without retrying successful requests', async () => {
const request = vi.fn()
.mockResolvedValueOnce(Response.json({ error: 'Another job is pending.' }, { status: 409 }))
.mockResolvedValueOnce(Response.json({ deploymentId: 'job-2' }, { status: 202 }))
.mockRejectedValueOnce(new Error('offline'));
const result = await queueFleetAction('restart', ['blocked', 'ready', 'uncertain'], request);
expect(result.queued).toEqual([{ server: 'ready', deploymentId: 'job-2' }]);
expect(result.failed).toEqual([
{ server: 'blocked', error: 'Another job is pending.' },
{ server: 'uncertain', error: expect.stringContaining('Check Provisioning before trying again') }
]);
expect(request).toHaveBeenCalledTimes(3);
});
it('does not report missing job IDs as successfully queued or submit an empty selection', async () => {
const request = vi.fn().mockResolvedValue(Response.json({}, { status: 202 }));
expect((await queueFleetAction('stop', ['one'], request)).queued).toEqual([]);
request.mockClear();
expect(await queueFleetAction('stop', [], request)).toEqual({ queued: [], failed: [] });
expect(request).not.toHaveBeenCalled();
});
it('renders a named dialog with all five actions and a disabled empty selection', () => {
const body = render(FleetActionsDialog, { props: { open: true, onqueued: vi.fn() } }).body;
for (const action of ['Restart', 'Start', 'Stop', 'Rebuild', 'Destroy']) expect(body).toContain(`>${action}</option>`);
expect(body).toContain('Fleet actions');
expect(body).toContain('Select all eligible');
expect(body).toContain('0 selected');
const submit = [...body.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].find(match => match[0].includes('Restart 0 servers'))?.[0];
expect(submit).toContain('disabled');
});
});
+6 -6
View File
@@ -59,7 +59,7 @@ describe('lifecycle impact copy', () => {
it('does not confuse unknown or invalid player counts with an empty lobby', () => { it('does not confuse unknown or invalid player counts with an empty lobby', () => {
for (const players of [null, -1, NaN, 1.5]) expect(lifecycleImpact('destroy', players, 30)) for (const players of [null, -1, NaN, 1.5]) expect(lifecycleImpact('destroy', players, 30))
.toBe('Player count is unknown. Removal waits for a live check confirming the lobby is empty.'); .toBe('Player count is unknown. Destruction waits for a live check confirming the lobby is empty.');
expect(lifecycleImpact('rebuild', 0, 30)).toBe('The lobby is currently empty. A live check will confirm this before rebuild begins.'); expect(lifecycleImpact('rebuild', 0, 30)).toBe('The lobby is currently empty. A live check will confirm this before rebuild begins.');
}); });
@@ -89,14 +89,14 @@ describe('contextual lifecycle confirmation', () => {
expect(body).not.toContain('Start the existing container'); expect(body).not.toContain('Start the existing container');
}); });
it('uses Remove server consistently and preserves recorded history in the explanation', () => { it('uses Destroy server consistently and preserves recorded history in the explanation', () => {
const body = render(DestroyServerModal, { props: { open: true, name: server.name, players: 4, round: 30 } }).body; const body = render(DestroyServerModal, { props: { open: true, name: server.name, players: 4, round: 30 } }).body;
expect(body).toContain('Remove Kino Der Toten · Lobby 1?'); expect(body).toContain('Destroy Kino Der Toten · Lobby 1?');
expect(body).toContain('4 players are in round 30.'); expect(body).toContain('4 players are in round 30.');
expect(body).toContain('Remove server when empty'); expect(body).toContain('Destroy server when empty');
expect(body).toContain('recorded history'); expect(body).toContain('recorded history');
expect(body).not.toContain('campaign details'); expect(body).not.toContain('campaign details');
expect(actionLabels.destroy).toBe('Remove'); expect(actionLabels.destroy).toBe('Destroy');
expect(actionCompleted.destroy).toBe('Server removed'); expect(actionCompleted.destroy).toBe('Server destroyed');
}); });
}); });