Remove key entry from launch dialogs

This commit is contained in:
2026-10-07 13:24:00 -04:00
parent ec45371478
commit bd7d1ed86f
4 changed files with 48 additions and 65 deletions
+12 -11
View File
@@ -33,8 +33,9 @@ It fills deficits without changing existing servers' files.
Choose **Fill missing servers** or **Match collection exactly**, then review Choose **Fill missing servers** or **Match collection exactly**, then review
the server IDs and estimated capacity. Native host ports are assigned later. the server IDs and estimated capacity. Native host ports are assigned later.
4. Launches covered by unused keys show **Game key available**. Each game’s 4. Launches covered by unused keys show **Game key available**. Each game’s
available keys are counted once across the full lineup. Add an unused Plutonium available keys are counted once across the full lineup. If more keys are
key for each remaining launch, or manage the game pools in **Keys**. needed, add them under **Administration → Keys**, then refresh the preview.
Launch is enabled when every new server has an available game key.
5. Choose **Queue N servers** (or confirm removals and choose **Apply** in exact 5. Choose **Queue N servers** (or confirm removals and choose **Apply** in exact
mode) and follow the collection progress page. mode) and follow the collection progress page.
If every target is already met, choose **Done**; no operation is created. If every target is already met, choose **Done**; no operation is created.
@@ -102,10 +103,9 @@ Apply validates the reviewed configuration, lobby IDs, keys, and fleet changes,
one database transaction, and returns the progress page ID. Native jobs initially one database transaction, and returns the progress page ID. Native jobs initially
have no host or port; each job performs its capacity checks asynchronously during have no host or port; each job performs its capacity checks asynchronously during
provisioning. If a server lacks an available game key or its reviewed identity changed, provisioning. If a server lacks an available game key or its reviewed identity changed,
no jobs or key changes from that submission are committed. Legacy submissions also no jobs or key reservations from that submission are committed. Legacy submissions
revalidate their reviewed ports. Refreshing also revalidate their reviewed ports. Refreshing the preview rechecks pool
the preview retains typed keys for launches in the same game that still need availability and resets removal confirmation.
keys, and resets removal confirmation.
Submitting the same preview again returns the same collection, including after Submitting the same preview again returns the same collection, including after
a lost response or an application restart. Reusing the collection with a new a lost response or an application restart. Reusing the collection with a new
@@ -145,13 +145,14 @@ remain visible in Provisioning activity and history.
2. Choose the current promoted setup and select a game/map, or select a collection. 2. Choose the current promoted setup and select a game/map, or select a collection.
3. Choose **Preview launch**. Review the image reference, configuration sources, 3. Choose **Preview launch**. Review the image reference, configuration sources,
managed files and versions, selected map, lobby, port, and prerequisite checks. managed files and versions, selected map, lobby, port, and prerequisite checks.
4. Use an available key from the game pool, or enter an unused key for that game. 4. The launch automatically uses an available key from the game pool. If none is
available, manage the pool under **Administration → Keys**, then refresh the
preview.
5. Choose **Launch server** and follow the operation to verified health. 5. Choose **Launch server** and follow the operation to verified health.
A preview lasts 30 minutes. It neither reserves capacity nor saves a key. If A preview lasts 30 minutes. It reserves neither capacity nor a key. If
another launch takes the lobby, submission stops before saving the key; another launch takes the lobby, submission stops before reserving a key;
refresh the preview and review the newly assigned ID. A typed key can follow refresh the preview and review the newly assigned ID. Pool availability is checked again at
the new lobby within the same game. Pool availability is checked again at
submission, and keys are reserved atomically so concurrent launches cannot share submission, and keys are reserved atomically so concurrent launches cannot share
a credential. a credential.
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, tick } from 'svelte'; import { onMount } from 'svelte';
import { isDialogBackdrop } from '$ui/dialog.js'; import { isDialogBackdrop } from '$ui/dialog.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
@@ -20,22 +20,21 @@
let hostSelection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' }); let hostSelection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
let hostReady = $state(false); let hostReady = $state(false);
let confirmRemovals = $state(false); let confirmRemovals = $state(false);
let keys = $state<Record<string, string>>({});
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
let wasOpen = false; let wasOpen = false;
let session = 0; let session = 0;
let keyNotice = $state(''); let assignmentNotice = $state('');
let now = $state(Date.now()); let now = $state(Date.now());
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= 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); const keysReady = $derived(preview?.servers.filter(server => server.name && server.hasSavedKey).length ?? 0);
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); }); onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
let canLaunch = $derived(preview?.ready && preview.mode === mode && (preview.servers.length > 0 || preview.removals.length > 0) let canLaunch = $derived(preview?.ready && preview.mode === mode && (preview.servers.length > 0 || preview.removals.length > 0)
&& (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim()))); && (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && server.hasSavedKey));
$effect(() => { $effect(() => {
if (!dialog) return; if (!dialog) return;
if (open && !wasOpen) { wasOpen = true; mode = 'fill'; keys = {}; preview = null; keyNotice = ''; dialog.showModal(); void initialize(); } if (open && !wasOpen) { wasOpen = true; mode = 'fill'; preview = null; assignmentNotice = ''; dialog.showModal(); void initialize(); }
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); } else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
}); });
@@ -57,7 +56,7 @@
if (!hostReady) { error = 'Choose the latest uploaded game files and a valid runtime image before reviewing the launch.'; return; } if (!hostReady) { error = 'Choose the latest uploaded game files and a valid runtime image before reviewing the launch.'; return; }
const current = ++session; const current = ++session;
const previous = preview; const previous = preview;
busy = true; error = ''; keyNotice = ''; confirmRemovals = false; busy = true; error = ''; assignmentNotice = ''; confirmRemovals = false;
try { try {
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId, mode, libraryIds: hostSelection.libraryIds, image: hostSelection.image }) }); const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId, mode, libraryIds: hostSelection.libraryIds, image: hostSelection.image }) });
const body = await res.json(); const body = await res.json();
@@ -65,17 +64,7 @@
if (current === session) { if (current === session) {
const next = body as CollectionPreview; const next = body as CollectionPreview;
const names = new Set(next.servers.map(server => server.name)); const names = new Set(next.servers.map(server => server.name));
const enteredByGame = new Map<ZombieGame, string[]>(); if (previous && (previous.servers.length !== next.servers.length || previous.servers.some(server => !names.has(server.name)))) assignmentNotice = 'Server assignments changed. Review the updated launch order before applying.';
for (const server of previous?.servers ?? []) {
const value = server.name ? keys[server.name]?.trim() : '';
if (!value) continue;
const game = previous!.entries[server.entryIndex].recipe.game;
enteredByGame.set(game, [...(enteredByGame.get(game) ?? []), value]);
}
keys = Object.fromEntries(next.servers.filter(server => server.name && !server.hasSavedKey)
.map(server => [server.name!, enteredByGame.get(next.entries[server.entryIndex].recipe.game)?.shift() ?? '']));
if (previous && (previous.servers.length !== next.servers.length || previous.servers.some(server => !names.has(server.name)))) keyNotice = 'Server assignments changed. Entered keys were retained for launches in the same game that still need a key.';
else if (previous) keyNotice = 'Preview refreshed. Entered keys are retained for games that still need them.';
preview = next; now = Date.now(); preview = next; now = Date.now();
} }
} catch (err) { if (current === session) error = (err as Error).message; } } catch (err) { if (current === session) error = (err as Error).message; }
@@ -86,7 +75,7 @@
if (!preview || !canLaunch || expired || busy) return; if (!preview || !canLaunch || expired || busy) return;
busy = true; error = ''; busy = true; error = '';
try { try {
const res = await fetch('/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: preview.id, keys }) }); const res = await fetch('/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: preview.id }) });
const body = await res.json(); const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.'); if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.');
open = false; open = false;
@@ -94,11 +83,6 @@
} catch (err) { error = (err as Error).message; } } catch (err) { error = (err as Error).message; }
finally { busy = false; } finally { busy = false; }
} }
async function nextMissing() {
await tick();
dialog?.querySelector<HTMLInputElement>('input[data-missing="true"]')?.focus();
}
</script> </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}> <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}>
@@ -113,10 +97,10 @@
{#if open && games.length}<NativeHostSelection {games} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} onchange={() => { preview = null; confirmRemovals = false; }} />{/if} {#if open && games.length}<NativeHostSelection {games} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} onchange={() => { preview = null; confirmRemovals = false; }} />{/if}
<p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this collection across both games.' : 'Create missing lobbies and keep all existing servers.'}</p> <p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this collection across both games.' : 'Create missing lobbies and keep all existing servers.'}</p>
{#if error}<p class="error" role="alert">{error}</p>{/if} {#if error}<p class="error" role="alert">{error}</p>{/if}
{#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Review the fleet changes, capacity estimate, and server keys. Queued launches wait automatically when hosts need more capacity or preparation.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if} {#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Review the fleet changes, capacity estimate, and game key availability. Queued launches wait automatically when hosts need more capacity or preparation.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if}
{:else} {:else}
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if} {#if assignmentNotice}<p role="status" class="muted">{assignmentNotice}</p>{/if}
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; entered keys stay with their game.</p>{/if} {#if expired}<p class="error" role="status">This preview expired. Refresh before launch to recheck capacity and key availability.</p>{/if}
<div class="lineup"><strong>{preview.mode === 'exact' ? `${preview.retainedServers.length} keep · ${preview.servers.length} create · ${preview.removals.length} remove` : preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Collection already satisfied'}</strong> <div class="lineup"><strong>{preview.mode === 'exact' ? `${preview.retainedServers.length} keep · ${preview.servers.length} create · ${preview.removals.length} remove` : preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Collection 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> <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. {preview.mode === 'exact' ? 'Existing deployed lobbies are kept before queued creations, then the lowest lobby numbers. Retained servers are not changed or restarted.' : 'Only missing servers are created. Extra servers remain and existing servers are not updated.'}</p> <p class="muted">Targets count inventory, including stopped servers; they do not mean every server is ready to play. {preview.mode === 'exact' ? 'Existing deployed lobbies are kept before queued creations, then the lowest lobby numbers. Retained servers are not changed or restarted.' : 'Only missing servers are created. Extra servers remain and existing servers are not updated.'}</p>
@@ -141,7 +125,8 @@
{#each preview.blockers as blocker}<p class="error" role="alert">{blocker}</p>{/each} {#each preview.blockers as blocker}<p class="error" role="alert">{blocker}</p>{/each}
{#each preview.warnings ?? [] as warning}<p class="warning" role="status">{warning}</p>{/each} {#each preview.warnings ?? [] as warning}<p class="warning" role="status">{warning}</p>{/each}
{#if preview.servers.length || preview.removals.length} {#if preview.servers.length || preview.removals.length}
{#if preview.servers.length}<p class="muted">Available keys are assigned from each game’s pool. Add an unused key for each remaining launch below. Manage pools in <a href="/server-keys" target="_blank" rel="noopener">Keys (opens in a new tab)</a>. Apply saves the reviewed jobs and opens their progress page. Each launch waits for a startup slot and host capacity.</p>{/if} {#if preview.servers.length}<p class="muted">Available keys are assigned automatically from each game’s pool. Apply saves the reviewed jobs and opens their progress page. Each launch waits for a startup slot and host capacity.</p>{/if}
{#if preview.servers.some(server => !server.hasSavedKey)}<p class="muted" role="status">There are not enough unused keys for these launches. Add keys to the affected game pools in <a href="/server-keys" target="_blank" rel="noopener">Keys (opens in a new tab)</a>, then Refresh preview to enable launch.</p>{/if}
{#if preview.removals.length && preview.servers.some(server => !server.hasSavedKey)}<p class="muted">Keys held by servers awaiting removal are still in use. To reuse those keys, remove the servers first and refresh this preview after cleanup finishes.</p>{/if} {#if preview.removals.length && preview.servers.some(server => !server.hasSavedKey)}<p class="muted">Keys held by servers awaiting removal are still in use. To reuse those keys, remove the servers first and refresh this preview after cleanup finishes.</p>{/if}
<form onsubmit={event => { event.preventDefault(); void launch(); }}> <form onsubmit={event => { event.preventDefault(); void launch(); }}>
{#if preview.removals.length} {#if preview.removals.length}
@@ -159,14 +144,14 @@
<span class="order" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span> <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.native ? 'Host and port assigned when provisioning starts' : server.port === null ? 'No available port' : `Port ${server.port}`}</span></div> <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.native ? 'Host and port assigned when provisioning starts' : server.port === null ? 'No available port' : `Port ${server.port}`}</span></div>
{#if server.name} {#if server.name}
{#if server.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Game key available</p> {#if server.hasSavedKey}<p class="key-status key-ready"><span aria-hidden="true">✓</span> Game key available</p>
{:else}<label><span>{preview.entries[server.entryIndex].recipe.game.toUpperCase()} pool key required</span><input aria-label={`Unused ${preview.entries[server.entryIndex].recipe.game.toUpperCase()} key for launch ${index + 1}`} data-missing={!keys[server.name]?.trim()} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder="Paste an unused game key" /></label>{/if} {:else}<p class="key-status key-missing">No unused {preview.entries[server.entryIndex].recipe.game.toUpperCase()} key available</p>{/if}
{/if} {/if}
</li> </li>
{/each} {/each}
</ol> </ol>
{/if} {/if}
<div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Pool availability is checked again when you apply. 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={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div> <div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Pool availability is checked again when you apply. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p></div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div>
</form> </form>
{:else}<p class="muted">{preview.mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet already meets or exceeds every target in this collection.'} No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if} {:else}<p class="muted">{preview.mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet already meets or exceeds every target in this collection.'} 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 new server is automatically placed on an eligible host with the latest game files and prepared runtime. CPU, memory, host readiness, synced files, runtime availability, and local port reservations are checked again before deployment. Existing servers keep their current files.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details> <details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each new server is automatically placed on an eligible host with the latest game files and prepared runtime. CPU, memory, host readiness, synced files, runtime availability, and local port reservations are checked again before deployment. Existing servers keep their current files.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
@@ -185,10 +170,11 @@
.removals li { display: grid; gap: 4px; font-size: 13px; } .removals li { display: grid; gap: 4px; font-size: 13px; }
.confirm-removals { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; } .confirm-removals { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; }
.confirm-removals input { width: auto; margin-top: 2px; } .confirm-removals input { width: auto; margin-top: 2px; }
.key-ready { color: var(--state-online); font-size: 13px; font-weight: 600; } .key-status { font-size: 13px; font-weight: 600; }
.key-ready { color: var(--state-online); }
.key-missing { color: var(--state-warning); }
a { color: var(--accent-primary-hover); } a { color: var(--accent-primary-hover); }
@media (max-width: 600px) { .server-row .key-ready { grid-column: 2; } } @media (max-width: 600px) { .server-row .key-status { grid-column: 2; } }
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); } .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); } .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); } .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); }
@@ -196,7 +182,7 @@
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; } 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; } 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); } .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); } 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); }.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; } .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; } } @media (max-width: 600px) { .composer { padding: 20px; }.lineup { padding: 16px; }.server-row { grid-template-columns: 24px minmax(0, 1fr); gap: 12px; padding: 16px; }.launch-footer { align-items: stretch; flex-direction: column; gap: 16px; }.actions { justify-content: space-between; }h2 { font-size: 22px; } }
</style> </style>
+13 -17
View File
@@ -25,7 +25,6 @@
let preview = $state<LaunchPreview | null>(null); let preview = $state<LaunchPreview | null>(null);
let hostSelection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' }); let hostSelection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
let hostReady = $state(false); let hostReady = $state(false);
let plutoKey = $state('');
let busy = $state(false); let busy = $state(false);
let loaded = $state(false); let loaded = $state(false);
let error = $state(''); let error = $state('');
@@ -35,7 +34,7 @@
let saving = $state(false); let saving = $state(false);
let wasOpen = false; let wasOpen = false;
let session = 0; let session = 0;
let keyNotice = $state(''); let lobbyNotice = $state('');
let selectionNotice = $state(''); let selectionNotice = $state('');
let now = $state(Date.now()); let now = $state(Date.now());
let heading: HTMLHeadingElement; let heading: HTMLHeadingElement;
@@ -50,7 +49,7 @@
async function initialize() { async function initialize() {
const current = ++session; const current = ++session;
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; keyNotice = ''; selectionNotice = ''; preview = null; error = ''; loaded = false; busy = false; lobbyNotice = ''; selectionNotice = '';
blueprintName = ''; description = ''; savedName = ''; saving = false; blueprintName = ''; description = ''; savedName = ''; saving = false;
blueprintId = initialBlueprintId; blueprintId = initialBlueprintId;
game = initialGame; game = initialGame;
@@ -77,7 +76,7 @@
function selectionChanged() { function selectionChanged() {
if (preview) selectionNotice = 'The launch settings or game files changed. Review the launch again with the latest files.'; if (preview) selectionNotice = 'The launch settings or game files changed. Review the launch again with the latest files.';
preview = null; plutoKey = ''; keyNotice = ''; preview = null; lobbyNotice = '';
} }
async function review() { async function review() {
@@ -88,7 +87,7 @@
if (!hostReady) { error = 'Wait for a host to finish preparing the latest game files and runtime.'; return; } if (!hostReady) { error = 'Wait for a host to finish preparing the latest game files and runtime.'; return; }
const current = session; const current = session;
const previousName = preview?.name; const previousName = preview?.name;
busy = true; error = ''; savedName = ''; keyNotice = ''; selectionNotice = ''; busy = true; error = ''; savedName = ''; lobbyNotice = ''; selectionNotice = '';
try { try {
const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }), body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }),
@@ -97,8 +96,7 @@
const body = await res.json(); const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.'); if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
if (current === session) { if (current === session) {
if (previousName && previousName !== body.name) keyNotice = 'The available lobby changed. Any entered key can still be used for this game.'; if (previousName && previousName !== body.name) lobbyNotice = 'The available lobby changed. Review the updated server before launching.';
if (body.hasSavedKey) plutoKey = '';
preview = body; now = Date.now(); preview = body; now = Date.now();
await tick(); heading?.focus(); await tick(); heading?.focus();
} }
@@ -107,12 +105,12 @@
} }
async function launch() { async function launch() {
if (!preview || !preview.ready || !hostReady || expired || busy) return; if (!preview || !preview.ready || !preview.hasSavedKey || !hostReady || expired || busy || saving) return;
busy = true; error = ''; busy = true; error = '';
try { try {
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, 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, body: JSON.stringify({ game: preview.recipe.game, gameMode: 'zombie', mapSlug: preview.recipe.mapSlug,
expectedName: preview.name, previewId: preview.id, plutoKey: plutoKey.trim() || undefined }) }); expectedName: preview.name, previewId: preview.id }) });
const body = await res.json(); const body = await res.json();
if (!res.ok || !body.ok) throw new Error(body.error ?? 'Could not queue this launch.'); if (!res.ok || !body.ok) throw new Error(body.error ?? 'Could not queue this launch.');
open = false; open = false;
@@ -147,8 +145,8 @@
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…'}</p> <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} {#if error}<Button onclick={initialize}>Try again</Button>{/if}
{:else if preview} {:else if preview}
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if} {#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. Entered keys stay with this game when you refresh.</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="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> <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> <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>
@@ -156,11 +154,9 @@
<form onsubmit={event => { event.preventDefault(); void launch(); }}> <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> <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> {#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Game key available</p>
{:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste an unused key for this game" /></label>{/if} {: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}
<p class="muted">{preview.hasSavedKey ? 'An unused key from this game’s pool will be assigned automatically. Availability is checked again at launch.' : 'Add an unused key for this game. Launch saves it in the game pool and assigns it to this server until removal is confirmed.'} <a href={`/server-keys?game=${preview.recipe.game}`} 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; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !hostReady || !preview.ready || !preview.hasSavedKey}>Launch server</Button></div>
<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> <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>
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter an unused key for this game to enable launch.</p>{/if}
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Capacity is checked again at launch. The host and port are assigned automatically.</p>
</form> </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> <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} {#if savedName}<p class="success" role="status">“{savedName}” is ready in your collection library.</p>{:else}
@@ -172,7 +168,7 @@
{:else} {:else}
<form onsubmit={event => { event.preventDefault(); void review(); }}> <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> <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> {#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>
{: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} {: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="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> <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>
+1 -1
View File
@@ -104,7 +104,7 @@
{#if notice}<p role="status">{notice}</p>{/if} {#if notice}<p role="status">{notice}</p>{/if}
<h2>Saved keys</h2> <h2>Saved keys</h2>
{#if loading}<p>Loading keys…</p> {#if loading}<p>Loading keys…</p>
{:else if !filtered.length}<EmptyState title={keys.length ? "No matching keys" : "No saved keys"} description="Add a key to a game pool here or when launching a server." /> {:else if !filtered.length}<EmptyState title={keys.length ? "No matching keys" : "No saved keys"} description="Add keys here. Launches automatically use an available key from their game’s pool." />
{:else} {:else}
<div class="table-wrap"><table> <div class="table-wrap"><table>
<thead><tr><th>Game / label</th><th>Availability</th><th>Last saved</th><th>Actions</th></tr></thead> <thead><tr><th>Game / label</th><th>Availability</th><th>Last saved</th><th>Actions</th></tr></thead>