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); }
|
||||
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user