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
@@ -1,22 +1,34 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Host, NativeDeploymentSelection } from '$lib/hosts.js';
import type { GameLibrary } from '$lib/game-files.js';
import type { ZombieGame } from '$lib/map-aliases.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';
let { games, selection = $bindable(), ready = $bindable(false), disabled = false, network = false, onchange }:
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; disabled?: boolean; network?: boolean; onchange?: () => void } = $props();
let { games, selection = $bindable(), ready = $bindable(false), status = $bindable(), disabled = false, network = false, compact = false, refreshVersion = 0, onchange }:
{ 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 defaultRuntimeImage = $state('');
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 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)); }
$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); });
$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(() => {
if (disabled || !defaultRuntimeImage || runtimeSelection === selection) return;
if (!selection.image) selection.image = defaultRuntimeImage;
@@ -28,17 +40,62 @@
if (loading || error || disabled) return;
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 {
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.');
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 ?? '';
} 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>
{#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">
<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}
@@ -57,11 +114,17 @@
</div>
{/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>
<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>
{#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}
<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>{@render networkFields()}</details>{/if}
</section>
{/if}
<style>
.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; }
</style>