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
+68 -70
View File
@@ -3,7 +3,7 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { zombieMaps, zombieMapBySlug, type ZombieGame } from '$lib/map-aliases.js';
import type { ZombieGame } from '$lib/map-aliases.js';
import { confirmAction } from '$ui/confirm.js';
import { serverTitle } from '$ui/server-labels.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
@@ -15,31 +15,34 @@
import Field from '$lib/ui/components/primitives/Field.svelte';
import Input from '$lib/ui/components/primitives/Input.svelte';
interface SavedKey { server_id: string; deployed: number; updated_at: string }
interface SavedKey { id: string; game: ZombieGame; label: string; assigned_servers: string[]; updated_at: string }
const games: ZombieGame[] = ['t5', 't6'];
let open = $state(false);
let search = $state('');
let gameFilter = $state('');
let limit = $state(25);
let keys = $state<SavedKey[]>([]);
let serverId = $state('');
let editing = $state(false);
let rawMode = $state(false);
let game = $state<ZombieGame>('t5');
let mapSlug = $state('theater');
let lobby = $state(1);
const chosenId = $derived(editing || rawMode ? serverId.trim() : `${game}_zombie_${mapSlug}_${lobby}`);
const validId = $derived.by(() => {
const match = /^(t5|t6)_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(chosenId);
return !!match && !!zombieMapBySlug(match[1], match[2]);
});
$effect(() => { search = $page.url.searchParams.get('server') ?? ''; limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25); });
function saveSearch() { const url = new URL($page.url); if (search) url.searchParams.set('server', search); else url.searchParams.delete('server'); if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit'); void goto(url, { replaceState: true, keepFocus: true, noScroll: true }); }
let label = $state('');
let plutoKey = $state('');
let busy = $state(false);
let loading = $state(true);
let error = $state('');
let notice = $state('');
const filtered = $derived(keys.filter(key => (key.server_id + ' ' + serverTitle(key.server_id)).toLowerCase().includes(search.toLowerCase())));
$effect(() => {
search = $page.url.searchParams.get('search') ?? $page.url.searchParams.get('server') ?? '';
gameFilter = $page.url.searchParams.get('game') ?? '';
limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25);
});
const filtered = $derived(keys.filter(key => (!gameFilter || key.game === gameFilter)
&& [key.game, key.label, ...key.assigned_servers.flatMap(name => [name, serverTitle(name)])].join(' ').toLowerCase().includes(search.toLowerCase())));
function saveSearch() {
const url = new URL($page.url); url.searchParams.delete('server');
if (search) url.searchParams.set('search', search); else url.searchParams.delete('search');
if (gameFilter) url.searchParams.set('game', gameFilter); else url.searchParams.delete('game');
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
void goto(url, { replaceState: true, keepFocus: true, noScroll: true });
}
async function load() {
const res = await fetch('/api/server-keys');
if (!res.ok) throw new Error('Could not load server keys');
@@ -47,39 +50,36 @@
}
async function reload() { try { await load(); error = ''; } catch (e) { error = (e as Error).message; } finally { loading = false; } }
onMount(() => { void reload(); });
function reset() { serverId = ''; plutoKey = ''; editing = false; rawMode = false; }
function addKey() {
reset(); error = '';
const match = /^(t5|t6)_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec($page.url.searchParams.get('server') ?? '');
if (match && zombieMapBySlug(match[1], match[2])) { game = match[1] as ZombieGame; mapSlug = match[2]; lobby = Number(match[3]); }
plutoKey = ''; label = ''; error = '';
if (gameFilter === 't5' || gameFilter === 't6') game = gameFilter;
open = true;
}
function savedAt(value: string) { return $timeFormat.dateTime(value); }
async function save(e: SubmitEvent) {
e.preventDefault();
if (!validId || !plutoKey.trim() || busy) return;
if (!plutoKey.trim() || busy) return;
busy = true; error = ''; notice = '';
try {
const res = await fetch('/api/server-keys', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId: chosenId, plutoKey })
body: JSON.stringify({ game, label, plutoKey })
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not save key');
reset(); open = false; notice = 'Server key saved.'; await load();
plutoKey = ''; label = ''; open = false; notice = 'Key saved to the game pool.'; await load();
} catch (e) { error = (e as Error).message; }
finally { busy = false; }
}
async function remove(id: string) {
if (!await confirmAction({title:'Remove saved key?',message:`Remove the key for ${serverTitle(id)} (${id})? Running servers are unaffected.`,confirmLabel:'Remove key',danger:true})) return;
async function remove(key: SavedKey) {
if (key.assigned_servers.length) return;
if (!await confirmAction({ title: 'Remove saved key?', message: `Remove ${key.label || 'this key'} from the ${key.game.toUpperCase()} pool?`, confirmLabel: 'Remove key', danger: true })) return;
busy = true; error = ''; notice = '';
try {
const res = await fetch('/api/server-keys', {
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId: id })
method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: key.id })
});
if (!res.ok) throw new Error('Could not remove key');
if (serverId === id) reset();
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not remove key');
notice = 'Saved key removed.'; await load();
} catch (e) { error = (e as Error).message; }
finally { busy = false; }
@@ -88,72 +88,70 @@
<svelte:head><title>Keys · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Keys" subtitle="Save a Plutonium key for each server ID. Keys are kept when a server is destroyed.">
<SectionHeader title="Keys" subtitle="Pool Plutonium keys by game. Any available key can launch any map or lobby in that game.">
{#snippet actions()}<Button variant="primary" onclick={addKey}>Add key</Button>{/snippet}
</SectionHeader>
<p class="key-help">Saved keys are reused when a server is created or rebuilt. Electric Cherry does not check whether Plutonium accepts them. Updating or removing a saved key does not change an existing server.</p>
<div class="ui-toolbar"><input type="search" aria-label="Search keys" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveSearch(); }} placeholder="Search servers…" /></div>
<p class="key-help">A key stays assigned while its server exists, including when stopped, and becomes available after removal is confirmed. Rebuilds keep the same key. Use the optional label to record its Plutonium hostname; saving a label does not change that hostname.</p>
{#if !loading}<div class="pools" aria-label="Game key pools">{#each games as poolGame}
{@const pool = keys.filter(key => key.game === poolGame)}
<div><strong>{poolGame === 't5' ? 'Black Ops I' : 'Black Ops II'}</strong><span>{pool.filter(key => !key.assigned_servers.length).length} available · {pool.filter(key => key.assigned_servers.length).length} assigned · {pool.length} total</span></div>
{/each}</div>{/if}
<div class="ui-toolbar">
<input type="search" aria-label="Search keys" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveSearch(); }} placeholder="Search labels or assigned servers…" />
<select aria-label="Filter keys by game" bind:value={gameFilter} onchange={() => { limit = 25; saveSearch(); }}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select>
</div>
{#if error && !open}<div role="alert" class="ui-error">{error}<Button size="sm" onclick={reload}>Retry</Button></div>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
<h2>Saved keys</h2>
{#if loading}<p>Loading keys…</p>
{:else if !filtered.length}<EmptyState title={keys.length ? "No matching keys" : "No saved keys"} description="Add a key here or save one when creating a server." />
{: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}
<div class="table-wrap"><table>
<thead><tr><th>Server ID</th><th>Server</th><th>Last saved</th><th>Actions</th></tr></thead>
<tbody>{#each filtered.slice(0,limit) as key (key.server_id)}
<thead><tr><th>Game / label</th><th>Availability</th><th>Last saved</th><th>Actions</th></tr></thead>
<tbody>{#each filtered.slice(0, limit) as key (key.id)}
<tr>
<td>{#if key.deployed}<a href="/servers/{encodeURIComponent(key.server_id)}"><ServerIdentity name={key.server_id} /></a>{:else}<ServerIdentity name={key.server_id} />{/if}<code>{key.server_id}</code></td>
<td>{key.deployed ? 'Server exists' : 'No server deployed'}</td>
<td>{savedAt(key.updated_at)}</td>
<td><div class="actions">
<Button ariaLabel={`Update key for ${serverTitle(key.server_id)}`} disabled={busy} onclick={() => { serverId = key.server_id; plutoKey = ''; editing = true; error = ''; notice = ''; open = true; }}>Update</Button>
<Button ariaLabel={`Remove key for ${serverTitle(key.server_id)}`} disabled={busy} onclick={() => remove(key.server_id)}>Remove</Button>
</div></td>
<td><strong>{key.game.toUpperCase()}</strong><span class="key-label">{key.label || 'Unlabeled key'}</span></td>
<td>{#if key.assigned_servers.length}<span>Assigned</span>{#each key.assigned_servers as name}<div class="assignment"><ServerIdentity {name} /><code>{name}</code></div>{/each}{:else}<span class="available">Available</span>{/if}</td>
<td>{$timeFormat.dateTime(key.updated_at)}</td>
<td><Button ariaLabel={`Remove ${key.label || key.game.toUpperCase() + ' key'}`} disabled={busy || !!key.assigned_servers.length} onclick={() => remove(key)}>Remove</Button>{#if key.assigned_servers.length}<small>In use</small>{/if}</td>
</tr>
{/each}</tbody>
</table></div>
{/if}
<ListFooter total={filtered.length} bind:limit onchange={saveSearch} label="keys" />
</div>
<Dialog bind:open title={editing ? 'Update key' : 'Add key'} busy={busy}>
<Dialog bind:open title="Add key" busy={busy}>
{#if error}<p role="alert" class="error">{error}</p>{/if}
<form onsubmit={save}>
{#if !editing}<label class="raw-toggle"><input type="checkbox" bind:checked={rawMode} disabled={busy} onchange={() => { if (!rawMode) serverId = `${game}_zombie_${mapSlug}_${lobby}`; }} /> Enter a raw server ID</label>{/if}
{#if editing || rawMode}<Field label="Server ID" hint="Use a T5 or T6 map slug and lobby number from 1 to 20, e.g. t5_zombie_theater_2 or t6_zombie_tomb_1." error={!validId && serverId ? 'Choose a recognized map and lobby number 1–20.' : null}>
<Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono />
</Field>{:else}<div class="id-picker"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = zombieMaps(event.currentTarget.value)[0].slug}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each zombieMaps(game) as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label><label>Lobby<input type="number" min="1" max="20" step="1" bind:value={lobby} disabled={busy} required /></label></div><p>Server ID: <code>{chosenId}</code></p>{/if}
{#if !editing && keys.some(key => key.server_id === chosenId)}<p role="status">A key is already saved for this ID. Saving replaces it for the next deployment; the running server keeps its current key.</p>{/if}
<Field label="Plutonium server key" hint="Used when this server ID is created or rebuilt. Existing servers keep their current key.">
<Input type="password" bind:value={plutoKey} placeholder="Paste key" autocomplete="off" required disabled={busy} />
</Field>
<div class="actions">
<Button type="submit" variant="primary" disabled={busy || loading || !validId || !plutoKey.trim()} loading={busy}>{editing ? 'Update key' : 'Save key'}</Button>
<Button onclick={() => open = false} disabled={busy}>Cancel</Button>
</div>
</form>
{#if !validId || !plutoKey.trim()}<p role="status">Choose a valid server ID and enter its Plutonium key to save.</p>{/if}
<label>Game<select bind:value={game} disabled={busy}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label>
<Field label="Label (optional)" hint="Record the hostname you chose in Plutonium, or another name that helps identify this key.">
<Input bind:value={label} placeholder="e.g. Electric Cherry 5" disabled={busy} />
</Field>
<Field label="Plutonium server key" hint="Available to any new lobby in this game. Existing credentials are never displayed.">
<Input type="password" bind:value={plutoKey} placeholder="Paste key" autocomplete="off" required disabled={busy} />
</Field>
<div class="actions"><Button type="submit" variant="primary" disabled={busy || loading || !plutoKey.trim()} loading={busy}>Save key</Button><Button onclick={() => open = false} disabled={busy}>Cancel</Button></div>
</form>
</Dialog>
<style>
a { color: var(--accent-primary-hover); }
.page { display: grid; gap: 24px; }
.key-help { margin: 0; font-size: var(--text-sm); line-height: 1.65; }
.id-picker { display: grid; grid-template-columns: 1fr 1.4fr 80px; gap: 12px; }.id-picker label { display: grid; gap: 8px; font-size: 14px; }.id-picker input, .id-picker select { width: 100%; min-width: 0; padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; }.raw-toggle { display: flex; gap: 8px; align-items: center; font-size: 14px; }
@media (max-width: 600px) { .id-picker { grid-template-columns: 1fr; } }
.pools { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
.pools > div { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.pools span, .key-label, small { color: var(--text-secondary); font-size: var(--text-sm); }
.key-label, small { display: block; margin-top: 6px; }
.assignment { margin-top: 8px; }
.available { color: var(--state-online); }
h2 { font-size: var(--text-md); }
p { color: var(--text-secondary); }
form { display: grid; gap: var(--space-4); max-width: 620px; padding: var(--space-6); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); margin: var(--space-6) 0; }
form, label { display: grid; gap: var(--space-4); }
label { gap: 8px; font-size: 14px; }
select { padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; }
.actions { display: flex; gap: var(--space-2); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; text-align: left; }
th, td { padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
th { color: var(--text-tertiary); font-size: var(--text-sm); }
code { font-family: var(--font-mono); }
code { display: block; font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
.error { color: var(--state-danger); }
.page { display:grid; gap:24px; }
form { margin:0; padding:0; border:0; }
code { display:block; font-size:12px; color:var(--text-secondary); margin-top:6px; }
</style>