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
+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); }