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

203 lines
19 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 DeployCollectionDialog from './DeployCollectionDialog.svelte';
import NativeHostSelection from './NativeHostSelection.svelte';
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';
let { open = $bindable(false), initialBlueprintId = '', sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '', onblueprint }:
{ open?: boolean; initialBlueprintId?: string; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string; onblueprint?: () => void } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let game = $state<ZombieGame>('t5');
let mapSlug = $state('');
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
let blueprints = $state<BlueprintSummary[]>([]);
let blueprintId = $state('');
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>({ hostId: '', libraryIds: {}, image: '' });
let hostReady = $state(false);
let plutoKey = $state('');
let busy = $state(false);
let loaded = $state(false);
let error = $state('');
let blueprintName = $state('');
let description = $state('');
let savedName = $state('');
let saving = $state(false);
let wasOpen = false;
let session = 0;
let keyNotice = $state('');
let selectionNotice = $state('');
let now = $state(Date.now());
let heading: HTMLHeadingElement;
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
$effect(() => {
if (!dialog) return;
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void initialize(); }
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
});
async function initialize() {
const current = ++session;
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; keyNotice = ''; selectionNotice = '';
blueprintName = ''; description = ''; savedName = ''; saving = false;
blueprintId = initialBlueprintId;
game = initialGame;
mapSlug = initialMapSlug;
hostSelection = { hostId: '', libraryIds: {}, image: '' }; hostReady = false;
try {
const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]);
if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and collections. Try again.');
const [mapBody, blueprintBody] = await Promise.all([mapRes.json(), blueprintRes.json()]);
if (current !== session) return;
catalogs = { t5: mapBody.t5Zombie, t6: mapBody.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) {
const source = await fetch(`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`).then(response => response.json());
if (current !== session) return;
if (!source.recipe) throw new Error('The original launch recipe is unavailable.');
game = source.recipe.game; mapSlug = source.recipe.mapSlug;
}
loaded = true;
} catch (err) { if (current === session) error = (err as Error).message; }
}
function selectionChanged() {
if (preview) selectionNotice = 'The host or game files changed. Review the launch again with the latest files.';
preview = null; plutoKey = ''; keyNotice = '';
}
async function review() {
if (busy) return;
if (!sourceDeploymentId && selectedBlueprint) {
open = false; collectionOpen = true; return;
}
if (!hostReady) { error = 'Choose a host and wait for its latest game files and runtime to finish automatic setup.'; return; }
const current = session;
const previousName = preview?.name;
busy = true; error = ''; savedName = ''; keyNotice = ''; selectionNotice = '';
try {
const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }),
hostId: hostSelection.hostId, 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) { plutoKey = ''; keyNotice = 'The available server ID changed. Enter the key for the new ID below; the previous key was not transferred.'; }
if (body.hasSavedKey) plutoKey = '';
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() {
if (!preview || !preview.ready || !hostReady || expired || busy) return;
busy = true; error = '';
try {
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ game: preview.recipe.game, gameMode: 'zombie', mapSlug: preview.recipe.mapSlug,
expectedName: preview.name, previewId: preview.id, plutoKey: plutoKey.trim() || undefined }) });
const body = await res.json();
if (!res.ok || !body.ok) throw new Error(body.error ?? 'Could not queue this launch.');
open = false;
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
} catch (err) { error = (err as Error).message; }
finally { busy = 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>
<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}>
<div class="composer">
<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>
<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>
{#if error}<div class="error" role="alert">{error}</div>{/if}
{#if selectionNotice}<p class="muted" role="status">{selectionNotice}</p>{/if}
{#if open && loaded && (sourceDeploymentId || !selectedBlueprint)}<NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy || saving} network onchange={selectionChanged} />{/if}
{#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 keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
{#if expired}<p class="error" role="status">This preview expired. Refresh it to recheck capacity. Keys stay entered when the server ID is unchanged.</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>{preview.port ? `Port ${preview.port} · ` : ''}Fresh identity, with its own server key.</p></div>
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
{:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if}
<p class="muted">{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Electric Cherry does not check whether Plutonium accepts it. Update it in Keys.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p>
<div class="launch-actions"><Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !hostReady || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</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">Choose a host for the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.</p><Button disabled={busy || !hostReady} onclick={review}>Review launch</Button>
{:else}
<form onsubmit={event => { event.preventDefault(); void review(); }}>
<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>
{#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 their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
{: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}
<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>
<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>
</form>
{/if}
</div>
</dialog>
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
<style>
.key-ready { color: var(--state-online); font-weight: 600; }
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); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
.composer { display: grid; gap: 24px; padding: 30px; } header, .section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.eyebrow { font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: .13em; text-transform: uppercase; display: block; margin-bottom: 8px; }
h2 { font-size: 25px; letter-spacing: -.03em; margin: 0; } h3 { font-size: 15px; margin: 0; }
.close { background: none; color: var(--text-secondary); border: 0; font-size: 27px; cursor: pointer; }
.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); }
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; }
.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); }
.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); }
.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); }
.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; }
.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); }
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; }
@media (max-width: 540px) { .composer { padding: 20px; } .fields { grid-template-columns: 1fr; } .steps { gap: 12px; font-size: var(--text-xs); } h2 { font-size: 22px; } }
</style>