Add multi-server blueprint collections and atomic launches
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user