Simplify single-server launch flow

This commit is contained in:
2026-10-07 13:56:42 -04:00
parent bd7d1ed86f
commit 2e1e5ea35f
9 changed files with 670 additions and 204 deletions
+32 -23
View File
@@ -139,22 +139,28 @@ Every server links to its Provisioning operation timeline and recovery options,
and each child operation links back to its collection launch. Individual operations and each child operation links back to its collection launch. Individual operations
remain visible in Provisioning activity and history. remain visible in Provisioning activity and history.
## Create and review a single-server launch ## Create a single server
1. Choose **Single server** on Provisioning (or the deploy action on the fleet). 1. Open **Create server** from the fleet or **Single server** from Provisioning.
2. Choose the current promoted setup and select a game/map, or select a collection. 2. Choose a game and map. The dialog automatically checks the current promoted
3. Choose **Preview launch**. Review the image reference, configuration sources, setup, latest game files, available lobby, and game key pool as selections change.
managed files and versions, selected map, lobby, port, and prerequisite checks. 3. Review the readiness summary and resolve any displayed blockers. When no game
4. The launch automatically uses an available key from the game pool. If none is key is available, open **Administration → Keys** and add one or wait for a key
available, manage the pool under **Administration → Keys**, then refresh the to be released. Returning to the launch dialog rechecks availability; **Retry checks**
preview. also runs the checks again.
5. Choose **Launch server** and follow the operation to verified health. 4. If needed, expand **Advanced settings** for runtime and network overrides.
Expand **Setup details** to inspect the image, configuration, managed files,
game-file upload time, and internal server ID.
5. Choose **Launch server** in the persistent footer and follow the operation to
verified health. **Cancel** closes the dialog without creating a server.
A preview lasts 30 minutes. It reserves neither capacity nor a key. If The single-server dialog uses the current promoted setup. Create and launch
another launch takes the lobby, submission stops before reserving a key; collections separately from **Fleet → Collections**.
refresh the preview and review the newly assigned ID. Pool availability is checked again at
submission, and keys are reserved atomically so concurrent launches cannot share Automatic checks reserve neither capacity nor a key. The reviewed preview lasts
a credential. 30 minutes. If another launch takes the lobby or key, submission stops; retry the
checks before launching. Pool availability is checked again at submission, and
keys are reserved atomically so concurrent launches cannot share a credential.
Host selection, game-file versions, runtime configuration and lobby availability Host selection, game-file versions, runtime configuration and lobby availability
are checked before queueing. Provisioning assigns host capacity and physical are checked before queueing. Provisioning assigns host capacity and physical
@@ -165,9 +171,11 @@ verify the engine map, not the variant's start location.
## Save and reuse a collection ## Save and reuse a collection
Expand **Make this a reusable collection** in a launch preview and give it a name Choose **Create collection** under **Fleet → Collections**, give it a name and
and optional description. You can save the map even when host configuration or optional description, and add maps with their desired lobby counts. A collection
available capacity blocks launching. Names are unique without regard to case. can contain a single map with a target of one lobby. You can save it even when
host configuration or available capacity blocks launching. Names are unique
without regard to case.
Each map in a collection retains the game, exact map variant, and desired lobby Each map in a collection retains the game, exact map variant, and desired lobby
count. Image references, managed files, and composed configuration are resolved count. Image references, managed files, and composed configuration are resolved
@@ -178,11 +186,12 @@ Use **Conform fleet** on a collection card to review missing servers. Remove a
collection from the library when it is no longer needed; its existing servers, collection from the library when it is no longer needed; its existing servers,
reviewed previews, and deployment history remain intact. reviewed previews, and deployment history remain intact.
On a creation detail page, **Launch again** selects the original game and map On a creation detail page, **Launch another server** opens the single-server
with the next available lobby and port, using the latest promoted setup. The dialog with the original game and map selected. You can change either selection
preview also lets you save that map as a collection with a target of one lobby. before launching. Checks use the current promoted setup and latest game files,
The recorded files remain visible as deployment history. Older deployments and provisioning assigns a fresh lobby and port. The recorded files remain
without a recorded recipe retain their history but cannot offer this action. visible as deployment history. Older deployments without a recorded recipe
retain their history but cannot offer this action.
## Promotions and deployment history ## Promotions and deployment history
@@ -190,7 +199,7 @@ A preview shows the currently promoted managed files, configuration, and image
reference for the selected map. It does not pin those versions. New launches reference for the selected map. It does not pin those versions. New launches
resolve the promoted setup again when provisioning begins, so resolve the promoted setup again when provisioning begins, so
promotions made after a preview or while a job is queued apply to that server. promotions made after a preview or while a job is queued apply to that server.
This applies to collections, single-server launches, and **Launch again**. This applies to collections, single-server launches, and **Launch another server**.
Previews and queued jobs store file metadata without copying script or plugin Previews and queued jobs store file metadata without copying script or plugin
contents for every lobby. Provisioning captures the bytes when each job starts. contents for every lobby. Provisioning captures the bytes when each job starts.
+4 -3
View File
@@ -68,9 +68,10 @@ base game files; each instance has its own writable data. See
## Launch your first server ## Launch your first server
1. Save Plutonium server keys in the T5 or T6 pool under **Administration → Keys**. 1. Save Plutonium server keys in the T5 or T6 pool under **Administration → Keys**.
2. Create a server, or build a reusable map lineup in **Fleet → Collections**. 2. Open **Create server** and choose a game and map. Readiness checks run
3. Review the launch and apply it. Provisioning queues the work, then assigns a automatically. For a reusable lineup, use **Fleet → Collections** instead.
host and port when capacity is available. 3. Resolve any launch blockers and choose **Launch server**. Provisioning queues
the work, then assigns a host and port when capacity is available.
4. Follow the operation until readiness checks pass, then verify that players 4. Follow the operation until readiness checks pass, then verify that players
can find and join the server. can find and join the server.
+8 -6
View File
@@ -26,12 +26,14 @@ the agent owns local execution and its durable operation journal.
progress and errors. Wait for verification before launching. Hosts report progress and errors. Wait for verification before launching. Hosts report
connection and readiness separately; deployment also checks the particular connection and readiness separately; deployment also checks the particular
game files, image, capacity and ports required for the launch. game files, image, capacity and ports required for the launch.
6. Launch a server or collection. The latest game files and default runtime 6. Open **Create server**, choose a game and map, and review the automatic
are selected automatically. An advanced runtime override is readiness checks. Launch collections separately from **Fleet → Collections**.
available for images an admin has already prepared on the destination host. The latest game files and default runtime are selected automatically.
If the latest files change after previewing, refresh the preview **Advanced settings** in the single-server dialog includes a runtime override
before launching. for images an admin has already prepared on the destination host, along with
Apply saves the requested lobby IDs. Provisioning selects a host and atomically optional network overrides. If the latest files change after the checks run,
let the dialog recheck the setup before launching; **Retry checks** also rechecks it.
Launching saves the requested lobby IDs. Provisioning selects a host and atomically
reserves UDP/TCP ports when capacity is available. reserves UDP/TCP ports when capacity is available.
An available Plutonium key from the game pool is reserved for each launch. An available Plutonium key from the game pool is reserved for each launch.
Its assignment survives stops and rebuilds and ends after confirmed removal. Its assignment survives stops and rebuilds and ends after confirmed removal.
+10 -2
View File
@@ -7,6 +7,13 @@ Provisioning owns the queues, safety checks, execution, and
recovery for all six actions. Create a server using **Create server**. For an existing recovery for all six actions. Create a server using **Create server**. For an existing
server, choose **Manage server** or open its **Server actions** menu. server, choose **Manage server** or open its **Server actions** menu.
**Create server** opens one dialog with game and map choices followed by automatic
readiness checks. Blockers explain what needs attention before launch. Runtime
and network overrides are under **Advanced settings**; image, configuration,
managed files, upload time, and internal server ID are under **Setup details**.
The footer keeps **Cancel** and **Launch server** available while reviewing the
setup. Create and launch reusable lineups separately in **Fleet → Collections**.
| Action | Behavior | | Action | Behavior |
| --- | --- | | --- | --- |
| Create | Create a new instance using the latest promoted files and verify container health, game status, the selected map, and the Plutonium revision. | | Create | Create a new instance using the latest promoted files and verify container health, game status, the selected map, and the Plutonium revision. |
@@ -164,8 +171,9 @@ Create and rebuild capture a setup when queued and select the latest promoted
files again when creation begins. The page distinguishes **Setup captured when files again when creation begins. The page distinguishes **Setup captured when
queued** from **Files selected for this attempt**. A queued capture does not pin queued** from **Files selected for this attempt**. A queued capture does not pin
versions; an attempted selection is not proof that every file was installed. versions; an attempted selection is not proof that every file was installed.
**Launch another server** uses the latest promoted setup with a fresh identity and **Launch another server** opens the creation dialog with the recorded game and
port, rather than repairing or replaying the recorded job. map as editable starting values. It checks the latest promoted setup and creates
a separate server with a fresh identity and port.
### History and refresh behavior ### History and refresh behavior
+207 -156
View File
@@ -1,198 +1,249 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js'; import { onMount, onDestroy, tick } from 'svelte';
import { onMount, tick } from 'svelte';
import { isDialogBackdrop } from '$ui/dialog.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import LaunchRecipe from './LaunchRecipe.svelte';
import DeployCollectionDialog from './DeployCollectionDialog.svelte';
import NativeHostSelection from './NativeHostSelection.svelte'; import NativeHostSelection from './NativeHostSelection.svelte';
import { createServerLaunchController, type ServerLaunchState } from '$ui/server-launch.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 { NativeDeploymentSelection } from '$lib/hosts.js';
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js'; import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '', onblueprint }: let { open = $bindable(false), sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '' }:
{ open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string; onblueprint?: () => void } = $props(); { open?: boolean; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let game = $state<ZombieGame>('t5'); let game = $state<ZombieGame>('t5');
let mapSlug = $state(''); let mapSlug = $state('');
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] }); let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
let blueprints = $state<BlueprintSummary[]>([]); let gameSelect = $state<HTMLSelectElement>();
let blueprintId = $state(''); let selection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
let collectionOpen = $state(false);
let maps = $derived(catalogs[game]);
let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
let preview = $state<LaunchPreview | null>(null);
let hostSelection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
let hostReady = $state(false); let hostReady = $state(false);
let busy = $state(false); let hostStatus = $state<NativeHostSelectionStatus>({ loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null });
let view = $state<ServerLaunchState>({ preview: null, checking: false, error: '' });
const controller = createServerLaunchController(state => { view = state; });
let loaded = $state(false); let loaded = $state(false);
let error = $state(''); let loadingError = $state('');
let blueprintName = $state(''); let launchError = $state('');
let description = $state(''); let launching = $state(false);
let savedName = $state(''); let unconfirmedLaunch = $state(false);
let saving = $state(false); let refreshVersion = $state(0);
let wasOpen = false;
let session = 0;
let lobbyNotice = $state('');
let selectionNotice = $state('');
let now = $state(Date.now()); let now = $state(Date.now());
let heading: HTMLHeadingElement; let wasOpen = false;
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now); let visit = 0;
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); }); let initialization: AbortController | undefined;
let submission: AbortController | undefined;
const maps = $derived(catalogs[game]);
const preview = $derived(view.preview);
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
const imageValid = $derived(/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image));
const checksBlocked = $derived(preview?.checks.filter(check => check.state === 'blocked') ?? []);
const missingKey = $derived(!!preview?.name && !preview.hasSavedKey);
const canLaunch = $derived(!launching && !!preview?.name && (unconfirmedLaunch ||
(hostReady && !hostStatus.loading && !view.checking && !expired && preview.ready && preview.hasSavedKey)));
const checking = $derived(!loaded && !loadingError || hostStatus.loading || view.checking);
const footerStatus = $derived(launching ? 'Queueing server…' : unconfirmedLaunch ? 'Retry to confirm this launch.'
: loadingError ? 'Could not load launch options.' : checking ? 'Checking availability…'
: missingKey ? 'No game key available.' : canLaunch ? 'Ready to launch' : 'Launch unavailable');
$effect(() => { $effect(() => {
if (!dialog) return; if (open && !wasOpen) { wasOpen = true; void initialize(); }
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void initialize(); } else if (!open && wasOpen) {
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); } wasOpen = false; visit++; initialization?.abort(); controller.clear();
}
});
$effect(() => {
// Selection changes invalidate the old preview before another launch can use it.
const input = open && loaded && hostReady && !hostStatus.loading ? {
game, mapSlug, libraryId: selection.libraryIds[game]!, image: selection.image,
bindAddress: selection.bindAddress || undefined, publicAddress: selection.publicAddress || undefined,
publicPort: selection.publicPort
} : null;
if (!launching && !unconfirmedLaunch) controller.select(input);
}); });
async function initialize() { async function initialize() {
const current = ++session; const current = ++visit;
preview = null; error = ''; loaded = false; busy = false; lobbyNotice = ''; selectionNotice = ''; initialization?.abort(); initialization = new AbortController();
blueprintName = ''; description = ''; savedName = ''; saving = false; const signal = initialization.signal;
blueprintId = initialBlueprintId; controller.clear(); loaded = false; loadingError = ''; launchError = ''; launching = false; unconfirmedLaunch = false;
game = initialGame; game = initialGame; mapSlug = initialMapSlug;
mapSlug = initialMapSlug; selection = { libraryIds: {}, image: '' }; hostReady = false;
hostSelection = { libraryIds: {}, image: '' }; hostReady = false; hostStatus = { loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null };
try { try {
const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]); const response = await fetch('/api/maps', { signal });
if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and collections. Try again.'); if (!response.ok) throw new Error('Could not load maps. Try again.');
const [mapBody, blueprintBody] = await Promise.all([mapRes.json(), blueprintRes.json()]); const body = await response.json();
if (current !== session) return; if (current !== visit) return;
catalogs = { t5: mapBody.t5Zombie, t6: mapBody.t6Zombie }; catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
blueprints = blueprintBody.blueprints;
if (!maps.some(m => m.slug === mapSlug)) mapSlug = maps[0]?.slug ?? '';
if (blueprintId && !blueprints.some(b => b.id === blueprintId)) throw new Error('This collection is no longer available. Close this dialog and choose another starting point.');
if (sourceDeploymentId) { if (sourceDeploymentId) {
const source = await fetch(`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`).then(response => response.json()); const sourceResponse = await fetch(`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`, { signal });
if (current !== session) return; const source = await sourceResponse.json();
if (!source.recipe) throw new Error('The original launch recipe is unavailable.'); if (current !== visit) return;
if (!sourceResponse.ok || !source.recipe || !['t5', 't6'].includes(source.recipe.game)
|| !catalogs[source.recipe.game as ZombieGame].some(map => map.slug === source.recipe.mapSlug)) {
throw new Error('The original launch map is unavailable.');
}
game = source.recipe.game; mapSlug = source.recipe.mapSlug; game = source.recipe.game; mapSlug = source.recipe.mapSlug;
} }
if (!catalogs[game].some(map => map.slug === mapSlug)) mapSlug = catalogs[game][0]?.slug ?? '';
loaded = true; loaded = true;
} catch (err) { if (current === session) error = (err as Error).message; } await tick();
if (current === visit && open) gameSelect?.focus();
} catch (error) {
if (current === visit && !signal.aborted) loadingError = error instanceof Error ? error.message : 'Could not load launch options.';
}
} }
function selectionChanged() { function refreshChecks() {
if (preview) selectionNotice = 'The launch settings or game files changed. Review the launch again with the latest files.'; if (!open || launching || unconfirmedLaunch) return;
preview = null; lobbyNotice = ''; if (!loaded) { void initialize(); return; }
launchError = '';
controller.clear();
refreshVersion++;
} }
function selectionChanged() { launchError = ''; }
async function review() { onMount(() => {
if (busy) return; const refreshOnReturn = () => { if (document.visibilityState !== 'hidden') refreshChecks(); };
if (!sourceDeploymentId && selectedBlueprint) { window.addEventListener('focus', refreshOnReturn);
open = false; collectionOpen = true; return; document.addEventListener('visibilitychange', refreshOnReturn);
} const timer = setInterval(() => { now = Date.now(); }, 1000);
if (!hostReady) { error = 'Wait for a host to finish preparing the latest game files and runtime.'; return; } return () => {
const current = session; window.removeEventListener('focus', refreshOnReturn);
const previousName = preview?.name; document.removeEventListener('visibilitychange', refreshOnReturn);
busy = true; error = ''; savedName = ''; lobbyNotice = ''; selectionNotice = ''; clearInterval(timer);
try { };
const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, });
body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }), onDestroy(() => { visit++; initialization?.abort(); submission?.abort(); controller.dispose(); });
libraryId: hostSelection.libraryIds[game], image: hostSelection.image,
bindAddress: hostSelection.bindAddress || undefined, publicAddress: hostSelection.publicAddress || undefined, publicPort: hostSelection.publicPort }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
if (current === session) {
if (previousName && previousName !== body.name) lobbyNotice = 'The available lobby changed. Review the updated server before launching.';
preview = body; now = Date.now();
await tick(); heading?.focus();
}
} catch (err) { if (current === session) error = (err as Error).message; }
finally { if (current === session) busy = false; }
}
async function launch() { async function launch() {
if (!preview || !preview.ready || !preview.hasSavedKey || !hostReady || expired || busy || saving) return; if (!canLaunch || !preview) return;
busy = true; error = ''; const current = visit;
const reviewed = preview;
controller.suspend(); launching = true; launchError = '';
submission = new AbortController();
try { try {
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, const response = await fetch('/api/servers', { method: 'POST', signal: submission.signal,
body: JSON.stringify({ game: preview.recipe.game, gameMode: 'zombie', mapSlug: preview.recipe.mapSlug, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
expectedName: preview.name, previewId: preview.id }) }); game: reviewed.recipe.game, gameMode: 'zombie', mapSlug: reviewed.recipe.mapSlug,
const body = await res.json(); expectedName: reviewed.name, previewId: reviewed.id
if (!res.ok || !body.ok) throw new Error(body.error ?? 'Could not queue this launch.'); }) });
const body = await response.json();
if (current !== visit) return;
if (!response.ok || !body.ok) {
unconfirmedLaunch = response.status >= 500;
launchError = body.error ?? 'Could not queue this launch.';
if (!unconfirmedLaunch) { controller.clear(); refreshVersion++; }
return;
}
open = false; open = false;
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`); await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
} catch (err) { error = (err as Error).message; } } catch (error) {
finally { busy = false; } if (current === visit) {
// Retain the preview ID: retrying a possibly accepted launch must be idempotent.
unconfirmedLaunch = true;
launchError = 'Could not confirm the launch. Retry to check the same request, or view Provisioning.';
} }
} finally { if (current === visit) launching = false; }
async function save() {
if (!preview || saving) return;
const current = session;
saving = true; error = '';
try {
const res = await fetch('/api/blueprints', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: blueprintName, description, previewId: preview.id }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not save collection.');
if (current === session) { savedName = body.blueprint.name; blueprints = [...blueprints, body.blueprint]; onblueprint?.(); }
} catch (err) { if (current === session) error = (err as Error).message; }
finally { if (current === session) saving = false; }
} }
</script> </script>
<dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}> <Dialog bind:open title="Create server" busy={launching}>
<div class="composer"> <div class="launch-content">
<header><div><h2 bind:this={heading} tabindex="-1">{preview ? 'Review server launch' : 'Create server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close server creation dialog">×</button></header> {#if loadingError}
<ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol> <p class="issue" role="alert">{loadingError}</p>
{#if error}<div class="error" role="alert">{error}</div>{/if} <button class="text-button" onclick={refreshChecks}>Retry checks</button>
{#if selectionNotice}<p class="muted" role="status">{selectionNotice}</p>{/if} {:else if !loaded}
{#if open && loaded && (sourceDeploymentId || !selectedBlueprint)}<NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy || saving} network onchange={selectionChanged} />{/if} <p class="muted" role="status">Loading maps…</p>
{#if !loaded}
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…'}</p>
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
{:else if preview}
{#if lobbyNotice}<p role="status" class="muted">{lobbyNotice}</p>{/if}
{#if expired}<p class="error" role="status">This preview expired. Refresh it to recheck capacity and key availability.</p>{/if}
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From collection: {preview.blueprintName}</p>{/if}</section>
<section class="preflight"><div class="section-title"><h3>Before you launch</h3><button class="text-button" disabled={busy || saving} onclick={review}>Refresh preview</button></div>
<ul>{#each preview.checks as check}<li class:blocked={check.state === 'blocked'}><span class="check" aria-label={check.state}>{check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}</span><div><strong>{check.label}</strong><p>{check.detail}</p></div></li>{/each}</ul>
</section>
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
<div class="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>An available key from this game’s pool is assigned when you launch.</p></div>
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Game key available</p>
{:else}<p class="muted" role="status">No unused {preview.recipe.game.toUpperCase()} keys are available. Add a key in <a href={`/server-keys?game=${preview.recipe.game}`} target="_blank" rel="noopener">Keys (opens in a new tab)</a>, then Refresh preview to enable launch.</p>{/if}
<div class="launch-actions"><Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !hostReady || !preview.ready || !preview.hasSavedKey}>Launch server</Button></div>
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Capacity and key availability are checked again at launch. The host and port are assigned automatically.</p>
</form>
<details class="save"><summary>Make this a reusable collection</summary><p class="muted">Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</p>
{#if savedName}<p class="success" role="status">“{savedName}” is ready in your collection library.</p>{:else}
<form onsubmit={event => { event.preventDefault(); void save(); }}><label>Collection name<input bind:value={blueprintName} maxlength="80" required placeholder="Friday night Kino" disabled={saving || busy} /></label><label>Description <span class="muted">(optional)</span><input bind:value={description} maxlength="500" placeholder="What makes this setup special?" disabled={saving || busy} /></label><Button type="submit" disabled={saving || busy || !blueprintName.trim()} loading={saving}>Save collection</Button></form>
{/if}
</details>
{:else if sourceDeploymentId}
<p class="muted">Launch the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.</p><Button disabled={busy || !hostReady} onclick={review}>Review launch</Button>
{:else} {:else}
<form onsubmit={event => { event.preventDefault(); void review(); }}> <div class="fields">
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label> <label>Game<select bind:this={gameSelect} bind:value={game} disabled={launching || unconfirmedLaunch}
{#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and game key availability together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section> onchange={event => { game = event.currentTarget.value as ZombieGame; mapSlug = catalogs[game][0]?.slug ?? ''; selectionChanged(); }}>
{:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if} <option value="t5">Black Ops I</option><option value="t6">Black Ops II</option>
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div> </select></label>
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && (!mapSlug || !hostReady))}>Preview launch →</Button></div> <label>Map<select bind:value={mapSlug} disabled={launching || unconfirmedLaunch} onchange={selectionChanged}>
</form> {#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}
</select></label>
</div>
<section class="summary" aria-label="Server summary" aria-busy={checking}>
<strong>{preview?.name ? serverTitle(preview.name) : maps.find(map => map.slug === mapSlug)?.friendly ?? 'Choose a map'}</strong>
<p class="muted">Uses the current production setup when the server starts.</p>
<div class="readiness" aria-label="Launch readiness">
{#if checking}<span role="status">Checking availability…</span>
{:else}
<span class:ready={hostStatus.filesReady}>{hostStatus.missingGames.length ? 'Game files missing' : hostStatus.filesReady ? 'Game files ready' : 'Game files preparing'}</span>
{#if preview?.hasSavedKey}<span class="ready">Key available</span>{/if}
<span>Automatic host &amp; port</span>
{/if} {/if}
</div> </div>
</dialog> </section>
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
{#if launchError}<div class="issue" role="alert"><p>{launchError}</p>{#if unconfirmedLaunch}<a href="/provisioning" target="_blank" rel="noopener">View Provisioning ↗</a>{/if}</div>{/if}
{#if missingKey}<div class="issue" role="status"><p>No unused {game === 't5' ? 'Black Ops I' : 'Black Ops II'} keys are available.</p><a href={`/server-keys?game=${game}`} target="_blank" rel="noopener">Manage keys ↗</a></div>{/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 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 && hostStatus.hasHosts}<p class="issue" role="alert">{view.error}</p>{/if}
{#if !hostStatus.loading && !hostStatus.error && !hostStatus.hasHosts}<div class="issue" role="status"><p>Add a host to run your server.</p><a href="/hosts" target="_blank" rel="noopener">Manage hosts ↗</a></div>{/if}
{#each checksBlocked as check}<p class="issue" role="status">{check.detail}</p>{/each}
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKey || checksBlocked.length)}<button class="text-button retry" disabled={launching} onclick={refreshChecks}>Retry checks</button>{/if}
{#if open}<NativeHostSelection games={[game]} bind:selection bind:ready={hostReady} bind:status={hostStatus}
compact network {refreshVersion} disabled={launching || unconfirmedLaunch} onchange={selectionChanged} />{/if}
{#if preview}<details class="setup-details">
<summary>Setup details</summary>
<dl>
<div><dt>Server ID</dt><dd><code>{preview.name ?? 'No lobby available'}</code></dd></div>
<div><dt>Configuration</dt><dd>{preview.recipe.configSource}</dd></div>
<div><dt>Container image</dt><dd><code>{preview.recipe.image}</code></dd></div>
{#if hostStatus.uploadedAt}<div><dt>Game files uploaded</dt><dd>{$timeFormat.dateTime(hostStatus.uploadedAt)}</dd></div>{/if}
</dl>
<h3>{preview.recipe.files.length} managed files</h3>
<ul class="files">{#each preview.recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version === null ? 'Generated' : `v${file.version}`} · {file.kind}</span></div><code>{file.path}</code></li>{/each}</ul>
</details>{/if}
{/if}
</div>
{#snippet actions()}
<div class="launch-actions">
<p class:ready={canLaunch && !unconfirmedLaunch} role="status">{footerStatus}</p>
<div><Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant="primary" loading={launching} disabled={!canLaunch} onclick={launch}>{unconfirmedLaunch ? 'Retry launch' : 'Launch server'}</Button></div>
</div>
{/snippet}
</Dialog>
<style> <style>
.key-ready { color: var(--state-online); font-weight: 600; } .launch-content { display: grid; gap: 18px; }
dialog { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: 0; width: min(700px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); } .fields { display: grid; grid-template-columns: 1fr 1.3fr; gap: 14px; }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); } label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-sm); }
.composer { display: grid; gap: 24px; padding: 30px; } header, .section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; } select { width: 100%; }
.eyebrow { font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: .13em; text-transform: uppercase; display: block; margin-bottom: 8px; } .summary { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); }
h2 { font-size: 25px; letter-spacing: -.03em; margin: 0; } h3 { font-size: 15px; margin: 0; } .summary > strong { font-size: 17px; line-height: 1.4; }
.close { background: none; color: var(--text-secondary); border: 0; font-size: 27px; cursor: pointer; } p { margin: 0; font-size: var(--text-sm); line-height: 1.5; }
.steps { list-style: none; padding: 0 0 20px; margin: 0; display: flex; gap: 24px; border-bottom: 1px solid var(--border-subtle); font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); }.steps span { font-family: var(--font-sans); margin-left: 6px; }.steps .active { color: var(--accent-primary-hover); } .muted { color: var(--text-secondary); font-size: var(--text-xs); }
form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-md); padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; } .readiness { display: flex; flex-wrap: wrap; gap: 8px 14px; font-size: var(--text-xs); color: var(--text-secondary); }
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } p { font-size: 13px; line-height: 1.6; margin: 0; } .muted, .caption { color: var(--text-secondary); } .caption { font-size: var(--text-xs); } .ready { color: var(--state-online); }
.recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); } .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); }
.preflight { display: grid; gap: 16px; } .preflight ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; } .preflight li { display: flex; gap: 12px; align-items: flex-start; }.preflight strong { font-size: 13px; font-weight: 550; }.preflight p { color: var(--text-secondary); font-size: 12px; }.check { display: grid; place-items: center; flex: 0 0 22px; height: 22px; border: 1px solid var(--border-strong); border-radius: 50%; font-size: 12px; color: var(--accent-primary-hover); }.blocked .check { color: var(--state-danger); border-color: var(--state-danger); } a, .text-button { color: var(--accent-primary-hover); font-size: var(--text-sm); }
.identity { padding-top: 20px; border-top: 1px solid var(--border-subtle); }.identity strong { font-family: var(--font-mono); font-size: 15px; overflow-wrap: anywhere; }.identity p { color: var(--text-secondary); margin-top: 6px; } .text-button { border: 0; padding: 0; background: none; text-align: left; cursor: pointer; }
.launch-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 8px; }.error { padding: 12px; color: var(--state-danger); border: 1px solid var(--state-danger); border-radius: var(--radius-md); font-size: 13px; }.success { color: var(--accent-primary-hover); } .retry { justify-self: start; }
a, .text-button { color: var(--accent-primary-hover); }.text-button { background: none; border: 0; cursor: pointer; font: inherit; font-size: 12px; }.save { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.save summary { cursor: pointer; font-size: 13px; }.save p, .save form { margin-top: 16px; } .setup-details { border-top: 1px solid var(--border-subtle); padding-top: 14px; min-width: 0; }
@media (max-width: 540px) { .composer { padding: 20px; } .fields { grid-template-columns: 1fr; } .steps { gap: 12px; font-size: var(--text-xs); } h2 { font-size: 22px; } } summary { cursor: pointer; font-size: var(--text-sm); }
dl { display: grid; gap: 14px; margin: 16px 0; }
dt { color: var(--text-secondary); font-size: var(--text-xs); }
dd { margin: 5px 0 0; overflow-wrap: anywhere; font-size: var(--text-sm); line-height: 1.5; }
code { font-family: var(--font-mono); font-size: var(--text-xs); overflow-wrap: anywhere; }
h3 { font-size: var(--text-sm); margin: 18px 0 8px; }
.files { list-style: none; padding: 0; margin: 0; }
.files li { display: grid; gap: 5px; padding: 10px 0; border-bottom: 1px solid var(--border-subtle); }
.files li > div { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.files strong { font-size: var(--text-sm); font-weight: 500; }
.files span, .files code { font-size: var(--text-xs); color: var(--text-secondary); }
.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: 440px) { .fields { grid-template-columns: 1fr; } .launch-actions { align-items: stretch; flex-direction: column; } .launch-actions > div { justify-content: flex-end; } }
</style> </style>
@@ -1,22 +1,34 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import type { Host, NativeDeploymentSelection } from '$lib/hosts.js'; import type { Host, NativeDeploymentSelection } from '$lib/hosts.js';
import type { GameLibrary } from '$lib/game-files.js'; import type { GameLibrary } from '$lib/game-files.js';
import type { ZombieGame } from '$lib/map-aliases.js'; import type { ZombieGame } from '$lib/map-aliases.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { latestGameFiles, selectLatestGameFiles } from '$ui/native-game-files.js'; import { latestGameFiles, selectLatestGameFiles, type NativeHostSelectionStatus } from '$ui/native-game-files.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
let { games, selection = $bindable(), ready = $bindable(false), disabled = false, network = false, onchange }: let { games, selection = $bindable(), ready = $bindable(false), status = $bindable(), disabled = false, network = false, compact = false, refreshVersion = 0, onchange }:
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; disabled?: boolean; network?: boolean; onchange?: () => void } = $props(); { games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; status?: NativeHostSelectionStatus; disabled?: boolean; network?: boolean; compact?: boolean; refreshVersion?: number; onchange?: () => void } = $props();
let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state(''); let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state('');
let defaultRuntimeImage = $state(''); let defaultRuntimeImage = $state('');
let runtimeSelection: NativeDeploymentSelection | null = null; let runtimeSelection: NativeDeploymentSelection | null = null;
let requestVersion = 0;
let activeController: AbortController | undefined;
const eligible = $derived(hosts.filter(host => host.readiness === 'ready' && host.inventory.runtimeImages?.includes(selection.image))); const eligible = $derived(hosts.filter(host => host.readiness === 'ready' && host.inventory.runtimeImages?.includes(selection.image)));
const latest = $derived(latestGameFiles(libraries)); const latest = $derived(latestGameFiles(libraries));
function cacheReady(game: ZombieGame) { return !!latest[game]?.caches.some(cache => eligible.some(host => host.id === cache.hostId) && ['ready', 'outdated'].includes(cache.status)); } function cacheReady(game: ZombieGame) { return !!latest[game]?.caches.some(cache => eligible.some(host => host.id === cache.hostId) && ['ready', 'outdated'].includes(cache.status)); }
$effect(() => { ready = !loading && !error && /^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image) $effect(() => { ready = !loading && !error && /^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image)
&& games.length > 0 && games.every(game => !!latest[game]?.id && selection.libraryIds[game] === latest[game]?.id); }); && games.length > 0 && games.every(game => !!latest[game]?.id && selection.libraryIds[game] === latest[game]?.id); });
$effect(() => {
status = {
loading,
error,
hasHosts: hosts.length > 0,
filesReady: games.length > 0 && games.every(cacheReady),
missingGames: games.filter(game => !latest[game]?.id),
uploadedAt: games.map(game => latest[game]?.publishedAt ?? latest[game]?.createdAt)
.filter((value): value is string => !!value).sort().at(-1) ?? null,
};
});
$effect(() => { $effect(() => {
if (disabled || !defaultRuntimeImage || runtimeSelection === selection) return; if (disabled || !defaultRuntimeImage || runtimeSelection === selection) return;
if (!selection.image) selection.image = defaultRuntimeImage; if (!selection.image) selection.image = defaultRuntimeImage;
@@ -28,17 +40,62 @@
if (loading || error || disabled) return; if (loading || error || disabled) return;
if (selectLatestGameFiles(selection.libraryIds, latest, games)) onchange?.(); if (selectLatestGameFiles(selection.libraryIds, latest, games)) onchange?.();
}); });
async function load() { async function load(signal?: AbortSignal) {
const version = ++requestVersion;
activeController?.abort();
const controller = new AbortController();
activeController = controller;
const abort = () => controller.abort();
if (signal?.aborted) controller.abort();
else signal?.addEventListener('abort', abort, { once: true });
try { try {
const [hostResponse, libraryResponse] = await Promise.all([fetch('/api/hosts'), fetch('/api/game-files')]); const [hostResponse, libraryResponse] = await Promise.all([fetch('/api/hosts', { signal: controller.signal }), fetch('/api/game-files', { signal: controller.signal })]);
if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game files.'); if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game files.');
const [hostBody, libraryBody] = await Promise.all([hostResponse.json(), libraryResponse.json()]); hosts = hostBody.hosts; libraries = libraryBody.libraries; error = ''; const [hostBody, libraryBody] = await Promise.all([hostResponse.json(), libraryResponse.json()]);
if (version !== requestVersion || controller.signal.aborted) return;
hosts = hostBody.hosts; libraries = libraryBody.libraries; error = '';
defaultRuntimeImage = hostBody.defaultRuntimeImage ?? ''; defaultRuntimeImage = hostBody.defaultRuntimeImage ?? '';
} catch (cause) { error = (cause as Error).message; } finally { loading = false; } } catch (cause) {
if (version === requestVersion && !controller.signal.aborted) error = (cause as Error).message;
} finally {
signal?.removeEventListener('abort', abort);
if (version === requestVersion && !controller.signal.aborted) loading = false;
} }
onMount(() => pollVisible(load, 5000)); }
$effect(() => {
// Restart the poll for a parent refresh, sharing one initial request.
refreshVersion;
loading = true;
const stop = pollVisible(load, 5000);
return () => {
stop();
requestVersion++;
activeController?.abort();
};
});
</script> </script>
{#snippet runtimeFields()}
<label>{compact ? 'Runtime image' : 'Plutainer runtime image'}<input required bind:value={selection.image} disabled={disabled} oninput={() => onchange?.()} /></label>
{#if compact}<p>Custom images must be prepared on a host before it can run this server.</p>{/if}
{/snippet}
{#snippet networkFields()}
<label>Local bind address<input bind:value={selection.bindAddress} placeholder="Host default" disabled={disabled} oninput={() => onchange?.()} /></label>
<label>Public / NAT address<input bind:value={selection.publicAddress} placeholder="Host default" disabled={disabled} oninput={() => onchange?.()} /></label>
<label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} oninput={() => onchange?.()} /></label>
<p>Public mapping does not change the local Docker port reservation.</p>
{/snippet}
{#if compact}
<details class="advanced">
<summary>Advanced settings</summary>
<div class="advanced-fields">
{@render runtimeFields()}
{#if network}<fieldset><legend>Network overrides</legend>{@render networkFields()}</fieldset>{/if}
</div>
</details>
{:else}
<section class="selection" aria-label="Deployment host and game files"> <section class="selection" aria-label="Deployment host and game files">
<div class="title"><h3>Host and game files</h3><button type="button" disabled={disabled || loading} onclick={() => { onchange?.(); void load(); }}>Refresh hosts</button></div> <div class="title"><h3>Host and game files</h3><button type="button" disabled={disabled || loading} onclick={() => { onchange?.(); void load(); }}>Refresh hosts</button></div>
{#if error}<p role="alert" class="error">{error}</p>{/if} {#if error}<p role="alert" class="error">{error}</p>{/if}
@@ -57,11 +114,17 @@
</div> </div>
{/each}{/if} {/each}{/if}
<p>Hosts keep the latest T5 and T6 files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p> <p>Hosts keep the latest T5 and T6 files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
<details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must first be prepared by an admin from the destination host page.</p><label>Plutainer runtime image<input required bind:value={selection.image} disabled={disabled} onchange={() => onchange?.()} /></label></details> <details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must first be prepared by an admin from the destination host page.</p>{@render runtimeFields()}</details>
{#if network}<details><summary>Network overrides</summary><label>Local bind address<input bind:value={selection.bindAddress} placeholder="Host default" disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT address<input bind:value={selection.publicAddress} placeholder="Host default" disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} onchange={() => onchange?.()} /></label><p>Public mapping does not change the local Docker port reservation.</p></details>{/if} {#if network}<details><summary>Network overrides</summary>{@render networkFields()}</details>{/if}
</section> </section>
{/if}
<style> <style>
.game-files { display:grid; gap:5px; } .game-files strong { font-size:13px; font-weight:600; } .game-files { display:grid; gap:5px; } .game-files strong { font-size:13px; font-weight:600; }
.advanced { border-top:1px solid var(--border-subtle); padding-top:16px; }
.advanced summary { color:var(--text-secondary); }
.advanced-fields { padding-top:2px; }
fieldset { margin:18px 0 0; padding:0; border:0; min-width:0; }
legend { padding:0; font-size:13px; font-weight:600; }
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size:13px; } input { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size:12px; line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size:12px; } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size:13px; } details label { margin-top:14px; } details p { margin-top:12px; } .selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size:13px; } input { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size:12px; line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size:12px; } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size:13px; } details label { margin-top:14px; } details p { margin-top:12px; }
</style> </style>
+10
View File
@@ -1,7 +1,17 @@
import type { GameLibrary, GameLibraryGame, GameLibraryVersion } from '../game-files.js'; import type { GameLibrary, GameLibraryGame, GameLibraryVersion } from '../game-files.js';
import type { ZombieGame } from '../map-aliases.js';
export type LatestGameFiles = Partial<Record<GameLibraryGame, GameLibraryVersion>>; export type LatestGameFiles = Partial<Record<GameLibraryGame, GameLibraryVersion>>;
export interface NativeHostSelectionStatus {
loading: boolean;
error: string;
hasHosts: boolean;
filesReady: boolean;
missingGames: ZombieGame[];
uploadedAt: string | null;
}
/** Use the controller's latest upload, even while that upload is still syncing to a host. */ /** Use the controller's latest upload, even while that upload is still syncing to a host. */
export function latestGameFiles(libraries: GameLibrary[]): LatestGameFiles { export function latestGameFiles(libraries: GameLibrary[]): LatestGameFiles {
const latest: LatestGameFiles = {}; const latest: LatestGameFiles = {};
+130
View File
@@ -0,0 +1,130 @@
import type { LaunchPreview } from '../deployment-plan.js';
import type { ZombieGame } from '../map-aliases.js';
export interface ServerLaunchInput {
game: ZombieGame;
mapSlug: string;
libraryId?: string;
image: string;
bindAddress?: string;
publicAddress?: string;
publicPort?: number;
}
export interface ServerLaunchState {
preview: LaunchPreview | null;
checking: boolean;
error: string;
}
/** Keep only a current preview available while launch selections and prerequisites change. */
export function createServerLaunchController(
onChange: (state: ServerLaunchState) => void,
dependencies: { fetch?: typeof fetch; debounceMs?: number } = {},
) {
const fetchPreview = dependencies.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
const debounceMs = dependencies.debounceMs ?? 200;
let selection: string | null = null;
let state: ServerLaunchState = { preview: null, checking: false, error: '' };
let revision = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let request: AbortController | undefined;
let suspended = false;
let disposed = false;
function publish(next: ServerLaunchState) {
if (state.preview === next.preview && state.checking === next.checking && state.error === next.error) return;
state = next;
onChange(next);
}
function cancel() {
revision++;
clearTimeout(timer);
timer = undefined;
request?.abort();
request = undefined;
}
async function check(current: number) {
if (disposed || suspended || current !== revision || !selection) return;
timer = undefined;
const controller = new AbortController();
request = controller;
try {
const response = await fetchPreview('/api/provisioning/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: selection,
signal: controller.signal,
});
const body = await response.json();
if (disposed || suspended || current !== revision) return;
if (!response.ok) throw new Error(body.error ?? 'Could not check launch readiness. Try again.');
const preview = body as LaunchPreview;
const expiresAt = new Date(preview.expiresAt).getTime();
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
throw new Error('The launch check expired. Try again.');
}
publish({ preview, checking: false, error: '' });
if (!disposed && !suspended && current === revision) {
timer = setTimeout(refresh, Math.min(expiresAt - Date.now(), 2_147_483_647));
}
} catch (error) {
if (!disposed && !suspended && current === revision) {
publish({ preview: null, checking: false,
error: error instanceof Error ? error.message : 'Could not check launch readiness. Try again.' });
}
} finally {
if (current === revision) request = undefined;
}
}
function refresh() {
if (disposed || !selection || (state.checking && !suspended)) return;
suspended = false;
cancel();
publish({ preview: null, checking: true, error: '' });
void check(revision);
}
function clear() {
if (disposed) return;
cancel();
selection = null;
suspended = false;
publish({ preview: null, checking: false, error: '' });
}
return {
select(input: ServerLaunchInput | null) {
if (disposed) return;
if (!input) { clear(); return; }
// Explicit fields keep identity stable and exclude unrelated launch or collection metadata.
const next = JSON.stringify({ game: input.game, mapSlug: input.mapSlug, libraryId: input.libraryId,
image: input.image, bindAddress: input.bindAddress || undefined,
publicAddress: input.publicAddress || undefined, publicPort: input.publicPort });
if (selection === next) return;
cancel();
selection = next;
suspended = false;
publish({ preview: null, checking: true, error: '' });
const current = revision;
timer = setTimeout(() => void check(current), debounceMs);
},
refresh,
clear,
/** Freeze a settled preview during submission. refresh() resumes checks after a failed launch. */
suspend() {
if (disposed) return;
suspended = true;
cancel();
publish({ ...state, checking: false });
},
dispose() {
cancel();
selection = null;
disposed = true;
},
};
}
+192
View File
@@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { LaunchPreview } from '../../src/lib/deployment-plan.js';
import { createServerLaunchController, type ServerLaunchInput, type ServerLaunchState } from '../../src/lib/ui/server-launch.js';
const selection: ServerLaunchInput = { game: 't5', mapSlug: 'kino', libraryId: 'library-1', image: 'runtime:latest' };
const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
const preview = (id = 'preview-1', expiresIn = 60_000): LaunchPreview => ({
id, expiresAt: new Date(Date.now() + expiresIn).toISOString(), name: 't5_zombie_theater_1',
port: null, hasSavedKey: true, blueprintName: null, checks: [], ready: true,
recipe: { game: 't5', mapSlug: 'kino', mapName: 'Kino Der Toten', expectedMap: 'zombie_theater',
capturedAt: new Date().toISOString(), image: 'runtime:latest', configFile: 'server.cfg',
configSource: 'T5 Zombies', files: [], fingerprint: 'recipe-1' },
});
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
let controller: ReturnType<typeof createServerLaunchController>;
let state: ServerLaunchState;
let fetchPreview: ReturnType<typeof vi.fn<typeof fetch>>;
let changes: ReturnType<typeof vi.fn<(state: ServerLaunchState) => void>>;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-07T18:00:00Z'));
state = { preview: null, checking: false, error: '' };
fetchPreview = vi.fn<typeof fetch>();
changes = vi.fn((next: ServerLaunchState) => { state = next; });
controller = createServerLaunchController(changes, { fetch: fetchPreview });
});
afterEach(() => { controller.dispose(); vi.useRealTimers(); vi.restoreAllMocks(); });
describe('automatic server launch checks', () => {
it('debounces rapid selections and sends only the final game, map, and native overrides', async () => {
fetchPreview.mockResolvedValue(response(preview()));
controller.select(selection);
expect(state).toEqual({ preview: null, checking: true, error: '' });
await vi.advanceTimersByTimeAsync(150);
controller.select({ ...selection, mapSlug: 'five', publicPort: 30100 });
await vi.advanceTimersByTimeAsync(199);
expect(fetchPreview).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(fetchPreview).toHaveBeenCalledTimes(1);
expect(fetchPreview.mock.calls[0][0]).toBe('/api/provisioning/preview');
expect(JSON.parse(fetchPreview.mock.calls[0][1]!.body as string)).toEqual({ ...selection, mapSlug: 'five', publicPort: 30100 });
expect(state.preview?.id).toBe('preview-1');
expect(state.checking).toBe(false);
});
it('invalidates a settled preview synchronously when the selection changes', async () => {
fetchPreview.mockResolvedValue(response(preview()));
controller.select(selection);
await vi.advanceTimersByTimeAsync(200);
expect(state.preview).not.toBeNull();
controller.select({ ...selection, image: 'runtime:other' });
expect(state).toEqual({ preview: null, checking: true, error: '' });
});
it('does not duplicate requests for the same selection or overlapping refreshes', async () => {
const request = deferred<Response>();
fetchPreview.mockReturnValue(request.promise);
controller.select(selection);
controller.select({ ...selection });
await vi.advanceTimersByTimeAsync(200);
controller.refresh();
controller.refresh();
controller.select({ ...selection, publicAddress: '' });
expect(fetchPreview).toHaveBeenCalledTimes(1);
request.resolve(response(preview()));
await vi.advanceTimersByTimeAsync(0);
controller.select({ ...selection });
await vi.advanceTimersByTimeAsync(200);
expect(fetchPreview).toHaveBeenCalledTimes(1);
expect(state.preview?.id).toBe('preview-1');
});
it.each(['success', 'error'] as const)('aborts changed requests and ignores stale %s responses', async (result) => {
const first = deferred<Response>();
fetchPreview.mockReturnValueOnce(first.promise).mockResolvedValueOnce(response(preview('new-preview')));
controller.select(selection);
await vi.advanceTimersByTimeAsync(200);
const signal = fetchPreview.mock.calls[0][1]!.signal!;
controller.select({ ...selection, mapSlug: 'five' });
expect(signal.aborted).toBe(true);
await vi.advanceTimersByTimeAsync(200);
if (result === 'success') first.resolve(response(preview('old-preview')));
else first.reject(new Error('Old network error'));
await vi.advanceTimersByTimeAsync(0);
expect(state.preview?.id).toBe('new-preview');
expect(state.error).toBe('');
expect(state.checking).toBe(false);
});
it('ignores a response from a dismissed session after reopening the same selection', async () => {
const first = deferred<Response>();
fetchPreview.mockReturnValueOnce(first.promise).mockResolvedValueOnce(response(preview('reopened-preview')));
controller.select(selection);
await vi.advanceTimersByTimeAsync(200);
controller.clear();
expect(state).toEqual({ preview: null, checking: false, error: '' });
expect(fetchPreview.mock.calls[0][1]!.signal!.aborted).toBe(true);
controller.select(selection);
await vi.advanceTimersByTimeAsync(200);
first.resolve(response(preview('dismissed-preview')));
await vi.advanceTimersByTimeAsync(0);
expect(state.preview?.id).toBe('reopened-preview');
});
it('cancels a pending debounce when prerequisite data becomes unavailable', async () => {
controller.select(selection);
controller.select(null);
await vi.advanceTimersByTimeAsync(1000);
expect(fetchPreview).not.toHaveBeenCalled();
expect(state).toEqual({ preview: null, checking: false, error: '' });
});
it('refreshes immediately and never retains an old preview if the refresh fails', async () => {
const refresh = deferred<Response>();
fetchPreview.mockResolvedValueOnce(response(preview())).mockReturnValueOnce(refresh.promise);
controller.select(selection);
await vi.advanceTimersByTimeAsync(200);
controller.refresh();
expect(fetchPreview).toHaveBeenCalledTimes(2);
expect(state).toEqual({ preview: null, checking: true, error: '' });
refresh.resolve(response({ error: 'No runtime is available.' }, 409));
await vi.advanceTimersByTimeAsync(0);
expect(state).toEqual({ preview: null, checking: false, error: 'No runtime is available.' });
fetchPreview.mockResolvedValueOnce(response(preview('retry-preview')));
controller.refresh();
expect(state.error).toBe('');
await vi.advanceTimersByTimeAsync(0);
expect(state.preview?.id).toBe('retry-preview');
});
it('automatically renews an expired preview and removes it while checking', async () => {
const renewal = deferred<Response>();
fetchPreview.mockResolvedValueOnce(response(preview('initial', 1000))).mockReturnValueOnce(renewal.promise);
controller.select(selection);
await vi.advanceTimersByTimeAsync(999);
expect(state.preview?.id).toBe('initial');
await vi.advanceTimersByTimeAsync(1);
expect(state).toEqual({ preview: null, checking: true, error: '' });
expect(fetchPreview).toHaveBeenCalledTimes(2);
renewal.resolve(response(preview('renewed')));
await vi.advanceTimersByTimeAsync(0);
expect(state.preview?.id).toBe('renewed');
});
it('rejects an already expired response without entering a refresh loop', async () => {
fetchPreview.mockResolvedValue(response(preview('expired', 100)));
controller.select(selection);
await vi.advanceTimersByTimeAsync(2000);
expect(fetchPreview).toHaveBeenCalledTimes(1);
expect(state).toEqual({ preview: null, checking: false, error: 'The launch check expired. Try again.' });
});
it('freezes a preview during submission and resumes checks after a failed launch', async () => {
fetchPreview.mockResolvedValueOnce(response(preview('submitted', 1000)));
controller.select(selection);
await vi.advanceTimersByTimeAsync(200);
controller.suspend();
controller.select({ ...selection });
await vi.advanceTimersByTimeAsync(1000);
expect(state.preview?.id).toBe('submitted');
expect(fetchPreview).toHaveBeenCalledTimes(1);
fetchPreview.mockResolvedValueOnce(response(preview('retry')));
controller.refresh();
expect(state.preview).toBeNull();
await vi.advanceTimersByTimeAsync(0);
expect(state.preview?.id).toBe('retry');
});
it('ignores in-flight responses and future input after disposal', async () => {
const request = deferred<Response>();
fetchPreview.mockReturnValueOnce(request.promise);
controller.select(selection);
await vi.advanceTimersByTimeAsync(200);
const previousChanges = changes.mock.calls.length;
controller.dispose();
request.resolve(response(preview()));
controller.select({ ...selection, mapSlug: 'five' });
controller.refresh();
await vi.advanceTimersByTimeAsync(60_000);
expect(changes).toHaveBeenCalledTimes(previousChanges);
expect(fetchPreview).toHaveBeenCalledTimes(1);
expect(fetchPreview.mock.calls[0][1]!.signal!.aborted).toBe(true);
});
});