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

340 lines
25 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { goto } from '$app/navigation';
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
import NativeHostSelection from './NativeHostSelection.svelte';
import { createCollectionLaunchController, type CollectionLaunchState } from '$ui/collection-launch.js';
import { collectionMapGroups, collectionKeyCoverage, collectionCapacitySummary } from '$ui/collection-summary.js';
import { serverTitle } from '$ui/server-labels.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
import type { NativeDeploymentSelection } from '$lib/hosts.js';
import type { ZombieGame } from '$lib/map-aliases.js';
import type { BlueprintSummary, CollectionMode } from '$lib/deployment-plan.js';
let { open = $bindable(false), blueprintId = '' }: { open?: boolean; blueprintId?: string } = $props();
const id = $props.id();
let mode = $state<CollectionMode>('fill');
let modeInput = $state<HTMLInputElement>();
let games = $state<ZombieGame[]>([]);
let collectionName = $state('Launch collection');
let selection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
let hostReady = $state(false);
let hostStatus = $state<NativeHostSelectionStatus>({ loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null });
let confirmRemovals = $state(false);
let view = $state<CollectionLaunchState>({ preview: null, checking: false, error: '', changeNotice: '' });
let settledId: string | undefined;
const controller = createCollectionLaunchController(state => {
if (state.checking || state.preview?.id !== settledId) confirmRemovals = false;
settledId = state.preview?.id;
view = state;
});
let loaded = $state(false);
let loadingError = $state('');
let launchError = $state('');
let launching = $state(false);
let unconfirmedLaunch = $state(false);
let refreshVersion = $state(0);
let now = $state(Date.now());
let wasOpen = false;
let activeBlueprint = '';
let visit = 0;
let initialization: AbortController | undefined;
let submission: AbortController | undefined;
const preview = $derived(view.preview);
const mapGroups = $derived(preview ? collectionMapGroups(preview) : []);
const keyCoverage = $derived(preview ? collectionKeyCoverage(preview) : []);
const missingKeys = $derived(keyCoverage.filter(pool => pool.available < pool.required - pool.unassigned));
const hasChanges = $derived(!!preview && (preview.servers.length > 0 || preview.removals.length > 0));
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
const checking = $derived((!loaded && !loadingError) || (loaded && (hostStatus.loading || view.checking)));
const imageValid = $derived(/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image));
const currentReady = $derived(!!preview && preview.mode === mode && hostReady && !hostStatus.loading && !view.checking && !expired);
const noChanges = $derived(currentReady && preview?.ready && !hasChanges);
const canLaunch = $derived(!launching && !!preview && (unconfirmedLaunch || (currentReady && preview.ready && hasChanges
&& preview.servers.every(server => server.name && server.hasSavedKey) && (!preview.removals.length || confirmRemovals))));
const footerStatus = $derived(launching ? 'Queueing changes…' : unconfirmedLaunch ? 'Retry to confirm this launch.'
: loadingError ? 'Could not load collection.' : checking ? 'Checking fleet changes…' : noChanges ? 'Collection targets met'
: missingKeys.length ? 'More game keys needed.' : currentReady && preview?.ready && preview.removals.length && !confirmRemovals
? 'Confirm the listed removals.' : canLaunch ? 'Ready to queue' : 'Launch unavailable');
const actionLabel = $derived(unconfirmedLaunch ? 'Retry launch' : preview?.removals.length
? `Create ${preview.servers.length} · Remove ${preview.removals.length}`
: `Queue ${preview?.servers.length ?? 0} server${preview?.servers.length === 1 ? '' : 's'}`);
$effect(() => {
if (open && (!wasOpen || activeBlueprint !== blueprintId)) {
wasOpen = true; activeBlueprint = blueprintId; void initialize();
} else if (!open && wasOpen) {
wasOpen = false; visit++; initialization?.abort(); controller.clear();
}
});
$effect(() => {
const input = open && loaded && hostReady && !hostStatus.loading ? {
blueprintId, mode, image: selection.image,
libraryIds: Object.fromEntries(games.map(game => [game, selection.libraryIds[game]]))
} : null;
if (!launching && !unconfirmedLaunch) controller.select(input);
});
async function initialize() {
const current = ++visit;
initialization?.abort(); initialization = new AbortController();
const signal = initialization.signal;
controller.clear(); loaded = false; loadingError = ''; launchError = ''; launching = false; unconfirmedLaunch = false;
mode = 'fill'; games = []; collectionName = 'Launch collection'; confirmRemovals = false;
selection = { libraryIds: {}, image: '' }; hostReady = false;
hostStatus = { loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null };
try {
const response = await fetch('/api/blueprints', { signal });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load collection.');
if (current !== visit) return;
const blueprint = (body.blueprints as BlueprintSummary[]).find(item => item.id === blueprintId);
if (!blueprint) throw new Error('This collection is no longer available.');
games = [...new Set(blueprint.entries.map(entry => entry.game))]; collectionName = blueprint.name;
loaded = true;
await tick();
if (current === visit && open) modeInput?.focus();
} catch (error) {
if (current === visit && !signal.aborted) loadingError = error instanceof Error ? error.message : 'Could not load collection.';
}
}
function selectionChanged() { launchError = ''; confirmRemovals = false; }
function refreshChecks() {
if (!open || launching || unconfirmedLaunch) return;
if (!loaded) { void initialize(); return; }
launchError = ''; confirmRemovals = false;
controller.select(null); refreshVersion++;
}
onMount(() => {
let returning: ReturnType<typeof setTimeout>;
const refreshOnReturn = () => {
clearTimeout(returning);
if (document.visibilityState !== 'hidden') returning = setTimeout(refreshChecks, 100);
};
window.addEventListener('focus', refreshOnReturn);
document.addEventListener('visibilitychange', refreshOnReturn);
const timer = setInterval(() => { now = Date.now(); }, 1000);
return () => {
clearTimeout(returning); clearInterval(timer);
window.removeEventListener('focus', refreshOnReturn);
document.removeEventListener('visibilitychange', refreshOnReturn);
};
});
onDestroy(() => { visit++; initialization?.abort(); submission?.abort(); controller.dispose(); });
async function launch() {
if (!canLaunch || !preview) return;
const current = visit;
const reviewed = preview;
controller.suspend(); launching = true; launchError = '';
submission = new AbortController();
try {
const response = await fetch('/api/deployment-collections', { method: 'POST', signal: submission.signal,
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: reviewed.id }) });
const body = await response.json();
if (current !== visit) return;
if (!response.ok) {
unconfirmedLaunch = response.status >= 500;
launchError = body.error ?? 'Could not queue this collection.';
if (!unconfirmedLaunch) { controller.select(null); refreshVersion++; }
return;
}
if (!body.ok) throw new Error('The launch response could not be confirmed.');
open = false;
if (body.collectionId) await goto(`/collections/launches/${encodeURIComponent(body.collectionId)}`);
} catch {
if (current === visit) {
// The queue may already be committed. Keep its preview ID for an idempotent retry.
unconfirmedLaunch = true;
launchError = 'Could not confirm the launch. Retry the same request, or check collection launches.';
}
} finally { if (current === visit) launching = false; }
}
</script>
<Dialog bind:open title={collectionName} description="Launch collection" wide busy={launching}>
<div class="launch-content">
{#if loadingError}
<p class="issue" role="alert">{loadingError}</p>
<button class="text-button" onclick={refreshChecks}>Retry checks</button>
{:else if !loaded}
<p class="muted" role="status">Loading collection…</p>
{:else}
<div class="mode-selection">
<fieldset disabled={launching || unconfirmedLaunch}>
<legend class="sr-only">Apply collection</legend>
<label class:selected={mode === 'fill'}><input bind:this={modeInput} type="radio" name={`${id}-collection-mode`} value="fill" bind:group={mode} onchange={selectionChanged} />Fill missing</label>
<label class:selected={mode === 'exact'}><input type="radio" name={`${id}-collection-mode`} value="exact" bind:group={mode} onchange={selectionChanged} />Match exactly</label>
</fieldset>
<p class="muted">{mode === 'exact' ? 'Match the entire fleet across both games. Excess lobbies and maps outside this collection will be removed.' : 'Create missing lobbies. All existing servers and their settings stay as they are.'}</p>
</div>
<section class="change-summary" aria-label="Fleet changes" aria-busy={checking}>
{#if preview}
<strong>{noChanges ? 'Collection targets met' : `${preview.servers.length} to create · ${preview.removals.length} to remove`}</strong>
{#if mode === 'exact' && preview.retainedServers.length}<p class="muted">{preview.retainedServers.length} existing or queued servers kept.</p>{/if}
<div class="readiness">
{#each keyCoverage as pool}<span class:ready={pool.available >= pool.required} class:warning={pool.available < pool.required}>{pool.game.toUpperCase()} keys: {pool.unassigned ? 'waiting for lobby availability' : `${pool.available} of ${pool.required} required available`}</span>{/each}
{#if preview.servers.length}<span class:ready={hostStatus.filesReady}>{hostStatus.filesReady ? 'Game files ready' : 'Game files preparing'}</span>{/if}
</div>
{#if preview.servers.length}<p class="muted">{collectionCapacitySummary(preview.availability)}</p>{/if}
{#if noChanges}<p class="muted">{mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet meets or exceeds every target.'} Existing servers keep their current settings.</p>{/if}
{:else}<p role="status">{checking ? 'Checking fleet changes…' : 'Resolve the issues below to check this collection.'}</p>{/if}
</section>
{#if launchError}<div class="issue" role="alert"><p>{launchError}</p>{#if unconfirmedLaunch}<a href="/collections?view=active" target="_blank" rel="noopener">View collection launches ↗</a>{/if}</div>{/if}
{#if view.changeNotice}<p class="issue" role="status">{view.changeNotice}</p>{/if}
{#if hostStatus.error}<p class="issue" role="alert">{hostStatus.error}</p>
{:else if !hostStatus.loading && hostStatus.missingGames.length}<div class="issue" role="status"><p>Upload {hostStatus.missingGames.map(game => game.toUpperCase()).join(' and ')} game files before launching.</p><a href="/game-files" target="_blank" rel="noopener">Manage game files ↗</a></div>
{:else if !hostStatus.loading && !imageValid}<p class="issue" role="status">Enter a valid runtime image in Advanced settings.</p>
{:else if view.error}<p class="issue" role="alert">{view.error}</p>{/if}
{#if missingKeys.length}<div class="issue" role="status">
{#each missingKeys as pool}{@const shortfall = pool.required - pool.unassigned - pool.available}<p>{pool.game.toUpperCase()} needs {shortfall} more unused key{shortfall === 1 ? '' : 's'}. <a href={`/server-keys?game=${pool.game}`} target="_blank" rel="noopener">Manage keys ↗</a></p>{/each}
{#if preview?.removals.length}<p>Keys assigned to removals stay in use until cleanup finishes. Complete those removals first to reuse their keys.</p>{/if}
</div>{/if}
{#each preview?.blockers ?? [] as blocker}<p class="issue" role="alert">{blocker}</p>{/each}
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKeys.length || preview?.blockers.length)}<button class="text-button" disabled={launching} onclick={refreshChecks}>Retry checks</button>{/if}
{#if preview}
<section class="lineup" aria-label="Collection map targets">
{#each mapGroups as group}
<table>
<caption>{group.label}</caption>
<thead><tr><th scope="col">Map</th><th scope="col">Target</th><th scope="col">Existing / queued</th><th scope="col">Create</th>{#if mode === 'exact'}<th scope="col">Remove</th>{/if}</tr></thead>
<tbody>{#each group.rows as row}<tr><th scope="row">{row.mapName}</th><td>{row.target}</td><td>{row.existing}</td><td class:quantity={row.create > 0}>{row.create}</td>{#if mode === 'exact'}<td class:danger={row.remove > 0}>{row.remove}</td>{/if}</tr>{/each}</tbody>
</table>
{/each}
<p class="muted">Stopped and unhealthy servers also count toward targets. See Server details for health.{#if mode === 'exact'} Existing totals include the listed removals.{/if}</p>
</section>
{#if preview.removals.length}<section class="removals" aria-label="Servers to remove">
<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>
<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} />Destroy the {preview.removals.length} listed servers and apply this collection.</label>
</section>{/if}
<details class="details">
<summary>Server details</summary>
<div class="detail-body">
{#if preview.servers.length}<h3>Launch order</h3><p class="muted">Hosts and ports are assigned when provisioning starts.</p>
<ol class="server-list">{#each preview.servers as server}<li><strong>{server.name ? serverTitle(server.name) : preview.entries[server.entryIndex].recipe.mapName}</strong><code>{server.name ?? 'No available lobby'}</code></li>{/each}</ol>
{/if}
{#if preview.entries.some(entry => entry.existing)}<h3>Existing and queued servers</h3>
<ul class="existing-list">{#each preview.entries as entry}{#each entry.coverage ?? [] as server}<li><a href={server.href} target="_blank" rel="noopener">{serverTitle(server.name)} ↗</a><span>{server.health === 'online' ? 'Healthy' : server.health}{#if mode === 'exact'} · {preview.retainedServers.includes(server.name) ? 'Keep' : 'Remove'}{/if}</span></li>{/each}{/each}</ul>
{/if}
{#if !preview.servers.length && !preview.entries.some(entry => entry.existing)}<p class="muted">No new or matching servers.</p>{/if}
</div>
</details>
{#if preview.availability && preview.servers.length}<details class="details">
<summary>Capacity details</summary>
<div class="detail-body">
<dl class="capacity">
<div><dt>CPU available</dt><dd>{preview.availability.cpuCoresAvailable === null ? 'Unknown' : `${preview.availability.cpuCoresAvailable.toFixed(1)} cores`}</dd></div>
<div><dt>Memory available</dt><dd>{preview.availability.memoryBytesAvailable === null ? 'Unknown' : `${(preview.availability.memoryBytesAvailable / 1024 ** 3).toFixed(1)} GiB`}</dd></div>
<div><dt>Ports available</dt><dd>{preview.availability.portsAvailable ?? 'Unknown'}</dd></div>
<div><dt>Startup slots</dt><dd>{preview.availability.startupSlots ?? 'Unknown'}{#if preview.availability.startupLimit !== null} / {preview.availability.startupLimit}{/if}</dd></div>
</dl>
{#each preview.warnings ?? [] as warning}<p class="muted">{warning}</p>{/each}
<p class="muted">{preview.availability.updatedAt ? `Updated ${$timeFormat.dateTime(preview.availability.updatedAt)}.` : 'Waiting for host reports.'} Capacity is checked for each server when it starts.</p>
<a href="/capacity" target="_blank" rel="noopener">View capacity and history ↗</a>
</div>
</details>{/if}
{/if}
{#if open}<NativeHostSelection {games} bind:selection bind:ready={hostReady} bind:status={hostStatus}
compact {refreshVersion} disabled={launching || unconfirmedLaunch} onchange={selectionChanged} />{/if}
{#if preview}<details class="details">
<summary>Setup details</summary>
<div class="detail-body">
<p class="muted">New servers use the current production setup when provisioning starts. Existing servers keep their files and configuration.</p>
<dl><div><dt>Runtime image</dt><dd><code>{selection.image}</code></dd></div>
{#each games as game}{@const files = hostStatus.gameFiles?.[game]}
{#if files?.uploadedAt}<div><dt>{game.toUpperCase()} game files uploaded</dt><dd>{$timeFormat.dateTime(files.uploadedAt)}</dd></div>{/if}
{/each}
</dl>
{#each preview.entries as entry}<details class="map-setup">
<summary>{entry.recipe.mapName} · {entry.recipe.game.toUpperCase()}</summary>
<div class="detail-body"><p>{entry.recipe.configSource}</p><h3>{entry.recipe.files.length} managed files</h3>
<ul class="file-list">{#each entry.recipe.files as file}<li><strong>{file.name}</strong><span>{file.version === null ? 'Generated' : `v${file.version}`} · {file.kind}</span><code>{file.path}</code></li>{/each}</ul>
</div>
</details>{/each}
</div>
</details>{/if}
{/if}
</div>
{#snippet actions()}
<div class="launch-actions">
<p class:ready={canLaunch && !unconfirmedLaunch || noChanges} role="status">{footerStatus}</p>
<div>{#if noChanges}<Button variant="primary" onclick={() => open = false}>Done</Button>
{:else}<Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant={preview?.removals.length ? 'danger' : 'primary'} loading={launching} disabled={!canLaunch} onclick={launch}>{actionLabel}</Button>{/if}
</div>
</div>
{/snippet}
</Dialog>
<style>
.launch-content { display: grid; gap: 16px; min-width: 0; }
.mode-selection { display: grid; gap: 9px; }
fieldset { display: flex; gap: 8px; border: 0; padding: 0; margin: 0; }
fieldset label { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border: 1px solid var(--border-control); border-radius: var(--radius-md); font-size: var(--text-sm); cursor: pointer; }
fieldset label.selected { background: var(--surface-raised); border-color: var(--accent-primary); }
fieldset input { margin: 0; }
.change-summary { display: grid; gap: 8px; padding: 14px 16px; background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.change-summary > strong { font-size: var(--text-md); line-height: 1.4; }
.readiness { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--text-secondary); font-size: var(--text-xs); }
.readiness:empty { display: none; }
p { margin: 0; font-size: var(--text-sm); line-height: 1.5; }
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
.ready, .quantity { color: var(--state-online); }
.warning { color: var(--state-warning); }
.danger { color: var(--state-danger); }
.issue { display: grid; gap: 8px; padding: 12px; border: 1px solid color-mix(in srgb, var(--state-warning) 40%, transparent); border-radius: var(--radius-md); color: var(--state-warning); font-size: var(--text-sm); }
a, .text-button { color: var(--accent-primary-hover); font-size: var(--text-sm); }
.text-button { justify-self: start; border: 0; padding: 0; background: none; text-align: left; cursor: pointer; }
.lineup { display: grid; gap: 10px; min-width: 0; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: var(--text-xs); }
caption { text-align: left; color: var(--text-secondary); font-size: var(--text-xs); font-weight: 600; padding-bottom: 8px; }
th, td { padding: 8px 6px; text-align: center; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
th:first-child { width: 40%; text-align: left; }
thead th { color: var(--text-secondary); font-weight: 500; }
tbody th { font-weight: 500; }
td { font-variant-numeric: tabular-nums; }
.quantity, .danger { font-weight: 600; }
.details { border-top: 1px solid var(--border-subtle); padding-top: 14px; min-width: 0; }
summary { cursor: pointer; font-size: var(--text-sm); }
.detail-body { display: grid; gap: 12px; margin-top: 14px; min-width: 0; }
h3 { font-size: var(--text-sm); margin: 0; }
dl { display: grid; gap: 14px; margin: 0; }
dt { color: var(--text-secondary); font-size: var(--text-xs); }
dd { margin: 5px 0 0; overflow-wrap: anywhere; font-size: var(--text-sm); }
code { font-family: var(--font-mono); font-size: var(--text-xs); overflow-wrap: anywhere; }
.capacity { grid-template-columns: repeat(4, 1fr); }
.server-list { margin: 0; padding-left: 24px; }
.server-list li { padding: 8px 0; font-size: var(--text-sm); }
.server-list code { display: block; margin-top: 4px; color: var(--text-secondary); }
.existing-list, .file-list, .removals ul { list-style: none; padding: 0; margin: 0; }
.existing-list li { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 6px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); }
.existing-list span, .file-list span { color: var(--text-secondary); font-size: var(--text-xs); }
.file-list li { display: grid; gap: 5px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); font-size: var(--text-sm); }
.map-setup { padding: 10px 0; border-top: 1px solid var(--border-subtle); }
.removals { display: grid; gap: 12px; border: 1px solid var(--state-danger); border-radius: var(--radius-md); padding: 14px; }
.removals h3 { color: var(--state-danger); }
.removals p, .removals li span { color: var(--text-secondary); font-size: var(--text-xs); }
.removals li { display: grid; gap: 4px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); font-size: var(--text-sm); }
.removals code { color: var(--text-secondary); }
.confirmation { display: flex; align-items: flex-start; gap: 9px; font-size: var(--text-sm); line-height: 1.5; }
.confirmation input { margin: 3px 0 0; flex-shrink: 0; }
.launch-actions { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.launch-actions > p { color: var(--text-secondary); font-size: var(--text-xs); }
.launch-actions > p.ready { color: var(--state-online); }
.launch-actions > div { display: flex; align-items: center; gap: 8px; }
@media (max-width: 600px) { .capacity { grid-template-columns: repeat(2, 1fr); } th:first-child { width: 34%; } th, td { padding: 8px 3px; } }
@media (max-width: 440px) { fieldset label { flex: 1; padding: 10px; font-size: var(--text-xs); } .launch-actions { align-items: stretch; flex-direction: column; } .launch-actions > div { justify-content: flex-end; } }
</style>