Pool Plutonium keys by game for launches and autoscaling

This commit is contained in:
2026-10-07 11:24:53 -04:00
parent 56e5a2d06d
commit ec45371478
47 changed files with 956 additions and 305 deletions
@@ -65,9 +65,17 @@
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 && !server.hasSavedKey).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.';
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.';
preview = next; now = Date.now();
}
} catch (err) { if (current === session) error = (err as Error).message; }
@@ -108,7 +116,7 @@
{#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}
{: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}
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; entered keys stay with their game.</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>
@@ -133,7 +141,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">Saved keys are reused automatically. Add keys only where required below. Update saved keys 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 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.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}
<section class="removals" aria-label="Servers to remove">
@@ -150,14 +159,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> Saved key ready</p>
{:else}<label><span>Server key required</span><input aria-label={`Key for ${server.name}`} data-missing={!keys[server.name]?.trim()} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder="Paste this server’s key" /></label>{/if}
{#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}
</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">Key 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={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>{#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>
</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>
@@ -97,7 +97,7 @@
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
if (current === session) {
if (previousName && previousName !== body.name) { plutoKey = ''; keyNotice = 'The available server ID changed. Enter the key for the new ID below; the previous key was not transferred.'; }
if (previousName && previousName !== body.name) keyNotice = 'The available lobby changed. Any entered key can still be used for this game.';
if (body.hasSavedKey) plutoKey = '';
preview = body; now = Date.now();
await tick(); heading?.focus();
@@ -148,18 +148,18 @@
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
{:else if preview}
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
{#if expired}<p class="error" role="status">This preview expired. Refresh it to recheck capacity. Keys stay entered when the server ID is unchanged.</p>{/if}
{#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}
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From collection: {preview.blueprintName}</p>{/if}</section>
<section class="preflight"><div class="section-title"><h3>Before you launch</h3><button class="text-button" disabled={busy || saving} onclick={review}>Refresh preview</button></div>
<ul>{#each preview.checks as check}<li class:blocked={check.state === 'blocked'}><span class="check" aria-label={check.state}>{check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}</span><div><strong>{check.label}</strong><p>{check.detail}</p></div></li>{/each}</ul>
</section>
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
<div class="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>Fresh identity, with its own server key.</p></div>
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
{:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if}
<p class="muted">{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Electric Cherry does not check whether Plutonium accepts it. Update it in Keys.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p>
<div class="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>
{: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}
<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; plutoKey = ''; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !hostReady || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
{#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>
<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>