119 lines
14 KiB
Svelte
119 lines
14 KiB
Svelte
<script lang="ts">
|
||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||
import { onMount, tick } from 'svelte';
|
||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||
import { goto } from '$app/navigation';
|
||
import Button from './primitives/Button.svelte';
|
||
import LaunchRecipe from './LaunchRecipe.svelte';
|
||
import ServerIdentity from './ServerIdentity.svelte';
|
||
import type { CollectionPreview } from '$lib/deployment-plan.js';
|
||
|
||
let { open = $bindable(false), blueprintId = '' }: { open?: boolean; blueprintId?: string } = $props();
|
||
let dialog: HTMLDialogElement | undefined = $state();
|
||
let preview = $state<CollectionPreview | null>(null);
|
||
let keys = $state<Record<string, string>>({});
|
||
let busy = $state(false);
|
||
let error = $state('');
|
||
let wasOpen = false;
|
||
let session = 0;
|
||
let keyNotice = $state('');
|
||
let now = $state(Date.now());
|
||
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
|
||
const keysReady = $derived(preview?.servers.filter(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())).length ?? 0);
|
||
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
|
||
let canLaunch = $derived(preview?.ready && preview.servers.length > 0 && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
|
||
|
||
$effect(() => {
|
||
if (!dialog) return;
|
||
if (open && !wasOpen) { wasOpen = true; keys = {}; preview = null; keyNotice = ''; dialog.showModal(); void review(); }
|
||
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
|
||
});
|
||
|
||
async function review() {
|
||
const current = ++session;
|
||
const previous = preview;
|
||
busy = true; error = ''; keyNotice = '';
|
||
try {
|
||
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId }) });
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body.error ?? 'Could not preview the collection.');
|
||
if (current === session) {
|
||
const next = body as CollectionPreview;
|
||
const names = new Set(next.servers.map(server => server.name));
|
||
keys = Object.fromEntries(next.servers.filter(server => server.name).map(server => [server.name!, keys[server.name!] ?? '']));
|
||
if (previous && (previous.servers.length !== next.servers.length || previous.servers.some(server => !names.has(server.name)))) keyNotice = 'Server assignments changed. Keys were retained only for the exact IDs still shown below. Review the new assignments.';
|
||
else if (previous) keyNotice = 'Preview refreshed. Entered keys are retained for these same server IDs.';
|
||
preview = next; now = Date.now();
|
||
}
|
||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||
finally { if (current === session) busy = false; }
|
||
}
|
||
|
||
async function launch() {
|
||
if (!preview || !canLaunch || expired || busy) return;
|
||
busy = true; error = '';
|
||
try {
|
||
const res = await fetch('/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: preview.id, keys }) });
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.');
|
||
open = false;
|
||
if (body.collectionId) await goto(`/deployments/collections/${encodeURIComponent(body.collectionId)}`);
|
||
} catch (err) { error = (err as Error).message; }
|
||
finally { busy = false; }
|
||
}
|
||
|
||
async function nextMissing() {
|
||
await tick();
|
||
dialog?.querySelector<HTMLInputElement>('input[data-missing="true"]')?.focus();
|
||
}
|
||
</script>
|
||
|
||
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||
<div class="composer">
|
||
<header><div><span class="eyebrow">Collection launch</span><h2>{preview?.blueprintName ?? 'Preparing your collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection preview">×</button></header>
|
||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||
{#if !preview}<p class="muted">{busy ? 'Finding a lobby and port for every server…' : 'Refresh to prepare another preview.'}</p>{#if !busy}<Button onclick={review}>Refresh preview</Button>{/if}
|
||
{:else}
|
||
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
|
||
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if}
|
||
<div class="lineup"><strong>{preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Blueprint already satisfied'}</strong>
|
||
<table><thead><tr><th>Map</th><th>Target</th><th>Existing / queued</th><th>To create</th></tr></thead><tbody>{#each preview.entries as entry}<tr><th>{entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></th><td>{entry.count}</td><td title={entry.existingServers.join(', ')}>{entry.existing}</td><td class="quantity">{entry.missing}</td></tr>{/each}</tbody></table>
|
||
<p class="muted">Targets count inventory, including stopped servers; they do not mean every server is ready to play. Only missing servers are created. Extra servers remain and existing servers are not updated.</p>
|
||
{#if preview.entries.some(entry => entry.existing)}<details class="coverage" open={!preview.servers.length}><summary>Review existing servers</summary>{#each preview.entries.filter(entry => entry.existing) as entry}<div class="coverage-map"><strong>{entry.recipe.mapName}: {entry.existing} existing</strong><p class="muted">{[...new Set((entry.coverage ?? []).map(server => server.health))].map(health => `${entry.coverage.filter(server => server.health === health).length} ${health}`).join(', ')}</p><ul>{#each entry.coverage ?? [] as server}<li><a href={server.href}><ServerIdentity name={server.name} /></a><span>{server.health === 'online' ? 'Healthy' : server.health}</span></li>{/each}</ul></div>{/each}</details>{/if}
|
||
</div>
|
||
<div class="section-title"><h3>{preview.servers.length ? 'Your launch order' : 'Nothing to create'}</h3><button class="text-button" disabled={busy} onclick={review}>Refresh preview</button></div>
|
||
{#each preview.blockers as blocker}<p class="error" role="alert">{blocker}</p>{/each}
|
||
{#if preview.servers.length}
|
||
<p class="muted">Add a key for each new lobby below, or reuse its saved key. Fleet counts and available slots are checked again when you queue the launch.</p>
|
||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||
<ol class="servers" aria-label="Server launch order">
|
||
{#each preview.servers as server, index (server.name ?? `unavailable-${index}`)}
|
||
<li class="server-row">
|
||
<span class="order" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
||
<div class="server-identity">{#if index === 0 || preview.servers[index - 1].entryIndex !== server.entryIndex}<span class="map-group">{preview.entries[server.entryIndex].recipe.game.toUpperCase()} · {preview.entries[server.entryIndex].recipe.mapName}</span>{/if}<strong>{#if server.name}<ServerIdentity name={server.name} />{:else}{preview.entries[server.entryIndex].recipe.mapName}{/if}</strong><span class="server-id">{server.name ?? 'No available lobby'} · {server.port === null ? 'No available port' : `Port ${server.port}`}</span></div>
|
||
{#if server.name}<label><span>{server.hasSavedKey ? 'Saved key available' : 'Server key required'}</span><input aria-label={`Key for ${server.name}`} data-missing={!server.hasSavedKey && !keys[server.name]?.trim()} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder={server.hasSavedKey ? 'Leave blank to reuse' : 'Paste this server’s key'} /></label>{/if}
|
||
</li>
|
||
{/each}
|
||
</ol>
|
||
<div class="launch-footer"><div><p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p><p class="caption">Validity is checked at startup. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || expired || !canLaunch}>Queue {preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} →</Button></div></div>
|
||
</form>
|
||
{:else}<p class="muted">Your fleet already meets or exceeds every target in this blueprint. No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
|
||
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each server uses the latest promoted setup when its turn begins. The host must have the selected games and DLC. This preview checks settings and capacity; it does not probe game files, images, credentials, or network access.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
|
||
{/if}
|
||
</div>
|
||
</dialog>
|
||
|
||
<style>
|
||
input { scroll-margin-block: 32px 190px; }
|
||
.launch-footer { position: sticky; bottom: -28px; background: var(--surface); z-index: 2; padding: 16px 0 20px; border-top: 1px solid var(--border-strong); box-shadow: 0 -12px 16px var(--surface); }
|
||
.map-group { font-size: 12px; color: var(--accent-primary-hover); }
|
||
.coverage { margin-top: 16px; font-size: 14px; }.coverage summary { cursor: pointer; }.coverage-map { padding-top: 14px; }.coverage ul { list-style: none; padding: 0; }.coverage li { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; }.coverage a { color: var(--accent-primary-hover); }.coverage li span { color: var(--text-secondary); }
|
||
@media (max-width: 600px) { .launch-footer { bottom: -20px; } }
|
||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
|
||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: var(--text-xs); }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
|
||
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: var(--text-xs); }
|
||
form { display: grid; gap: 20px; }.servers { margin: 0; padding: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; }.server-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 220px; align-items: center; gap: 16px; padding: 20px 16px; }.server-row + .server-row { border-top: 1px solid var(--border-subtle); }.order { font-family: var(--font-mono); color: var(--text-tertiary); font-size: 12px; align-self: start; padding-top: 3px; }.server-identity { display: grid; gap: 8px; min-width: 0; }.server-row strong { font-size: 14px; overflow-wrap: anywhere; }.server-id { font-family: var(--font-mono); font-size: 11px; line-height: 1.6; color: var(--text-secondary); overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-control); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
|
||
.launch-footer { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-wrap: wrap; }.requirements { margin-top: 16px; }button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
||
@media (max-width: 600px) { .composer { padding: 20px; }.lineup { padding: 16px; }.server-row { grid-template-columns: 24px minmax(0, 1fr); gap: 12px; padding: 16px; }.server-row label { grid-column: 2; }.launch-footer { align-items: stretch; flex-direction: column; gap: 16px; }.actions { justify-content: space-between; }h2 { font-size: 22px; } }
|
||
</style>
|