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

125 lines
16 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 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>
{#if preview.servers.length}
<div class="pacing-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M7 3v18M17 3v18"/><path d="M4 6h6M14 12h6M4 18h6" stroke-linecap="round"/></svg>
<div><strong>{preview.launchConcurrency === 1 ? 'A steady launch, one server at a time' : `A steady launch, up to ${preview.launchConcurrency} servers at a time`}</strong><p>{preview.launchConcurrency === 1 ? 'Each server finishes its startup checks before the next begins.' : 'New servers start as earlier launches finish their startup checks.'} Other launches share this queue, keeping startup load under control.</p></div>
</div>
{/if}
<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); }
.pacing-note { display: flex; align-items: flex-start; gap: 14px; background: color-mix(in srgb, var(--accent-primary) 8%, transparent); border-left: 2px solid var(--accent-primary); padding: 16px; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }.pacing-note svg { flex: 0 0 24px; width: 24px; height: 24px; color: var(--accent-primary-hover); }.pacing-note strong { font-size: 14px; }.pacing-note p { margin-top: 6px; color: var(--text-secondary); font-size: 12px; line-height: 1.6; }.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>