Add multi-server blueprint collections and atomic launches
This commit is contained in:
@@ -70,10 +70,13 @@ the sweep marks pending rows `manual` so nothing hangs forever.
|
|||||||
|
|
||||||
## Deployment blueprints and launch previews
|
## Deployment blueprints and launch previews
|
||||||
|
|
||||||
Deployments now includes a blueprint library and a two-step launch composer.
|
Deployments includes a blueprint builder for whole server collections: choose
|
||||||
Preview a promoted game/map setup, save its managed files as a reusable blueprint,
|
multiple T5/T6 maps, set a lobby count for each, and launch the lineup in one action.
|
||||||
or launch again from a recorded deployment. Previews check configuration and
|
The collection preview shows every server ID, port, and saved-key requirement.
|
||||||
available capacity; every queued creation retains its own launch recipe.
|
All jobs and capacity reservations are committed together before provisioning;
|
||||||
|
one collection page tracks each server's independent progress and failures.
|
||||||
|
Existing single-server blueprints can be expanded with **Edit**. Single-server
|
||||||
|
launches and launching again from recorded recipes remain available.
|
||||||
The progress page explains waiting conditions, shows elapsed time, and can resume
|
The progress page explains waiting conditions, shows elapsed time, and can resume
|
||||||
health verification of an interrupted creation when its original server remains.
|
health verification of an interrupted creation when its original server remains.
|
||||||
See [Deployment blueprints](docs/deployment-blueprints.md) for the workflow,
|
See [Deployment blueprints](docs/deployment-blueprints.md) for the workflow,
|
||||||
|
|||||||
@@ -1,12 +1,49 @@
|
|||||||
# Deployment blueprints
|
# Deployment blueprints
|
||||||
|
|
||||||
The Deployments page is the starting point for reusable server launches. This
|
The Deployments page is the starting point for reusable server collections. A
|
||||||
release adds saved blueprints, launch previews, immutable managed-file recipes,
|
blueprint describes a whole lineup: for example, two Kino lobbies, one Moon lobby,
|
||||||
launching again from history, and clearer progress and recovery.
|
and two Town lobbies, launched together from one preview.
|
||||||
|
|
||||||
|
## Build and launch a collection
|
||||||
|
|
||||||
|
1. Choose **Create blueprint** on Deployments and name the lineup.
|
||||||
|
2. Add maps from Black Ops I or II and set the number of lobbies for each.
|
||||||
|
3. Choose **Save & preview** to review every new server ID and game port.
|
||||||
|
4. Enter each lobby's Plutonium key, or leave its field blank to reuse the key
|
||||||
|
saved for that exact server ID.
|
||||||
|
5. Choose **Launch all N servers** and follow the collection progress page.
|
||||||
|
|
||||||
|
Blueprints support up to 40 distinct game/map selections, 1–20 lobbies per map,
|
||||||
|
and 100 servers total. The host's configured port range and existing fleet may
|
||||||
|
allow fewer. A blueprint can be saved even when there is insufficient capacity
|
||||||
|
to launch it. Each map captures its promoted managed files when added.
|
||||||
|
|
||||||
|
Use **Edit** on a blueprint card to change its name, description, maps, or lobby
|
||||||
|
counts. Existing map recipes are retained when their counts change; newly added
|
||||||
|
maps capture their current promoted setup. Already reviewed launches keep their
|
||||||
|
original lineup and file contents, including after blueprint edits or removal.
|
||||||
|
Blueprints saved by the earlier single-server release load as one-server lineups
|
||||||
|
and can be expanded directly.
|
||||||
|
|
||||||
|
A collection preview does not reserve capacity. At submission, all lobby IDs,
|
||||||
|
ports, and keys are validated again. The entire collection is then queued in one
|
||||||
|
database transaction. If any server lacks a valid key or capacity has changed,
|
||||||
|
no jobs, reservations, or key changes from that submission are committed. Refreshing
|
||||||
|
the preview clears typed keys so they cannot be applied to changed server IDs.
|
||||||
|
|
||||||
|
Submitting the same preview again returns the same collection, including after
|
||||||
|
a lost response or an application restart. Reusing the blueprint with a new
|
||||||
|
preview deliberately creates another collection with fresh available capacity.
|
||||||
|
|
||||||
|
The worker provisions servers sequentially; health verification and failures are
|
||||||
|
recorded separately for each. One failure does not undo servers already created
|
||||||
|
or prevent the rest of the queued collection from being attempted. The collection
|
||||||
|
page summarizes verified, active, and failed operations and links to every
|
||||||
|
server's timeline and recovery options. Each child links back to its collection.
|
||||||
|
|
||||||
## Create and review a launch
|
## Create and review a launch
|
||||||
|
|
||||||
1. Choose **Create a server** on Deployments (or the deploy action on the fleet).
|
1. Choose **Single server** on Deployments (or the deploy action on the fleet).
|
||||||
2. Choose the current promoted setup and select a game/map, or select a blueprint.
|
2. Choose the current promoted setup and select a game/map, or select a blueprint.
|
||||||
3. Choose **Preview launch**. Review the image reference, configuration sources,
|
3. Choose **Preview launch**. Review the image reference, configuration sources,
|
||||||
managed files and versions, selected map, lobby, port, and prerequisite checks.
|
managed files and versions, selected map, lobby, port, and prerequisite checks.
|
||||||
@@ -31,12 +68,11 @@ Expand **Make this a reusable blueprint** in a launch preview and give it a name
|
|||||||
and optional description. You can save a recipe even when host configuration or
|
and optional description. You can save a recipe even when host configuration or
|
||||||
available capacity blocks launching. Names are unique without regard to case.
|
available capacity blocks launching. Names are unique without regard to case.
|
||||||
|
|
||||||
Blueprints retain the game, map variant, image reference, composed configuration
|
Each map in a blueprint retains the game, map variant, image reference, composed configuration
|
||||||
when available, generated map configuration, and matching promoted script/plugin
|
when available, generated map configuration, and matching promoted script/plugin
|
||||||
bytes. Each blueprint is a saved recipe: saving another variation requires a new
|
bytes. Credentials, lobby IDs, and ports are not copied from the source server.
|
||||||
name. Credentials, lobby IDs, and ports are not copied from the source server.
|
|
||||||
|
|
||||||
Use **Preview launch** on a blueprint card to assign fresh capacity. Remove a
|
Use **Launch N servers** on a blueprint card to review fresh capacity. Remove a
|
||||||
blueprint from the library when it is no longer needed; its existing servers,
|
blueprint from the library when it is no longer needed; its existing servers,
|
||||||
reviewed previews, and deployment history remain intact.
|
reviewed previews, and deployment history remain intact.
|
||||||
|
|
||||||
@@ -89,10 +125,20 @@ require inspection rather than replaying provisioning automatically.
|
|||||||
|
|
||||||
- `GET /api/blueprints`: list summaries.
|
- `GET /api/blueprints`: list summaries.
|
||||||
- `POST /api/blueprints`: save `{ name, description?, previewId }` or
|
- `POST /api/blueprints`: save `{ name, description?, previewId }` or
|
||||||
`{ name, description?, deploymentId }`.
|
`{ name, description?, deploymentId }` for a single-server recipe, or
|
||||||
|
`{ name, description?, entries: [{ game, mapSlug, count }] }` for a collection.
|
||||||
|
- `PUT /api/blueprints/:id`: edit `{ name, description?, entries }`. Unchanged
|
||||||
|
game/map selections keep their saved recipes.
|
||||||
- `DELETE /api/blueprints/:id`: remove a library entry.
|
- `DELETE /api/blueprints/:id`: remove a library entry.
|
||||||
|
- `POST /api/deployment-collections/preview`: preview `{ blueprintId }`.
|
||||||
|
- `POST /api/deployment-collections`: atomically queue `{ previewId, keys? }`,
|
||||||
|
where `keys` maps exact preview server IDs to optional replacement keys.
|
||||||
|
- `GET /api/deployment-collections`: list collection progress summaries.
|
||||||
|
- `GET /api/deployment-collections/:id`: collection and per-server progress.
|
||||||
- `POST /api/deployments/preview`: preview `{ game, mapSlug }`, `{ blueprintId }`,
|
- `POST /api/deployments/preview`: preview `{ game, mapSlug }`, `{ blueprintId }`,
|
||||||
or `{ deploymentId }`. Returns an ID, expiry, recipe summary, capacity, key
|
or `{ deploymentId }` for a single-server recipe. Multi-server blueprints must
|
||||||
|
use the collection endpoint; they cannot silently launch only one entry.
|
||||||
|
Returns an ID, expiry, recipe summary, capacity, key
|
||||||
availability (never its value), and prerequisite checks.
|
availability (never its value), and prerequisite checks.
|
||||||
- `POST /api/servers`: existing request plus optional `previewId`; the game and
|
- `POST /api/servers`: existing request plus optional `previewId`; the game and
|
||||||
map must match that preview. `expectedName` remains supported.
|
map must match that preview. `expectedName` remains supported.
|
||||||
@@ -100,5 +146,5 @@ require inspection rather than replaying provisioning automatically.
|
|||||||
`canResumeVerification` for lifecycle operations.
|
`canResumeVerification` for lifecycle operations.
|
||||||
- `POST /api/deployments/:id/resume`: resume eligible creation health checks.
|
- `POST /api/deployments/:id/resume`: resume eligible creation health checks.
|
||||||
|
|
||||||
Bulk collections, scheduled game nights, and beta-gated expansion are future
|
Scheduled game nights and beta-gated expansion remain future layers. Collection
|
||||||
layers; they are not activated by saving or launching a blueprint.
|
launches begin processing immediately after all requests are queued.
|
||||||
|
|||||||
@@ -29,6 +29,34 @@ export interface BlueprintSummary {
|
|||||||
description: string;
|
description: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
recipe: LaunchRecipeSummary;
|
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 {
|
export interface LaunchPreview {
|
||||||
|
|||||||
@@ -361,6 +361,28 @@ CREATE TABLE IF NOT EXISTS server_launch_recipes (
|
|||||||
blueprint_name TEXT
|
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.
|
-- Persist state transitions atomically, including changes made by background workers.
|
||||||
-- Polls with unchanged status/error do not produce duplicate activity entries.
|
-- Polls with unchanged status/error do not produce duplicate activity entries.
|
||||||
CREATE TABLE IF NOT EXISTS beta_events (
|
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
|
* 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.
|
* 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()
|
const rows = db()
|
||||||
.prepare(`SELECT name FROM servers WHERE name LIKE ? UNION
|
.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
|
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 }>;
|
.all(`${baseName}_%`) as Array<{ name: string }>;
|
||||||
const taken = new Set<number>();
|
const taken = new Set<number>();
|
||||||
const prefix = `${baseName}_`;
|
const prefix = `${baseName}_`;
|
||||||
for (const r of rows) {
|
for (const r of [...rows, ...additionalNames.map(name => ({ name }))]) {
|
||||||
if (!r.name.startsWith(prefix)) continue;
|
if (!r.name.startsWith(prefix)) continue;
|
||||||
const tail = r.name.slice(prefix.length);
|
const tail = r.name.slice(prefix.length);
|
||||||
if (/^\d+$/.test(tail)) taken.add(Number.parseInt(tail, 10));
|
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
|
* from the DB (which EC controls end-to-end under the new naming scheme, so
|
||||||
* the DB is authoritative — no need to ask Komodo).
|
* the DB is authoritative — no need to ask Komodo).
|
||||||
*/
|
*/
|
||||||
function nextAvailablePort(): number | null {
|
export function nextAvailablePort(additionalPorts: number[] = []): number | null {
|
||||||
const rows = db()
|
const rows = db()
|
||||||
.prepare(`SELECT game_port FROM servers WHERE game_port IS NOT NULL UNION
|
.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
|
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')`)
|
WHERE d.state IN ('queued', 'running', 'awaiting-health')`)
|
||||||
.all() as Array<{ game_port: number }>;
|
.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++) {
|
for (let p = config.portRange.min; p <= config.portRange.max; p++) {
|
||||||
if (!taken.has(p)) return p;
|
if (!taken.has(p)) return p;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ export function getDeployment(id: string) {
|
|||||||
if (!deployment) return null;
|
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 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 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,
|
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 {
|
export function canResumeVerification(id: string): boolean {
|
||||||
|
|||||||
@@ -21,6 +21,44 @@ export interface LaunchRecipe {
|
|||||||
files: (LaunchFileSummary & { content: string })[];
|
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 {
|
export function captureRecipe(game: ZombieGame, mapSlug: string): LaunchRecipe {
|
||||||
const map = zombieMapBySlug(game, mapSlug);
|
const map = zombieMapBySlug(game, mapSlug);
|
||||||
if (!map) throw new Error('Choose a supported game and map.');
|
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 } {
|
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;
|
const { entries, blueprintName } = blueprintEntries(id);
|
||||||
if (!row) throw new Error('This blueprint is no longer available. Choose another starting point.');
|
if (entries.length !== 1 || entries[0].count !== 1) throw new Error('This blueprint launches multiple servers. Use the collection preview.');
|
||||||
return { recipe: JSON.parse(row.recipe), blueprintName: row.name };
|
return { recipe: entries[0].recipe, blueprintName };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listBlueprints(): BlueprintSummary[] {
|
export function listBlueprints(): BlueprintSummary[] {
|
||||||
return (db().prepare('SELECT * FROM server_blueprints ORDER BY name COLLATE NOCASE').all() as {
|
return (db().prepare('SELECT * FROM server_blueprints ORDER BY name COLLATE NOCASE').all() as BlueprintRow[]).map(summarizeBlueprint);
|
||||||
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)) }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (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 (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;
|
let recipe: LaunchRecipe | undefined;
|
||||||
if (typeof input.previewId === 'string') {
|
if (typeof input.previewId === 'string') {
|
||||||
const row = db().prepare('SELECT recipe FROM server_launch_previews WHERE id = ? AND expires_at > ?')
|
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;
|
.get(input.previewId, new Date().toISOString()) as { recipe: string } | undefined;
|
||||||
if (row) recipe = JSON.parse(row.recipe);
|
if (row) recipe = JSON.parse(row.recipe);
|
||||||
} else if (typeof input.deploymentId === 'string') recipe = recipeForDeployment(input.deploymentId)?.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 (!recipe && input.entries === undefined) 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.');
|
const entries = input.entries !== undefined ? buildEntries(input.entries) : [{ count: 1, recipe: recipe! }];
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const createdAt = new Date().toISOString();
|
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 (?, ?, ?, ?, ?)')
|
db().prepare('INSERT INTO server_blueprints (id, name, description, created_at, recipe) VALUES (?, ?, ?, ?, ?)')
|
||||||
.run(id, input.name.trim(), input.description ?? '', createdAt, JSON.stringify(recipe));
|
.run(id, name, input.description ?? '', createdAt, saved);
|
||||||
return { id, name: input.name.trim(), description: (input.description as string) ?? '', createdAt, recipe: summarizeRecipe(recipe) };
|
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 {
|
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 { goto } from '$app/navigation';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
import LaunchRecipe from './LaunchRecipe.svelte';
|
import LaunchRecipe from './LaunchRecipe.svelte';
|
||||||
|
import DeployCollectionDialog from './DeployCollectionDialog.svelte';
|
||||||
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
|
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
|
||||||
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
|
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
|
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
|
||||||
let blueprints = $state<BlueprintSummary[]>([]);
|
let blueprints = $state<BlueprintSummary[]>([]);
|
||||||
let blueprintId = $state('');
|
let blueprintId = $state('');
|
||||||
|
let collectionOpen = $state(false);
|
||||||
let maps = $derived(catalogs[game]);
|
let maps = $derived(catalogs[game]);
|
||||||
let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
|
let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
|
||||||
let preview = $state<LaunchPreview | null>(null);
|
let preview = $state<LaunchPreview | null>(null);
|
||||||
@@ -54,6 +56,9 @@
|
|||||||
|
|
||||||
async function review() {
|
async function review() {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
if (!sourceDeploymentId && selectedBlueprint && selectedBlueprint.totalServers > 1) {
|
||||||
|
open = false; collectionOpen = true; return;
|
||||||
|
}
|
||||||
const current = session;
|
const current = session;
|
||||||
busy = true; error = ''; plutoKey = ''; preview = null; savedName = '';
|
busy = true; error = ''; plutoKey = ''; preview = null; savedName = '';
|
||||||
try {
|
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>
|
<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}
|
{:else}
|
||||||
<form onsubmit={event => { event.preventDefault(); void review(); }}>
|
<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>
|
<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"><LaunchRecipe recipe={selectedBlueprint.recipe} />{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
|
{#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}
|
{: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="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>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
|
||||||
|
|
||||||
<style>
|
<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); }
|
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 { 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 } }) {
|
export function DELETE({ params }: { params: { id: string } }) {
|
||||||
return deleteBlueprint(params.id) ? json({ ok: true }) : json({ error: 'Blueprint not found.' }, { status: 404 });
|
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 Button from '$ui/components/primitives/Button.svelte';
|
||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
import DeployServerDialog from '$ui/components/DeployServerDialog.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 {
|
interface Deployment {
|
||||||
id: string; server: string; action: 'create' | 'destroy';
|
id: string; server: string; action: 'create' | 'destroy';
|
||||||
@@ -18,6 +20,10 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let blueprintId = $state('');
|
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 blueprints = $state<BlueprintSummary[]>([]);
|
||||||
let blueprintsLoading = $state(true);
|
let blueprintsLoading = $state(true);
|
||||||
let blueprintError = $state('');
|
let blueprintError = $state('');
|
||||||
@@ -33,13 +39,18 @@
|
|||||||
if (refreshing) return;
|
if (refreshing) return;
|
||||||
refreshing = true;
|
refreshing = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/deployments');
|
const [res, collectionRes] = await Promise.all([fetch('/api/deployments'), fetch('/api/deployment-collections')]);
|
||||||
if (!res.ok) throw new Error('Could not load server deployments.');
|
if (!res.ok || !collectionRes.ok) throw new Error('Could not load server deployments.');
|
||||||
deployments = (await res.json()).deployments;
|
deployments = (await res.json()).deployments;
|
||||||
|
collections = (await collectionRes.json()).collections;
|
||||||
error = '';
|
error = '';
|
||||||
} catch (err) { error = (err as Error).message; }
|
} catch (err) { error = (err as Error).message; }
|
||||||
finally { loading = false; refreshing = false; }
|
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(() => {
|
onMount(() => {
|
||||||
void load();
|
void load();
|
||||||
void loadBlueprints();
|
void loadBlueprints();
|
||||||
@@ -73,29 +84,33 @@
|
|||||||
|
|
||||||
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
|
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
|
||||||
<div class="page">
|
<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 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>
|
<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>
|
||||||
<Button variant="primary" onclick={() => { blueprintId = ''; open = true; }}>Create a server →</Button>
|
<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>
|
</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>
|
<section>
|
||||||
<h2>Your blueprints <span>{blueprints.length}</span></h2>
|
<h2>Your blueprints <span>{blueprints.length}</span></h2>
|
||||||
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
|
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
|
||||||
{#if blueprintsLoading}<p>Loading blueprints…</p>
|
{#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}
|
{:else}
|
||||||
<div class="blueprints">
|
<div class="blueprints">
|
||||||
{#each blueprints as blueprint (blueprint.id)}
|
{#each blueprints as blueprint (blueprint.id)}
|
||||||
<article class="blueprint">
|
<article class="blueprint">
|
||||||
<div class="blueprint-top">
|
<div class="blueprint-top">
|
||||||
<span class="eyebrow">{blueprint.recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'} · {blueprint.recipe.mapName}</span>
|
<span class="eyebrow">Server collection</span>
|
||||||
<span class="file-count">{blueprint.recipe.files.length} file{blueprint.recipe.files.length === 1 ? '' : 's'}</span>
|
<span class="file-count">{blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3>{blueprint.name}</h3>
|
<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">
|
<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>
|
<button class="remove" disabled={!!removing} onclick={() => confirmRemove = blueprint.id}>Remove</button>
|
||||||
</div>
|
</div>
|
||||||
{#if confirmRemove === blueprint.id}
|
{#if confirmRemove === blueprint.id}
|
||||||
@@ -111,6 +126,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
{#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>
|
<section>
|
||||||
<h2>In flight <span>{active.length}</span></h2>
|
<h2>In flight <span>{active.length}</span></h2>
|
||||||
{#if loading}<p>Loading deployments...</p>
|
{#if loading}<p>Loading deployments...</p>
|
||||||
@@ -149,6 +176,7 @@
|
|||||||
.row-head { justify-content: space-between; }
|
.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 { 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; }
|
.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; }
|
.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; }
|
.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; }
|
.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;
|
stage: string; started_at: string; finished_at: string | null;
|
||||||
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
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 detail = $state<Detail | null>(null);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let refreshing = false;
|
let refreshing = false;
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: deployment?.server ?? 'Details' }]} />{/snippet}
|
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: deployment?.server ?? 'Details' }]} />{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
{#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}
|
{#if deployment}
|
||||||
<section class="summary">
|
<section class="summary">
|
||||||
<div class="top"><Badge variant={deployment.state === 'done' ? 'online' : deployment.state === 'failed' ? 'danger' : 'warning'}>{stateLabel(deployment.state)}</Badge>
|
<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>
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
import type { CollectionPreview } from '../../src/lib/deployment-plan.js';
|
||||||
|
|
||||||
|
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
|
||||||
|
komodoCreateDeployment: vi.fn(async () => ({ ok: true })),
|
||||||
|
komodoDeploy: vi.fn(async () => ({ ok: true })),
|
||||||
|
komodoDestroyDeployment: vi.fn(async () => ({ ok: true })),
|
||||||
|
komodoDeleteDeployment: vi.fn(async () => ({ ok: true })),
|
||||||
|
komodoInspectHealthy: vi.fn(async () => ({ ok: true, state: 'running' })),
|
||||||
|
komodoEnsureTag: vi.fn(async (name: string) => ({ ok: true, id: name })),
|
||||||
|
komodoUpdateTagsOnResource: vi.fn(async () => ({ ok: true }))
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
|
||||||
|
rconGetPlayerCount: vi.fn(async () => 0),
|
||||||
|
rconStatusResult: vi.fn(async (server: string) => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '',
|
||||||
|
status: { players: 0, map: server.startsWith('t6_') ? 'zm_transit' : server.includes('_moon_') ? 'zombie_moon' : 'zombie_theater', gametype: null, roster: [] } }))
|
||||||
|
}));
|
||||||
|
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
const entries = [
|
||||||
|
{ game: 't5', mapSlug: 'theater', count: 2 },
|
||||||
|
{ game: 't5', mapSlug: 'moon', count: 1 },
|
||||||
|
{ game: 't6', mapSlug: 'town', count: 2 }
|
||||||
|
];
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules(); vi.clearAllMocks();
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
vi.stubEnv('KOMODO_ADDRESS', 'http://komodo.test');
|
||||||
|
vi.stubEnv('KOMODO_API_KEY', 'private-api-key');
|
||||||
|
vi.stubEnv('KOMODO_API_SECRET', 'private-api-secret');
|
||||||
|
vi.stubEnv('KOMODO_TARGET_SERVER', 'test-host');
|
||||||
|
vi.stubEnv('RCON_PASSWORD', 'private-rcon-password');
|
||||||
|
vi.stubEnv('SERVER_PORT_MIN', '28960');
|
||||||
|
vi.stubEnv('SERVER_PORT_MAX', '28980');
|
||||||
|
});
|
||||||
|
afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); });
|
||||||
|
|
||||||
|
async function previewLineup() {
|
||||||
|
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
|
||||||
|
const { previewCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||||
|
const blueprint = saveBlueprint({ name: 'Friday Zombies', entries });
|
||||||
|
return { blueprint, preview: previewCollection(blueprint.id) };
|
||||||
|
}
|
||||||
|
function keysFor(preview: CollectionPreview) {
|
||||||
|
return Object.fromEntries(preview.servers.filter(server => server.name).map(server => [server.name!, 'key-' + server.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('multi-server blueprints', () => {
|
||||||
|
it('saves a mixed-game lineup and previews unique slots and global ports without reserving them', async () => {
|
||||||
|
const { blueprint, preview } = await previewLineup();
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { previewCreation } = await import('../../src/lib/server/fleet/create.js');
|
||||||
|
expect(blueprint.totalServers).toBe(5);
|
||||||
|
expect(blueprint.entries.map(entry => entry.count)).toEqual([2, 1, 2]);
|
||||||
|
expect(preview.ready).toBe(true);
|
||||||
|
expect(preview.servers.map(server => server.name)).toEqual(['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_moon_1', 't6_zombie_town_1', 't6_zombie_town_2']);
|
||||||
|
expect(preview.servers.map(server => server.port)).toEqual([28960, 28961, 28962, 28963, 28964]);
|
||||||
|
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]);
|
||||||
|
expect(db().prepare('SELECT * FROM server_keys').all()).toEqual([]);
|
||||||
|
expect(JSON.stringify(preview)).not.toContain('private-');
|
||||||
|
expect(preview.entries[0].recipe.files[0]).not.toHaveProperty('content');
|
||||||
|
expect(() => previewCreation({ blueprintId: blueprint.id })).toThrow(/multiple servers/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upgrades an existing one-server blueprint and preserves its saved bytes when counts change', async () => {
|
||||||
|
const { captureRecipe, listBlueprints, updateBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
|
||||||
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const recipe = captureRecipe('t5', 'theater');
|
||||||
|
db().prepare('INSERT INTO server_blueprints (id,name,created_at,recipe) VALUES (?, ?, ?, ?)').run('legacy', 'Old favorite', new Date().toISOString(), JSON.stringify(recipe));
|
||||||
|
closeDb();
|
||||||
|
const old = listBlueprints()[0];
|
||||||
|
expect(old.totalServers).toBe(1);
|
||||||
|
expect(old.entries).toEqual([{ count: 1, recipe: old.recipe }]);
|
||||||
|
const edited = updateBlueprint('legacy', { name: 'Whole lineup', entries });
|
||||||
|
expect(edited.totalServers).toBe(5);
|
||||||
|
expect(edited.entries[0].recipe.fingerprint).toBe(old.recipe.fingerprint);
|
||||||
|
expect(listBlueprints()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([[], [{ game: 't5', mapSlug: 'theater', count: 0 }], [{ game: 't5', mapSlug: 'theater', count: 21 }],
|
||||||
|
[{ game: 't5', mapSlug: 'theater', count: 1.5 }], [{ game: 't5', mapSlug: 'theater', count: '2' }],
|
||||||
|
[{ game: 't5', mapSlug: 'town', count: 1 }], [entries[0], entries[0]]].map(input => ({ input })))('rejects an invalid lineup: $input', async ({ input }) => {
|
||||||
|
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
|
||||||
|
expect(() => saveBlueprint({ name: 'Invalid', entries: input })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports insufficient capacity without preventing blueprint creation', async () => {
|
||||||
|
vi.stubEnv('SERVER_PORT_MAX', '28961');
|
||||||
|
const { blueprint, preview } = await previewLineup();
|
||||||
|
const { queueCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
expect(blueprint.totalServers).toBe(5);
|
||||||
|
expect(preview.ready).toBe(false);
|
||||||
|
expect(preview.servers.filter(server => server.port === null)).toHaveLength(3);
|
||||||
|
expect(() => queueCollection({ previewId: preview.id, keys: keysFor(preview) })).toThrow(/not enough free game ports/);
|
||||||
|
expect(db().prepare('SELECT * FROM server_launch_collections').all()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('atomic collection launches', () => {
|
||||||
|
it('queues all five servers, persists one group, and completes each independently', async () => {
|
||||||
|
const { preview } = await previewLineup();
|
||||||
|
const { queueCollection, getCollection, listCollections } = await import('../../src/lib/server/fleet/collections.js');
|
||||||
|
const { processCreations } = await import('../../src/lib/server/fleet/create.js');
|
||||||
|
const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js');
|
||||||
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||||
|
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
|
||||||
|
expect(komodoCreateDeployment).not.toHaveBeenCalled();
|
||||||
|
expect(getCollection(queued.collectionId)?.collection).toMatchObject({ total: 5, active: 5, done: 0, failed: 0 });
|
||||||
|
closeDb();
|
||||||
|
await processCreations();
|
||||||
|
const detail = getCollection(queued.collectionId)!;
|
||||||
|
expect(detail.collection).toMatchObject({ total: 5, active: 0, done: 5, failed: 0 });
|
||||||
|
expect(listCollections()).toHaveLength(1);
|
||||||
|
expect(komodoCreateDeployment).toHaveBeenCalledTimes(5);
|
||||||
|
expect(getDeployment(detail.servers[0].id)?.collection).toEqual({ id: queued.collectionId, name: 'Friday Zombies' });
|
||||||
|
expect(await readFile(join(fixture.t6, 't6_zombie_town_2', 'configs/electric_cherry_map.cfg'), 'utf8')).toContain('execgts zm_standard_town.cfg map zm_transit');
|
||||||
|
expect(JSON.stringify(detail)).not.toContain('key-t5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back all earlier jobs and key changes if the final server has no valid key', async () => {
|
||||||
|
const { preview } = await previewLineup();
|
||||||
|
const { queueCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { saveServerKey, getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
|
||||||
|
saveServerKey(preview.servers[0].name!, 'original-key');
|
||||||
|
const keys = keysFor(preview);
|
||||||
|
delete keys[preview.servers[4].name!];
|
||||||
|
expect(() => queueCollection({ previewId: preview.id, keys })).toThrow(/No saved key/);
|
||||||
|
for (const table of ['server_launch_collections', 'server_collection_members', 'server_creation_jobs', 'server_deployments', 'server_launch_previews', 'server_launch_recipes']) {
|
||||||
|
expect(db().prepare(`SELECT * FROM ${table}`).all()).toEqual([]);
|
||||||
|
}
|
||||||
|
expect(getServerKey(preview.servers[0].name!)).toBe('original-key');
|
||||||
|
expect(db().prepare('SELECT * FROM server_keys').all()).toHaveLength(1);
|
||||||
|
keys[preview.servers[4].name!] = 'invalid key';
|
||||||
|
expect(() => queueCollection({ previewId: preview.id, keys })).toThrow(/without whitespace/);
|
||||||
|
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]);
|
||||||
|
expect(queueCollection({ previewId: preview.id, keys: keysFor(preview) }).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects changed capacity before saving keys or partially queueing the lineup', async () => {
|
||||||
|
const { preview } = await previewLineup();
|
||||||
|
const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
|
||||||
|
const { queueCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
queueCreateServer({ gameMode: 'zombie', mapSlug: 'cosmodrome', plutoKey: 'other-server-key' });
|
||||||
|
expect(() => queueCollection({ previewId: preview.id, keys: keysFor(preview) })).toThrow(/availability changed/);
|
||||||
|
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toHaveLength(1);
|
||||||
|
expect(db().prepare('SELECT * FROM server_keys').all()).toHaveLength(1);
|
||||||
|
expect(db().prepare('SELECT * FROM server_launch_collections').all()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses saved keys and returns the same collection on resubmission after restart and expiry', async () => {
|
||||||
|
const { preview, blueprint } = await previewLineup();
|
||||||
|
const { queueCollection, previewCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||||
|
const { saveServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
|
||||||
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
for (const server of preview.servers) saveServerKey(server.name!, `saved-${server.name}`);
|
||||||
|
const ready = previewCollection(blueprint.id);
|
||||||
|
expect(ready.servers.every(server => server.hasSavedKey)).toBe(true);
|
||||||
|
const queued = queueCollection({ previewId: ready.id });
|
||||||
|
db().prepare("UPDATE server_collection_previews SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(ready.id);
|
||||||
|
closeDb();
|
||||||
|
expect(queueCollection({ previewId: ready.id })).toEqual(queued);
|
||||||
|
expect(getCollection(queued.collectionId)?.servers).toHaveLength(5);
|
||||||
|
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toHaveLength(5);
|
||||||
|
const next = previewCollection(blueprint.id);
|
||||||
|
expect(next.servers.map(server => server.name)).toEqual(['t5_zombie_theater_3', 't5_zombie_theater_4', 't5_zombie_moon_2', 't6_zombie_town_3', 't6_zombie_town_4']);
|
||||||
|
expect(next.servers.map(server => server.port)).toEqual([28965, 28966, 28967, 28968, 28969]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the reviewed lineup and bytes after blueprint edits, deletion, and promotion', async () => {
|
||||||
|
const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { script, version } = createScript({ name: 'Classic perks', kind: 'script', scopeType: 'game', scopeValue: 't5', targetRelPath: 'perks.gsc', content: Buffer.from('original') });
|
||||||
|
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
|
||||||
|
const { preview, blueprint } = await previewLineup();
|
||||||
|
const { updateBlueprint, deleteBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
|
||||||
|
const { queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||||
|
const { processCreations } = await import('../../src/lib/server/fleet/create.js');
|
||||||
|
const newer = uploadNewVersion(script.id, Buffer.from('new version'));
|
||||||
|
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(newer.id, script.id);
|
||||||
|
updateBlueprint(blueprint.id, { name: 'Changed', entries: [{ ...entries[0], count: 1 }] });
|
||||||
|
deleteBlueprint(blueprint.id);
|
||||||
|
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
|
||||||
|
await processCreations();
|
||||||
|
expect(getCollection(queued.collectionId)?.collection).toMatchObject({ name: 'Friday Zombies', total: 5, done: 5 });
|
||||||
|
for (const name of ['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_moon_1']) {
|
||||||
|
expect(await readFile(join(fixture.t5, name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('original');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a provisioning failure separate while the other servers complete', async () => {
|
||||||
|
const { preview } = await previewLineup();
|
||||||
|
const { queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||||
|
const { processCreations } = await import('../../src/lib/server/fleet/create.js');
|
||||||
|
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||||
|
vi.mocked(komodoCreateDeployment).mockResolvedValueOnce({ ok: false, kind: 'http-error', message: 'Host rejected this container' });
|
||||||
|
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
|
||||||
|
await processCreations();
|
||||||
|
const detail = getCollection(queued.collectionId)!;
|
||||||
|
expect(detail.collection).toMatchObject({ total: 5, active: 0, done: 4, failed: 1 });
|
||||||
|
expect(detail.servers[0]).toMatchObject({ state: 'failed', error: expect.stringContaining('Host rejected') });
|
||||||
|
expect(detail.servers.slice(1).every(server => server.state === 'done')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates collection API payloads and rejects an unused expired preview', async () => {
|
||||||
|
const { preview } = await previewLineup();
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { queueCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||||
|
const { POST } = await import('../../src/routes/api/deployment-collections/+server.js');
|
||||||
|
for (const body of [null, [], {}, { previewId: preview.id, keys: [] }, { previewId: preview.id, keys: { wrong: 'key' } }]) {
|
||||||
|
const response = await POST({ request: new Request('http://localhost/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) });
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
}
|
||||||
|
db().prepare("UPDATE server_collection_previews SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(preview.id);
|
||||||
|
expect(() => queueCollection({ previewId: preview.id, keys: keysFor(preview) })).toThrow(/expired/);
|
||||||
|
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user