Add deployment blueprints and reviewed launch recipes
This commit is contained in:
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user