Conform blueprint launches to desired fleet counts
This commit is contained in:
@@ -71,8 +71,10 @@ the sweep marks pending rows `manual` so nothing hangs forever.
|
||||
## Deployment blueprints and launch previews
|
||||
|
||||
Deployments includes a blueprint builder for whole server collections: choose
|
||||
multiple T5/T6 maps, set a lobby count for each, and launch the lineup in one action.
|
||||
The collection preview shows every server ID, port, and saved-key requirement.
|
||||
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
|
||||
shows target/existing/missing counts and IDs, ports, and key requirements for new
|
||||
servers only. Already-satisfied targets create nothing; excess servers are kept.
|
||||
All jobs and capacity reservations are committed together before provisioning;
|
||||
one collection page tracks each server's independent progress and failures.
|
||||
Existing single-server blueprints can be expanded with **Edit**. Single-server
|
||||
|
||||
@@ -2,16 +2,31 @@
|
||||
|
||||
The Deployments page is the starting point for reusable server collections. A
|
||||
blueprint describes a whole lineup: for example, two Kino lobbies, one Moon lobby,
|
||||
and two Town lobbies, launched together from one preview.
|
||||
and two Town lobbies. Counts are desired fleet totals: existing and queued
|
||||
servers count toward each target, and only missing servers are created.
|
||||
|
||||
For example, a blueprint targeting four Kino and two Der Riese lobbies, with
|
||||
two Kino already registered, creates two Kino and two Der Riese lobbies.
|
||||
The preview shows **Target / Existing or queued / To create** for each map.
|
||||
Matching is by game and exact map variant; T6 Town and Farm are distinct even
|
||||
though they share an engine map. Provisioning identities are counted once when
|
||||
they appear in both the server registry and active creation queue.
|
||||
|
||||
Registered servers count even if stopped or unhealthy; conforming does not
|
||||
restart or reconfigure them. Failed creation jobs without a registered server,
|
||||
destroyed records, and servers awaiting removal do not count. Excess servers and
|
||||
maps outside the blueprint are kept. This action fills deficits; it does not
|
||||
destroy servers or change existing files to match saved recipes.
|
||||
|
||||
## Build and launch a collection
|
||||
|
||||
1. Choose **Create blueprint** on Deployments and name the lineup.
|
||||
2. Add maps from Black Ops I or II and set the number of lobbies for each.
|
||||
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.
|
||||
4. Enter each lobby's Plutonium key, or leave its field blank to reuse the key
|
||||
saved for that exact server ID.
|
||||
5. Choose **Launch all N servers** and follow the collection progress page.
|
||||
5. Choose **Create N missing servers** and follow their collection progress page.
|
||||
If every target is already met, choose **Done**; no operation is created.
|
||||
|
||||
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
|
||||
@@ -33,7 +48,11 @@ the preview clears typed keys so they cannot be applied to changed server IDs.
|
||||
|
||||
Submitting the same preview again returns the same collection, including after
|
||||
a lost response or an application restart. Reusing the blueprint with a new
|
||||
preview deliberately creates another collection with fresh available capacity.
|
||||
preview recomputes the shortfall, including servers queued by earlier launches.
|
||||
It does not add another full copy of the lineup. If the shortfall or assigned
|
||||
capacity changes before submission, the request is rejected without changes and
|
||||
requires a fresh preview. Previously saved additive previews are checked against
|
||||
the current shortfall too.
|
||||
|
||||
The worker provisions servers sequentially; health verification and failures are
|
||||
recorded separately for each. One failure does not undo servers already created
|
||||
@@ -72,7 +91,7 @@ Each map in a blueprint retains the game, map variant, image reference, composed
|
||||
when available, generated map configuration, and matching promoted script/plugin
|
||||
bytes. Credentials, lobby IDs, and ports are not copied from the source server.
|
||||
|
||||
Use **Launch N servers** on a blueprint card to review fresh capacity. Remove a
|
||||
Use **Conform fleet** on a blueprint card to review missing servers. Remove a
|
||||
blueprint from the library when it is no longer needed; its existing servers,
|
||||
reviewed previews, and deployment history remain intact.
|
||||
|
||||
@@ -133,11 +152,13 @@ require inspection rather than replaying provisioning automatically.
|
||||
- `POST /api/deployment-collections/preview`: preview `{ blueprintId }`.
|
||||
- `POST /api/deployment-collections`: atomically queue `{ previewId, keys? }`,
|
||||
where `keys` maps exact preview server IDs to optional replacement keys.
|
||||
A satisfied preview returns `{ ok: true, collectionId: null }` with HTTP 200,
|
||||
without creating a collection, saving keys, or kicking the worker.
|
||||
- `GET /api/deployment-collections`: list collection progress summaries.
|
||||
- `GET /api/deployment-collections/:id`: collection and per-server progress.
|
||||
- `POST /api/deployments/preview`: preview `{ game, mapSlug }`, `{ blueprintId }`,
|
||||
or `{ deploymentId }` for a single-server recipe. Multi-server blueprints must
|
||||
use the collection endpoint; they cannot silently launch only one entry.
|
||||
- `POST /api/deployments/preview`: preview `{ game, mapSlug }` or `{ deploymentId }`
|
||||
for an explicitly new single-server recipe. All blueprints, including one-server
|
||||
targets, must use the collection endpoint so existing servers count correctly.
|
||||
Returns an ID, expiry, recipe summary, capacity, key
|
||||
availability (never its value), and prerequisite checks.
|
||||
- `POST /api/servers`: existing request plus optional `previewId`; the game and
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface CollectionPreview {
|
||||
id: string;
|
||||
expiresAt: string;
|
||||
blueprintName: string;
|
||||
entries: { count: number; recipe: LaunchRecipeSummary }[];
|
||||
entries: { count: number; existing: number; missing: number; existingServers: string[]; recipe: LaunchRecipeSummary }[];
|
||||
servers: { entryIndex: number; name: string | null; port: number | null; hasSavedKey: boolean }[];
|
||||
blockers: string[];
|
||||
ready: boolean;
|
||||
|
||||
@@ -5,11 +5,39 @@ import { config, configFileForGame, komodoConfigured, rconConfigured } from '../
|
||||
import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js';
|
||||
import { nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js';
|
||||
import { getServerKey } from './server-keys.js';
|
||||
import { parseServerName, zombieMapBySlug, zombieMaps } from './map-aliases.js';
|
||||
|
||||
function coverage(entries: BlueprintEntry[]) {
|
||||
type Server = { name: string; game: string | null; map_slug: string | null; map: string | null };
|
||||
const registered = db().prepare('SELECT name, game, map_slug, map FROM servers WHERE destroyed_at IS NULL').all() as Server[];
|
||||
const queued = db().prepare(`SELECT j.server AS name, substr(j.server, 1, 2) AS game, j.map_slug, j.expected_map AS map
|
||||
FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
|
||||
WHERE d.state IN ('queued', 'running', 'awaiting-health')`).all() as Server[];
|
||||
const removing = new Set((db().prepare("SELECT server FROM server_deployments WHERE action = 'destroy' AND state IN ('waiting', 'running')").all() as { server: string }[]).map(row => row.server));
|
||||
// A provisioning server can appear in both tables; count its identity once.
|
||||
const servers = [...new Map([...queued, ...registered].map(server => [server.name, server])).values()];
|
||||
return entries.map(entry => {
|
||||
const existingServers = servers.filter(server => {
|
||||
if (removing.has(server.name)) return false;
|
||||
const parsed = parseServerName(server.name);
|
||||
const game = server.game ?? parsed?.game;
|
||||
if (game !== entry.recipe.game) return false;
|
||||
if (server.map_slug) return server.map_slug === entry.recipe.mapSlug;
|
||||
const namedMap = parsed?.mapCanonical ? zombieMapBySlug(game, parsed.mapSlug) : undefined;
|
||||
if (namedMap && (!server.map || server.map === namedMap.canonical)) return namedMap.slug === entry.recipe.mapSlug;
|
||||
// Legacy canonical maps are usable only when they identify one variant.
|
||||
const matches = zombieMaps(game).filter(map => map.canonical === server.map);
|
||||
return matches.length === 1 && matches[0].slug === entry.recipe.mapSlug;
|
||||
}).map(server => server.name).sort();
|
||||
return { existingServers, existing: existingServers.length, missing: Math.max(0, entry.count - existingServers.length) };
|
||||
});
|
||||
}
|
||||
|
||||
function allocate(entries: BlueprintEntry[]): CollectionPreview['servers'] {
|
||||
const names: string[] = [];
|
||||
const ports: number[] = [];
|
||||
return entries.flatMap((entry, entryIndex) => Array.from({ length: entry.count }, () => {
|
||||
const counts = coverage(entries);
|
||||
return entries.flatMap((entry, entryIndex) => Array.from({ length: counts[entryIndex].missing }, () => {
|
||||
const base = `${entry.recipe.game}_zombie_${entry.recipe.mapSlug}`;
|
||||
const lobby = nextLobbyNumber(base, names);
|
||||
const name = lobby === null ? null : `${base}_${lobby}`;
|
||||
@@ -22,11 +50,12 @@ function allocate(entries: BlueprintEntry[]): CollectionPreview['servers'] {
|
||||
|
||||
function blockersFor(entries: BlueprintEntry[], servers: CollectionPreview['servers']): string[] {
|
||||
const blockers: string[] = [];
|
||||
if (!servers.length) return blockers;
|
||||
if (!komodoConfigured() || !config.komodo.targetServer) blockers.push('Configure Komodo credentials and a target host before launching.');
|
||||
if (!rconConfigured()) blockers.push('Configure RCON_PASSWORD so every new server can be verified.');
|
||||
if (servers.some(server => !server.name)) blockers.push('There are not enough free lobbies for one or more maps (20 per map). Reduce their counts or free capacity.');
|
||||
if (servers.some(server => server.port === null)) blockers.push(`There are not enough free game ports in ${config.portRange.min}–${config.portRange.max} for the whole collection.`);
|
||||
if (entries.some(entry => entry.recipe.configFile !== configFileForGame(entry.recipe.game))) blockers.push('A saved config filename no longer matches the host settings. Create a new blueprint with the current setup.');
|
||||
if (servers.some(server => entries[server.entryIndex].recipe.configFile !== configFileForGame(entries[server.entryIndex].recipe.game))) blockers.push('A saved config filename no longer matches the host settings. Create a new blueprint with the current setup.');
|
||||
return blockers;
|
||||
}
|
||||
|
||||
@@ -35,6 +64,7 @@ export function previewCollection(blueprintId: unknown): CollectionPreview {
|
||||
return db().transaction(() => {
|
||||
const { entries, blueprintName } = blueprintEntries(blueprintId);
|
||||
const servers = allocate(entries);
|
||||
const counts = coverage(entries);
|
||||
const blockers = blockersFor(entries, servers);
|
||||
const id = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
||||
@@ -42,12 +72,12 @@ export function previewCollection(blueprintId: unknown): CollectionPreview {
|
||||
db().prepare('INSERT INTO server_collection_previews (id, expires_at, blueprint_name, entries, servers) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(id, expiresAt, blueprintName, JSON.stringify(entries), JSON.stringify(servers));
|
||||
return { id, expiresAt, blueprintName, servers, blockers, ready: blockers.length === 0,
|
||||
entries: entries.map(entry => ({ count: entry.count, recipe: summarizeRecipe(entry.recipe) })) };
|
||||
entries: entries.map((entry, index) => ({ count: entry.count, ...counts[index], recipe: summarizeRecipe(entry.recipe) })) };
|
||||
})();
|
||||
}
|
||||
|
||||
/** Reserve and enqueue the entire collection atomically. No external operations here. */
|
||||
export function queueCollection(input: { previewId?: unknown; keys?: unknown }): { ok: true; collectionId: string } {
|
||||
export function queueCollection(input: { previewId?: unknown; keys?: unknown }): { ok: true; collectionId: string | null } {
|
||||
if (typeof input.previewId !== 'string' || !input.previewId) throw new Error('Review a collection preview before launching.');
|
||||
if (input.keys !== undefined && (!input.keys || typeof input.keys !== 'object' || Array.isArray(input.keys))) throw new Error('Provide keys indexed by the exact server ID.');
|
||||
const keys = (input.keys ?? {}) as Record<string, unknown>;
|
||||
@@ -63,13 +93,14 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }):
|
||||
const servers = allocate(entries);
|
||||
const blockers = blockersFor(entries, servers);
|
||||
if (blockers.length) throw new Error(blockers.join(' '));
|
||||
if (servers.some((server, i) => server.name !== reviewed[i]?.name || server.port !== reviewed[i]?.port)) {
|
||||
throw new Error('Lobby or port availability changed. Nothing was queued. Refresh the preview and review each server key.');
|
||||
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.');
|
||||
}
|
||||
const expected = new Set(servers.map(server => server.name));
|
||||
for (const [name, value] of Object.entries(keys)) {
|
||||
if (!expected.has(name) || typeof value !== 'string') throw new Error('Keys must be strings for server IDs shown in this preview.');
|
||||
}
|
||||
if (!servers.length) return { ok: true as const, collectionId: null };
|
||||
const collectionId = randomUUID();
|
||||
db().prepare('INSERT INTO server_launch_collections (id, name, created_at) VALUES (?, ?, ?)')
|
||||
.run(collectionId, preview.blueprint_name, new Date().toISOString());
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<dialog bind:this={dialog} aria-label="Blueprint builder" onclick={event => { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="builder">
|
||||
<header><div><span class="eyebrow">One blueprint. Your whole lineup.</span><h2>{blueprint ? 'Edit your blueprint' : 'Build a server collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close blueprint builder">×</button></header>
|
||||
<p class="muted">Choose your maps and how many lobbies to launch for each. Mix Black Ops I and II in the same blueprint.</p>
|
||||
<p class="muted">Choose the total lobbies you want for each map. Existing and queued servers count; only missing lobbies are created. Mix Black Ops I and II in the same blueprint.</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if !loaded}<p class="muted">Loading maps…</p>{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
||||
{:else}
|
||||
@@ -79,14 +79,14 @@
|
||||
<span class="number">{String(index + 1).padStart(2, '0')}</span>
|
||||
<label>Game<select aria-label={`Game ${index + 1}`} bind:value={row.game} disabled={busy} onchange={event => row.mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
<label>Map<select aria-label={`Map ${index + 1}`} bind:value={row.mapSlug} disabled={busy}>{#each catalogs[row.game] as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label>
|
||||
<label>Lobbies<input aria-label={`Lobbies ${index + 1}`} type="number" min="1" max="20" step="1" bind:value={row.count} required disabled={busy} /></label>
|
||||
<label>Target lobbies<input aria-label={`Lobbies ${index + 1}`} type="number" min="1" max="20" step="1" bind:value={row.count} required disabled={busy} /></label>
|
||||
<button type="button" class="remove" aria-label={`Remove map ${index + 1}`} disabled={busy || rows.length === 1} onclick={() => rows = rows.filter(item => item.key !== row.key)}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
<Button disabled={busy || rows.length >= 40} onclick={addMap}>+ Add another map</Button>
|
||||
</div>
|
||||
<p class="muted">{blueprint ? 'Changing lobby counts keeps the saved files for each map. Newly added maps use their current promoted setup.' : 'Each map saves its current promoted setup. Before launch, you’ll review every new server ID, port, and key.'}</p>
|
||||
<div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save blueprint</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview {total} server{total === 1 ? '' : 's'} →</Button></div>
|
||||
<div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save blueprint</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview missing servers →</Button></div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
let error = $state('');
|
||||
let wasOpen = false;
|
||||
let session = 0;
|
||||
let canLaunch = $derived(preview?.ready && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
|
||||
let canLaunch = $derived(preview?.ready && preview.servers.length > 0 && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
|
||||
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
@@ -40,7 +40,7 @@
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.');
|
||||
open = false;
|
||||
await goto(`/deployments/collections/${encodeURIComponent(body.collectionId)}`);
|
||||
if (body.collectionId) await goto(`/deployments/collections/${encodeURIComponent(body.collectionId)}`);
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
@@ -52,10 +52,14 @@
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if !preview}<p class="muted">{busy ? 'Finding a lobby and port for every server…' : 'Refresh to prepare another preview.'}</p>{#if !busy}<Button onclick={review}>Refresh preview</Button>{/if}
|
||||
{:else}
|
||||
<div class="lineup"><strong>{preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} · one launch</strong><ul>{#each preview.entries as entry}<li><span class="quantity">{entry.count}×</span> {entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></li>{/each}</ul></div>
|
||||
<div class="section-title"><h3>Review every lobby</h3><button class="text-button" disabled={busy} onclick={review}>Refresh preview</button></div>
|
||||
<div class="lineup"><strong>{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>
|
||||
<p class="muted">Existing and queued servers count toward each map’s target, including stopped servers. Extra servers are kept. Servers awaiting removal do not count.</p>
|
||||
</div>
|
||||
<div class="section-title"><h3>{preview.servers.length ? 'Review the missing lobbies' : '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}
|
||||
<p class="muted">Each server needs its own key. Saved keys are reused for the exact IDs below. All slots are checked again before anything is queued.</p>
|
||||
{#if preview.servers.length}
|
||||
<p class="muted">Only the missing servers below need keys. Saved keys are reused for these exact IDs. Fleet counts and slots are checked again before anything is queued.</p>
|
||||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||||
<div class="servers">
|
||||
{#each preview.servers as server, index}
|
||||
@@ -66,9 +70,10 @@
|
||||
{/each}
|
||||
</div>
|
||||
<p class="muted">The host must have the selected games and DLC. This preview checks settings and capacity; it does not probe game files, images, credentials, or network access.</p>
|
||||
<div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || !canLaunch}>Launch all {preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} →</Button></div>
|
||||
<div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || !canLaunch}>Create {preview.servers.length} missing server{preview.servers.length === 1 ? '' : 's'} →</Button></div>
|
||||
<p class="caption">Your collection is queued in one action. Servers provision in sequence and verify independently. Preview expires at {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.</p>
|
||||
</form>
|
||||
{: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}
|
||||
<details class="recipes"><summary>Review saved files for each map</summary>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -77,7 +82,7 @@
|
||||
<style>
|
||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: 16px; background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
|
||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: 10px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: 11px; }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
|
||||
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 12px; padding: 20px; }.lineup > strong { font-size: 18px; }.lineup ul { display: flex; gap: 10px 24px; flex-wrap: wrap; margin: 16px 0 0; padding: 0; list-style: none; font-size: 13px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); margin-left: 6px; }
|
||||
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 12px; padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: 11px; }
|
||||
form, .servers { display: grid; gap: 16px; }.server-row { display: grid; grid-template-columns: 1fr 240px; align-items: center; gap: 20px; border: 1px solid var(--border-subtle); border-radius: 10px; padding: 16px; }.server-row > div { min-width: 0; }.server-row strong { font-family: var(--font-mono); font-size: 13px; overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: 11px; color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-strong); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
@media (max-width: 600px) { .composer { padding: 20px; }.server-row { grid-template-columns: 1fr; gap: 12px; } }
|
||||
</style>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
async function review() {
|
||||
if (busy) return;
|
||||
if (!sourceDeploymentId && selectedBlueprint && selectedBlueprint.totalServers > 1) {
|
||||
if (!sourceDeploymentId && selectedBlueprint) {
|
||||
open = false; collectionOpen = true; return;
|
||||
}
|
||||
const current = session;
|
||||
|
||||
@@ -9,7 +9,7 @@ export async function POST({ request }: { request: Request }) {
|
||||
const body = await request.json();
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection preview and its server keys.');
|
||||
const result = queueCollection(body);
|
||||
void kick().catch(err => console.error('[collection] worker kick failed:', err));
|
||||
return json(result, { status: 202 });
|
||||
if (result.collectionId) void kick().catch(err => console.error('[collection] worker kick failed:', err));
|
||||
return json(result, { status: result.collectionId ? 202 : 200 });
|
||||
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a launch selection.');
|
||||
if (body.blueprintId !== undefined) throw new Error('Blueprints use target counts. Use /api/deployment-collections/preview to preview only missing servers.');
|
||||
return json(previewCreation(body));
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
|
||||
@@ -84,9 +84,9 @@
|
||||
|
||||
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Deployments" subtitle="Build a lineup of maps and lobbies. Launch them together and follow every server online." />
|
||||
<SectionHeader title="Deployments" subtitle="Set your target lineup. Add only the servers your fleet is missing." />
|
||||
<div class="launch-banner">
|
||||
<div><span class="eyebrow">One blueprint. A whole game night.</span><h2>Two Kino. One Moon. Two Town. One launch.</h2><p>Choose your maps, set the lobby counts, and save the lineup. Preview every server and launch the whole collection together.</p></div>
|
||||
<div><span class="eyebrow">One blueprint. A whole game night.</span><h2>Two Kino. One Moon. Two Town. One launch.</h2><p>Choose your maps and target lobby counts. Existing and queued servers count toward the blueprint; preview and create only the shortfall.</p></div>
|
||||
<div class="banner-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create blueprint →</Button><Button variant="ghost" onclick={() => { blueprintId = ''; open = true; }}>Single server</Button></div>
|
||||
</div>
|
||||
<DeployServerDialog bind:open onblueprint={loadBlueprints} />
|
||||
@@ -96,20 +96,20 @@
|
||||
<h2>Your blueprints <span>{blueprints.length}</span></h2>
|
||||
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
|
||||
{#if blueprintsLoading}<p>Loading blueprints…</p>
|
||||
{:else if !blueprints.length}<div class="blueprint-empty"><strong>Plan the whole lineup.</strong><p>Add maps from either game, choose how many lobbies each needs, and launch them together from one blueprint.</p><button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first blueprint →</button></div>
|
||||
{:else if !blueprints.length}<div class="blueprint-empty"><strong>Plan the whole lineup.</strong><p>Add maps from either game, choose the total lobbies each needs, and fill any gaps from one blueprint.</p><button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first blueprint →</button></div>
|
||||
{:else}
|
||||
<div class="blueprints">
|
||||
{#each blueprints as blueprint (blueprint.id)}
|
||||
<article class="blueprint">
|
||||
<div class="blueprint-top">
|
||||
<span class="eyebrow">Server collection</span>
|
||||
<span class="file-count">{blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</span>
|
||||
<span class="file-count">{blueprint.totalServers} target server{blueprint.totalServers === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<h3>{blueprint.name}</h3>
|
||||
<p>{blueprint.description || 'Your saved lineup, ready to launch together.'}</p>
|
||||
<p>{blueprint.description || 'Your target lineup. Only missing servers will be created.'}</p>
|
||||
<ul class="blueprint-lineup">{#each blueprint.entries as entry}<li><strong>{entry.count}×</strong> {entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></li>{/each}</ul>
|
||||
<div class="blueprint-footer">
|
||||
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Launch {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'} →</Button>
|
||||
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Conform fleet →</Button>
|
||||
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
|
||||
<button class="remove" disabled={!!removing} onclick={() => confirmRemove = blueprint.id}>Remove</button>
|
||||
</div>
|
||||
|
||||
@@ -102,6 +102,77 @@ describe('multi-server blueprints', () => {
|
||||
});
|
||||
|
||||
describe('atomic collection launches', () => {
|
||||
it('fills the target shortfall and counts registered/in-flight identities only once', async () => {
|
||||
const { createServer } = await import('../../src/lib/server/fleet/create.js');
|
||||
for (let i = 0; i < 2; i++) expect((await createServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'existing-key' })).ok).toBe(true);
|
||||
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
|
||||
const { previewCollection, queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||
const blueprint = saveBlueprint({ name: 'Friday targets', entries: [
|
||||
{ game: 't5', mapSlug: 'theater', count: 4 }, { game: 't5', mapSlug: 'factory', count: 2 }
|
||||
] });
|
||||
const preview = previewCollection(blueprint.id);
|
||||
expect(preview.entries.map(({ count, existing, missing }) => ({ count, existing, missing }))).toEqual([
|
||||
{ count: 4, existing: 2, missing: 2 }, { count: 2, existing: 0, missing: 2 }
|
||||
]);
|
||||
expect(preview.servers.map(server => server.name)).toEqual(['t5_zombie_theater_3', 't5_zombie_theater_4', 't5_zombie_factory_1', 't5_zombie_factory_2']);
|
||||
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
|
||||
expect(getCollection(queued.collectionId!)?.collection.total).toBe(4);
|
||||
expect(previewCollection(blueprint.id).servers).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps excess and unrelated servers, including stopped servers, without requiring launch settings for a no-op', 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-key' });
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare("UPDATE servers SET container_state = 'stopped'").run();
|
||||
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
|
||||
const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||
const { config } = await import('../../src/lib/server/config.js');
|
||||
// Fixture-only configuration changes simulate missing integrations after creation.
|
||||
Object.assign(config.rcon, { password: '' });
|
||||
Object.assign(config.komodo, { targetServer: '' });
|
||||
const blueprint = saveBlueprint({ name: 'One Kino', entries: [{ game: 't5', mapSlug: 'theater', count: 1 }] });
|
||||
const preview = previewCollection(blueprint.id);
|
||||
expect(preview.entries[0]).toMatchObject({ existing: 2, missing: 0 });
|
||||
expect(preview).toMatchObject({ ready: true, blockers: [], servers: [] });
|
||||
expect(queueCollection({ previewId: preview.id })).toEqual({ ok: true, collectionId: null });
|
||||
expect(db().prepare('SELECT * FROM server_launch_collections').all()).toEqual([]);
|
||||
expect(db().prepare('SELECT * FROM servers').all()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rechecks shrinking deficits at submission and rejects an older additive preview', async () => {
|
||||
const { preview, blueprint } = await previewLineup();
|
||||
const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { queueCollection, previewCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
queueCreateServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'existing-key' });
|
||||
expect(() => queueCollection({ previewId: preview.id, keys: keysFor(preview) })).toThrow(/changed/);
|
||||
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toHaveLength(1);
|
||||
const fresh = previewCollection(blueprint.id);
|
||||
expect(fresh.servers).toHaveLength(4);
|
||||
const extra = [...fresh.servers, { ...fresh.servers[0], name: 't5_zombie_theater_20', port: 28980 }];
|
||||
db().prepare('UPDATE server_collection_previews SET servers = ? WHERE id = ?').run(JSON.stringify(extra), fresh.id);
|
||||
expect(() => queueCollection({ previewId: fresh.id, keys: keysFor(fresh) })).toThrow(/changed/);
|
||||
});
|
||||
|
||||
it('matches exact T6 variants and excludes failed creations and pending destructions', async () => {
|
||||
const { createServer, queueCreateServer, destroyServer } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { finishDeployment } = await import('../../src/lib/server/fleet/deployments.js');
|
||||
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
|
||||
const { previewCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||
const farm = await createServer({ game: 't6', gameMode: 'zombie', mapSlug: 'farm', plutoKey: 'farm-key' });
|
||||
const town = await createServer({ game: 't6', gameMode: 'zombie', mapSlug: 'town', plutoKey: 'town-key' });
|
||||
if (!farm.ok || !town.ok) throw new Error('Fixture creation failed');
|
||||
destroyServer(town.name);
|
||||
const failed = queueCreateServer({ game: 't6', gameMode: 'zombie', mapSlug: 'town', plutoKey: 'failed-key' });
|
||||
if (!failed.ok) throw new Error(failed.message);
|
||||
finishDeployment(failed.deploymentId, { ok: false, message: 'Fixture failure' });
|
||||
const blueprint = saveBlueprint({ name: 'Town target', entries: [{ game: 't6', mapSlug: 'town', count: 1 }] });
|
||||
const preview = previewCollection(blueprint.id);
|
||||
expect(preview.entries[0]).toMatchObject({ existing: 0, missing: 1 });
|
||||
expect(preview.servers[0].name).toBe('t6_zombie_town_2');
|
||||
});
|
||||
|
||||
it('queues all five servers, persists one group, and completes each independently', async () => {
|
||||
const { preview } = await previewLineup();
|
||||
const { queueCollection, getCollection, listCollections } = await import('../../src/lib/server/fleet/collections.js');
|
||||
@@ -111,10 +182,10 @@ describe('atomic collection launches', () => {
|
||||
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
|
||||
expect(komodoCreateDeployment).not.toHaveBeenCalled();
|
||||
expect(getCollection(queued.collectionId)?.collection).toMatchObject({ total: 5, active: 5, done: 0, failed: 0 });
|
||||
expect(getCollection(queued.collectionId!)?.collection).toMatchObject({ total: 5, active: 5, done: 0, failed: 0 });
|
||||
closeDb();
|
||||
await processCreations();
|
||||
const detail = getCollection(queued.collectionId)!;
|
||||
const detail = getCollection(queued.collectionId!)!;
|
||||
expect(detail.collection).toMatchObject({ total: 5, active: 0, done: 5, failed: 0 });
|
||||
expect(listCollections()).toHaveLength(1);
|
||||
expect(komodoCreateDeployment).toHaveBeenCalledTimes(5);
|
||||
@@ -167,11 +238,13 @@ describe('atomic collection launches', () => {
|
||||
db().prepare("UPDATE server_collection_previews SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(ready.id);
|
||||
closeDb();
|
||||
expect(queueCollection({ previewId: ready.id })).toEqual(queued);
|
||||
expect(getCollection(queued.collectionId)?.servers).toHaveLength(5);
|
||||
expect(getCollection(queued.collectionId!)?.servers).toHaveLength(5);
|
||||
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toHaveLength(5);
|
||||
const next = previewCollection(blueprint.id);
|
||||
expect(next.servers.map(server => server.name)).toEqual(['t5_zombie_theater_3', 't5_zombie_theater_4', 't5_zombie_moon_2', 't6_zombie_town_3', 't6_zombie_town_4']);
|
||||
expect(next.servers.map(server => server.port)).toEqual([28965, 28966, 28967, 28968, 28969]);
|
||||
expect(next.servers).toEqual([]);
|
||||
expect(next.entries.map(entry => entry.existing)).toEqual([2, 1, 2]);
|
||||
expect(queueCollection({ previewId: next.id })).toEqual({ ok: true, collectionId: null });
|
||||
expect(db().prepare('SELECT * FROM server_launch_collections').all()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the reviewed lineup and bytes after blueprint edits, deletion, and promotion', async () => {
|
||||
@@ -189,7 +262,7 @@ describe('atomic collection launches', () => {
|
||||
deleteBlueprint(blueprint.id);
|
||||
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
|
||||
await processCreations();
|
||||
expect(getCollection(queued.collectionId)?.collection).toMatchObject({ name: 'Friday Zombies', total: 5, done: 5 });
|
||||
expect(getCollection(queued.collectionId!)?.collection).toMatchObject({ name: 'Friday Zombies', total: 5, done: 5 });
|
||||
for (const name of ['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_moon_1']) {
|
||||
expect(await readFile(join(fixture.t5, name, 'runtime/plutonium/storage/t5/raw/scripts/sp/perks.gsc'), 'utf8')).toBe('original');
|
||||
}
|
||||
@@ -203,14 +276,20 @@ describe('atomic collection launches', () => {
|
||||
vi.mocked(komodoCreateDeployment).mockResolvedValueOnce({ ok: false, kind: 'http-error', message: 'Host rejected this container' });
|
||||
const queued = queueCollection({ previewId: preview.id, keys: keysFor(preview) });
|
||||
await processCreations();
|
||||
const detail = getCollection(queued.collectionId)!;
|
||||
const detail = getCollection(queued.collectionId!)!;
|
||||
expect(detail.collection).toMatchObject({ total: 5, active: 0, done: 4, failed: 1 });
|
||||
expect(detail.servers[0]).toMatchObject({ state: 'failed', error: expect.stringContaining('Host rejected') });
|
||||
expect(detail.servers.slice(1).every(server => server.state === 'done')).toBe(true);
|
||||
});
|
||||
|
||||
it('validates collection API payloads and rejects an unused expired preview', async () => {
|
||||
const { preview } = await previewLineup();
|
||||
const { preview, blueprint } = await previewLineup();
|
||||
const { POST: singlePreview } = await import('../../src/routes/api/deployments/preview/+server.js');
|
||||
const oldEndpoint = await singlePreview({ request: new Request('http://localhost/api/deployments/preview', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId: blueprint.id })
|
||||
}) });
|
||||
expect(oldEndpoint.status).toBe(400);
|
||||
expect((await oldEndpoint.json()).error).toContain('target counts');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { queueCollection } = await import('../../src/lib/server/fleet/collections.js');
|
||||
const { POST } = await import('../../src/routes/api/deployment-collections/+server.js');
|
||||
|
||||
Reference in New Issue
Block a user