feat: match fleet to blueprint targets exactly

Add an optional exact blueprint mode with keep/create/remove previews, atomic queueing, cancellation of unstarted excess creations, and persistent dependencies for safe port reuse. Track removals alongside creations and preserve fill mode as the default.

Validation: type check, production build, and 103 deployment tests passed. The full suite has one telemetry assertion failure also reproduced on the previous commit.

Fixes #42
This commit is contained in:
2026-09-27 13:36:08 -04:00
parent 1aa7f15371
commit 0b0e5b5bdb
13 changed files with 517 additions and 83 deletions
+6
View File
@@ -147,6 +147,12 @@ multiple T5/T6 maps and set the desired total lobbies for each. **Conform fleet*
counts existing and queued servers and creates only the shortfall. The preview counts existing and queued servers and creates only the shortfall. The preview
shows target/existing/missing counts and IDs, ports, and key requirements for new shows target/existing/missing counts and IDs, ports, and key requirements for new
servers only. Already-satisfied targets create nothing; excess servers are kept. servers only. Already-satisfied targets create nothing; excess servers are kept.
Choose **Match blueprint exactly** in the preview to also remove excess lobbies
and maps outside the blueprint. Review the keep/create/remove lists and confirm
removals. Matching servers stay untouched; unstarted excess creations are cancelled,
and running servers wait for a fresh zero-player check before removal. New lobbies
can reserve ports from these removals and wait until they are released. The collection
page tracks both actions. **Fill missing servers** remains the default.
All jobs and capacity reservations are committed together before provisioning; All jobs and capacity reservations are committed together before provisioning;
one collection page tracks each server's independent progress and failures. one collection page tracks each server's independent progress and failures.
New servers start one at a time by default across all collections and individual New servers start one at a time by default across all collections and individual
+45 -9
View File
@@ -17,20 +17,53 @@ they appear in both the server registry and active creation queue.
Registered servers count even if stopped or unhealthy; conforming does not Registered servers count even if stopped or unhealthy; conforming does not
restart or reconfigure them. Failed creation jobs without a registered server, restart or reconfigure them. Failed creation jobs without a registered server,
destroyed records, and servers awaiting removal do not count. Excess servers and destroyed records, and servers awaiting removal do not count. The default
maps outside the blueprint are kept. This action fills deficits; it does not **Fill missing servers** mode keeps excess servers and maps outside the blueprint.
destroy servers or change existing servers' files. It fills deficits without changing existing servers' files.
## Build and launch a collection ## Build and launch a collection
1. Choose **Create blueprint** on Deployments and name the lineup. 1. Choose **Create blueprint** on Deployments and name the lineup.
2. Add maps from Black Ops I or II and set the desired total lobbies for each. 2. Add maps from Black Ops I or II and set the desired total lobbies for each.
3. Choose **Save & preview** to review every new server ID and game port. 3. Choose **Save & preview** (or **Preview fleet changes** on a saved blueprint).
Choose **Fill missing servers** or **Match blueprint exactly**, then review
the server IDs and game ports.
4. Lobbies with a saved key show **Saved key ready** and reuse it automatically. 4. Lobbies with a saved key show **Saved key ready** and reuse it automatically.
Enter a Plutonium key only for lobbies missing one. Update saved keys in **Keys**. Enter a Plutonium key only for lobbies missing one. Update saved keys in **Keys**.
5. Choose **Queue N servers** and follow their collection progress page. 5. Choose **Queue N servers** (or confirm removals and choose **Apply** in exact
mode) and follow the collection progress page.
If every target is already met, choose **Done**; no operation is created. If every target is already met, choose **Done**; no operation is created.
### Match blueprint exactly
Exact mode applies to the entire registered and queued fleet across both games.
The preview lists servers to keep, create, and remove. It keeps deployed lobbies
before unstarted creations, then the lowest lobby numbers within each game/map
variant. It removes excess lobbies and maps outside the blueprint. Retained
servers keep their containers, files, ports, and configuration without a restart.
The option is chosen per launch; opening another preview defaults to fill mode.
Review the removal list and check its confirmation before applying. Removed
servers lose their containers, files, and registrations; saved keys remain.
Unstarted creations are cancelled without booting a container. Running servers
use the normal removal queue and require a fresh zero-player RCON response.
Occupied or unreachable servers wait. Already pending removals are reused.
Creation reservations and removals are queued in one database transaction. A
missing key or changed fleet rejects the entire request. Any fleet identity,
map, port, or pending-removal change since an exact preview requires a new review.
Servers added after submission are not included. Applying the same preview again
returns the same collection without repeating operations.
Ports belonging to reviewed removals can be reserved for new lobbies even when
the current port range is full. A new lobby waits until its port's removal has
completed; a failed, interrupted, or cancelled removal keeps that launch blocked,
with the reason shown in its progress. Other launches with available capacity can
proceed. Reservations and dependencies survive app restarts. The progress page
tracks creations and removals, including removal-only plans, and lists the servers
that were retained. The existing operation detail page offers removal cancellation
and cleanup recovery where applicable.
Blueprints support up to 40 distinct game/map selections, 1–20 lobbies per map, 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 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 allow fewer. A blueprint can be saved even when there is insufficient capacity
@@ -48,7 +81,8 @@ 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 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, 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 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. the preview retains typed keys only for unchanged server IDs still missing a saved
key, and resets removal confirmation.
Submitting the same preview again returns the same collection, including after Submitting the same preview again returns the same collection, including after
a lost response or an application restart. Reusing the blueprint with a new a lost response or an application restart. Reusing the blueprint with a new
@@ -174,15 +208,17 @@ repeats container creation or deployment as part of this recovery.
- `PUT /api/blueprints/:id`: edit `{ name, description?, entries }`. All entries - `PUT /api/blueprints/:id`: edit `{ name, description?, entries }`. All entries
use the latest promoted setup when launched. use the latest promoted setup when launched.
- `DELETE /api/blueprints/:id`: remove a library entry. - `DELETE /api/blueprints/:id`: remove a library entry.
- `POST /api/deployment-collections/preview`: preview `{ blueprintId }`, including - `POST /api/deployment-collections/preview`: preview `{ blueprintId, mode? }`,
the configured `launchConcurrency`. where mode is `fill` (default) or `exact`. Returns `mode`, `retainedServers`,
`removals` (name/reason), creation `servers`, and configured `launchConcurrency`.
- `POST /api/deployment-collections`: atomically queue `{ previewId, keys? }`, - `POST /api/deployment-collections`: atomically queue `{ previewId, keys? }`,
where `keys` maps exact preview server IDs to optional replacement keys. where `keys` maps exact preview server IDs to optional replacement keys.
A satisfied preview returns `{ ok: true, collectionId: null }` with HTTP 200, A satisfied preview returns `{ ok: true, collectionId: null }` with HTTP 200,
without creating a collection, saving keys, or kicking the worker. without creating a collection, saving keys, or kicking the worker.
- `GET /api/deployment-collections`: list collection progress summaries with - `GET /api/deployment-collections`: list collection progress summaries with
separate `queued`, `provisioning`, and `verifying` counts. separate `queued`, `provisioning`, and `verifying` counts.
- `GET /api/deployment-collections/:id`: collection and per-server progress, - `GET /api/deployment-collections/:id`: collection mode, retained server IDs,
per-operation progress with `action: create | destroy`,
each waiting server's global `queuePosition`, and the shared queue's concurrency, each waiting server's global `queuePosition`, and the shared queue's concurrency,
active startups, and queued count. active startups, and queued count.
- `POST /api/deployments/preview`: preview `{ game, mapSlug }` or `{ deploymentId }` - `POST /api/deployments/preview`: preview `{ game, mapSlug }` or `{ deploymentId }`
+8 -1
View File
@@ -32,11 +32,16 @@ export interface BlueprintSummary {
totalServers: number; totalServers: number;
} }
export type CollectionMode = 'fill' | 'exact';
export interface CollectionPreview { export interface CollectionPreview {
id: string; id: string;
expiresAt: string; expiresAt: string;
blueprintName: string; blueprintName: string;
launchConcurrency: number; launchConcurrency: number;
mode: CollectionMode;
retainedServers: string[];
removals: { name: string; reason: string }[];
entries: { count: number; existing: number; missing: number; existingServers: string[]; entries: { count: number; existing: number; missing: number; existingServers: string[];
coverage: { name: string; health: string; href: string }[]; recipe: LaunchRecipeSummary }[]; coverage: { name: string; health: string; href: string }[]; recipe: LaunchRecipeSummary }[];
servers: { entryIndex: number; name: string | null; port: number | null; hasSavedKey: boolean }[]; servers: { entryIndex: number; name: string | null; port: number | null; hasSavedKey: boolean }[];
@@ -55,12 +60,14 @@ export interface CollectionSummary {
queued: number; queued: number;
provisioning: number; provisioning: number;
verifying: number; verifying: number;
mode: CollectionMode;
} }
export interface CollectionDetail { export interface CollectionDetail {
collection: CollectionSummary; collection: CollectionSummary;
retainedServers: string[];
queue: { concurrency: number; active: { id: string; server: string; state: string; stage: string }[]; queued: number }; queue: { concurrency: number; active: { id: string; server: string; state: string; stage: string }[]; queued: number };
servers: { id: string; server: string; port: number; map_slug: string; state: string; stage: string; servers: { id: string; server: string; action: 'create' | 'destroy'; port: number | null; map_slug: string | null; state: string; stage: string;
started_at: string; finished_at: string | null; error: string | null; server_exists: number; queuePosition: number | null }[]; started_at: string; finished_at: string | null; error: string | null; server_exists: number; queuePosition: number | null }[];
} }
+8
View File
@@ -249,6 +249,14 @@ export function db(): Database.Database {
migrateTable(handle, 'beta_scripts', BETA_SCRIPTS_COLUMNS); migrateTable(handle, 'beta_scripts', BETA_SCRIPTS_COLUMNS);
migrateTable(handle, 'deployment_scripts', DEPLOYMENT_SCRIPTS_COLUMNS); migrateTable(handle, 'deployment_scripts', DEPLOYMENT_SCRIPTS_COLUMNS);
migrateTable(handle, 'deployments', DEPLOYMENTS_COLUMNS); migrateTable(handle, 'deployments', DEPLOYMENTS_COLUMNS);
migrateTable(handle, 'server_launch_collections', [
{ name: 'mode', ddl: "ALTER TABLE server_launch_collections ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'" },
{ name: 'retained_servers', ddl: "ALTER TABLE server_launch_collections ADD COLUMN retained_servers TEXT NOT NULL DEFAULT '[]'" }
]);
migrateTable(handle, 'server_collection_previews', [
{ name: 'mode', ddl: "ALTER TABLE server_collection_previews ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'" },
{ name: 'fleet_snapshot', ddl: 'ALTER TABLE server_collection_previews ADD COLUMN fleet_snapshot TEXT' }
]);
migrateTable(handle, 'server_telemetry', [{ name: 'payload', ddl: 'ALTER TABLE server_telemetry ADD COLUMN payload TEXT' }]); migrateTable(handle, 'server_telemetry', [{ name: 'payload', ddl: 'ALTER TABLE server_telemetry ADD COLUMN payload TEXT' }]);
// Kind-referencing indexes: safe here because migrateTable above just // Kind-referencing indexes: safe here because migrateTable above just
// ALTER'd the column into existence if it was missing. // ALTER'd the column into existence if it was missing.
+20 -1
View File
@@ -467,7 +467,9 @@ CREATE TABLE IF NOT EXISTS server_launch_recipes (
CREATE TABLE IF NOT EXISTS server_launch_collections ( CREATE TABLE IF NOT EXISTS server_launch_collections (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'fill',
retained_servers TEXT NOT NULL DEFAULT '[]'
); );
CREATE TABLE IF NOT EXISTS server_collection_members ( CREATE TABLE IF NOT EXISTS server_collection_members (
@@ -483,9 +485,26 @@ CREATE TABLE IF NOT EXISTS server_collection_previews (
blueprint_name TEXT NOT NULL, blueprint_name TEXT NOT NULL,
entries TEXT NOT NULL, entries TEXT NOT NULL,
servers TEXT NOT NULL, servers TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'fill',
fleet_snapshot TEXT,
collection_id TEXT REFERENCES server_launch_collections(id) ON DELETE CASCADE collection_id TEXT REFERENCES server_launch_collections(id) ON DELETE CASCADE
); );
-- A pending removal can be shared by multiple reviewed collection requests.
CREATE TABLE IF NOT EXISTS server_collection_removals (
collection_id TEXT NOT NULL REFERENCES server_launch_collections(id) ON DELETE CASCADE,
deployment_id TEXT NOT NULL REFERENCES server_deployments(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
PRIMARY KEY (collection_id, deployment_id)
);
-- Reusing a port is safe only after the reviewed removal has completed.
CREATE TABLE IF NOT EXISTS server_creation_dependencies (
deployment_id TEXT NOT NULL REFERENCES server_deployments(id) ON DELETE CASCADE,
removal_id TEXT NOT NULL REFERENCES server_deployments(id) ON DELETE CASCADE,
PRIMARY KEY (deployment_id, removal_id)
);
-- 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 (
+87 -25
View File
@@ -1,9 +1,11 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { CollectionDetail, CollectionPreview, CollectionSummary } from '$lib/deployment-plan.js'; import type { CollectionDetail, CollectionMode, CollectionPreview, CollectionSummary } from '$lib/deployment-plan.js';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { config, komodoConfigured, rconConfigured } from '../config.js'; import { config, komodoConfigured, rconConfigured } from '../config.js';
import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js'; import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js';
import { creationQueue, nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js'; import { creationQueue, destroyServer, nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js';
import { deploymentStage, finishDeployment, recordDeploymentEvent } from './deployments.js';
import { housePartyTargets } from './house-party.js';
import { getServerKey } from './server-keys.js'; import { getServerKey } from './server-keys.js';
import { classifyHealth } from './health.js'; import { classifyHealth } from './health.js';
import { getServer } from './servers.js'; import { getServer } from './servers.js';
@@ -43,7 +45,29 @@ function coverage(entries: BlueprintEntry[]) {
}); });
} }
function allocate(entries: BlueprintEntry[]): CollectionPreview['servers'] { function fleetSnapshot() {
return housePartyTargets().map(name => ({ name,
server: db().prepare('SELECT game, map_slug, map, game_port FROM servers WHERE name = ?').get(name) ?? null,
creation: db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'create' ORDER BY rowid DESC LIMIT 1").get(name) ?? null,
removal: db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) ?? null
}));
}
function collectionPlan(entries: BlueprintEntry[], mode: CollectionMode) {
const counts = coverage(entries);
// Preserve deployed lobbies before queued ones, then the lowest lobby IDs.
const retainedServers = counts.flatMap((entry, index) => [...entry.existingServers].sort((a, b) =>
Number(!!getServer(b)) - Number(!!getServer(a)) || a.localeCompare(b, 'en', { numeric: true })
).slice(0, mode === 'exact' ? entries[index].count : undefined));
const retained = new Set(retainedServers);
const matching = new Set(counts.flatMap(entry => entry.existingServers));
const removals = mode === 'exact' ? housePartyTargets().filter(name => !retained.has(name)).map(name => ({ name,
reason: matching.has(name) ? 'Exceeds the target lobby count' : 'Outside this blueprint or already awaiting removal'
})) : [];
return { counts, retainedServers, removals };
}
function allocate(entries: BlueprintEntry[], releasingServers: string[] = []): CollectionPreview['servers'] {
const names: string[] = []; const names: string[] = [];
const ports: number[] = []; const ports: number[] = [];
const counts = coverage(entries); const counts = coverage(entries);
@@ -51,7 +75,7 @@ function allocate(entries: BlueprintEntry[]): CollectionPreview['servers'] {
const base = `${entry.recipe.game}_zombie_${entry.recipe.mapSlug}`; const base = `${entry.recipe.game}_zombie_${entry.recipe.mapSlug}`;
const lobby = nextLobbyNumber(base, names); const lobby = nextLobbyNumber(base, names);
const name = lobby === null ? null : `${base}_${lobby}`; const name = lobby === null ? null : `${base}_${lobby}`;
const port = nextAvailablePort(ports); const port = nextAvailablePort(ports, releasingServers);
if (name) names.push(name); if (name) names.push(name);
if (port !== null) ports.push(port); if (port !== null) ports.push(port);
return { entryIndex, name, port, hasSavedKey: !!name && !!getServerKey(name) }; return { entryIndex, name, port, hasSavedKey: !!name && !!getServerKey(name) };
@@ -68,19 +92,21 @@ function blockersFor(servers: CollectionPreview['servers']): string[] {
return blockers; return blockers;
} }
export function previewCollection(blueprintId: unknown): CollectionPreview { export function previewCollection(blueprintId: unknown, mode: unknown = 'fill'): CollectionPreview {
if (typeof blueprintId !== 'string' || !blueprintId) throw new Error('Choose a blueprint to launch.'); if (typeof blueprintId !== 'string' || !blueprintId) throw new Error('Choose a blueprint to launch.');
return db().transaction(() => { if (mode !== 'fill' && mode !== 'exact') throw new Error('Choose fill or exact blueprint mode.');
return db().transaction((): CollectionPreview => {
const { entries, blueprintName } = blueprintEntries(blueprintId); const { entries, blueprintName } = blueprintEntries(blueprintId);
const servers = allocate(entries); const { counts, retainedServers, removals } = collectionPlan(entries, mode);
const counts = coverage(entries); const servers = allocate(entries, removals.map(server => server.name));
const blockers = blockersFor(servers); const blockers = blockersFor(servers);
if (removals.length && (!komodoConfigured() || !rconConfigured())) blockers.push('Configure Komodo and RCON before removing servers.');
const id = randomUUID(); const id = randomUUID();
const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString(); 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('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 (?, ?, ?, ?, ?)') db().prepare('INSERT INTO server_collection_previews (id, expires_at, blueprint_name, entries, servers, mode, fleet_snapshot) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(id, expiresAt, blueprintName, JSON.stringify(entries), JSON.stringify(servers)); .run(id, expiresAt, blueprintName, JSON.stringify(entries), JSON.stringify(servers), mode, mode === 'exact' ? JSON.stringify(fleetSnapshot()) : null);
return { id, expiresAt, blueprintName, launchConcurrency: config.createConcurrency, servers, blockers, ready: blockers.length === 0, return { id, expiresAt, blueprintName, mode, retainedServers, removals, launchConcurrency: config.createConcurrency, servers, blockers, ready: blockers.length === 0,
entries: entries.map((entry, index) => ({ count: entry.count, ...counts[index], recipe: summarizeRecipe(entry.recipe) })) }; entries: entries.map((entry, index) => ({ count: entry.count, ...counts[index], recipe: summarizeRecipe(entry.recipe) })) };
})(); })();
} }
@@ -92,15 +118,21 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }):
const keys = (input.keys ?? {}) as Record<string, unknown>; const keys = (input.keys ?? {}) as Record<string, unknown>;
return db().transaction(() => { return db().transaction(() => {
const preview = db().prepare('SELECT * FROM server_collection_previews WHERE id = ?').get(input.previewId) as { 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; id: string; expires_at: string; blueprint_name: string; entries: string; servers: string; collection_id: string | null; mode: CollectionMode; fleet_snapshot: string | null;
} | undefined; } | undefined;
if (!preview) throw new Error('Collection preview expired or was not found. Refresh the preview.'); 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.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.'); 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 entries: BlueprintEntry[] = JSON.parse(preview.entries);
const reviewed: CollectionPreview['servers'] = JSON.parse(preview.servers); const reviewed: CollectionPreview['servers'] = JSON.parse(preview.servers);
const servers = allocate(entries); if (preview.mode === 'exact' && preview.fleet_snapshot !== JSON.stringify(fleetSnapshot())) {
throw new Error('The fleet changed. Nothing was queued. Refresh the preview and review the servers to keep and remove.');
}
const { retainedServers, removals } = collectionPlan(entries, preview.mode);
const releasingServers = removals.map(server => server.name);
const servers = allocate(entries, releasingServers);
const blockers = blockersFor(servers); const blockers = blockersFor(servers);
if (removals.length && (!komodoConfigured() || !rconConfigured())) blockers.push('Configure Komodo and RCON before removing servers.');
if (blockers.length) throw new Error(blockers.join(' ')); if (blockers.length) throw new Error(blockers.join(' '));
if (servers.length !== reviewed.length || servers.some((server, i) => server.entryIndex !== reviewed[i]?.entryIndex || server.name !== reviewed[i]?.name || server.port !== reviewed[i]?.port)) { if (servers.length !== reviewed.length || servers.some((server, i) => server.entryIndex !== reviewed[i]?.entryIndex || server.name !== reviewed[i]?.name || server.port !== reviewed[i]?.port)) {
throw new Error('Fleet counts, lobby or port availability changed. Nothing was queued. Refresh the preview and review each server key.'); throw new Error('Fleet counts, lobby or port availability changed. Nothing was queued. Refresh the preview and review each server key.');
@@ -109,36 +141,65 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }):
for (const [name, value] of Object.entries(keys)) { 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.'); if (!expected.has(name) || typeof value !== 'string') throw new Error('Keys must be strings for server IDs shown in this preview.');
} }
if (!servers.length) return { ok: true as const, collectionId: null }; if (!servers.length && !removals.length) return { ok: true as const, collectionId: null };
const collectionId = randomUUID(); const collectionId = randomUUID();
db().prepare('INSERT INTO server_launch_collections (id, name, created_at) VALUES (?, ?, ?)') db().prepare('INSERT INTO server_launch_collections (id, name, created_at, mode, retained_servers) VALUES (?, ?, ?, ?, ?)')
.run(collectionId, preview.blueprint_name, new Date().toISOString()); .run(collectionId, preview.blueprint_name, new Date().toISOString(), preview.mode, JSON.stringify(retainedServers));
const removalJobs = removals.map((server, position) => {
const ports = (db().prepare(`SELECT game_port AS port FROM servers WHERE name = ? UNION
SELECT j.port FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
WHERE j.server = ? AND d.state IN ('queued', 'running', 'awaiting-health')`).all(server.name, server.name) as { port: number | null }[]).map(row => row.port);
const queued = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'create' AND state = 'queued'").get(server.name) as { id: string } | undefined;
let id: string;
if (queued && !getServer(server.name)) {
// No container or files exist yet: cancel the reservation instead of booting just to remove it.
const result = destroyServer(server.name, 'blueprint-exact');
if (!result.ok || !result.deploymentId) throw new Error('Could not queue removal for ' + server.name);
id = result.deploymentId;
db().prepare("UPDATE server_deployments SET state = 'cancelled', stage = 'Cancelled by exact blueprint', finished_at = ? WHERE id = ?").run(new Date().toISOString(), queued.id);
recordDeploymentEvent(queued.id, 'Queued creation cancelled by exact blueprint; saved key retained.');
deploymentStage(id, 'Cancelled queued creation; no server was started');
finishDeployment(id, { ok: true });
} else {
const result = destroyServer(server.name, 'blueprint-exact');
if (!result.ok || !result.deploymentId) throw new Error('Could not queue removal for ' + server.name);
id = result.deploymentId;
}
db().prepare('INSERT INTO server_collection_removals (collection_id, deployment_id, position) VALUES (?, ?, ?)').run(collectionId, id, position);
return { id, ports };
});
servers.forEach((server, index) => { servers.forEach((server, index) => {
const recipe = entries[server.entryIndex].recipe; const recipe = entries[server.entryIndex].recipe;
const childPreviewId = randomUUID(); const childPreviewId = randomUUID();
db().prepare('INSERT INTO server_launch_previews (id, expires_at, server, port, recipe, blueprint_name) VALUES (?, ?, ?, ?, ?, ?)') 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); .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, 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' }); expectedName: server.name!, previewId: childPreviewId, plutoKey: keys[server.name!] as string | undefined, createdBy: 'ui' }, releasingServers);
// Throw so the outer transaction also reverses all earlier keys and children. // 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.`); 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 (?, ?, ?)') db().prepare('INSERT INTO server_collection_members (collection_id, deployment_id, position) VALUES (?, ?, ?)')
.run(collectionId, result.deploymentId, index); .run(collectionId, result.deploymentId, index);
for (const removal of removalJobs.filter(removal => removal.ports.includes(server.port))) {
db().prepare('INSERT INTO server_creation_dependencies (deployment_id, removal_id) VALUES (?, ?)').run(result.deploymentId, removal.id);
}
}); });
db().prepare('UPDATE server_collection_previews SET collection_id = ? WHERE id = ?').run(collectionId, preview.id); db().prepare('UPDATE server_collection_previews SET collection_id = ? WHERE id = ?').run(collectionId, preview.id);
return { ok: true as const, collectionId }; return { ok: true as const, collectionId };
})(); })();
} }
const summarySql = `SELECT c.id, c.name, c.created_at, COUNT(d.id) AS total, const membershipSql = `SELECT collection_id, deployment_id, position FROM server_collection_members
UNION ALL SELECT collection_id, deployment_id, position FROM server_collection_removals`;
const summarySql = `SELECT c.id, c.name, c.created_at, c.mode, COUNT(d.id) AS total,
COALESCE(SUM(d.state IN ('done', 'warning')), 0) AS done, 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 ('failed', 'interrupted', 'cancelled')), 0) AS failed,
COALESCE(SUM(d.state IN ('queued', 'running', 'awaiting-health', 'waiting')), 0) AS active, COALESCE(SUM(d.state IN ('queued', 'running', 'awaiting-health', 'waiting')), 0) AS active,
COALESCE(SUM(d.state = 'queued'), 0) AS queued, COALESCE(SUM(d.state IN ('queued', 'waiting')), 0) AS queued,
COALESCE(SUM(d.state = 'running'), 0) AS provisioning, COALESCE(SUM(d.state = 'running'), 0) AS provisioning,
COALESCE(SUM(d.state = 'awaiting-health'), 0) AS verifying COALESCE(SUM(d.state = 'awaiting-health'), 0) AS verifying
FROM server_launch_collections c FROM server_launch_collections c
LEFT JOIN server_collection_members m ON m.collection_id = c.id LEFT JOIN (${membershipSql}) m ON m.collection_id = c.id
LEFT JOIN server_deployments d ON d.id = m.deployment_id`; LEFT JOIN server_deployments d ON d.id = m.deployment_id`;
export function listCollections(): CollectionSummary[] { export function listCollections(): CollectionSummary[] {
@@ -148,12 +209,13 @@ export function listCollections(): CollectionSummary[] {
export function getCollection(id: string): CollectionDetail | null { export function getCollection(id: string): CollectionDetail | null {
const collection = db().prepare(summarySql + ' WHERE c.id = ? GROUP BY c.id').get(id) as CollectionSummary | undefined; const collection = db().prepare(summarySql + ' WHERE c.id = ? GROUP BY c.id').get(id) as CollectionSummary | undefined;
if (!collection) return null; 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, const servers = db().prepare(`SELECT d.id, d.server, d.action, 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 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 FROM (${membershipSql}) 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']; LEFT JOIN server_creation_jobs j ON j.deployment_id = d.id WHERE m.collection_id = ? ORDER BY d.action DESC, m.position`).all(id) as CollectionDetail['servers'];
const queue = creationQueue(); const queue = creationQueue();
const positions = new Map(queue.queued.map((job, index) => [job.id, index + 1])); const positions = new Map(queue.queued.map((job, index) => [job.id, index + 1]));
return { collection, queue: { concurrency: config.createConcurrency, active: queue.active, queued: queue.queued.length }, const stored = db().prepare('SELECT retained_servers FROM server_launch_collections WHERE id = ?').get(id) as { retained_servers: string };
return { collection, retainedServers: JSON.parse(stored.retained_servers), queue: { concurrency: config.createConcurrency, active: queue.active, queued: queue.queued.length },
servers: servers.map(server => ({ ...server, queuePosition: positions.get(server.id) ?? null })) }; servers: servers.map(server => ({ ...server, queuePosition: positions.get(server.id) ?? null })) };
} }
+32 -11
View File
@@ -83,13 +83,13 @@ export function nextLobbyNumber(baseName: string, additionalNames: string[] = []
* 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).
*/ */
export function nextAvailablePort(additionalPorts: number[] = []): number | null { export function nextAvailablePort(additionalPorts: number[] = [], releasingServers: string[] = []): number | null {
const rows = db() const rows = db()
.prepare(`SELECT game_port FROM servers WHERE game_port IS NOT NULL UNION .prepare(`SELECT name, 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.server AS name, 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<{ name: string; game_port: number }>;
const taken = new Set([...rows.map((r) => r.game_port), ...additionalPorts]); const taken = new Set([...rows.filter(row => !releasingServers.includes(row.name)).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;
} }
@@ -195,7 +195,8 @@ export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blue
checks, ready: checks.every(check => check.state !== 'blocked') }; checks, ready: checks.every(check => check.state !== 'blocked') };
} }
export function queueCreateServer(input: CreateServerInput): CreateServerResult { // releasingServers is internal to atomic collection queueing; HTTP callers cannot set it.
export function queueCreateServer(input: CreateServerInput, releasingServers: string[] = []): CreateServerResult {
return db().transaction((): CreateServerResult => { return db().transaction((): CreateServerResult => {
const preview = input.previewId ? db().prepare('SELECT * FROM server_launch_previews WHERE id = ?').get(input.previewId) as PreviewRow | undefined : undefined; const preview = input.previewId ? db().prepare('SELECT * FROM server_launch_previews WHERE id = ?').get(input.previewId) as PreviewRow | undefined : undefined;
if (input.previewId && !preview) return { ok: false, kind: 'bad-request', message: 'Launch preview expired or was not found. Review a fresh preview.' }; if (input.previewId && !preview) return { ok: false, kind: 'bad-request', message: 'Launch preview expired or was not found. Review a fresh preview.' };
@@ -206,13 +207,13 @@ export function queueCreateServer(input: CreateServerInput): CreateServerResult
// A repeated submit after a lost response returns the same operation. // A repeated submit after a lost response returns the same operation.
if (preview?.deployment_id) return { ok: true, name: preview.server!, port: preview.port!, deploymentId: preview.deployment_id }; if (preview?.deployment_id) return { ok: true, name: preview.server!, port: preview.port!, deploymentId: preview.deployment_id };
if (preview && preview.expires_at <= new Date().toISOString()) return { ok: false, kind: 'bad-request', message: 'Launch preview expired. Review a fresh preview.' }; if (preview && preview.expires_at <= new Date().toISOString()) return { ok: false, kind: 'bad-request', message: 'Launch preview expired. Review a fresh preview.' };
if (preview && (!preview.server || !preview.port || preview.server !== nextName(input) || preview.port !== nextAvailablePort())) { if (preview && (!preview.server || !preview.port || preview.server !== nextName(input) || preview.port !== nextAvailablePort([], releasingServers))) {
return { ok: false, kind: 'no-slot', message: 'Available lobby or port changed. Refresh the preview and review the new server key.' }; return { ok: false, kind: 'no-slot', message: 'Available lobby or port changed. Refresh the preview and review the new server key.' };
} }
if (recipe && !config.rcon.password) { if (recipe && !config.rcon.password) {
return { ok: false, kind: 'unconfigured', message: 'Health-check settings changed. Refresh the preview.' }; return { ok: false, kind: 'unconfigured', message: 'Health-check settings changed. Refresh the preview.' };
} }
const prepared = prepareCreation(input); const prepared = prepareCreation(input, releasingServers);
if (!prepared.ok) return prepared; if (!prepared.ok) return prepared;
// A preview records choices, not version pins. Promotions remain authoritative. // A preview records choices, not version pins. Promotions remain authoritative.
const currentRecipe = captureRecipe(input.game ?? 't5', prepared.mapEntry.slug); const currentRecipe = captureRecipe(input.game ?? 't5', prepared.mapEntry.slug);
@@ -247,7 +248,7 @@ export async function createServer(input: CreateServerInput): Promise<CreateServ
return outcome ?? queued; return outcome ?? queued;
} }
function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<CreateServerResult, { ok: false }> { function prepareCreation(input: CreateServerInput, releasingServers: string[] = []): PreparedCreation | Extract<CreateServerResult, { ok: false }> {
const game = input.game ?? 't5'; const game = input.game ?? 't5';
if (game !== 't5' && game !== 't6') { if (game !== 't5' && game !== 't6') {
return { ok: false, kind: 'bad-request', message: 'Supported games are t5 and t6' }; return { ok: false, kind: 'bad-request', message: 'Supported games are t5 and t6' };
@@ -279,7 +280,7 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<C
const plutoKey = input.plutoKey?.trim() || getServerKey(name); const plutoKey = input.plutoKey?.trim() || getServerKey(name);
if (!plutoKey) return { ok: false, kind: 'bad-request', message: `No saved key for ${name}. Enter a Plutonium server key.` }; if (!plutoKey) return { ok: false, kind: 'bad-request', message: `No saved key for ${name}. Enter a Plutonium server key.` };
const port = nextAvailablePort(); const port = nextAvailablePort([], releasingServers);
if (port === null) { if (port === null) {
return { return {
ok: false, ok: false,
@@ -640,6 +641,16 @@ export function creationQueue() {
return { active: jobs.filter(job => job.state !== 'queued'), queued: jobs.filter(job => job.state === 'queued') }; return { active: jobs.filter(job => job.state !== 'queued'), queued: jobs.filter(job => job.state === 'queued') };
} }
function creationBlocker(id: string): string | null {
const dependency = db().prepare(`SELECT d.server, d.state FROM server_creation_dependencies dep
JOIN server_deployments d ON d.id = dep.removal_id
WHERE dep.deployment_id = ? AND d.state NOT IN ('done', 'warning') LIMIT 1`).get(id) as { server: string; state: string } | undefined;
if (dependency) return `Waiting for removal of ${dependency.server} (${dependency.state}) before reusing its port`;
const occupied = db().prepare(`SELECT s.name FROM servers s JOIN server_creation_jobs j ON s.game_port = j.port
WHERE j.deployment_id = ? LIMIT 1`).get(id) as { name: string } | undefined;
return occupied ? `Waiting for port to be released by ${occupied.name}` : null;
}
async function runCreations(): Promise<CreationOutcomes> { async function runCreations(): Promise<CreationOutcomes> {
const outcomes: CreationOutcomes = new Map(); const outcomes: CreationOutcomes = new Map();
if (creating || destroying.size > 0) return outcomes; if (creating || destroying.size > 0) return outcomes;
@@ -696,12 +707,22 @@ async function runCreations(): Promise<CreationOutcomes> {
// worker pass into an unbounded stream of expensive container startups. // worker pass into an unbounded stream of expensive container startups.
const queue = creationQueue(); const queue = creationQueue();
const available = Math.max(0, config.createConcurrency - queue.active.length); const available = Math.max(0, config.createConcurrency - queue.active.length);
for (const { id } of queue.queued.slice(0, available)) { const eligible = queue.queued.filter(({ id }) => {
const blocker = creationBlocker(id);
if (blocker) deploymentStage(id, blocker);
return !blocker;
});
for (const { id } of eligible.slice(0, available)) {
if (creating || destroying.size > 0) break; if (creating || destroying.size > 0) break;
// API calls can cancel a selected job or resume an older startup while // API calls can cancel a selected job or resume an older startup while
// a previous provision awaits Komodo. Recheck without expanding this pass. // a previous provision awaits Komodo. Recheck without expanding this pass.
if (creationQueue().active.length >= config.createConcurrency) break; if (creationQueue().active.length >= config.createConcurrency) break;
if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND action = 'create' AND state = 'queued'").get(id)) continue; if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND action = 'create' AND state = 'queued'").get(id)) continue;
const blocker = creationBlocker(id);
if (blocker) {
deploymentStage(id, blocker);
continue;
}
try { outcomes.set(id, await provisionCreation(id)); } try { outcomes.set(id, await provisionCreation(id)); }
catch (err) { catch (err) {
const failure = err instanceof Error ? err : new Error(String(err)); const failure = err instanceof Error ? err : new Error(String(err));
+3 -1
View File
@@ -27,7 +27,9 @@ 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 const collection = db().prepare(`SELECT c.id, c.name FROM (
SELECT collection_id, deployment_id FROM server_collection_members
UNION ALL SELECT collection_id, deployment_id FROM server_collection_removals) m
JOIN server_launch_collections c ON c.id = m.collection_id WHERE m.deployment_id = ?`).get(id) ?? null; 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), canResumeCleanup: canResumeCleanup(id), canCancelRemoval: canCancelRemoval(id), collection }; canResumeVerification: canResumeVerification(id), canResumeCleanup: canResumeCleanup(id), canCancelRemoval: canCancelRemoval(id), collection };
@@ -6,11 +6,13 @@
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import LaunchRecipe from './LaunchRecipe.svelte'; import LaunchRecipe from './LaunchRecipe.svelte';
import ServerIdentity from './ServerIdentity.svelte'; import ServerIdentity from './ServerIdentity.svelte';
import type { CollectionPreview } from '$lib/deployment-plan.js'; import type { CollectionMode, CollectionPreview } from '$lib/deployment-plan.js';
let { open = $bindable(false), blueprintId = '' }: { open?: boolean; blueprintId?: string } = $props(); let { open = $bindable(false), blueprintId = '' }: { open?: boolean; blueprintId?: string } = $props();
let dialog: HTMLDialogElement | undefined = $state(); let dialog: HTMLDialogElement | undefined = $state();
let preview = $state<CollectionPreview | null>(null); let preview = $state<CollectionPreview | null>(null);
let mode = $state<CollectionMode>('fill');
let confirmRemovals = $state(false);
let keys = $state<Record<string, string>>({}); let keys = $state<Record<string, string>>({});
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
@@ -21,20 +23,21 @@
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now); const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
const keysReady = $derived(preview?.servers.filter(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())).length ?? 0); const keysReady = $derived(preview?.servers.filter(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())).length ?? 0);
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); }); onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
let canLaunch = $derived(preview?.ready && preview.servers.length > 0 && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim()))); let canLaunch = $derived(preview?.ready && preview.mode === mode && (preview.servers.length > 0 || preview.removals.length > 0)
&& (!preview.removals.length || confirmRemovals) && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
$effect(() => { $effect(() => {
if (!dialog) return; if (!dialog) return;
if (open && !wasOpen) { wasOpen = true; keys = {}; preview = null; keyNotice = ''; dialog.showModal(); void review(); } if (open && !wasOpen) { wasOpen = true; mode = 'fill'; keys = {}; preview = null; keyNotice = ''; dialog.showModal(); void review(); }
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); } else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
}); });
async function review() { async function review() {
const current = ++session; const current = ++session;
const previous = preview; const previous = preview;
busy = true; error = ''; keyNotice = ''; busy = true; error = ''; keyNotice = ''; confirmRemovals = false;
try { try {
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId }) }); const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId, mode }) });
const body = await res.json(); const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not preview the collection.'); if (!res.ok) throw new Error(body.error ?? 'Could not preview the collection.');
if (current === session) { if (current === session) {
@@ -71,21 +74,38 @@
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}> <dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<div class="composer"> <div class="composer">
<header><div><span class="eyebrow">Collection 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> <header><div><span class="eyebrow">Collection 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>
<label class="mode">Apply blueprint
<select bind:value={mode} disabled={busy} onchange={event => { mode = event.currentTarget.value as CollectionMode; void review(); }}>
<option value="fill">Fill missing servers</option>
<option value="exact">Match blueprint exactly</option>
</select>
</label>
<p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this blueprint across both games.' : 'Create missing lobbies and keep all existing servers.'}</p>
{#if error}<p class="error" role="alert">{error}</p>{/if} {#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} {#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} {:else}
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if} {#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if} {#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if}
<div class="lineup"><strong>{preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Blueprint already satisfied'}</strong> <div class="lineup"><strong>{preview.mode === 'exact' ? `${preview.retainedServers.length} keep · ${preview.servers.length} create · ${preview.removals.length} remove` : preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Blueprint already satisfied'}</strong>
<table><thead><tr><th>Map</th><th>Target</th><th>Existing / queued</th><th>To create</th></tr></thead><tbody>{#each preview.entries as entry}<tr><th>{entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></th><td>{entry.count}</td><td title={entry.existingServers.join(', ')}>{entry.existing}</td><td class="quantity">{entry.missing}</td></tr>{/each}</tbody></table> <table><thead><tr><th>Map</th><th>Target</th><th>Existing / queued</th><th>To create</th></tr></thead><tbody>{#each preview.entries as entry}<tr><th>{entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></th><td>{entry.count}</td><td title={entry.existingServers.join(', ')}>{entry.existing}</td><td class="quantity">{entry.missing}</td></tr>{/each}</tbody></table>
<p class="muted">Targets count inventory, including stopped servers; they do not mean every server is ready to play. Only missing servers are created. Extra servers remain and existing servers are not updated.</p> <p class="muted">Targets count inventory, including stopped servers; they do not mean every server is ready to play. {preview.mode === 'exact' ? 'Existing deployed lobbies are kept before queued creations, then the lowest lobby numbers. Retained servers are not changed or restarted.' : 'Only missing servers are created. Extra servers remain and existing servers are not updated.'}</p>
{#if preview.entries.some(entry => entry.existing)}<details class="coverage" open={!preview.servers.length}><summary>Review existing servers</summary>{#each preview.entries.filter(entry => entry.existing) as entry}<div class="coverage-map"><strong>{entry.recipe.mapName}: {entry.existing} existing</strong><p class="muted">{[...new Set((entry.coverage ?? []).map(server => server.health))].map(health => `${entry.coverage.filter(server => server.health === health).length} ${health}`).join(', ')}</p><ul>{#each entry.coverage ?? [] as server}<li><a href={server.href}><ServerIdentity name={server.name} /></a><span>{server.health === 'online' ? 'Healthy' : server.health}</span></li>{/each}</ul></div>{/each}</details>{/if} {#if preview.mode === 'exact' && preview.retainedServers.length}<details class="coverage"><summary>Servers to keep ({preview.retainedServers.length})</summary><ul>{#each preview.retainedServers as name}<li><ServerIdentity {name} /><span>{name}</span></li>{/each}</ul></details>{/if}
{#if preview.mode === 'fill' && preview.entries.some(entry => entry.existing)}<details class="coverage" open={!preview.servers.length}><summary>Review existing servers</summary>{#each preview.entries.filter(entry => entry.existing) as entry}<div class="coverage-map"><strong>{entry.recipe.mapName}: {entry.existing} existing</strong><p class="muted">{[...new Set((entry.coverage ?? []).map(server => server.health))].map(health => `${entry.coverage.filter(server => server.health === health).length} ${health}`).join(', ')}</p><ul>{#each entry.coverage ?? [] as server}<li><a href={server.href}><ServerIdentity name={server.name} /></a><span>{server.health === 'online' ? 'Healthy' : server.health}</span></li>{/each}</ul></div>{/each}</details>{/if}
</div> </div>
<div class="section-title"><h3>{preview.servers.length ? 'Your launch order' : 'Nothing to create'}</h3><button class="text-button" disabled={busy} onclick={review}>Refresh preview</button></div> <div class="section-title"><h3>{preview.mode === 'exact' ? 'Review fleet changes' : preview.servers.length ? 'Your launch order' : 'Nothing to create'}</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} {#each preview.blockers as blocker}<p class="error" role="alert">{blocker}</p>{/each}
{#if preview.servers.length} {#if preview.servers.length || preview.removals.length}
<p class="muted">Saved keys are reused automatically. Add keys only where required below. Update saved keys in <a href="/server-keys" target="_blank" rel="noopener">Keys (opens in a new tab)</a>. Fleet counts and available slots are checked again when you queue the launch.</p> {#if preview.servers.length}<p class="muted">Saved keys are reused automatically. Add keys only where required below. Update saved keys in <a href="/server-keys" target="_blank" rel="noopener">Keys (opens in a new tab)</a>. Fleet counts and available slots are checked again when you queue the launch.</p>{/if}
<form onsubmit={event => { event.preventDefault(); void launch(); }}> <form onsubmit={event => { event.preventDefault(); void launch(); }}>
{#if preview.removals.length}
<section class="removals" aria-label="Servers to remove">
<h3>Remove {preview.removals.length} server{preview.removals.length === 1 ? '' : 's'}</h3>
<p class="muted">Containers, files, and registrations will be removed after a fresh zero-player check. Busy or unreachable servers wait. Unstarted creations are cancelled. Saved keys are kept. New servers reusing these ports wait for removal to finish.</p>
<ul>{#each preview.removals as server}<li><strong><ServerIdentity name={server.name} /></strong><span class="server-id">{server.name}</span><span class="muted">{server.reason}</span></li>{/each}</ul>
<label class="confirm-removals"><input type="checkbox" bind:checked={confirmRemovals} disabled={busy || preview.mode !== mode} />Remove the {preview.removals.length} listed servers and apply this blueprint.</label>
</section>
{/if}
{#if preview.servers.length}
<ol class="servers" aria-label="Server launch order"> <ol class="servers" aria-label="Server launch order">
{#each preview.servers as server, index (server.name ?? `unavailable-${index}`)} {#each preview.servers as server, index (server.name ?? `unavailable-${index}`)}
<li class="server-row"> <li class="server-row">
@@ -98,15 +118,22 @@
</li> </li>
{/each} {/each}
</ol> </ol>
<div class="launch-footer"><div><p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p><p class="caption">Validity is checked at startup. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || expired || !canLaunch}>Queue {preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} →</Button></div></div> {/if}
<div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Key validity is checked at startup. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div>
</form> </form>
{:else}<p class="muted">Your fleet already meets or exceeds every target in this blueprint. No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if} {:else}<p class="muted">{preview.mode === 'exact' ? 'Your fleet already matches this blueprint.' : 'Your fleet already meets or exceeds every target in this blueprint.'} No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each server uses the latest promoted setup when its turn begins. 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>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details> <details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each server uses the latest promoted setup when its turn begins. 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>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
{/if} {/if}
</div> </div>
</dialog> </dialog>
<style> <style>
select { padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
.removals { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--state-danger); border-radius: var(--radius-md); }
.removals ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 16px; }
.removals li { display: grid; gap: 4px; font-size: 13px; }
.confirm-removals { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; }
.confirm-removals input { width: auto; margin-top: 2px; }
.key-ready { color: var(--state-online); font-size: 13px; font-weight: 600; } .key-ready { color: var(--state-online); font-size: 13px; font-weight: 600; }
a { color: var(--accent-primary-hover); } a { color: var(--accent-primary-hover); }
@media (max-width: 600px) { .server-row .key-ready { grid-column: 2; } } @media (max-width: 600px) { .server-row .key-ready { grid-column: 2; } }
@@ -4,6 +4,6 @@ import { previewCollection } from '$lib/server/fleet/collections.js';
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
try { try {
const body = await request.json(); const body = await request.json();
return json(previewCollection(body?.blueprintId)); return json(previewCollection(body?.blueprintId, body?.mode));
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); } } catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
} }
+5 -5
View File
@@ -155,10 +155,10 @@
<span class="file-count">{blueprint.totalServers} target server{blueprint.totalServers === 1 ? '' : 's'}</span> <span class="file-count">{blueprint.totalServers} target server{blueprint.totalServers === 1 ? '' : 's'}</span>
</div> </div>
<h3>{blueprint.name}</h3> <h3>{blueprint.name}</h3>
<p>{blueprint.description || 'Your target lineup. Only missing servers will be created.'}</p> <p>{blueprint.description || 'Fill missing lobbies or match your fleet to this lineup exactly.'}</p>
<ul class="blueprint-lineup">{#each blueprint.entries as entry}<li><strong>{entry.count}×</strong> {entry.mapName} <small>{entry.game.toUpperCase()}</small></li>{/each}</ul> <ul class="blueprint-lineup">{#each blueprint.entries as entry}<li><strong>{entry.count}×</strong> {entry.mapName} <small>{entry.game.toUpperCase()}</small></li>{/each}</ul>
<div class="blueprint-footer"> <div class="blueprint-footer">
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Preview missing servers →</Button> <Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Preview fleet changes →</Button>
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button> <button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
<button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button> <button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button>
</div> </div>
@@ -175,10 +175,10 @@
<h2>Collection launches</h2> <h2>Collection launches</h2>
<div class="rows">{#each visibleCollections.slice(0,collectionLimit) as collection (collection.id)} <div class="rows">{#each visibleCollections.slice(0,collectionLimit) as collection (collection.id)}
<a class="deployment-row collection-row" href="/deployments/collections/{collection.id}"> <a class="deployment-row collection-row" href="/deployments/collections/{collection.id}">
<div class="row-head"><strong>{collection.name}</strong><Badge variant={collection.active ? 'info' : collection.failed ? 'danger' : 'online'}>{collection.active ? collection.provisioning + collection.verifying ? 'Launching' : 'Queued' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></div> <div class="row-head"><strong>{collection.name}</strong><Badge variant={collection.active ? 'info' : collection.failed ? 'danger' : 'online'}>{collection.active ? collection.provisioning + collection.verifying ? collection.mode === 'exact' ? 'Applying blueprint' : 'Launching' : 'Queued' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></div>
<div class="collection-counts"><span><strong>{collection.done} / {collection.total}</strong> ready</span>{#if collection.provisioning + collection.verifying}<span>{collection.provisioning + collection.verifying} launching</span>{/if}{#if collection.queued}<span>{collection.queued} queued</span>{/if}{#if collection.failed}<span class="error">{collection.failed} need attention</span>{/if}</div> <div class="collection-counts"><span><strong>{collection.done} / {collection.total}</strong> {collection.mode === 'exact' ? 'completed' : 'ready'}</span>{#if collection.provisioning + collection.verifying}<span>{collection.provisioning + collection.verifying} {collection.mode === 'exact' ? 'in progress' : 'launching'}</span>{/if}{#if collection.queued}<span>{collection.queued} queued</span>{/if}{#if collection.failed}<span class="error">{collection.failed} need attention</span>{/if}</div>
<div class="collection-meter" aria-hidden="true"><span class="verified" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span><span class="unsuccessful" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span></div> <div class="collection-meter" aria-hidden="true"><span class="verified" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span><span class="unsuccessful" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span></div>
<div class="collection-footer"><span class="meta">Queued {fmt(collection.created_at)}</span><span class="collection-link">{collection.active ? 'Follow launch' : 'View results'} →</span></div> <div class="collection-footer"><span class="meta">Queued {fmt(collection.created_at)}</span><span class="collection-link">{collection.active ? 'Follow progress' : 'View results'} →</span></div>
</a> </a>
{/each}</div> {/each}</div>
<ListFooter total={visibleCollections.length} bind:limit={collectionLimit} onchange={() => saveContext()} step={10} label="collections" /> <ListFooter total={visibleCollections.length} bind:limit={collectionLimit} onchange={() => saveContext()} step={10} label="collections" />
@@ -14,13 +14,23 @@
let requestNumber = 0; let requestNumber = 0;
let collection = $derived(detail?.collection); let collection = $derived(detail?.collection);
const launching = (server: { state: string }) => ['running', 'awaiting-health'].includes(server.state); const launching = (server: { state: string }) => ['running', 'awaiting-health'].includes(server.state);
const needsAttention = (server: { state: string }) => ['failed', 'interrupted', 'warning'].includes(server.state); const needsAttention = (server: { state: string }) => ['failed', 'interrupted', 'warning', 'cancelled'].includes(server.state);
const exact = $derived(collection?.mode === 'exact');
const currentServers = $derived(detail?.servers.filter(launching) ?? []); const currentServers = $derived(detail?.servers.filter(launching) ?? []);
const otherLaunches = $derived(detail?.queue.active.filter(active => !detail?.servers.some(server => server.id === active.id)) ?? []); const otherLaunches = $derived(detail?.queue.active.filter(active => !detail?.servers.some(server => server.id === active.id)) ?? []);
const firstQueued = $derived(detail?.servers.find(server => server.state === 'queued')); const firstQueued = $derived(detail?.servers.find(server => server.state === 'queued'));
const labels: Record<string, string> = { queued: 'Queued', running: 'Launching', 'awaiting-health': 'Checking readiness', done: 'Ready', warning: 'Needs review', failed: 'Failed', interrupted: 'Interrupted' }; const labels: Record<string, string> = { queued: 'Queued', running: 'Launching', 'awaiting-health': 'Checking readiness', done: 'Ready', warning: 'Needs review', failed: 'Failed', interrupted: 'Interrupted', waiting: 'Waiting for empty server', cancelled: 'Cancelled' };
const waitingForPort = (server: CollectionServer) => server.state === 'queued' && (server.stage.startsWith('Waiting for removal') || server.stage.startsWith('Waiting for port'));
function stateLabel(server: CollectionServer) {
if (waitingForPort(server)) return 'Waiting for removal';
if (server.state === 'queued' && server.queuePosition === 1) return 'Up next';
if (server.action === 'destroy' && server.state === 'done') return 'Removed';
if (server.action === 'destroy' && server.state === 'running') return 'Removing';
return labels[server.state] ?? server.state;
}
function queueHint(server: CollectionServer) { function queueHint(server: CollectionServer) {
if (waitingForPort(server)) return server.stage;
if (server.queuePosition === 1) return 'Next in the launch queue. Starts when a place opens.'; if (server.queuePosition === 1) return 'Next in the launch queue. Starts when a place opens.';
if (server.queuePosition) return `${server.queuePosition - 1} queued server${server.queuePosition === 2 ? '' : 's'} ahead. Starts automatically when it is this lobby’s turn.`; if (server.queuePosition) return `${server.queuePosition - 1} queued server${server.queuePosition === 2 ? '' : 's'} ahead. Starts automatically when it is this lobby’s turn.`;
return 'Waiting for its turn in the launch queue.'; return 'Waiting for its turn in the launch queue.';
@@ -49,37 +59,37 @@
{#if collection && detail} {#if collection && detail}
<section class="summary" aria-label="Collection progress"> <section class="summary" aria-label="Collection progress">
<div class="summary-heading"> <div class="summary-heading">
<div><span class="eyebrow">Collection progress</span><h2>{collection.active ? currentServers.length ? 'Bringing your lineup online' : 'Your lineup is queued' : collection.failed ? 'Launch complete with issues' : 'Your lineup is ready'}</h2></div> <div><span class="eyebrow">Collection progress</span><h2>{exact ? collection.active ? 'Applying your blueprint' : collection.failed ? 'Blueprint finished with issues' : 'Blueprint applied' : collection.active ? currentServers.length ? 'Bringing your lineup online' : 'Your lineup is queued' : collection.failed ? 'Launch complete with issues' : 'Your lineup is ready'}</h2></div>
<span class="completion"><strong>{collection.done + collection.failed}</strong> / {collection.total} finished</span> <span class="completion"><strong>{collection.done + collection.failed}</strong> / {collection.total} finished</span>
</div> </div>
<div class="progress" role="progressbar" aria-label="Finished server launches" aria-valuemin={0} aria-valuemax={collection.total || 1} aria-valuenow={collection.done + collection.failed} aria-valuetext={`${collection.done} ready, ${collection.failed} need attention, ${collection.active} remaining`}> <div class="progress" role="progressbar" aria-label="Finished fleet operations" aria-valuemin={0} aria-valuemax={collection.total || 1} aria-valuenow={collection.done + collection.failed} aria-valuetext={`${collection.done} completed, ${collection.failed} need attention, ${collection.active} remaining`}>
<span class="progress-ready" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span> <span class="progress-ready" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span>
<span class="progress-failed" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span> <span class="progress-failed" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span>
</div> </div>
<dl class="counts"> <dl class="counts">
<div class="ready-count"><dt>Ready</dt><dd>{collection.done}</dd></div> <div class="ready-count"><dt>{exact ? 'Completed' : 'Ready'}</dt><dd>{collection.done}</dd></div>
<div><dt>Launching</dt><dd>{collection.provisioning + collection.verifying}</dd></div> <div><dt>{exact ? 'In progress' : 'Launching'}</dt><dd>{collection.provisioning + collection.verifying}</dd></div>
<div><dt>Queued</dt><dd>{collection.queued}</dd></div> <div><dt>Queued</dt><dd>{collection.queued}</dd></div>
<div class:attention-count={collection.failed > 0}><dt>Need attention</dt><dd>{collection.failed}</dd></div> <div class:attention-count={collection.failed > 0}><dt>Need attention</dt><dd>{collection.failed}</dd></div>
</dl> </dl>
<p class="screen-reader" role="status">{collection.done} ready, {collection.provisioning + collection.verifying} launching, {collection.queued} queued, {collection.failed} need attention.</p> <p class="screen-reader" role="status">{collection.done} completed, {collection.provisioning + collection.verifying} in progress, {collection.queued} queued, {collection.failed} need attention.</p>
</section> </section>
{#if currentServers.length} {#if currentServers.length}
<section class="current-launches" aria-label="Launching now"> <section class="current-launches" aria-label="In progress">
<span class="eyebrow">Launching now</span> <span class="eyebrow">In progress</span>
{#each currentServers as server (server.id)} {#each currentServers as server (server.id)}
<a class="current-launch" href="/deployments/{server.id}"> <a class="current-launch" href="/deployments/{server.id}">
<span class="activity" aria-hidden="true"></span> <span class="activity" aria-hidden="true"></span>
<div><strong><ServerIdentity name={server.server} /></strong><p>{server.state === 'awaiting-health' ? 'Checking the container, connection, and expected map.' : server.stage}</p></div> <div><strong>{server.action === 'destroy' ? 'Removing: ' : ''}<ServerIdentity name={server.server} /></strong><p>{server.state === 'awaiting-health' ? 'Checking the container, connection, and expected map.' : server.stage}</p></div>
<span class="operation-link">Follow progress <span aria-hidden="true">→</span></span> <span class="operation-link">Follow progress <span aria-hidden="true">→</span></span>
</a> </a>
{/each} {/each}
</section> </section>
{:else if collection.queued > 0} {:else if firstQueued}
<aside class="waiting-note"> <aside class="waiting-note">
<span class="waiting-icon" aria-hidden="true">◷</span> <span class="waiting-icon" aria-hidden="true">◷</span>
<div><strong>{otherLaunches.length ? 'Waiting for an earlier launch' : firstQueued?.queuePosition && firstQueued.queuePosition > 1 ? 'Earlier servers are ahead in the queue' : 'Your launch is up next'}</strong> <div><strong>{waitingForPort(firstQueued) ? 'Waiting for a removal to release capacity' : otherLaunches.length ? 'Waiting for an earlier launch' : firstQueued?.queuePosition && firstQueued.queuePosition > 1 ? 'Earlier servers are ahead in the queue' : 'Your launch is up next'}</strong>
{#if otherLaunches.length} {#if otherLaunches.length}
<p>Your collection will start automatically as earlier launches finish. Currently launching:</p> <p>Your collection will start automatically as earlier launches finish. Currently launching:</p>
<div class="other-launches">{#each otherLaunches as active (active.id)}<a href="/deployments/{active.id}"><ServerIdentity name={active.server} /> <span aria-hidden="true">→</span></a>{/each}</div> <div class="other-launches">{#each otherLaunches as active (active.id)}<a href="/deployments/{active.id}"><ServerIdentity name={active.server} /> <span aria-hidden="true">→</span></a>{/each}</div>
@@ -88,17 +98,22 @@
</aside> </aside>
{/if} {/if}
{#if exact && detail.retainedServers.length}
<details><summary>{detail.retainedServers.length} retained server{detail.retainedServers.length === 1 ? '' : 's'} (unchanged)</summary>
<ul>{#each detail.retainedServers as name}<li><ServerIdentity {name} /> <span class="muted">{name}</span></li>{/each}</ul>
</details>
{/if}
<section class="queue" aria-labelledby="queue-title"> <section class="queue" aria-labelledby="queue-title">
<div class="queue-heading"><h2 id="queue-title">Launch order</h2><span class="muted">{collection.total} server{collection.total === 1 ? '' : 's'}</span></div> <div class="queue-heading"><h2 id="queue-title">{exact ? 'Fleet changes' : 'Launch order'}</h2><span class="muted">{collection.total} operation{collection.total === 1 ? '' : 's'}</span></div>
<ol class="servers"> <ol class="servers">
{#each detail.servers as server, index (server.id)} {#each detail.servers as server, index (server.id)}
<li class:launching={launching(server)} class:attention={needsAttention(server)}> <li class:launching={launching(server)} class:attention={needsAttention(server)}>
<a class="server" href="/deployments/{server.id}"> <a class="server" href="/deployments/{server.id}">
<span class="order" class:ready={server.state === 'done'} aria-hidden="true">{server.state === 'done' ? '✓' : String(index + 1).padStart(2, '0')}</span> <span class="order" class:ready={server.state === 'done'} aria-hidden="true">{server.state === 'done' ? '✓' : String(index + 1).padStart(2, '0')}</span>
<div class="server-content"> <div class="server-content">
<div class="server-heading"><strong><ServerIdentity name={server.server} /></strong><Badge variant={needsAttention(server) ? 'danger' : server.state === 'done' ? 'online' : launching(server) ? 'info' : 'neutral'}>{server.state === 'queued' && server.queuePosition === 1 ? 'Up next' : labels[server.state] ?? server.state}</Badge></div> <div class="server-heading"><strong><ServerIdentity name={server.server} /></strong><Badge variant={needsAttention(server) ? 'danger' : server.state === 'done' ? 'online' : launching(server) ? 'info' : 'neutral'}>{stateLabel(server)}</Badge></div>
<p class:error={!!server.error}>{server.error ?? (server.state === 'queued' ? queueHint(server) : server.state === 'done' ? 'Startup checks passed.' : server.stage)}</p> <p class:error={!!server.error}>{server.error ?? (server.state === 'queued' ? queueHint(server) : server.state === 'done' ? server.action === 'destroy' ? server.stage : 'Startup checks passed.' : server.stage)}</p>
<div class="server-meta"><span>Port {server.port}</span><span>{needsAttention(server) ? 'Review & recover' : 'View operation'} <span aria-hidden="true">→</span></span></div> <div class="server-meta"><span>{server.action === 'destroy' ? 'Remove server' : `Create server · Port ${server.port}`}</span><span>{needsAttention(server) ? 'Review & recover' : 'View operation'} <span aria-hidden="true">→</span></span></div>
</div> </div>
</a> </a>
</li> </li>
+231
View File
@@ -359,3 +359,234 @@ describe('atomic collection launches', () => {
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]); expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]);
}); });
}); });
describe('matching a blueprint exactly', () => {
async function blueprint(entries: { game: string; mapSlug: string; count: number }[]) {
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
return saveBlueprint({ name: 'Exact lineup', entries });
}
it('keeps the lowest deployed lobbies, removes excess and unrelated maps, and adds only the shortfall', async () => {
const { createServer, processCreations, processDestructions } = await import('../../src/lib/server/fleet/create.js');
for (const mapSlug of ['theater', 'theater', 'moon']) await createServer({ gameMode: 'zombie', mapSlug, plutoKey: 'existing' });
const { db } = await import('../../src/lib/server/db/index.js');
const before = db().prepare("SELECT * FROM servers WHERE name = 't5_zombie_theater_1'").get();
const { previewCollection, queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
const plan = await blueprint([{ game: 't5', mapSlug: 'theater', count: 1 }, { game: 't6', mapSlug: 'town', count: 1 }]);
const preview = previewCollection(plan.id, 'exact');
expect(preview.retainedServers).toEqual(['t5_zombie_theater_1']);
expect(preview.removals.map(server => server.name)).toEqual(['t5_zombie_moon_1', 't5_zombie_theater_2']);
expect(preview.servers.map(server => server.name)).toEqual(['t6_zombie_town_1']);
const { komodoDestroyDeployment, komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
vi.mocked(komodoCreateDeployment).mockClear();
const result = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
expect(komodoDestroyDeployment).not.toHaveBeenCalled();
expect(komodoCreateDeployment).not.toHaveBeenCalled();
expect(getCollection(result.collectionId!)?.collection).toMatchObject({ mode: 'exact', total: 3, active: 3, queued: 3 });
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'gone' }).mockResolvedValueOnce({ ok: false, reason: 'gone' });
await processDestructions();
await processCreations();
await processCreations();
expect(getCollection(result.collectionId!)?.collection).toMatchObject({ total: 3, done: 3, active: 0 });
expect(getCollection(result.collectionId!)?.servers.filter(server => server.action === 'destroy')).toHaveLength(2);
expect(db().prepare("SELECT * FROM servers WHERE name = 't5_zombie_theater_1'").get()).toEqual(before);
expect(db().prepare('SELECT name FROM servers ORDER BY name').all()).toEqual([{ name: 't5_zombie_theater_1' }, { name: 't6_zombie_town_1' }]);
expect(komodoDestroyDeployment).not.toHaveBeenCalledWith('t5_zombie_theater_1');
});
it('supports removal-only plans, shares existing removals, and records retained servers', async () => {
const { createServer } = await import('../../src/lib/server/fleet/create.js');
for (let i = 0; i < 2; i++) await createServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'existing' });
const plan = await blueprint([{ game: 't5', mapSlug: 'theater', count: 1 }]);
const { previewCollection, queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
const first = previewCollection(plan.id, 'exact');
expect(first.servers).toEqual([]);
const a = queueCollection({ previewId: first.id });
const b = queueCollection({ previewId: previewCollection(plan.id, 'exact').id });
const firstDetail = getCollection(a.collectionId!)!;
expect(firstDetail.retainedServers).toEqual(['t5_zombie_theater_1']);
expect(firstDetail.servers).toHaveLength(1);
expect(getCollection(b.collectionId!)?.servers[0].id).toBe(firstDetail.servers[0].id);
expect(firstDetail.servers[0]).toMatchObject({ action: 'destroy', state: 'waiting', queuePosition: null });
const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js');
expect(getDeployment(firstDetail.servers[0].id)?.collection).toBeTruthy();
});
it('cancels unstarted excess creations without booting them and retains their saved keys', async () => {
const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
const first = queueCreateServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'first' });
const second = queueCreateServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'second' });
if (!first.ok || !second.ok) throw new Error('Fixture queue failed');
const plan = await blueprint([{ game: 't5', mapSlug: 'theater', count: 1 }]);
const { previewCollection, queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
const queued = queueCollection({ previewId: previewCollection(plan.id, 'exact').id });
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(second.deploymentId)).toEqual({ state: 'cancelled' });
expect(db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(first.deploymentId)).toEqual({ state: 'queued' });
expect(getCollection(queued.collectionId!)?.collection).toMatchObject({ done: 1, total: 1, active: 0 });
const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
expect(getServerKey(second.name)).toBe('second');
const { komodoCreateDeployment, komodoDestroyDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
expect(komodoCreateDeployment).not.toHaveBeenCalled();
expect(komodoDestroyDeployment).not.toHaveBeenCalled();
});
it('rejects fleet changes even when the create shortfall stays the same', async () => {
const { createServer, queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
await createServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'existing' });
const plan = await blueprint([{ game: 't5', mapSlug: 'theater', count: 1 }]);
const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js');
const preview = previewCollection(plan.id, 'exact');
queueCreateServer({ gameMode: 'zombie', mapSlug: 'moon', plutoKey: 'new' });
expect(() => queueCollection({ previewId: preview.id })).toThrow(/fleet changed/);
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare("SELECT id FROM server_deployments WHERE action = 'destroy'").all()).toEqual([]);
});
it('rolls back removals and creation cancellations when a required key is missing', async () => {
const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
const old = queueCreateServer({ gameMode: 'zombie', mapSlug: 'moon', plutoKey: 'existing' });
if (!old.ok) throw new Error(old.message);
const plan = await blueprint([{ game: 't5', mapSlug: 'theater', count: 1 }]);
const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js');
const preview = previewCollection(plan.id, 'exact');
expect(() => queueCollection({ previewId: preview.id })).toThrow(/No saved key/);
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(old.deploymentId)).toEqual({ state: 'queued' });
for (const table of ['server_collection_removals', 'server_launch_collections', 'server_creation_dependencies']) expect(db().prepare(`SELECT * FROM ${table}`).all()).toEqual([]);
expect(db().prepare("SELECT id FROM server_deployments WHERE action = 'destroy'").all()).toEqual([]);
});
it('reuses a full fleet port only after empty-server removal, across restart and repeated submission', async () => {
vi.stubEnv('SERVER_PORT_MAX', '28960');
const { createServer, processCreations, processDestructions } = await import('../../src/lib/server/fleet/create.js');
await createServer({ gameMode: 'zombie', mapSlug: 'moon', plutoKey: 'existing' });
await processCreations();
const plan = await blueprint([{ game: 't5', mapSlug: 'theater', count: 1 }]);
const { previewCollection, queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
const preview = previewCollection(plan.id, 'exact');
expect(preview).toMatchObject({ ready: true, servers: [{ port: 28960 }] });
const result = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
expect(queueCollection({ previewId: preview.id })).toEqual(result);
const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js');
const { komodoCreateDeployment, komodoDestroyDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
vi.mocked(komodoCreateDeployment).mockClear();
for (const players of [2, null]) {
vi.mocked(rconGetPlayerCount).mockResolvedValueOnce(players);
await processDestructions();
await processCreations();
expect(komodoDestroyDeployment).not.toHaveBeenCalled();
expect(komodoCreateDeployment).not.toHaveBeenCalled();
expect(getCollection(result.collectionId!)?.servers.find(server => server.action === 'create')?.stage).toContain('Waiting for removal');
}
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'gone' });
await processDestructions();
await processCreations();
await processCreations();
expect(komodoCreateDeployment).toHaveBeenCalledTimes(1);
expect(getCollection(result.collectionId!)?.collection).toMatchObject({ total: 2, done: 2, active: 0 });
});
it('does not reuse a port after a removal is cancelled', async () => {
vi.stubEnv('SERVER_PORT_MAX', '28960');
const { createServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
await createServer({ gameMode: 'zombie', mapSlug: 'moon', plutoKey: 'existing' });
await processCreations();
const plan = await blueprint([{ game: 't5', mapSlug: 'theater', count: 1 }]);
const { previewCollection, queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
const preview = previewCollection(plan.id, 'exact');
const result = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
const { cancelRemoval } = await import('../../src/lib/server/fleet/deployments.js');
expect(cancelRemoval(getCollection(result.collectionId!)!.servers.find(server => server.action === 'destroy')!.id)).toBe(true);
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
vi.mocked(komodoCreateDeployment).mockClear();
await processCreations();
expect(komodoCreateDeployment).not.toHaveBeenCalled();
expect(getCollection(result.collectionId!)?.collection.failed).toBe(1);
expect(getCollection(result.collectionId!)?.servers.find(server => server.action === 'create')?.stage).toContain('(cancelled)');
});
it('allows independent launches to pass a failed removal without exceeding the startup limit', async () => {
const { createServer, processCreations, processDestructions } = await import('../../src/lib/server/fleet/create.js');
await createServer({ gameMode: 'zombie', mapSlug: 'moon', plutoKey: 'existing' });
await processCreations();
const plan = await blueprint([{ game: 't5', mapSlug: 'theater', count: 2 }]);
const { previewCollection, queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
const preview = previewCollection(plan.id, 'exact');
const result = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
const { komodoCreateDeployment, komodoDestroyDeployment } = await import('../../src/lib/server/integrations/komodo.js');
vi.mocked(komodoCreateDeployment).mockClear();
vi.mocked(komodoDestroyDeployment).mockResolvedValueOnce({ ok: false, kind: 'http-error', message: 'Could not stop container' });
await processDestructions();
await processCreations();
const creates = getCollection(result.collectionId!)!.servers.filter(server => server.action === 'create');
expect(creates[0]).toMatchObject({ state: 'queued', stage: expect.stringContaining('(failed)') });
expect(creates[1]).toMatchObject({ state: 'awaiting-health' });
expect(komodoCreateDeployment).toHaveBeenCalledTimes(1);
expect(getCollection(result.collectionId!)!.collection).toMatchObject({ failed: 1, queued: 1, verifying: 1 });
});
it('rejects insufficient final capacity before cancelling any existing creation', async () => {
vi.stubEnv('SERVER_PORT_MAX', '28960');
const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
const old = queueCreateServer({ gameMode: 'zombie', mapSlug: 'moon', plutoKey: 'existing' });
if (!old.ok) throw new Error(old.message);
const plan = await blueprint([{ game: 't5', mapSlug: 'theater', count: 2 }]);
const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js');
const preview = previewCollection(plan.id, 'exact');
expect(preview.ready).toBe(false);
expect(() => queueCollection({ previewId: preview.id, keys: keysFor(preview) })).toThrow(/not enough free game ports/);
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(old.deploymentId)).toEqual({ state: 'queued' });
expect(db().prepare('SELECT * FROM server_launch_collections').all()).toEqual([]);
});
it('requires a new review when a pending removal is cancelled', async () => {
const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js');
const old = await createServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'existing' });
if (!old.ok) throw new Error(old.message);
const removal = destroyServer(old.name);
if (!removal.ok) throw new Error(removal.message);
const plan = await blueprint([{ game: 't5', mapSlug: 'theater', count: 1 }]);
const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js');
const preview = previewCollection(plan.id, 'exact');
const { cancelRemoval } = await import('../../src/lib/server/fleet/deployments.js');
cancelRemoval(removal.deploymentId!);
expect(() => queueCollection({ previewId: preview.id, keys: keysFor(preview) })).toThrow(/fleet changed/);
expect(previewCollection(plan.id, 'exact')).toMatchObject({ servers: [], removals: [], retainedServers: [old.name] });
});
it('keeps T6 variants distinct and leaves a matching fleet untouched', async () => {
const { createServer } = await import('../../src/lib/server/fleet/create.js');
await createServer({ game: 't6', gameMode: 'zombie', mapSlug: 'town', plutoKey: 'town' });
const plan = await blueprint([{ game: 't6', mapSlug: 'town', count: 1 }]);
const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js');
const noOp = previewCollection(plan.id, 'exact');
expect(noOp).toMatchObject({ retainedServers: ['t6_zombie_town_1'], removals: [], servers: [] });
expect(queueCollection({ previewId: noOp.id })).toEqual({ ok: true, collectionId: null });
await createServer({ game: 't6', gameMode: 'zombie', mapSlug: 'farm', plutoKey: 'farm' });
const preview = previewCollection(plan.id, 'exact');
expect(preview.retainedServers).toEqual(['t6_zombie_town_1']);
expect(preview.removals.map(server => server.name)).toEqual(['t6_zombie_farm_1']);
});
it('defaults old previews and collections to fill and validates the preview mode API', async () => {
const { blueprint: plan } = await previewLineup();
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
db().exec('ALTER TABLE server_collection_previews DROP COLUMN mode');
db().exec('ALTER TABLE server_collection_previews DROP COLUMN fleet_snapshot');
db().exec('ALTER TABLE server_launch_collections DROP COLUMN mode');
db().exec('ALTER TABLE server_launch_collections DROP COLUMN retained_servers');
closeDb();
const { previewCollection } = await import('../../src/lib/server/fleet/collections.js');
expect(previewCollection(plan.id).mode).toBe('fill');
const { POST } = await import('../../src/routes/api/deployment-collections/preview/+server.js');
for (const mode of ['exact', 'fill', 'invalid', null, true]) {
const response = await POST({ request: new Request('http://localhost/api/deployment-collections/preview', { method: 'POST', body: JSON.stringify({ blueprintId: plan.id, mode }) }) });
expect(response.status).toBe(mode === 'exact' || mode === 'fill' ? 200 : 400);
if (response.ok) expect((await response.json()).mode).toBe(mode);
}
});
});