Add multi-server blueprint collections and atomic launches

This commit is contained in:
2026-09-18 00:08:54 -04:00
parent 782c7c0bda
commit 989931a0f2
20 changed files with 868 additions and 50 deletions
+28
View File
@@ -29,6 +29,34 @@ export interface BlueprintSummary {
description: string;
createdAt: string;
recipe: LaunchRecipeSummary;
entries: { count: number; recipe: LaunchRecipeSummary }[];
totalServers: number;
}
export interface CollectionPreview {
id: string;
expiresAt: string;
blueprintName: string;
entries: { count: number; recipe: LaunchRecipeSummary }[];
servers: { entryIndex: number; name: string | null; port: number | null; hasSavedKey: boolean }[];
blockers: string[];
ready: boolean;
}
export interface CollectionSummary {
id: string;
name: string;
created_at: string;
total: number;
done: number;
failed: number;
active: number;
}
export interface CollectionDetail {
collection: CollectionSummary;
servers: { id: string; server: string; port: number; map_slug: string; state: string; stage: string;
started_at: string; finished_at: string | null; error: string | null; server_exists: number }[];
}
export interface LaunchPreview {
+22
View File
@@ -361,6 +361,28 @@ CREATE TABLE IF NOT EXISTS server_launch_recipes (
blueprint_name TEXT
);
CREATE TABLE IF NOT EXISTS server_launch_collections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_collection_members (
collection_id TEXT NOT NULL REFERENCES server_launch_collections(id) ON DELETE CASCADE,
deployment_id TEXT NOT NULL UNIQUE REFERENCES server_deployments(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
PRIMARY KEY (collection_id, position)
);
CREATE TABLE IF NOT EXISTS server_collection_previews (
id TEXT PRIMARY KEY,
expires_at TEXT NOT NULL,
blueprint_name TEXT NOT NULL,
entries TEXT NOT NULL,
servers TEXT NOT NULL,
collection_id TEXT REFERENCES server_launch_collections(id) ON DELETE CASCADE
);
-- Persist state transitions atomically, including changes made by background workers.
-- Polls with unchanged status/error do not produce duplicate activity entries.
CREATE TABLE IF NOT EXISTS beta_events (
+113
View File
@@ -0,0 +1,113 @@
import { randomUUID } from 'node:crypto';
import type { CollectionDetail, CollectionPreview, CollectionSummary } from '$lib/deployment-plan.js';
import { db } from '../db/index.js';
import { config, configFileForGame, komodoConfigured, rconConfigured } from '../config.js';
import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js';
import { nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js';
import { getServerKey } from './server-keys.js';
function allocate(entries: BlueprintEntry[]): CollectionPreview['servers'] {
const names: string[] = [];
const ports: number[] = [];
return entries.flatMap((entry, entryIndex) => Array.from({ length: entry.count }, () => {
const base = `${entry.recipe.game}_zombie_${entry.recipe.mapSlug}`;
const lobby = nextLobbyNumber(base, names);
const name = lobby === null ? null : `${base}_${lobby}`;
const port = nextAvailablePort(ports);
if (name) names.push(name);
if (port !== null) ports.push(port);
return { entryIndex, name, port, hasSavedKey: !!name && !!getServerKey(name) };
}));
}
function blockersFor(entries: BlueprintEntry[], servers: CollectionPreview['servers']): string[] {
const blockers: string[] = [];
if (!komodoConfigured() || !config.komodo.targetServer) blockers.push('Configure Komodo credentials and a target host before launching.');
if (!rconConfigured()) blockers.push('Configure RCON_PASSWORD so every new server can be verified.');
if (servers.some(server => !server.name)) blockers.push('There are not enough free lobbies for one or more maps (20 per map). Reduce their counts or free capacity.');
if (servers.some(server => server.port === null)) blockers.push(`There are not enough free game ports in ${config.portRange.min}–${config.portRange.max} for the whole collection.`);
if (entries.some(entry => entry.recipe.configFile !== configFileForGame(entry.recipe.game))) blockers.push('A saved config filename no longer matches the host settings. Create a new blueprint with the current setup.');
return blockers;
}
export function previewCollection(blueprintId: unknown): CollectionPreview {
if (typeof blueprintId !== 'string' || !blueprintId) throw new Error('Choose a blueprint to launch.');
return db().transaction(() => {
const { entries, blueprintName } = blueprintEntries(blueprintId);
const servers = allocate(entries);
const blockers = blockersFor(entries, servers);
const id = randomUUID();
const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
db().prepare('DELETE FROM server_collection_previews WHERE expires_at < ? AND collection_id IS NULL').run(new Date().toISOString());
db().prepare('INSERT INTO server_collection_previews (id, expires_at, blueprint_name, entries, servers) VALUES (?, ?, ?, ?, ?)')
.run(id, expiresAt, blueprintName, JSON.stringify(entries), JSON.stringify(servers));
return { id, expiresAt, blueprintName, servers, blockers, ready: blockers.length === 0,
entries: entries.map(entry => ({ count: entry.count, recipe: summarizeRecipe(entry.recipe) })) };
})();
}
/** Reserve and enqueue the entire collection atomically. No external operations here. */
export function queueCollection(input: { previewId?: unknown; keys?: unknown }): { ok: true; collectionId: string } {
if (typeof input.previewId !== 'string' || !input.previewId) throw new Error('Review a collection preview before launching.');
if (input.keys !== undefined && (!input.keys || typeof input.keys !== 'object' || Array.isArray(input.keys))) throw new Error('Provide keys indexed by the exact server ID.');
const keys = (input.keys ?? {}) as Record<string, unknown>;
return db().transaction(() => {
const preview = db().prepare('SELECT * FROM server_collection_previews WHERE id = ?').get(input.previewId) as {
id: string; expires_at: string; blueprint_name: string; entries: string; servers: string; collection_id: string | null;
} | undefined;
if (!preview) throw new Error('Collection preview expired or was not found. Refresh the preview.');
if (preview.collection_id) return { ok: true as const, collectionId: preview.collection_id };
if (preview.expires_at <= new Date().toISOString()) throw new Error('Collection preview expired. Refresh it before launching.');
const entries: BlueprintEntry[] = JSON.parse(preview.entries);
const reviewed: CollectionPreview['servers'] = JSON.parse(preview.servers);
const servers = allocate(entries);
const blockers = blockersFor(entries, servers);
if (blockers.length) throw new Error(blockers.join(' '));
if (servers.some((server, i) => server.name !== reviewed[i]?.name || server.port !== reviewed[i]?.port)) {
throw new Error('Lobby or port availability changed. Nothing was queued. Refresh the preview and review each server key.');
}
const expected = new Set(servers.map(server => server.name));
for (const [name, value] of Object.entries(keys)) {
if (!expected.has(name) || typeof value !== 'string') throw new Error('Keys must be strings for server IDs shown in this preview.');
}
const collectionId = randomUUID();
db().prepare('INSERT INTO server_launch_collections (id, name, created_at) VALUES (?, ?, ?)')
.run(collectionId, preview.blueprint_name, new Date().toISOString());
servers.forEach((server, index) => {
const recipe = entries[server.entryIndex].recipe;
const childPreviewId = randomUUID();
db().prepare('INSERT INTO server_launch_previews (id, expires_at, server, port, recipe, blueprint_name) VALUES (?, ?, ?, ?, ?, ?)')
.run(childPreviewId, preview.expires_at, server.name, server.port, JSON.stringify(recipe), preview.blueprint_name);
const result = queueCreateServer({ game: recipe.game, gameMode: 'zombie', mapSlug: recipe.mapSlug,
expectedName: server.name!, previewId: childPreviewId, plutoKey: keys[server.name!] as string | undefined, createdBy: 'ui' });
// Throw so the outer transaction also reverses all earlier keys and children.
if (!result.ok) throw new Error(`${server.name}: ${result.message} Nothing was queued.`);
db().prepare('INSERT INTO server_collection_members (collection_id, deployment_id, position) VALUES (?, ?, ?)')
.run(collectionId, result.deploymentId, index);
});
db().prepare('UPDATE server_collection_previews SET collection_id = ? WHERE id = ?').run(collectionId, preview.id);
return { ok: true as const, collectionId };
})();
}
const summarySql = `SELECT c.id, c.name, c.created_at, COUNT(d.id) AS total,
COALESCE(SUM(d.state IN ('done', 'warning')), 0) AS done,
COALESCE(SUM(d.state IN ('failed', 'interrupted')), 0) AS failed,
COALESCE(SUM(d.state IN ('queued', 'running', 'awaiting-health', 'waiting')), 0) AS active
FROM server_launch_collections c
LEFT JOIN server_collection_members m ON m.collection_id = c.id
LEFT JOIN server_deployments d ON d.id = m.deployment_id`;
export function listCollections(): CollectionSummary[] {
return db().prepare(summarySql + ' GROUP BY c.id ORDER BY c.created_at DESC, c.rowid DESC').all() as CollectionSummary[];
}
export function getCollection(id: string): CollectionDetail | null {
const collection = db().prepare(summarySql + ' WHERE c.id = ? GROUP BY c.id').get(id) as CollectionSummary | undefined;
if (!collection) return null;
const servers = db().prepare(`SELECT d.id, d.server, d.state, d.stage, d.started_at, d.finished_at, d.error, j.port, j.map_slug,
EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists
FROM server_collection_members m JOIN server_deployments d ON d.id = m.deployment_id
JOIN server_creation_jobs j ON j.deployment_id = d.id WHERE m.collection_id = ? ORDER BY m.position`).all(id) as CollectionDetail['servers'];
return { collection, servers };
}
+4 -4
View File
@@ -59,7 +59,7 @@ export interface CreateServerInput {
* the servers table. Caps at 20 to stay inside the port range — with 20 total
* ports across the whole fleet, no one map can hold more than that.
*/
function nextLobbyNumber(baseName: string): number | null {
export function nextLobbyNumber(baseName: string, additionalNames: string[] = []): number | null {
const rows = db()
.prepare(`SELECT name FROM servers WHERE name LIKE ? UNION
SELECT j.server AS name FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
@@ -67,7 +67,7 @@ function nextLobbyNumber(baseName: string): number | null {
.all(`${baseName}_%`) as Array<{ name: string }>;
const taken = new Set<number>();
const prefix = `${baseName}_`;
for (const r of rows) {
for (const r of [...rows, ...additionalNames.map(name => ({ name }))]) {
if (!r.name.startsWith(prefix)) continue;
const tail = r.name.slice(prefix.length);
if (/^\d+$/.test(tail)) taken.add(Number.parseInt(tail, 10));
@@ -83,13 +83,13 @@ function nextLobbyNumber(baseName: string): number | null {
* from the DB (which EC controls end-to-end under the new naming scheme, so
* the DB is authoritative — no need to ask Komodo).
*/
function nextAvailablePort(): number | null {
export function nextAvailablePort(additionalPorts: number[] = []): number | null {
const rows = db()
.prepare(`SELECT game_port FROM servers WHERE game_port IS NOT NULL UNION
SELECT j.port AS game_port FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
WHERE d.state IN ('queued', 'running', 'awaiting-health')`)
.all() as Array<{ game_port: number }>;
const taken = new Set(rows.map((r) => r.game_port));
const taken = new Set([...rows.map((r) => r.game_port), ...additionalPorts]);
for (let p = config.portRange.min; p <= config.portRange.max; p++) {
if (!taken.has(p)) return p;
}
+3 -1
View File
@@ -27,8 +27,10 @@ export function getDeployment(id: string) {
if (!deployment) return null;
const events = db().prepare('SELECT id, created_at, message FROM server_deployment_events WHERE deployment_id = ? ORDER BY id').all(id);
const launch = recipeForDeployment(id);
const collection = db().prepare(`SELECT c.id, c.name FROM server_collection_members m
JOIN server_launch_collections c ON c.id = m.collection_id WHERE m.deployment_id = ?`).get(id) ?? null;
return { deployment, events, recipe: launch ? summarizeRecipe(launch.recipe) : null, blueprintName: launch?.blueprintName ?? null,
canResumeVerification: canResumeVerification(id) };
canResumeVerification: canResumeVerification(id), collection };
}
export function canResumeVerification(id: string): boolean {
+68 -13
View File
@@ -21,6 +21,44 @@ export interface LaunchRecipe {
files: (LaunchFileSummary & { content: string })[];
}
export interface BlueprintEntry { count: number; recipe: LaunchRecipe }
interface BlueprintRow { id: string; name: string; description: string; created_at: string; recipe: string }
/** Existing one-server blueprints remain usable without a destructive migration. */
function readEntries(raw: string): BlueprintEntry[] {
const saved = JSON.parse(raw) as LaunchRecipe | { format: 2; entries: BlueprintEntry[] };
return saved.format === 2 ? saved.entries : [{ count: 1, recipe: saved }];
}
function summarizeBlueprint(row: BlueprintRow): BlueprintSummary {
const entries = readEntries(row.recipe).map(entry => ({ count: entry.count, recipe: summarizeRecipe(entry.recipe) }));
return { id: row.id, name: row.name, description: row.description, createdAt: row.created_at,
recipe: entries[0].recipe, entries, totalServers: entries.reduce((total, entry) => total + entry.count, 0) };
}
export function blueprintEntries(id: string): { entries: BlueprintEntry[]; blueprintName: string } {
const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined;
if (!row) throw new Error('This blueprint is no longer available. Choose another starting point.');
return { entries: readEntries(row.recipe), blueprintName: row.name };
}
function buildEntries(input: unknown, prior: BlueprintEntry[] = []): BlueprintEntry[] {
if (!Array.isArray(input) || input.length < 1 || input.length > 40) throw new Error('Add between 1 and 40 map selections.');
const seen = new Set<string>();
const entries = input.map(entry => {
if (!entry || (entry.game !== 't5' && entry.game !== 't6') || typeof entry.mapSlug !== 'string'
|| !zombieMapBySlug(entry.game, entry.mapSlug)) throw new Error('Each selection needs a supported game and map.');
if (!Number.isSafeInteger(entry.count) || entry.count < 1 || entry.count > 20) throw new Error('Choose 1–20 lobbies for each map.');
const key = `${entry.game}:${entry.mapSlug}`;
if (seen.has(key)) throw new Error('Each game/map can appear once. Increase its lobby count instead.');
seen.add(key);
const previous = prior.find(item => item.recipe.game === entry.game && item.recipe.mapSlug === entry.mapSlug);
return { count: entry.count as number, recipe: previous?.recipe ?? captureRecipe(entry.game, entry.mapSlug) };
});
if (entries.reduce((total, entry) => total + entry.count, 0) > 100) throw new Error('A blueprint can launch at most 100 servers.');
return entries;
}
export function captureRecipe(game: ZombieGame, mapSlug: string): LaunchRecipe {
const map = zombieMapBySlug(game, mapSlug);
if (!map) throw new Error('Choose a supported game and map.');
@@ -59,35 +97,52 @@ export function recipeForDeployment(id: string): { recipe: LaunchRecipe; bluepri
}
export function blueprintRecipe(id: string): { recipe: LaunchRecipe; blueprintName: string } {
const row = db().prepare('SELECT recipe, name FROM server_blueprints WHERE id = ?').get(id) as { recipe: string; name: string } | undefined;
if (!row) throw new Error('This blueprint is no longer available. Choose another starting point.');
return { recipe: JSON.parse(row.recipe), blueprintName: row.name };
const { entries, blueprintName } = blueprintEntries(id);
if (entries.length !== 1 || entries[0].count !== 1) throw new Error('This blueprint launches multiple servers. Use the collection preview.');
return { recipe: entries[0].recipe, blueprintName };
}
export function listBlueprints(): BlueprintSummary[] {
return (db().prepare('SELECT * FROM server_blueprints ORDER BY name COLLATE NOCASE').all() as {
id: string; name: string; description: string; created_at: string; recipe: string;
}[]).map(row => ({ id: row.id, name: row.name, description: row.description,
createdAt: row.created_at, recipe: summarizeRecipe(JSON.parse(row.recipe)) }));
return (db().prepare('SELECT * FROM server_blueprints ORDER BY name COLLATE NOCASE').all() as BlueprintRow[]).map(summarizeBlueprint);
}
export function saveBlueprint(input: { name: unknown; description?: unknown; previewId?: unknown; deploymentId?: unknown }): BlueprintSummary {
function blueprintName(input: { name: unknown; description?: unknown }, exceptId = ''): string {
if (typeof input.name !== 'string' || !input.name.trim() || input.name.trim().length > 80) throw new Error('Give the blueprint a name of 1–80 characters.');
if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 500)) throw new Error('Keep the description to 500 characters.');
if ((typeof input.previewId === 'string') === (typeof input.deploymentId === 'string')) throw new Error('Choose exactly one launch preview or recorded deployment.');
if (db().prepare('SELECT id FROM server_blueprints WHERE name = ? COLLATE NOCASE AND id != ?').get(input.name.trim(), exceptId)) throw new Error('A blueprint with this name already exists. Choose a new name.');
return input.name.trim();
}
export function saveBlueprint(input: { name: unknown; description?: unknown; previewId?: unknown; deploymentId?: unknown; entries?: unknown }): BlueprintSummary {
const name = blueprintName(input);
if ([input.previewId, input.deploymentId, input.entries].filter(value => value !== undefined).length !== 1) throw new Error('Choose exactly one launch preview, recorded deployment, or set of maps.');
let recipe: LaunchRecipe | undefined;
if (typeof input.previewId === 'string') {
const row = db().prepare('SELECT recipe FROM server_launch_previews WHERE id = ? AND expires_at > ?')
.get(input.previewId, new Date().toISOString()) as { recipe: string } | undefined;
if (row) recipe = JSON.parse(row.recipe);
} else if (typeof input.deploymentId === 'string') recipe = recipeForDeployment(input.deploymentId)?.recipe;
if (!recipe) throw new Error('The launch recipe is unavailable or the preview expired. Generate a fresh preview.');
if (db().prepare('SELECT id FROM server_blueprints WHERE name = ? COLLATE NOCASE').get(input.name.trim())) throw new Error('A blueprint with this name already exists. Choose a new name.');
if (!recipe && input.entries === undefined) throw new Error('The launch recipe is unavailable or the preview expired. Generate a fresh preview.');
const entries = input.entries !== undefined ? buildEntries(input.entries) : [{ count: 1, recipe: recipe! }];
const id = randomUUID();
const createdAt = new Date().toISOString();
const saved = JSON.stringify({ format: 2, entries });
db().prepare('INSERT INTO server_blueprints (id, name, description, created_at, recipe) VALUES (?, ?, ?, ?, ?)')
.run(id, input.name.trim(), input.description ?? '', createdAt, JSON.stringify(recipe));
return { id, name: input.name.trim(), description: (input.description as string) ?? '', createdAt, recipe: summarizeRecipe(recipe) };
.run(id, name, input.description ?? '', createdAt, saved);
return summarizeBlueprint({ id, name, description: (input.description as string) ?? '', created_at: createdAt, recipe: saved });
}
export function updateBlueprint(id: string, input: { name: unknown; description?: unknown; entries?: unknown }): BlueprintSummary {
return db().transaction(() => {
const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined;
if (!row) throw new Error('Blueprint not found.');
const name = blueprintName(input, id);
const entries = buildEntries(input.entries, readEntries(row.recipe));
const recipe = JSON.stringify({ format: 2, entries });
const description = (input.description as string) ?? '';
db().prepare('UPDATE server_blueprints SET name = ?, description = ?, recipe = ? WHERE id = ?').run(name, description, recipe, id);
return summarizeBlueprint({ ...row, name, description, recipe });
})();
}
export function deleteBlueprint(id: string): boolean {
@@ -0,0 +1,103 @@
<script lang="ts">
import Button from './primitives/Button.svelte';
import type { BlueprintSummary } from '$lib/deployment-plan.js';
import type { ZombieGame, ZombieMapEntry } from '$lib/map-aliases.js';
let { open = $bindable(false), blueprint = null, onsaved }:
{ open?: boolean; blueprint?: BlueprintSummary | null; onsaved: (blueprint: BlueprintSummary, launch: boolean) => void } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let name = $state('');
let description = $state('');
let rows = $state<{ key: number; game: ZombieGame; mapSlug: string; count: number }[]>([]);
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
let loaded = $state(false);
let busy = $state(false);
let error = $state('');
let total = $derived(rows.reduce((sum, row) => sum + (Number(row.count) || 0), 0));
let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 20));
let wasOpen = false;
let session = 0;
let nextKey = 0;
$effect(() => {
if (!dialog) return;
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void initialize(); }
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
});
async function initialize() {
const current = ++session;
name = blueprint?.name ?? ''; description = blueprint?.description ?? '';
rows = blueprint?.entries.map(entry => ({ key: nextKey++, game: entry.recipe.game, mapSlug: entry.recipe.mapSlug, count: entry.count })) ?? [];
error = ''; loaded = false; busy = false;
try {
const res = await fetch('/api/maps');
if (!res.ok) throw new Error('Could not load maps. Try again.');
const body = await res.json();
if (current !== session) return;
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
if (!rows.length) addMap();
loaded = true;
} catch (err) { if (current === session) error = (err as Error).message; }
}
function addMap() {
for (const game of ['t5', 't6'] as const) {
const map = catalogs[game].find(map => !rows.some(row => row.game === game && row.mapSlug === map.slug));
if (map) { rows = [...rows, { key: nextKey++, game, mapSlug: map.slug, count: 1 }]; return; }
}
}
async function save(launch: boolean) {
if (busy || !valid) return;
busy = true; error = '';
try {
const res = await fetch(blueprint ? `/api/blueprints/${encodeURIComponent(blueprint.id)}` : '/api/blueprints', {
method: blueprint ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, entries: rows.map(({ game, mapSlug, count }) => ({ game, mapSlug, count })) }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not save blueprint.');
open = false; onsaved(body.blueprint, launch);
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
</script>
<dialog bind:this={dialog} aria-label="Blueprint builder" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<div class="builder">
<header><div><span class="eyebrow">One blueprint. Your whole lineup.</span><h2>{blueprint ? 'Edit your blueprint' : 'Build a server collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close blueprint builder">×</button></header>
<p class="muted">Choose your maps and how many lobbies to launch for each. Mix Black Ops I and II in the same blueprint.</p>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if !loaded}<p class="muted">Loading maps…</p>{#if error}<Button onclick={initialize}>Try again</Button>{/if}
{:else}
<form onsubmit={event => { event.preventDefault(); void save(true); }}>
<div class="identity"><label>Blueprint name<input bind:value={name} maxlength="80" placeholder="Friday Zombies" required disabled={busy} /></label><label>Description (optional)<input bind:value={description} maxlength="500" placeholder="Our weekend lineup" disabled={busy} /></label></div>
<div class="lineup">
<div class="section-title"><h3>Your lineup</h3><span class="count">{total} server{total === 1 ? '' : 's'}</span></div>
{#each rows as row, index (row.key)}
<div class="map-row">
<span class="number">{String(index + 1).padStart(2, '0')}</span>
<label>Game<select aria-label={`Game ${index + 1}`} bind:value={row.game} disabled={busy} onchange={event => row.mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
<label>Map<select aria-label={`Map ${index + 1}`} bind:value={row.mapSlug} disabled={busy}>{#each catalogs[row.game] as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label>
<label>Lobbies<input aria-label={`Lobbies ${index + 1}`} type="number" min="1" max="20" step="1" bind:value={row.count} required disabled={busy} /></label>
<button type="button" class="remove" aria-label={`Remove map ${index + 1}`} disabled={busy || rows.length === 1} onclick={() => rows = rows.filter(item => item.key !== row.key)}>×</button>
</div>
{/each}
<Button disabled={busy || rows.length >= 40} onclick={addMap}>+ Add another map</Button>
</div>
<p class="muted">{blueprint ? 'Changing lobby counts keeps the saved files for each map. Newly added maps use their current promoted setup.' : 'Each map saves its current promoted setup. Before launch, you’ll review every new server ID, port, and key.'}</p>
<div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save blueprint</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview {total} server{total === 1 ? '' : 's'} →</Button></div>
</form>
{/if}
</div>
</dialog>
<style>
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: 16px; }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.builder { padding: 28px; display: grid; gap: 20px; }
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }.actions { justify-content: flex-end; }
.eyebrow { display: block; color: var(--text-secondary); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: 25px; letter-spacing: -.03em; } h3 { font-size: 16px; }.muted { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: 12px; min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-sunken); color: inherit; font: inherit; }
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: 10px; }.number { font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary); align-self: center; }.count { font-size: 12px; padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: 25px; cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: 13px; }button:disabled { opacity: .5; cursor: default; }
@media (max-width: 620px) { .builder { padding: 20px; }.identity { grid-template-columns: 1fr; }.map-row { grid-template-columns: 1fr 85px 25px; }.number { display: none; }.map-row label:first-of-type { grid-column: 1 / -1; }.actions { align-items: stretch; flex-direction: column; } }
</style>
@@ -0,0 +1,83 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Button from './primitives/Button.svelte';
import LaunchRecipe from './LaunchRecipe.svelte';
import type { CollectionPreview } from '$lib/deployment-plan.js';
let { open = $bindable(false), blueprintId = '' }: { open?: boolean; blueprintId?: string } = $props();
let dialog: HTMLDialogElement | undefined = $state();
let preview = $state<CollectionPreview | null>(null);
let keys = $state<Record<string, string>>({});
let busy = $state(false);
let error = $state('');
let wasOpen = false;
let session = 0;
let canLaunch = $derived(preview?.ready && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
$effect(() => {
if (!dialog) return;
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void review(); }
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
});
async function review() {
const current = ++session;
busy = true; error = ''; preview = null; keys = {};
try {
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not preview the collection.');
if (current === session) preview = body;
} catch (err) { if (current === session) error = (err as Error).message; }
finally { if (current === session) busy = false; }
}
async function launch() {
if (!preview || !canLaunch || busy) return;
busy = true; error = '';
try {
const res = await fetch('/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: preview.id, keys }) });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.');
open = false;
await goto(`/deployments/collections/${encodeURIComponent(body.collectionId)}`);
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
}
</script>
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<div class="composer">
<header><div><span class="eyebrow">Your lineup, ready to launch</span><h2>{preview?.blueprintName ?? 'Preparing your collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection preview">×</button></header>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if !preview}<p class="muted">{busy ? 'Finding a lobby and port for every server…' : 'Refresh to prepare another preview.'}</p>{#if !busy}<Button onclick={review}>Refresh preview</Button>{/if}
{:else}
<div class="lineup"><strong>{preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} · one launch</strong><ul>{#each preview.entries as entry}<li><span class="quantity">{entry.count}×</span> {entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></li>{/each}</ul></div>
<div class="section-title"><h3>Review every lobby</h3><button class="text-button" disabled={busy} onclick={review}>Refresh preview</button></div>
{#each preview.blockers as blocker}<p class="error" role="alert">{blocker}</p>{/each}
<p class="muted">Each server needs its own key. Saved keys are reused for the exact IDs below. All slots are checked again before anything is queued.</p>
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
<div class="servers">
{#each preview.servers as server, index}
<div class="server-row">
<div><span class="eyebrow">{preview.entries[server.entryIndex].recipe.mapName} · {server.port === null ? 'No available port' : `Port ${server.port}`}</span><strong>{server.name ?? `No available lobby (${index + 1})`}</strong></div>
{#if server.name}<label><span>{server.hasSavedKey ? 'Saved key ready' : 'Server key required'}</span><input aria-label={`Key for ${server.name}`} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder={server.hasSavedKey ? 'Leave blank to reuse' : 'Paste this lobby’s key'} /></label>{/if}
</div>
{/each}
</div>
<p class="muted">The host must have the selected games and DLC. This preview checks settings and capacity; it does not probe game files, images, credentials, or network access.</p>
<div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || !canLaunch}>Launch all {preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} →</Button></div>
<p class="caption">Your collection is queued in one action. Servers provision in sequence and verify independently. Preview expires at {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.</p>
</form>
<details class="recipes"><summary>Review saved files for each map</summary>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
{/if}
</div>
</dialog>
<style>
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: 10px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: 11px; }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 12px; padding: 20px; }.lineup > strong { font-size: 18px; }.lineup ul { display: flex; gap: 10px 24px; flex-wrap: wrap; margin: 16px 0 0; padding: 0; list-style: none; font-size: 13px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); margin-left: 6px; }
form, .servers { display: grid; gap: 16px; }.server-row { display: grid; grid-template-columns: 1fr 240px; align-items: center; gap: 20px; border: 1px solid var(--border-subtle); border-radius: 10px; padding: 16px; }.server-row > div { min-width: 0; }.server-row strong { font-family: var(--font-mono); font-size: 13px; overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: 11px; color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-strong); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
@media (max-width: 600px) { .composer { padding: 20px; }.server-row { grid-template-columns: 1fr; gap: 12px; } }
</style>
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import Button from './primitives/Button.svelte';
import LaunchRecipe from './LaunchRecipe.svelte';
import DeployCollectionDialog from './DeployCollectionDialog.svelte';
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
@@ -13,6 +14,7 @@
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
let blueprints = $state<BlueprintSummary[]>([]);
let blueprintId = $state('');
let collectionOpen = $state(false);
let maps = $derived(catalogs[game]);
let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
let preview = $state<LaunchPreview | null>(null);
@@ -54,6 +56,9 @@
async function review() {
if (busy) return;
if (!sourceDeploymentId && selectedBlueprint && selectedBlueprint.totalServers > 1) {
open = false; collectionOpen = true; return;
}
const current = session;
busy = true; error = ''; plutoKey = ''; preview = null; savedName = '';
try {
@@ -125,8 +130,8 @@
<p class="muted">{busy ? 'Preparing the original launch recipe…' : 'Review the original recipe with a fresh lobby and port.'}</p><Button disabled={busy} onclick={review}>Review launch</Button>
{:else}
<form onsubmit={event => { event.preventDefault(); void review(); }}>
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.recipe.mapName}</option>{/each}</select></label>
{#if selectedBlueprint}<section class="recipe-panel"><LaunchRecipe recipe={selectedBlueprint.recipe} />{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label>
{#if selectedBlueprint}<section class="recipe-panel">{#if selectedBlueprint.totalServers > 1}<h3>{selectedBlueprint.totalServers} servers in this blueprint</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.recipe.mapName} · {entry.recipe.game.toUpperCase()}</p>{/each}<p class="muted">Continue to review all servers and their keys together.</p>{:else}<LaunchRecipe recipe={selectedBlueprint.recipe} />{/if}{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
{:else}<p class="muted">Pick a map. We’ll gather its promoted scripts, plugins, and configuration into a recipe you can review and save.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if}
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the exact managed files, available lobby, and launch prerequisites before creating your server.</p></div>
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && !mapSlug)}>Preview launch →</Button></div>
@@ -134,6 +139,7 @@
{/if}
</div>
</dialog>
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
<style>
dialog { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: 18px; padding: 0; width: min(700px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); }
+9 -1
View File
@@ -1,6 +1,14 @@
import { json } from '@sveltejs/kit';
import { deleteBlueprint } from '$lib/server/fleet/launch-recipes.js';
import { deleteBlueprint, updateBlueprint } 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 });
}
export async function PUT({ params, request }: { params: { id: string }; request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a blueprint name and map selections.');
return json({ blueprint: updateBlueprint(params.id, body) });
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
}
@@ -0,0 +1,15 @@
import { json } from '@sveltejs/kit';
import { listCollections, queueCollection } from '$lib/server/fleet/collections.js';
import { kick } from '$lib/server/pipeline/worker.js';
export function GET() { return json({ collections: listCollections() }); }
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 collection preview and its server keys.');
const result = queueCollection(body);
void kick().catch(err => console.error('[collection] worker kick failed:', err));
return json(result, { status: 202 });
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
}
@@ -0,0 +1,8 @@
import { error, json } from '@sveltejs/kit';
import { getCollection } from '$lib/server/fleet/collections.js';
export function GET({ params }: { params: { id: string } }) {
const detail = getCollection(params.id);
if (!detail) error(404, 'Collection not found.');
return json(detail);
}
@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import { previewCollection } from '$lib/server/fleet/collections.js';
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
return json(previewCollection(body?.blueprintId));
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
}
+40 -12
View File
@@ -5,7 +5,9 @@
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';
import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte';
import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte';
import type { BlueprintSummary, CollectionSummary } from '$lib/deployment-plan.js';
interface Deployment {
id: string; server: string; action: 'create' | 'destroy';
@@ -18,6 +20,10 @@
let error = $state('');
let open = $state(false);
let blueprintId = $state('');
let builderOpen = $state(false);
let editingBlueprint = $state<BlueprintSummary | null>(null);
let collectionOpen = $state(false);
let collections = $state<CollectionSummary[]>([]);
let blueprints = $state<BlueprintSummary[]>([]);
let blueprintsLoading = $state(true);
let blueprintError = $state('');
@@ -33,13 +39,18 @@
if (refreshing) return;
refreshing = true;
try {
const res = await fetch('/api/deployments');
if (!res.ok) throw new Error('Could not load server deployments.');
const [res, collectionRes] = await Promise.all([fetch('/api/deployments'), fetch('/api/deployment-collections')]);
if (!res.ok || !collectionRes.ok) throw new Error('Could not load server deployments.');
deployments = (await res.json()).deployments;
collections = (await collectionRes.json()).collections;
error = '';
} catch (err) { error = (err as Error).message; }
finally { loading = false; refreshing = false; }
}
function blueprintSaved(blueprint: BlueprintSummary, launch: boolean) {
blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name));
if (launch) { blueprintId = blueprint.id; collectionOpen = true; }
}
onMount(() => {
void load();
void loadBlueprints();
@@ -73,29 +84,33 @@
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Deployments" subtitle="Your next game starts here. Save a setup, preview a launch, and follow it all the way online." />
<SectionHeader title="Deployments" subtitle="Build a lineup of maps and lobbies. Launch them together and follow every server 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><span class="eyebrow">One blueprint. A whole game night.</span><h2>Two Kino. One Moon. Two Town. One launch.</h2><p>Choose your maps, set the lobby counts, and save the lineup. Preview every server and launch the whole collection together.</p></div>
<div class="banner-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create blueprint →</Button><Button variant="ghost" onclick={() => { blueprintId = ''; open = true; }}>Single server</Button></div>
</div>
<DeployServerDialog bind:open initialBlueprintId={blueprintId} onblueprint={loadBlueprints} />
<DeployServerDialog bind:open onblueprint={loadBlueprints} />
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
<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 if !blueprints.length}<div class="blueprint-empty"><strong>Plan the whole lineup.</strong><p>Add maps from either game, choose how many lobbies each needs, and launch them together from one blueprint.</p><button onclick={() => { editingBlueprint = null; builderOpen = 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>
<span class="eyebrow">Server collection</span>
<span class="file-count">{blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</span>
</div>
<h3>{blueprint.name}</h3>
<p>{blueprint.description || 'A saved launch recipe, ready for a fresh lobby.'}</p>
<p>{blueprint.description || 'Your saved lineup, ready to launch together.'}</p>
<ul class="blueprint-lineup">{#each blueprint.entries as entry}<li><strong>{entry.count}×</strong> {entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></li>{/each}</ul>
<div class="blueprint-footer">
<Button onclick={() => { blueprintId = blueprint.id; open = true; }}>Preview launch →</Button>
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Launch {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'} →</Button>
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
<button class="remove" disabled={!!removing} onclick={() => confirmRemove = blueprint.id}>Remove</button>
</div>
{#if confirmRemove === blueprint.id}
@@ -111,6 +126,18 @@
{/if}
</section>
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
{#if collections.length}
<section>
<h2>Collection launches</h2>
<div class="rows">{#each collections as collection (collection.id)}
<a class="deployment-row" href="/deployments/collections/{collection.id}">
<div class="row-head"><strong>{collection.name}</strong><Badge variant={collection.failed ? 'danger' : collection.active ? 'info' : 'online'}>{collection.active ? 'In progress' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></div>
<p>{collection.done} of {collection.total} servers verified{collection.failed ? ` · ${collection.failed} need attention` : ''}{collection.active ? ` · ${collection.active} in progress` : ''}</p>
<span class="meta">Started {fmt(collection.created_at)}</span>
</a>
{/each}</div>
</section>
{/if}
<section>
<h2>In flight <span>{active.length}</span></h2>
{#if loading}<p>Loading deployments...</p>
@@ -149,6 +176,7 @@
.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; }
.banner-actions { display: flex; gap: 8px; flex-wrap: wrap; }.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: 13px; }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: 12px; cursor: pointer; }.blueprint-footer { flex-wrap: wrap; }
.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; }
+2 -1
View File
@@ -14,7 +14,7 @@
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 }[]; recipe: LaunchRecipeSummary | null; blueprintName: string | null; canResumeVerification: boolean }
interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[]; recipe: LaunchRecipeSummary | null; blueprintName: string | null; canResumeVerification: boolean; collection: { id: string; name: string } | null }
let detail = $state<Detail | null>(null);
let error = $state('');
let refreshing = false;
@@ -102,6 +102,7 @@
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: deployment?.server ?? 'Details' }]} />{/snippet}
</SectionHeader>
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
{#if detail?.collection}<a href="/deployments/collections/{detail.collection.id}">← Back to {detail.collection.name} collection</a>{/if}
{#if deployment}
<section class="summary">
<div class="top"><Badge variant={deployment.state === 'done' ? 'online' : deployment.state === 'failed' ? 'danger' : 'warning'}>{stateLabel(deployment.state)}</Badge>
@@ -0,0 +1,7 @@
import { error } from '@sveltejs/kit';
import { getCollection } from '$lib/server/fleet/collections.js';
export function load({ params }: { params: { id: string } }) {
if (!getCollection(params.id)) error(404, 'Collection not found.');
return {};
}
@@ -0,0 +1,56 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
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 type { CollectionDetail } from '$lib/deployment-plan.js';
let detail = $state<CollectionDetail | null>(null);
let error = $state('');
let requestNumber = 0;
let collection = $derived(detail?.collection);
const labels: Record<string, string> = { queued: 'Queued', running: 'Provisioning', 'awaiting-health': 'Verifying health', done: 'Verified', warning: 'Completed with warnings', failed: 'Failed', interrupted: 'Interrupted' };
async function load() {
const id = $page.params.id ?? '';
const current = ++requestNumber;
try {
const res = await fetch(`/api/deployment-collections/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error('Could not refresh this collection.');
const body = await res.json();
if (current === requestNumber && id === $page.params.id) { detail = body; error = ''; }
} catch (err) { if (current === requestNumber) error = (err as Error).message; }
}
$effect(() => { $page.params.id; detail = null; void load(); });
onMount(() => { const timer = setInterval(load, 2000); return () => { clearInterval(timer); requestNumber++; }; });
</script>
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title={collection?.name ?? 'Collection launch'} subtitle="One launch, individual progress for every server.">
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: 'Collection launch' }]} />{/snippet}
</SectionHeader>
{#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if}
{#if collection && detail}
<section class="summary">
<div class="top"><h2>{collection.active ? 'Your lineup is coming online' : collection.failed ? 'Some servers need attention' : 'Your lineup is ready'}</h2><Badge variant={collection.failed ? 'danger' : collection.active ? 'info' : 'online'}>{collection.active ? 'In progress' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></div>
<dl><div><dt>Servers</dt><dd>{collection.total}</dd></div><div><dt>Verified</dt><dd>{collection.done}</dd></div><div><dt>In progress</dt><dd>{collection.active}</dd></div><div><dt>Need attention</dt><dd>{collection.failed}</dd></div></dl>
<progress value={collection.done + collection.failed} max={collection.total || 1} aria-label="Finished server operations"></progress>
<p class="muted">Every server was queued together. Provisioning runs in sequence, then each server verifies its container, RCON, and map. Open a server’s operation for its full timeline{collection.failed ? ' and recovery options' : ''}.</p>
</section>
<section class="servers" aria-label="Servers in this collection">
{#each detail.servers as server (server.id)}
<a class="server" href="/deployments/{server.id}">
<div class="top"><strong>{server.server}</strong><Badge variant={server.state === 'done' ? 'online' : ['failed', 'interrupted'].includes(server.state) ? 'danger' : 'info'}>{labels[server.state] ?? server.state}</Badge></div>
<p>{server.stage}</p>
{#if server.error}<p class="error">{server.error}</p>{/if}
<div class="top muted"><span>Port {server.port} · {server.server.startsWith('t6_') ? 'Black Ops II' : 'Black Ops I'}</span><span>View operation →</span></div>
</a>
{/each}
</section>
{:else if !error}<p class="muted">Loading collection…</p>{/if}
</div>
<style>
.page { display: grid; gap: 28px; }.summary { display: grid; gap: 20px; padding: 24px; border: 1px solid var(--border-strong); border-radius: 14px; background: var(--surface); }.top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, p { margin: 0; }h2 { font-size: 21px; }.muted { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }dl { display: flex; flex-wrap: wrap; gap: 24px 50px; margin: 0; }dt { color: var(--text-secondary); font-size: 12px; }dd { font-family: var(--font-mono); font-size: 28px; margin: 8px 0 0; }progress { width: 100%; height: 8px; accent-color: var(--accent-primary); }.servers { display: grid; gap: 14px; }.server { display: grid; gap: 14px; padding: 20px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 10px; color: inherit; text-decoration: none; overflow-wrap: anywhere; }.server:hover { border-color: var(--border-strong); }.server strong { font-family: var(--font-mono); font-size: 14px; }.server p { font-size: 13px; line-height: 1.6; }.error { color: var(--state-danger); }
</style>