194 lines
22 KiB
Svelte
194 lines
22 KiB
Svelte
<script lang="ts">
|
||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||
import { onMount, tick } from 'svelte';
|
||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||
import { goto } from '$app/navigation';
|
||
import Button from './primitives/Button.svelte';
|
||
import LaunchRecipe from './LaunchRecipe.svelte';
|
||
import ServerIdentity from './ServerIdentity.svelte';
|
||
import NativeHostSelection from './NativeHostSelection.svelte';
|
||
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
||
import type { ZombieGame } from '$lib/map-aliases.js';
|
||
import type { BlueprintSummary, CollectionMode, CollectionPreview } from '$lib/deployment-plan.js';
|
||
|
||
let { open = $bindable(false), blueprintId = '' }: { open?: boolean; blueprintId?: string } = $props();
|
||
let dialog: HTMLDialogElement | undefined = $state();
|
||
let preview = $state<CollectionPreview | null>(null);
|
||
let mode = $state<CollectionMode>('fill');
|
||
let games = $state<ZombieGame[]>([]);
|
||
let collectionName = $state('Launch collection');
|
||
let hostSelection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
|
||
let hostReady = $state(false);
|
||
let confirmRemovals = $state(false);
|
||
let keys = $state<Record<string, string>>({});
|
||
let busy = $state(false);
|
||
let error = $state('');
|
||
let wasOpen = false;
|
||
let session = 0;
|
||
let keyNotice = $state('');
|
||
let now = $state(Date.now());
|
||
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
|
||
const keysReady = $derived(preview?.servers.filter(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())).length ?? 0);
|
||
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
|
||
let canLaunch = $derived(preview?.ready && preview.mode === mode && (preview.servers.length > 0 || preview.removals.length > 0)
|
||
&& (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
|
||
|
||
$effect(() => {
|
||
if (!dialog) return;
|
||
if (open && !wasOpen) { wasOpen = true; mode = 'fill'; keys = {}; preview = null; keyNotice = ''; dialog.showModal(); void initialize(); }
|
||
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
|
||
});
|
||
|
||
async function initialize() {
|
||
const current = ++session; busy = true; error = ''; games = []; hostReady = false;
|
||
hostSelection = { libraryIds: {}, image: '' };
|
||
try {
|
||
const response = await fetch('/api/blueprints'); const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error ?? 'Could not load collection.');
|
||
if (current !== session) 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;
|
||
} catch (cause) { if (current === session) error = (cause as Error).message; }
|
||
finally { if (current === session) busy = false; }
|
||
}
|
||
|
||
async function review() {
|
||
if (!hostReady) { error = 'Choose the latest uploaded game files and a valid runtime image before reviewing the launch.'; return; }
|
||
const current = ++session;
|
||
const previous = preview;
|
||
busy = true; error = ''; keyNotice = ''; confirmRemovals = false;
|
||
try {
|
||
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId, mode, libraryIds: hostSelection.libraryIds, image: hostSelection.image }) });
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body.error ?? 'Could not preview the collection.');
|
||
if (current === session) {
|
||
const next = body as CollectionPreview;
|
||
const names = new Set(next.servers.map(server => server.name));
|
||
keys = Object.fromEntries(next.servers.filter(server => server.name && !server.hasSavedKey).map(server => [server.name!, keys[server.name!] ?? '']));
|
||
if (previous && (previous.servers.length !== next.servers.length || previous.servers.some(server => !names.has(server.name)))) keyNotice = 'Server assignments changed. Keys were retained only for the exact IDs still shown below. Review the new assignments.';
|
||
else if (previous) keyNotice = 'Preview refreshed. Entered keys are retained for these same server IDs.';
|
||
preview = next; now = Date.now();
|
||
}
|
||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||
finally { if (current === session) busy = false; }
|
||
}
|
||
|
||
async function launch() {
|
||
if (!preview || !canLaunch || expired || busy) return;
|
||
busy = true; error = '';
|
||
try {
|
||
const res = await fetch('/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: preview.id, keys }) });
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.');
|
||
open = false;
|
||
if (body.collectionId) await goto(`/collections/launches/${encodeURIComponent(body.collectionId)}`);
|
||
} catch (err) { error = (err as Error).message; }
|
||
finally { busy = false; }
|
||
}
|
||
|
||
async function nextMissing() {
|
||
await tick();
|
||
dialog?.querySelector<HTMLInputElement>('input[data-missing="true"]')?.focus();
|
||
}
|
||
</script>
|
||
|
||
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||
<div class="composer">
|
||
<header><div><span class="eyebrow">Collection launch</span><h2>{preview?.blueprintName ?? collectionName}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection preview">×</button></header>
|
||
<label class="mode">Apply collection
|
||
<select bind:value={mode} disabled={busy} onchange={event => { mode = event.currentTarget.value as CollectionMode; preview = null; confirmRemovals = false; }}>
|
||
<option value="fill">Fill missing servers</option>
|
||
<option value="exact">Match collection exactly</option>
|
||
</select>
|
||
</label>
|
||
{#if open && games.length}<NativeHostSelection {games} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} onchange={() => { preview = null; confirmRemovals = false; }} />{/if}
|
||
<p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this collection across both games.' : 'Create missing lobbies and keep all existing servers.'}</p>
|
||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||
{#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Review the fleet changes, capacity estimate, and server keys. Queued launches wait automatically when hosts need more capacity or preparation.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if}
|
||
{:else}
|
||
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
|
||
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if}
|
||
<div class="lineup"><strong>{preview.mode === 'exact' ? `${preview.retainedServers.length} keep · ${preview.servers.length} create · ${preview.removals.length} remove` : preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Collection already satisfied'}</strong>
|
||
<table><thead><tr><th>Map</th><th>Target</th><th>Existing / queued</th><th>To create</th></tr></thead><tbody>{#each preview.entries as entry}<tr><th>{entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></th><td>{entry.count}</td><td title={entry.existingServers.join(', ')}>{entry.existing}</td><td class="quantity">{entry.missing}</td></tr>{/each}</tbody></table>
|
||
<p class="muted">Targets count inventory, including stopped servers; they do not mean every server is ready to play. {preview.mode === 'exact' ? 'Existing deployed lobbies are kept before queued creations, then the lowest lobby numbers. Retained servers are not changed or restarted.' : 'Only missing servers are created. Extra servers remain and existing servers are not updated.'}</p>
|
||
{#if preview.mode === 'exact' && preview.retainedServers.length}<details class="coverage"><summary>Servers to keep ({preview.retainedServers.length})</summary><ul>{#each preview.retainedServers as name}<li><ServerIdentity {name} /><span>{name}</span></li>{/each}</ul></details>{/if}
|
||
{#if preview.mode === 'fill' && preview.entries.some(entry => entry.existing)}<details class="coverage" open={!preview.servers.length}><summary>Review existing servers</summary>{#each preview.entries.filter(entry => entry.existing) as entry}<div class="coverage-map"><strong>{entry.recipe.mapName}: {entry.existing} existing</strong><p class="muted">{[...new Set((entry.coverage ?? []).map(server => server.health))].map(health => `${entry.coverage.filter(server => server.health === health).length} ${health}`).join(', ')}</p><ul>{#each entry.coverage ?? [] as server}<li><a href={server.href}><ServerIdentity name={server.name} /></a><span>{server.health === 'online' ? 'Healthy' : server.health}</span></li>{/each}</ul></div>{/each}</details>{/if}
|
||
</div>
|
||
{#if preview.availability && preview.servers.length}
|
||
<section class="availability" aria-label="Latest reported capacity">
|
||
<h3>Latest reported capacity</h3>
|
||
<a href="/capacity" target="_blank" rel="noopener">View capacity and history ↗</a>
|
||
<p class="muted">{preview.availability.estimatedAvailable === null ? 'An estimate is not available yet.' : `${preview.availability.estimatedAvailable} additional servers estimated to fit.`} {preview.availability.requested} requested.</p>
|
||
<dl>
|
||
<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>
|
||
<p class="caption">{preview.availability.updatedAt ? `Updated ${$timeFormat.dateTime(preview.availability.updatedAt)}.` : 'Waiting for host reports.'}{#if preview.availability.queued !== null} {preview.availability.queued} servers already queued.{/if} Provisioning checks capacity separately for each server before it starts.</p>
|
||
</section>
|
||
{/if}
|
||
<div class="section-title"><h3>{preview.mode === 'exact' ? 'Review fleet changes' : preview.servers.length ? 'Your launch order' : 'Nothing to create'}</h3><button class="text-button" disabled={busy} onclick={review}>Refresh preview</button></div>
|
||
{#each preview.blockers as blocker}<p class="error" role="alert">{blocker}</p>{/each}
|
||
{#each preview.warnings ?? [] as warning}<p class="warning" role="status">{warning}</p>{/each}
|
||
{#if preview.servers.length || preview.removals.length}
|
||
{#if preview.servers.length}<p class="muted">Saved keys are reused automatically. Add keys only where required below. Update saved keys in <a href="/server-keys" target="_blank" rel="noopener">Keys (opens in a new tab)</a>. Apply saves the reviewed jobs and opens their progress page. Each launch waits for a startup slot and host capacity.</p>{/if}
|
||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||
{#if preview.removals.length}
|
||
<section class="removals" aria-label="Servers to remove">
|
||
<h3>Remove {preview.removals.length} server{preview.removals.length === 1 ? '' : 's'}</h3>
|
||
<p class="muted">Containers, files, and registrations will be removed after a fresh zero-player check. Busy or unreachable servers wait. Unstarted creations are cancelled. Saved keys are kept. New servers reusing these ports wait for removal to finish.</p>
|
||
<ul>{#each preview.removals as server}<li><strong><ServerIdentity name={server.name} /></strong><span class="server-id">{server.name}</span><span class="muted">{server.reason}</span></li>{/each}</ul>
|
||
<label class="confirm-removals"><input type="checkbox" bind:checked={confirmRemovals} disabled={busy || preview.mode !== mode} />Remove the {preview.removals.length} listed servers and apply this collection.</label>
|
||
</section>
|
||
{/if}
|
||
{#if preview.servers.length}
|
||
<ol class="servers" aria-label="Server launch order">
|
||
{#each preview.servers as server, index (server.name ?? `unavailable-${index}`)}
|
||
<li class="server-row">
|
||
<span class="order" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
||
<div class="server-identity">{#if index === 0 || preview.servers[index - 1].entryIndex !== server.entryIndex}<span class="map-group">{preview.entries[server.entryIndex].recipe.game.toUpperCase()} · {preview.entries[server.entryIndex].recipe.mapName}</span>{/if}<strong>{#if server.name}<ServerIdentity name={server.name} />{:else}{preview.entries[server.entryIndex].recipe.mapName}{/if}</strong><span class="server-id">{server.name ?? 'No available lobby'} · {server.native ? 'Host and port assigned when provisioning starts' : server.port === null ? 'No available port' : `Port ${server.port}`}</span></div>
|
||
{#if server.name}
|
||
{#if server.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
|
||
{:else}<label><span>Server key required</span><input aria-label={`Key for ${server.name}`} data-missing={!keys[server.name]?.trim()} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder="Paste this server’s key" /></label>{/if}
|
||
{/if}
|
||
</li>
|
||
{/each}
|
||
</ol>
|
||
{/if}
|
||
<div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Key validity is checked at startup. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div>
|
||
</form>
|
||
{:else}<p class="muted">{preview.mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet already meets or exceeds every target in this collection.'} No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
|
||
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each new server is automatically placed on an eligible host with the latest game files and prepared runtime. CPU, memory, host readiness, synced files, runtime availability, and local port reservations are checked again before deployment. Existing servers keep their current files.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
|
||
{/if}
|
||
</div>
|
||
</dialog>
|
||
|
||
<style>
|
||
.availability { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||
.availability dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 16px; margin: 0; }
|
||
.availability dt { font-size: var(--text-xs); color: var(--text-secondary); }.availability dd { margin: 6px 0 0; font-size: 14px; font-variant-numeric: tabular-nums; }
|
||
.warning { color: var(--state-warning); font-size: 13px; line-height: 1.6; }
|
||
select { padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
|
||
.removals { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--state-danger); border-radius: var(--radius-md); }
|
||
.removals ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 16px; }
|
||
.removals li { display: grid; gap: 4px; font-size: 13px; }
|
||
.confirm-removals { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; }
|
||
.confirm-removals input { width: auto; margin-top: 2px; }
|
||
.key-ready { color: var(--state-online); font-size: 13px; font-weight: 600; }
|
||
a { color: var(--accent-primary-hover); }
|
||
@media (max-width: 600px) { .server-row .key-ready { grid-column: 2; } }
|
||
input { scroll-margin-block: 32px 190px; }
|
||
.launch-footer { position: sticky; bottom: -28px; background: var(--surface); z-index: 2; padding: 16px 0 20px; border-top: 1px solid var(--border-strong); box-shadow: 0 -12px 16px var(--surface); }
|
||
.map-group { font-size: 12px; color: var(--accent-primary-hover); }
|
||
.coverage { margin-top: 16px; font-size: 14px; }.coverage summary { cursor: pointer; }.coverage-map { padding-top: 14px; }.coverage ul { list-style: none; padding: 0; }.coverage li { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; }.coverage a { color: var(--accent-primary-hover); }.coverage li span { color: var(--text-secondary); }
|
||
@media (max-width: 600px) { .launch-footer { bottom: -20px; } }
|
||
dialog { width: min(820px, 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); }.composer { display: grid; gap: 20px; padding: 28px; }
|
||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: var(--text-xs); }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
|
||
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: var(--text-xs); }
|
||
form { display: grid; gap: 20px; }.servers { margin: 0; padding: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; }.server-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 220px; align-items: center; gap: 16px; padding: 20px 16px; }.server-row + .server-row { border-top: 1px solid var(--border-subtle); }.order { font-family: var(--font-mono); color: var(--text-tertiary); font-size: 12px; align-self: start; padding-top: 3px; }.server-identity { display: grid; gap: 8px; min-width: 0; }.server-row strong { font-size: 14px; overflow-wrap: anywhere; }.server-id { font-family: var(--font-mono); font-size: 11px; line-height: 1.6; color: var(--text-secondary); overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-control); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
|
||
.launch-footer { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-wrap: wrap; }.requirements { margin-top: 16px; }button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
||
@media (max-width: 600px) { .composer { padding: 20px; }.lineup { padding: 16px; }.server-row { grid-template-columns: 24px minmax(0, 1fr); gap: 12px; padding: 16px; }.server-row label { grid-column: 2; }.launch-footer { align-items: stretch; flex-direction: column; gap: 16px; }.actions { justify-content: space-between; }h2 { font-size: 22px; } }
|
||
</style>
|