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:
@@ -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
|
||||||
|
|||||||
@@ -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 }`
|
||||||
|
|||||||
@@ -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 }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 })) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 }); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user