Remove key entry from launch dialogs
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from './primitives/Button.svelte';
|
||||
@@ -20,22 +20,21 @@
|
||||
let hostSelection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
|
||||
let hostReady = $state(false);
|
||||
let confirmRemovals = $state(false);
|
||||
let keys = $state<Record<string, string>>({});
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let wasOpen = false;
|
||||
let session = 0;
|
||||
let keyNotice = $state('');
|
||||
let assignmentNotice = $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);
|
||||
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); });
|
||||
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(() => {
|
||||
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(); }
|
||||
});
|
||||
|
||||
@@ -57,7 +56,7 @@
|
||||
if (!hostReady) { error = 'Choose the latest uploaded game files and a valid runtime image before reviewing the launch.'; return; }
|
||||
const current = ++session;
|
||||
const previous = preview;
|
||||
busy = true; error = ''; keyNotice = ''; confirmRemovals = false;
|
||||
busy = true; error = ''; assignmentNotice = ''; confirmRemovals = false;
|
||||
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 body = await res.json();
|
||||
@@ -65,17 +64,7 @@
|
||||
if (current === session) {
|
||||
const next = body as CollectionPreview;
|
||||
const names = new Set(next.servers.map(server => server.name));
|
||||
const enteredByGame = new Map<ZombieGame, string[]>();
|
||||
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.';
|
||||
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.';
|
||||
preview = next; now = Date.now();
|
||||
}
|
||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||
@@ -86,7 +75,7 @@
|
||||
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 res = await fetch('/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: preview.id }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.');
|
||||
open = false;
|
||||
@@ -94,11 +83,6 @@
|
||||
} 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}>
|
||||
@@ -113,10 +97,10 @@
|
||||
{#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>
|
||||
{#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}
|
||||
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
|
||||
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; entered keys stay with their game.</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 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>
|
||||
<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>
|
||||
@@ -141,7 +125,8 @@
|
||||
{#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}
|
||||
{#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}
|
||||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||||
{#if preview.removals.length}
|
||||
@@ -159,14 +144,14 @@
|
||||
<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>
|
||||
{#if server.name}
|
||||
{#if server.hasSavedKey}<p class="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}
|
||||
{#if server.hasSavedKey}<p class="key-status key-ready"><span aria-hidden="true">✓</span> Game key available</p>
|
||||
{:else}<p class="key-status key-missing">No unused {preview.entries[server.entryIndex].recipe.game.toUpperCase()} key available</p>{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/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>
|
||||
{: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>
|
||||
@@ -185,10 +170,11 @@
|
||||
.removals li { display: grid; gap: 4px; font-size: 13px; }
|
||||
.confirm-removals { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; }
|
||||
.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); }
|
||||
@media (max-width: 600px) { .server-row .key-ready { grid-column: 2; } }
|
||||
input { scroll-margin-block: 32px 190px; }
|
||||
@media (max-width: 600px) { .server-row .key-status { grid-column: 2; } }
|
||||
.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); }
|
||||
@@ -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; }
|
||||
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); }
|
||||
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; }
|
||||
@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>
|
||||
|
||||
Reference in New Issue
Block a user