Files
electric-cherry/src/routes/server-keys/+page.svelte
T
josh 8ac420fe61 Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements.

Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
2026-09-19 21:34:34 -04:00

157 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { zombieMaps, zombieMapBySlug, 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';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Button from '$lib/ui/components/primitives/Button.svelte';
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 }
let open = $state(false);
let search = $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 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())));
async function load() {
const res = await fetch('/api/server-keys');
if (!res.ok) throw new Error('Could not load server keys');
keys = (await res.json()).keys;
}
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]); }
open = true;
}
function savedAt(value: string) { return new Date(value.includes('T') ? value : value.replace(' ', 'T') + 'Z').toLocaleString(); }
async function save(e: SubmitEvent) {
e.preventDefault();
if (!validId || !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 })
});
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();
} 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;
busy = true; error = ''; notice = '';
try {
const res = await fetch('/api/server-keys', {
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId: id })
});
if (!res.ok) throw new Error('Could not remove key');
if (serverId === id) reset();
notice = 'Saved key removed.'; await load();
} catch (e) { error = (e as Error).message; }
finally { busy = false; }
}
</script>
<svelte:head><title>Keys · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Server keys" subtitle="Save a Plutonium key for each server ID. Keys are kept when a server is destroyed.">
{#snippet actions()}<Button variant="primary" onclick={addKey}>Add key</Button>{/snippet}
</SectionHeader>
<div class="ui-toolbar"><input type="search" aria-label="Search server keys" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveSearch(); }} placeholder="Search servers…" /></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}
<div class="table-wrap"><table>
<thead><tr><th>Server ID</th><th>Status</th><th>Key</th><th>Actions</th></tr></thead>
<tbody>{#each filtered.slice(0,limit) as key (key.server_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>Stored, not validated<small>Saved {savedAt(key.updated_at)}<br />Used on next deployment</small></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>
</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}>
{#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="Changes apply the next time this server ID is deployed.">
<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}
</Dialog>
<style>
a { color: var(--accent-primary-hover); }small { display: block; color: var(--text-secondary); font-size: 12px; line-height: 1.6; margin-top: 5px; }
.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; } }
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; }
.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); }
.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>