Add deployment blueprints and reviewed launch recipes

This commit is contained in:
2026-09-17 23:43:12 -04:00
parent edd74f09de
commit 782c7c0bda
17 changed files with 975 additions and 319 deletions
+14
View File
@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import { listBlueprints, saveBlueprint } from '$lib/server/fleet/launch-recipes.js';
export function GET() { return json({ blueprints: listBlueprints() }); }
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a blueprint name and a launch recipe.');
return json({ blueprint: saveBlueprint(body) }, { status: 201 });
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
}
@@ -0,0 +1,6 @@
import { json } from '@sveltejs/kit';
import { deleteBlueprint } from '$lib/server/fleet/launch-recipes.js';
export function DELETE({ params }: { params: { id: string } }) {
return deleteBlueprint(params.id) ? json({ ok: true }) : json({ error: 'Blueprint not found.' }, { status: 404 });
}
@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import { resumeVerification } from '$lib/server/fleet/deployments.js';
import { kick } from '$lib/server/pipeline/worker.js';
export function POST({ params }: { params: { id: string } }) {
if (!resumeVerification(params.id)) return json({ error: 'This operation cannot resume verification. Refresh and inspect its server.' }, { status: 409 });
void kick().catch(err => console.error('[deployment] verification kick failed:', err));
return json({ ok: true }, { status: 202 });
}
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { previewCreation } from '$lib/server/fleet/create.js';
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a launch selection.');
return json(previewCreation(body));
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
}
+3 -1
View File
@@ -57,8 +57,9 @@ export async function POST({ request }: { request: Request }) {
mapSlug?: string;
plutoKey?: string;
expectedName?: string;
previewId?: string;
};
if (!body || (body.game !== undefined && body.game !== 't5' && body.game !== 't6') || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string')) {
if (!body || (body.game !== undefined && body.game !== 't5' && body.game !== 't6') || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string') || (body.previewId !== undefined && (typeof body.previewId !== 'string' || !body.previewId))) {
return json(
{ ok: false, error: 'game must be t5 or t6; gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings', kind: 'bad-request' },
{ status: 400 }
@@ -70,6 +71,7 @@ export async function POST({ request }: { request: Request }) {
mapSlug: body.mapSlug,
plutoKey: body.plutoKey,
expectedName: body.expectedName,
previewId: body.previewId,
createdBy: 'ui'
});
if (!result.ok) {
+69 -6
View File
@@ -5,6 +5,7 @@
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import type { BlueprintSummary } from '$lib/deployment-plan.js';
interface Deployment {
id: string; server: string; action: 'create' | 'destroy';
@@ -16,6 +17,12 @@
let loading = $state(true);
let error = $state('');
let open = $state(false);
let blueprintId = $state('');
let blueprints = $state<BlueprintSummary[]>([]);
let blueprintsLoading = $state(true);
let blueprintError = $state('');
let removing = $state('');
let confirmRemove = $state('');
let action = $state('all');
let search = $state('');
let refreshing = false;
@@ -35,9 +42,28 @@
}
onMount(() => {
void load();
void loadBlueprints();
const timer = setInterval(load, 2000);
return () => clearInterval(timer);
});
async function loadBlueprints() {
try {
const res = await fetch('/api/blueprints');
if (!res.ok) throw new Error('Could not load your blueprints.');
blueprints = (await res.json()).blueprints;
blueprintError = '';
} catch (err) { blueprintError = (err as Error).message; }
finally { blueprintsLoading = false; }
}
async function removeBlueprint(id: string) {
removing = id; blueprintError = '';
try {
const res = await fetch('/api/blueprints/' + encodeURIComponent(id), { method: 'DELETE' });
if (!res.ok) throw new Error('Could not remove blueprint.');
blueprints = blueprints.filter(b => b.id !== id); confirmRemove = '';
} catch (err) { blueprintError = (err as Error).message; }
finally { removing = ''; }
}
function fmt(value: string) { return new Date(value).toLocaleString(); }
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
function status(d: Deployment) {
@@ -47,11 +73,43 @@
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Deployments" subtitle="Server creations and destructions, from work in flight to operation history." />
<div class="toolbar">
<Button variant="primary" onclick={() => open = true}>Deploy new server</Button>
<SectionHeader title="Deployments" subtitle="Your next game starts here. Save a setup, preview a launch, and follow it all the way online." />
<div class="launch-banner">
<div><span class="eyebrow">From setup to first round</span><h2>Build it once. Bring it back anytime.</h2><p>Launch from your promoted setup or a saved blueprint, with the files and prerequisites laid out before you commit.</p></div>
<Button variant="primary" onclick={() => { blueprintId = ''; open = true; }}>Create a server →</Button>
</div>
<DeployServerDialog bind:open />
<DeployServerDialog bind:open initialBlueprintId={blueprintId} onblueprint={loadBlueprints} />
<section>
<h2>Your blueprints <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
{#if blueprintsLoading}<p>Loading blueprints…</p>
{:else if !blueprints.length}<div class="blueprint-empty"><strong>A good setup deserves a repeat.</strong><p>Preview a server and choose “Make this a reusable blueprint” to save its map, image reference, and managed files.</p><button onclick={() => { blueprintId = ''; open = true; }}>Create your first blueprint →</button></div>
{:else}
<div class="blueprints">
{#each blueprints as blueprint (blueprint.id)}
<article class="blueprint">
<div class="blueprint-top">
<span class="eyebrow">{blueprint.recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'} · {blueprint.recipe.mapName}</span>
<span class="file-count">{blueprint.recipe.files.length} file{blueprint.recipe.files.length === 1 ? '' : 's'}</span>
</div>
<h3>{blueprint.name}</h3>
<p>{blueprint.description || 'A saved launch recipe, ready for a fresh lobby.'}</p>
<div class="blueprint-footer">
<Button onclick={() => { blueprintId = blueprint.id; open = true; }}>Preview launch →</Button>
<button class="remove" disabled={!!removing} onclick={() => confirmRemove = blueprint.id}>Remove</button>
</div>
{#if confirmRemove === blueprint.id}
<div class="remove-confirm">
<p>Remove this blueprint? Existing servers and launch history are kept.</p>
<button disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>{removing === blueprint.id ? 'Removing…' : 'Remove blueprint'}</button>
<button disabled={!!removing} onclick={() => confirmRemove = ''}>Keep it</button>
</div>
{/if}
</article>
{/each}
</div>
{/if}
</section>
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
<section>
<h2>In flight <span>{active.length}</span></h2>
@@ -87,8 +145,13 @@
<style>
.page { display: flex; flex-direction: column; gap: 28px; }
.toolbar, .row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.toolbar, .row-head { justify-content: space-between; }
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.row-head { justify-content: space-between; }
.launch-banner { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 24px; border: 1px solid var(--border-strong); border-radius: 16px; padding: 28px; background: linear-gradient(120deg, color-mix(in oklab, var(--accent-primary) 10%, var(--surface)), var(--surface)); }
.launch-banner > div { max-width: 650px; }.launch-banner h2 { font-size: 25px; letter-spacing: -.03em; }.launch-banner p, .blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: 10px; color: var(--text-secondary); margin-bottom: 8px; }
.blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: 19px; overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: 11px; color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; }
.remove, .blueprint-empty button, .remove-confirm button { background: none; color: var(--accent-primary-hover); border: 0; font: inherit; font-size: 12px; cursor: pointer; }.remove { color: var(--text-secondary); }.remove-confirm { border-top: 1px solid var(--border-subtle); margin-top: 14px; }.remove-confirm button { margin-right: 12px; }.blueprint-empty { border: 1px dashed var(--border-strong); border-radius: 12px; padding: 24px; }.blueprint-empty strong { font-size: 15px; }.blueprint-empty button { padding: 8px 0 0; }
section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
p { margin: 8px 0; } a { color: var(--accent); }
+52 -6
View File
@@ -4,16 +4,23 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import LaunchRecipe from '$ui/components/LaunchRecipe.svelte';
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
interface Deployment {
id: string; expected_map?: string; server: string; action: 'create' | 'destroy'; state: string;
stage: string; started_at: string; finished_at: string | null;
triggered_by: string; error: string | null; warnings: string; server_exists: number;
}
interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[] }
interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[]; recipe: LaunchRecipeSummary | null; blueprintName: string | null; canResumeVerification: boolean }
let detail = $state<Detail | null>(null);
let error = $state('');
let refreshing = false;
let now = $state(Date.now());
let open = $state(false);
let resuming = $state(false);
let deployment = $derived(detail?.deployment);
let active = $derived(['queued', 'awaiting-health', 'waiting', 'running'].includes(deployment?.state ?? ''));
let warnings = $derived.by(() => {
@@ -29,9 +36,43 @@
: ['Starting', 'Preparing server files', 'Creating Komodo deployment', 'Starting container', 'Waiting for Komodo health', 'Waiting for RCON health', 'Waiting for selected map'];
if (deployment.state === 'waiting') return 1;
if (deployment.state === 'awaiting-health' && deployment.stage.startsWith('Waiting for health verification')) return 4;
if (deployment.state === 'done') return stages.length;
if (deployment.state === 'done' || deployment.state === 'warning') return stages.length;
return Math.max(0, stages.findIndex(stage => deployment.stage.startsWith(stage)));
});
let explanation = $derived.by(() => {
if (!deployment) return '';
if (deployment.state === 'done') return deployment.action === 'create'
? 'The container is healthy, RCON responds, and the reported engine map matches your selection.'
: 'The container, deployment, and server registration have been removed.';
if (deployment.state === 'warning') return 'The operation finished with the warnings below. Review any remaining cleanup.';
if (deployment.state === 'failed' || deployment.state === 'interrupted') return detail?.canResumeVerification
? 'The server is still registered. Inspect its status, then resume health verification without creating or restarting a container.'
: 'Inspect the error and any remaining container or files before launching again. The activity below records how far this operation reached.';
if (deployment.state === 'queued') return 'Your request is saved. Provisioning starts when the current server operation releases the worker.';
if (deployment.state === 'waiting') return 'The server stays online while players finish. Removal begins only after a live check confirms it is empty.';
if (deployment.stage.startsWith('Waiting for selected map')) return 'Container and RCON checks passed. Waiting for RCON to report the selected engine map. Inspect the server configuration if this persists.';
if (deployment.stage.startsWith('Waiting for RCON')) return 'The container is healthy. Waiting for the game to answer RCON; check its startup logs, password, and network if this persists.';
if (deployment.state === 'awaiting-health') return 'Waiting for the container to become healthy. Verification runs automatically; startup logs can help explain a delay.';
return deployment.action === 'create' ? 'Installing the saved launch recipe and starting its container. Health checks follow automatically.' : 'Removing the empty server and recording cleanup results.';
});
function duration(start: string, end: number) {
const seconds = Math.max(0, Math.floor((end - new Date(start).getTime()) / 1000));
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`;
}
let stageStarted = $derived(detail?.events.slice().reverse().find(event => event.message === deployment?.stage)?.created_at ?? deployment?.started_at);
async function resume() {
if (!deployment || resuming) return;
resuming = true;
try {
const res = await fetch(`/api/deployments/${encodeURIComponent(deployment.id)}/resume`, { method: 'POST' });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not resume verification.');
await load();
} catch (err) { error = (err as Error).message; }
finally { resuming = false; }
}
function stateLabel(state: string) {
return ({ queued: 'Queued', 'awaiting-health': 'Verifying health', waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' } as Record<string, string>)[state] ?? state;
}
@@ -49,7 +90,7 @@
}
$effect(() => { $page.params.id; detail = null; void load(); });
onMount(() => {
const timer = setInterval(load, 2000);
const timer = setInterval(() => { now = Date.now(); void load(); }, 2000);
return () => clearInterval(timer);
});
function fmt(iso: string) { return new Date(iso).toLocaleString(); }
@@ -67,7 +108,9 @@
{#if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if}
</div>
<h2>{deployment.state === 'done' ? (deployment.action === 'destroy' ? 'Server destroyed' : 'Server creation completed') : deployment.stage}</h2>
{#if deployment.state === 'waiting'}<p>The server stays online while players finish. Player count is checked automatically; destruction starts only after a live check reports zero players.</p>{/if}
<p class="explanation" aria-live="polite">{explanation}</p>
<div class="timing"><span>Total time <strong>{duration(deployment.started_at, deployment.finished_at ? new Date(deployment.finished_at).getTime() : now)}</strong></span>{#if active && stageStarted}<span>This step <strong>{duration(stageStarted, now)}</strong></span>{/if}</div>
{#if detail?.canResumeVerification}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume health verification</Button></div>{/if}
{#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if}
{#each warnings as warning}<p class="warning">{warning}</p>{/each}
{#if deployment.expected_map}<p>Expected map: <strong>{deployment.expected_map}</strong></p>{/if}
@@ -75,8 +118,9 @@
</section>
<section>
<h2>Operation flow</h2>
<ol class="flow">{#each steps as step, index}<li class:reached={index < currentStep} class:current={index === currentStep} aria-current={active && index === currentStep ? 'step' : undefined}><span>{index + 1}</span>{step}</li>{/each}</ol>
<ol class="flow">{#each steps as step, index}<li class:reached={index < currentStep} class:current={index === currentStep} class:failed={!active && ['failed', 'interrupted'].includes(deployment.state) && index === currentStep} aria-current={active && index === currentStep ? 'step' : undefined}><span>{index < currentStep ? '✓' : index + 1}</span>{step}</li>{/each}</ol>
</section>
{#if detail?.recipe}<section class="saved-recipe"><div class="top"><h2>{detail.blueprintName ? `Launched from ${detail.blueprintName}` : 'Recorded launch recipe'}</h2><Button onclick={() => open = true}>Launch again →</Button></div><p class="muted">Reuse the original managed files with a fresh lobby and port. Review the launch to save it as a blueprint.</p><LaunchRecipe recipe={detail.recipe} /></section><DeployServerDialog bind:open sourceDeploymentId={deployment.id} />{/if}
<section>
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
{#if detail && detail.events.length > 0}
@@ -89,7 +133,8 @@
<style>
.page { display: grid; gap: 28px; } section { display: grid; gap: 16px; }
.summary { padding: 24px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface); }
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.saved-recipe { padding: 24px; border: 1px solid var(--border-subtle); border-radius: 12px; background: var(--surface-sunken); }.explanation { color: var(--text-secondary); max-width: 800px; }.timing { display: flex; gap: 24px; font-size: 12px; color: var(--text-secondary); }.timing strong { color: var(--text-primary); margin-left: 8px; font-family: var(--font-mono); font-weight: 500; }
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; overflow-wrap: anywhere; }
h2 { font-size: 18px; margin: 0; } p { margin: 0; line-height: 1.6; } a { color: var(--accent-primary-hover); }
dl { display: flex; gap: 32px; flex-wrap: wrap; margin: 0; } dt, time, .muted { color: var(--text-secondary); font-size: 13px; } dd { margin: 6px 0 0; font-size: 14px; }
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
@@ -98,6 +143,7 @@
.flow li { border: 1px solid var(--border-subtle); color: var(--text-secondary); }
.flow li.reached { color: var(--text-primary); }
.flow li.current { color: var(--text-primary); border-color: var(--accent-primary); }
.flow li.failed { border-color: var(--state-danger); }
.timeline { list-style: none; padding: 0 0 0 18px; margin: 0; border-left: 2px solid var(--border-strong); }
.timeline li { position: relative; padding: 0 0 24px 12px; overflow-wrap: anywhere; }
.timeline li::before { content: ''; position: absolute; left: -24px; top: 5px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent-primary); }