Rename blueprints to Collections and add Operations tab

Fixes #46
This commit is contained in:
2026-09-27 14:50:55 -04:00
parent df265d1900
commit a7044f21a1
17 changed files with 198 additions and 159 deletions
+9 -9
View File
@@ -131,13 +131,13 @@ RCON, and selected-map verification.
No token, no session, no auth freeze. If Komodo or RCON aren't configured, No token, no session, no auth freeze. If Komodo or RCON aren't configured,
the sweep marks pending rows `manual` so nothing hangs forever. the sweep marks pending rows `manual` so nothing hangs forever.
## Provisioning blueprints and launch previews ## Provisioning collections and launch previews
Provisioning also offers **House Party Protocol**: review the entire fleet and queue Provisioning also offers **House Party Protocol**: review the entire fleet and queue
destruction in one action. This includes queued creations and reuses pending destruction in one action. This includes queued creations and reuses pending
destruction requests. Each server waits for a fresh zero-player reading; occupied destruction requests. Each server waits for a fresh zero-player reading; occupied
or unreachable servers remain queued. Containers, server files, and registrations or unreachable servers remain queued. Containers, server files, and registrations
are removed, while saved keys and blueprints are retained. The preview must be are removed, while saved keys and collections are retained. The preview must be
reviewed again if fleet membership changes before confirmation. Servers created reviewed again if fleet membership changes before confirmation. Servers created
after confirmation are not included. Progress appears in deployment history. after confirmation are not included. Progress appears in deployment history.
@@ -146,13 +146,13 @@ If deletion fails after Komodo removal, the operation detail page offers
repeating Komodo deletion. Requesting destruction again also resumes that cleanup. repeating Komodo deletion. Requesting destruction again also resumes that cleanup.
Recovery is blocked if a newer operation exists for the same server name. Recovery is blocked if a newer operation exists for the same server name.
Provisioning includes a blueprint builder for whole server collections: choose **Operations → Collections** includes a builder for whole server lineups: choose
multiple T5/T6 maps and set the desired total lobbies for each. **Conform fleet** 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 Choose **Match collection exactly** in the preview to also remove excess lobbies
and maps outside the blueprint. Review the keep/create/remove lists and confirm and maps outside the collection. Review the keep/create/remove lists and confirm
removals. Matching servers stay untouched; unstarted excess creations are cancelled, removals. Matching servers stay untouched; unstarted excess creations are cancelled,
and running servers wait for a fresh zero-player check before removal. New lobbies 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 can reserve ports from these removals and wait until they are released. The collection
@@ -167,16 +167,16 @@ shows the startup limit, and its progress page shows active startups and each
server's place in the shared queue. Set `DEPLOYMENT_CREATE_CONCURRENCY` to a server's place in the shared queue. Set `DEPLOYMENT_CREATE_CONCURRENCY` to a
positive integer to increase the limit. Each worker pass starts at most the positive integer to increase the limit. Each worker pass starts at most the
available number of slots; immediate failures do not drain the remaining queue. available number of slots; immediate failures do not drain the remaining queue.
Existing single-server blueprints can be expanded with **Edit**. Single-server Existing single-server collections can be expanded with **Edit**. Single-server
launches and launching again from recorded recipes remain available. launches and launching again from recorded recipes remain available.
Blueprints save maps and target lobby counts. Every new server uses the latest Collections save maps and target lobby counts. Every new server uses the latest
promoted scripts, plugins, and configuration when provisioning begins, including promoted scripts, plugins, and configuration when provisioning begins, including
promotions made after preview or while queued. Existing blueprints automatically promotions made after preview or while queued. Existing collections automatically
follow this policy. Deployment history records the files selected for that launch; follow this policy. Deployment history records the files selected for that launch;
**Launch again** uses the current promoted setup for the same game and map. **Launch again** uses the current promoted setup for the same game and map.
The progress page explains waiting conditions, shows elapsed time, and can resume The progress page explains waiting conditions, shows elapsed time, and can resume
health verification of an interrupted creation when its original server remains. health verification of an interrupted creation when its original server remains.
See [Deployment blueprints](docs/deployment-blueprints.md) for the workflow, See [Deployment collections](docs/deployment-blueprints.md) for the workflow,
promotion policy, and API details. promotion policy, and API details.
## Saved server keys ## Saved server keys
+33 -29
View File
@@ -1,14 +1,14 @@
# Deployment blueprints # Deployment collections
The Provisioning page is the starting point for reusable server collections. A Open **Operations → Collections** to manage reusable server lineups. A
blueprint describes a whole lineup: for example, two Kino lobbies, one Moon lobby, collection describes a whole lineup: for example, two Kino lobbies, one Moon lobby,
and two Town lobbies. Counts are desired fleet totals: existing and queued and two Town lobbies. Counts are desired fleet totals: existing and queued
servers count toward each target, and only missing servers are created. servers count toward each target, and only missing servers are created.
Blueprints save game/map selections and lobby counts. Every new server uses the Collections save game/map selections and lobby counts. Every new server uses the
latest promoted scripts, plugins, baseline, and matching configuration overlays latest promoted scripts, plugins, baseline, and matching configuration overlays
when provisioning begins. when provisioning begins.
For example, a blueprint targeting four Kino and two Der Riese lobbies, with For example, a collection targeting four Kino and two Der Riese lobbies, with
two Kino already registered, creates two Kino and two Der Riese lobbies. two Kino already registered, creates two Kino and two Der Riese lobbies.
The preview shows **Target / Existing or queued / To create** for each map. 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 Matching is by game and exact map variant; T6 Town and Farm are distinct even
@@ -18,15 +18,15 @@ 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. The default destroyed records, and servers awaiting removal do not count. The default
**Fill missing servers** mode keeps excess servers and maps outside the blueprint. **Fill missing servers** mode keeps excess servers and maps outside the collection.
It fills deficits without changing 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 Provisioning and name the lineup. 1. Choose **Create collection** on **Operations → Collections** 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** (or **Preview fleet changes** on a saved blueprint). 3. Choose **Save & preview** (or **Preview fleet changes** on a saved collection).
Choose **Fill missing servers** or **Match blueprint exactly**, then review Choose **Fill missing servers** or **Match collection exactly**, then review
the server IDs and game ports. 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**.
@@ -34,12 +34,12 @@ It fills deficits without changing existing servers' files.
mode) and follow the collection progress page. 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 ### Match collection exactly
Exact mode applies to the entire registered and queued fleet across both games. 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 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 before unstarted creations, then the lowest lobby numbers within each game/map
variant. It removes excess lobbies and maps outside the blueprint. Retained variant. It removes excess lobbies and maps outside the collection. Retained
servers keep their containers, files, ports, and configuration without a restart. servers keep their containers, files, ports, and configuration without a restart.
The option is chosen per launch; opening another preview defaults to fill mode. The option is chosen per launch; opening another preview defaults to fill mode.
@@ -64,17 +64,17 @@ tracks creations and removals, including removal-only plans, and lists the serve
that were retained. The existing operation detail page offers removal cancellation that were retained. The existing operation detail page offers removal cancellation
and cleanup recovery where applicable. and cleanup recovery where applicable.
Blueprints support up to 40 distinct game/map selections, 1–20 lobbies per map, Collections 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 collection can be saved even when there is insufficient capacity
to launch it. Saving a blueprint does not capture or pin managed file versions. to launch it. Saving a collection does not capture or pin managed file versions.
Use **Edit** on a blueprint card to change its name, description, maps, or lobby Use **Edit** on a collection card to change its name, description, maps, or lobby
counts. Already reviewed launches keep their original lineup, including after counts. Already reviewed launches keep their original lineup, including after
blueprint edits or removal; their managed files still follow the latest promotions. collection edits or removal; their managed files still follow the latest promotions.
Previously saved blueprints automatically use this policy without being resaved. Previously saved collections automatically use this policy without being resaved.
Blueprints saved by the earlier single-server release load as one-server lineups Collections saved by the earlier single-server release load as one-server lineups
and can be expanded directly. Any file snapshots in older blueprints are ignored and can be expanded directly. Any file snapshots in older collections are ignored
when preparing new launches. when preparing new launches.
A collection preview does not reserve capacity. At submission, all lobby IDs, A collection preview does not reserve capacity. At submission, all lobby IDs,
@@ -85,7 +85,7 @@ the preview retains typed keys only for unchanged server IDs still missing a sav
key, and resets removal confirmation. 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 collection with a new
preview recomputes the shortfall, including servers queued by earlier launches. 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 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 capacity changes before submission, the request is rejected without changes and
@@ -114,7 +114,7 @@ operation links back to its collection.
## Create and review a launch ## Create and review a launch
1. Choose **Single server** on Provisioning (or the deploy action on the fleet). 1. Choose **Single server** on Provisioning (or the deploy action on the fleet).
2. Choose the current promoted setup and select a game/map, or select a blueprint. 2. Choose the current promoted setup and select a game/map, or select a collection.
3. Choose **Preview launch**. Review the image reference, configuration sources, 3. Choose **Preview launch**. Review the image reference, configuration sources,
managed files and versions, selected map, lobby, port, and prerequisite checks. managed files and versions, selected map, lobby, port, and prerequisite checks.
4. Enter the Plutonium key for the displayed server ID, or reuse its saved key. 4. Enter the Plutonium key for the displayed server ID, or reuse its saved key.
@@ -132,24 +132,24 @@ rules, or credentials. Container and RCON verification happen after provisioning
T6 variants retain their exact catalog slug and launch ruleset; RCON health checks T6 variants retain their exact catalog slug and launch ruleset; RCON health checks
verify the engine map, not the variant's start location. verify the engine map, not the variant's start location.
## Save and reuse a blueprint ## Save and reuse a collection
Expand **Make this a reusable blueprint** in a launch preview and give it a name Expand **Make this a reusable collection** in a launch preview and give it a name
and optional description. You can save the map even when host configuration or and optional description. You can save the map even when host configuration or
available capacity blocks launching. Names are unique without regard to case. available capacity blocks launching. Names are unique without regard to case.
Each map in a blueprint retains the game, exact map variant, and desired lobby Each map in a collection retains the game, exact map variant, and desired lobby
count. Image references, managed files, and composed configuration are resolved count. Image references, managed files, and composed configuration are resolved
from the current setup for each new launch. Credentials, lobby IDs, and ports are from the current setup for each new launch. Credentials, lobby IDs, and ports are
not copied from the source server. not copied from the source server.
Use **Conform fleet** on a blueprint card to review missing servers. Remove a Use **Conform fleet** on a collection card to review missing servers. Remove a
blueprint from the library when it is no longer needed; its existing servers, collection from the library when it is no longer needed; its existing servers,
reviewed previews, and deployment history remain intact. reviewed previews, and deployment history remain intact.
On a creation detail page, **Launch again** selects the original game and map On a creation detail page, **Launch again** selects the original game and map
with the next available lobby and port, using the latest promoted setup. The with the next available lobby and port, using the latest promoted setup. The
preview also lets you save that map as a blueprint with a target of one lobby. preview also lets you save that map as a collection with a target of one lobby.
The recorded files remain visible as deployment history. Older deployments The recorded files remain visible as deployment history. Older deployments
without a recorded recipe retain their history but cannot offer this action. without a recorded recipe retain their history but cannot offer this action.
@@ -159,7 +159,7 @@ A preview shows the currently promoted managed files, configuration, and image
reference for the selected map. It does not pin those versions. New launches reference for the selected map. It does not pin those versions. New launches
resolve the promoted setup again when submitted and when provisioning begins, so resolve the promoted setup again when submitted and when provisioning begins, so
promotions made after a preview or while a job is queued apply to that server. promotions made after a preview or while a job is queued apply to that server.
This applies to blueprint collections, single-server launches, and **Launch again**. This applies to collection collections, single-server launches, and **Launch again**.
The files selected at provisioning, their hashes and version metadata, composed The files selected at provisioning, their hashes and version metadata, composed
configuration, selected map, and image reference are recorded in SQLite for configuration, selected map, and image reference are recorded in SQLite for
@@ -201,6 +201,10 @@ repeats container creation or deployment as part of this recovery.
## API ## API
Collections retain the existing `/api/blueprints` endpoints and `blueprintId` /
`blueprintName` fields for compatibility. Saved data needs no migration. Old
`/provisioning?view=blueprints` links redirect to `/collections`.
- `GET /api/blueprints`: list lineup summaries with game/map selections and counts. - `GET /api/blueprints`: list lineup summaries with game/map selections and counts.
- `POST /api/blueprints`: save `{ name, description?, previewId }` or - `POST /api/blueprints`: save `{ name, description?, previewId }` or
`{ name, description?, deploymentId }` for a single-map target of one lobby, or `{ name, description?, deploymentId }` for a single-map target of one lobby, or
@@ -222,7 +226,7 @@ repeats container creation or deployment as part of this recovery.
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 }`
for a new single server using the latest promoted setup. All blueprints, including one-server for a new single server using the latest promoted setup. All collections, including one-server
targets, must use the collection endpoint so existing servers count correctly. targets, must use the collection endpoint so existing servers count correctly.
Returns an ID, expiry, recipe summary, capacity, key Returns an ID, expiry, recipe summary, capacity, key
availability (never its value), and prerequisite checks. availability (never its value), and prerequisite checks.
+5 -5
View File
@@ -62,7 +62,7 @@ function collectionPlan(entries: BlueprintEntry[], mode: CollectionMode) {
const retained = new Set(retainedServers); const retained = new Set(retainedServers);
const matching = new Set(counts.flatMap(entry => entry.existingServers)); const matching = new Set(counts.flatMap(entry => entry.existingServers));
const removals = mode === 'exact' ? housePartyTargets().filter(name => !retained.has(name)).map(name => ({ name, 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' reason: matching.has(name) ? 'Exceeds the target lobby count' : 'Outside this collection or already awaiting removal'
})) : []; })) : [];
return { counts, retainedServers, removals }; return { counts, retainedServers, removals };
} }
@@ -93,8 +93,8 @@ function blockersFor(servers: CollectionPreview['servers']): string[] {
} }
export function previewCollection(blueprintId: unknown, mode: unknown = 'fill'): 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 collection to launch.');
if (mode !== 'fill' && mode !== 'exact') throw new Error('Choose fill or exact blueprint mode.'); if (mode !== 'fill' && mode !== 'exact') throw new Error('Choose fill or exact collection mode.');
return db().transaction((): CollectionPreview => { return db().transaction((): CollectionPreview => {
const { entries, blueprintName } = blueprintEntries(blueprintId); const { entries, blueprintName } = blueprintEntries(blueprintId);
const { counts, retainedServers, removals } = collectionPlan(entries, mode); const { counts, retainedServers, removals } = collectionPlan(entries, mode);
@@ -156,8 +156,8 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }):
const result = destroyServer(server.name, 'blueprint-exact'); const result = destroyServer(server.name, 'blueprint-exact');
if (!result.ok || !result.deploymentId) throw new Error('Could not queue removal for ' + server.name); if (!result.ok || !result.deploymentId) throw new Error('Could not queue removal for ' + server.name);
id = result.deploymentId; 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); db().prepare("UPDATE server_deployments SET state = 'cancelled', stage = 'Cancelled by exact collection', finished_at = ? WHERE id = ?").run(new Date().toISOString(), queued.id);
recordDeploymentEvent(queued.id, 'Queued creation cancelled by exact blueprint; saved key retained.'); recordDeploymentEvent(queued.id, 'Queued creation cancelled by exact collection; saved key retained.');
deploymentStage(id, 'Cancelled queued creation; no server was started'); deploymentStage(id, 'Cancelled queued creation; no server was started');
finishDeployment(id, { ok: true }); finishDeployment(id, { ok: true });
} else { } else {
+6 -6
View File
@@ -43,7 +43,7 @@ function summarizeBlueprint(row: BlueprintRow): BlueprintSummary {
export function blueprintEntries(id: string): { entries: BlueprintEntry[]; blueprintName: string } { export function blueprintEntries(id: string): { entries: BlueprintEntry[]; blueprintName: string } {
const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined; const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined;
if (!row) throw new Error('This blueprint is no longer available. Choose another starting point.'); if (!row) throw new Error('This collection is no longer available. Choose another starting point.');
const entries = readEntries(row.recipe).map(({ count, game, mapSlug }) => ({ count, recipe: captureRecipe(game, mapSlug) })); const entries = readEntries(row.recipe).map(({ count, game, mapSlug }) => ({ count, recipe: captureRecipe(game, mapSlug) }));
return { entries, blueprintName: row.name }; return { entries, blueprintName: row.name };
} }
@@ -60,7 +60,7 @@ function buildEntries(input: unknown): BlueprintSelection[] {
seen.add(key); seen.add(key);
return { count: entry.count as number, game: entry.game as ZombieGame, mapSlug: entry.mapSlug as string }; return { count: entry.count as number, game: entry.game as ZombieGame, mapSlug: entry.mapSlug as string };
}); });
if (entries.reduce((total, entry) => total + entry.count, 0) > 100) throw new Error('A blueprint can launch at most 100 servers.'); if (entries.reduce((total, entry) => total + entry.count, 0) > 100) throw new Error('A collection can launch at most 100 servers.');
return entries; return entries;
} }
@@ -103,7 +103,7 @@ export function recipeForDeployment(id: string): { recipe: LaunchRecipe; bluepri
export function blueprintRecipe(id: string): { recipe: LaunchRecipe; blueprintName: string } { export function blueprintRecipe(id: string): { recipe: LaunchRecipe; blueprintName: string } {
const { entries, blueprintName } = blueprintEntries(id); const { entries, blueprintName } = blueprintEntries(id);
if (entries.length !== 1 || entries[0].count !== 1) throw new Error('This blueprint launches multiple servers. Use the collection preview.'); if (entries.length !== 1 || entries[0].count !== 1) throw new Error('This collection launches multiple servers. Use the collection preview.');
return { recipe: entries[0].recipe, blueprintName }; return { recipe: entries[0].recipe, blueprintName };
} }
@@ -112,9 +112,9 @@ export function listBlueprints(): BlueprintSummary[] {
} }
function blueprintName(input: { name: unknown; description?: unknown }, exceptId = ''): string { function blueprintName(input: { name: unknown; description?: unknown }, exceptId = ''): string {
if (typeof input.name !== 'string' || !input.name.trim() || input.name.trim().length > 80) throw new Error('Give the blueprint a name of 1–80 characters.'); if (typeof input.name !== 'string' || !input.name.trim() || input.name.trim().length > 80) throw new Error('Give the collection a name of 1–80 characters.');
if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 500)) throw new Error('Keep the description to 500 characters.'); if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 500)) throw new Error('Keep the description to 500 characters.');
if (db().prepare('SELECT id FROM server_blueprints WHERE name = ? COLLATE NOCASE AND id != ?').get(input.name.trim(), exceptId)) throw new Error('A blueprint with this name already exists. Choose a new name.'); if (db().prepare('SELECT id FROM server_blueprints WHERE name = ? COLLATE NOCASE AND id != ?').get(input.name.trim(), exceptId)) throw new Error('A collection with this name already exists. Choose a new name.');
return input.name.trim(); return input.name.trim();
} }
@@ -141,7 +141,7 @@ export function saveBlueprint(input: { name: unknown; description?: unknown; pre
export function updateBlueprint(id: string, input: { name: unknown; description?: unknown; entries?: unknown }): BlueprintSummary { export function updateBlueprint(id: string, input: { name: unknown; description?: unknown; entries?: unknown }): BlueprintSummary {
return db().transaction(() => { return db().transaction(() => {
const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined; const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined;
if (!row) throw new Error('Blueprint not found.'); if (!row) throw new Error('Collection not found.');
const name = blueprintName(input, id); const name = blueprintName(input, id);
const entries = buildEntries(input.entries); const entries = buildEntries(input.entries);
const recipe = JSON.stringify({ format: 3, entries }); const recipe = JSON.stringify({ format: 3, entries });
@@ -17,7 +17,7 @@
let total = $derived(rows.reduce((sum, row) => sum + (Number(row.count) || 0), 0)); let total = $derived(rows.reduce((sum, row) => sum + (Number(row.count) || 0), 0));
const duplicate = (row: typeof rows[number]) => rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === row.mapSlug); const duplicate = (row: typeof rows[number]) => rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === row.mapSlug);
let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 20 && !duplicate(row))); let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 20 && !duplicate(row)));
let reason = $derived(!name.trim() ? 'Enter a blueprint name to save.' : total > 100 ? `Reduce the total by ${total - 100} to stay within 100 target lobbies.` : rows.some(duplicate) ? 'Choose each game and map once; increase its lobby count instead.' : !valid ? 'Choose a map and 1–20 target lobbies for every row.' : ''); let reason = $derived(!name.trim() ? 'Enter a collection name to save.' : total > 100 ? `Reduce the total by ${total - 100} to stay within 100 target lobbies.` : rows.some(duplicate) ? 'Choose each game and map once; increase its lobby count instead.' : !valid ? 'Choose a map and 1–20 target lobbies for every row.' : '');
let canAdd = $derived((['t5', 't6'] as const).some(game => catalogs[game].some(map => !rows.some(row => row.game === game && row.mapSlug === map.slug)))); let canAdd = $derived((['t5', 't6'] as const).some(game => catalogs[game].some(map => !rows.some(row => row.game === game && row.mapSlug === map.slug))));
let wasOpen = false; let wasOpen = false;
let session = 0; let session = 0;
@@ -60,22 +60,22 @@
method: blueprint ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, method: blueprint ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, entries: rows.map(({ game, mapSlug, count }) => ({ game, mapSlug, count })) }) }); body: JSON.stringify({ name, description, entries: rows.map(({ game, mapSlug, count }) => ({ game, mapSlug, count })) }) });
const body = await res.json(); const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not save blueprint.'); if (!res.ok) throw new Error(body.error ?? 'Could not save collection.');
open = false; onsaved(body.blueprint, launch); open = false; onsaved(body.blueprint, launch);
} catch (err) { error = (err as Error).message; } } catch (err) { error = (err as Error).message; }
finally { busy = false; } finally { busy = false; }
} }
</script> </script>
<dialog bind:this={dialog} aria-label="Blueprint builder" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}> <dialog bind:this={dialog} aria-label="Collection builder" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<div class="builder"> <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> <header><div><span class="eyebrow">One collection. Your whole lineup.</span><h2>{blueprint ? 'Edit your collection' : 'Build a server collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection builder">×</button></header>
<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> <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 collection.</p>
{#if error}<p class="error" role="alert">{error}</p>{/if} {#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} {#if !loaded}<p class="muted">Loading maps…</p>{#if error}<Button onclick={initialize}>Try again</Button>{/if}
{:else} {:else}
<form onsubmit={event => { event.preventDefault(); void save(true); }}> <form onsubmit={event => { event.preventDefault(); void save(true); }}>
<div class="identity"><label>Blueprint name<input bind:value={name} maxlength="80" placeholder="Friday Zombies" required disabled={busy} /></label><label>Description (optional)<input bind:value={description} maxlength="500" placeholder="Our weekend lineup" disabled={busy} /></label></div> <div class="identity"><label>Collection name<input bind:value={name} maxlength="80" placeholder="Friday Zombies" required disabled={busy} /></label><label>Description (optional)<input bind:value={description} maxlength="500" placeholder="Our weekend lineup" disabled={busy} /></label></div>
<div class="lineup"> <div class="lineup">
<div class="section-title"><h3>Your lineup</h3><span class="count" class:error={total > 100} role="status">{total} / 100 target lobbies</span></div> <div class="section-title"><h3>Your lineup</h3><span class="count" class:error={total > 100} role="status">{total} / 100 target lobbies</span></div>
{#each rows as row, index (row.key)} {#each rows as row, index (row.key)}
@@ -89,9 +89,9 @@
{/each} {/each}
<Button disabled={busy || !canAdd} onclick={addMap}>+ Add another map</Button> <Button disabled={busy || !canAdd} onclick={addMap}>+ Add another map</Button>
</div> </div>
<p class="muted">Blueprints save your maps and target lobby counts. Every new server uses the latest promoted scripts, plugins, and configuration. Before launch, you’ll review every new server ID, port, and key.</p> <p class="muted">Collections save your maps and target lobby counts. Every new server uses the latest promoted scripts, plugins, and configuration. Before launch, you’ll review every new server ID, port, and key.</p>
{#if reason}<p class="validation" role="status">{reason}</p>{/if} {#if reason}<p class="validation" role="status">{reason}</p>{/if}
<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> <div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save collection</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview missing servers →</Button></div>
</form> </form>
{/if} {/if}
</div> </div>
@@ -74,19 +74,19 @@
<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 <label class="mode">Apply collection
<select bind:value={mode} disabled={busy} onchange={event => { mode = event.currentTarget.value as CollectionMode; void review(); }}> <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="fill">Fill missing servers</option>
<option value="exact">Match blueprint exactly</option> <option value="exact">Match collection exactly</option>
</select> </select>
</label> </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> <p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this collection 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.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> <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` : 'Collection 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. {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> <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.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 === '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}
@@ -102,7 +102,7 @@
<h3>Remove {preview.removals.length} server{preview.removals.length === 1 ? '' : 's'}</h3> <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> <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> <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> <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 collection.</label>
</section> </section>
{/if} {/if}
{#if preview.servers.length} {#if preview.servers.length}
@@ -121,7 +121,7 @@
{/if} {/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> <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">{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} {:else}<p class="muted">{preview.mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet already meets or exceeds every target in this collection.'} 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>
@@ -52,13 +52,13 @@
mapSlug = initialMapSlug; mapSlug = initialMapSlug;
try { try {
const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]); const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]);
if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and blueprints. Try again.'); if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and collections. Try again.');
const [mapBody, blueprintBody] = await Promise.all([mapRes.json(), blueprintRes.json()]); const [mapBody, blueprintBody] = await Promise.all([mapRes.json(), blueprintRes.json()]);
if (current !== session) return; if (current !== session) return;
catalogs = { t5: mapBody.t5Zombie, t6: mapBody.t6Zombie }; catalogs = { t5: mapBody.t5Zombie, t6: mapBody.t6Zombie };
blueprints = blueprintBody.blueprints; blueprints = blueprintBody.blueprints;
if (!maps.some(m => m.slug === mapSlug)) mapSlug = maps[0]?.slug ?? ''; if (!maps.some(m => m.slug === mapSlug)) mapSlug = maps[0]?.slug ?? '';
if (blueprintId && !blueprints.some(b => b.id === blueprintId)) throw new Error('This blueprint is no longer available. Close this dialog and choose another starting point.'); if (blueprintId && !blueprints.some(b => b.id === blueprintId)) throw new Error('This collection is no longer available. Close this dialog and choose another starting point.');
loaded = true; loaded = true;
if (sourceDeploymentId) await review(); if (sourceDeploymentId) await review();
} catch (err) { if (current === session) error = (err as Error).message; } } catch (err) { if (current === session) error = (err as Error).message; }
@@ -110,7 +110,7 @@
const res = await fetch('/api/blueprints', { method: 'POST', headers: { 'Content-Type': 'application/json' }, const res = await fetch('/api/blueprints', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: blueprintName, description, previewId: preview.id }) }); body: JSON.stringify({ name: blueprintName, description, previewId: preview.id }) });
const body = await res.json(); const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not save blueprint.'); if (!res.ok) throw new Error(body.error ?? 'Could not save collection.');
if (current === session) { savedName = body.blueprint.name; blueprints = [...blueprints, body.blueprint]; onblueprint?.(); } if (current === session) { savedName = body.blueprint.name; blueprints = [...blueprints, body.blueprint]; onblueprint?.(); }
} catch (err) { if (current === session) error = (err as Error).message; } } catch (err) { if (current === session) error = (err as Error).message; }
finally { if (current === session) saving = false; } finally { if (current === session) saving = false; }
@@ -123,12 +123,12 @@
<ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol> <ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol>
{#if error}<div class="error" role="alert">{error}</div>{/if} {#if error}<div class="error" role="alert">{error}</div>{/if}
{#if !loaded} {#if !loaded}
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and blueprints…'}</p> <p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…'}</p>
{#if error}<Button onclick={initialize}>Try again</Button>{/if} {#if error}<Button onclick={initialize}>Try again</Button>{/if}
{:else if preview} {:else if preview}
{#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 it to recheck capacity. Keys stay entered when the server ID is unchanged.</p>{/if} {#if expired}<p class="error" role="status">This preview expired. Refresh it to recheck capacity. Keys stay entered when the server ID is unchanged.</p>{/if}
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From blueprint: {preview.blueprintName}</p>{/if}</section> <section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From collection: {preview.blueprintName}</p>{/if}</section>
<section class="preflight"><div class="section-title"><h3>Before you launch</h3><button class="text-button" disabled={busy || saving} onclick={review}>Refresh preview</button></div> <section class="preflight"><div class="section-title"><h3>Before you launch</h3><button class="text-button" disabled={busy || saving} onclick={review}>Refresh preview</button></div>
<ul>{#each preview.checks as check}<li class:blocked={check.state === 'blocked'}><span class="check" aria-label={check.state}>{check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}</span><div><strong>{check.label}</strong><p>{check.detail}</p></div></li>{/each}</ul> <ul>{#each preview.checks as check}<li class:blocked={check.state === 'blocked'}><span class="check" aria-label={check.state}>{check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}</span><div><strong>{check.label}</strong><p>{check.detail}</p></div></li>{/each}</ul>
</section> </section>
@@ -141,9 +141,9 @@
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if} {#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</p> <p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</p>
</form> </form>
<details class="save"><summary>Make this a reusable blueprint</summary><p class="muted">Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</p> <details class="save"><summary>Make this a reusable collection</summary><p class="muted">Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</p>
{#if savedName}<p class="success" role="status">“{savedName}” is ready in your blueprint library.</p>{:else} {#if savedName}<p class="success" role="status">“{savedName}” is ready in your collection library.</p>{:else}
<form onsubmit={event => { event.preventDefault(); void save(); }}><label>Blueprint name<input bind:value={blueprintName} maxlength="80" required placeholder="Friday night Kino" disabled={saving || busy} /></label><label>Description <span class="muted">(optional)</span><input bind:value={description} maxlength="500" placeholder="What makes this setup special?" disabled={saving || busy} /></label><Button type="submit" disabled={saving || busy || !blueprintName.trim()} loading={saving}>Save blueprint</Button></form> <form onsubmit={event => { event.preventDefault(); void save(); }}><label>Collection name<input bind:value={blueprintName} maxlength="80" required placeholder="Friday night Kino" disabled={saving || busy} /></label><label>Description <span class="muted">(optional)</span><input bind:value={description} maxlength="500" placeholder="What makes this setup special?" disabled={saving || busy} /></label><Button type="submit" disabled={saving || busy || !blueprintName.trim()} loading={saving}>Save collection</Button></form>
{/if} {/if}
</details> </details>
{:else if sourceDeploymentId} {:else if sourceDeploymentId}
@@ -152,7 +152,7 @@
<form onsubmit={event => { event.preventDefault(); void review(); }}> <form onsubmit={event => { event.preventDefault(); void review(); }}>
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label> <label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label>
{#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section> {#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
{:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a blueprint for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if} {:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if}
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div> <div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div>
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && !mapSlug)}>Preview launch →</Button></div> <div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && !mapSlug)}>Preview launch →</Button></div>
</form> </form>
@@ -49,7 +49,7 @@
<div class="content"> <div class="content">
<header><h2>Queue fleet removal</h2></header> <header><h2>Queue fleet removal</h2></header>
<p>Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p> <p>Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
<p>Destruction removes containers, server files, and fleet registrations. Saved keys and blueprints are kept.</p> <p>Destruction removes containers, server files, and fleet registrations. Saved keys and collections are kept.</p>
<p>In Provisioning’s Active tab, cancel individual waiting requests or all waiting removals. Once a server begins removal, that removal cannot be cancelled or undone.</p> <p>In Provisioning’s Active tab, cancel individual waiting requests or all waiting removals. Once a server begins removal, that removal cannot be cancelled or undone.</p>
{#if error}<p class="error" role="alert">{error}</p>{/if} {#if error}<p class="error" role="alert">{error}</p>{/if}
{#if servers} {#if servers}
+2 -2
View File
@@ -99,7 +99,7 @@
const tabs = [ const tabs = [
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') }, { href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') },
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) }, { href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
{ href: '/provisioning', label: 'Operations', match: (p: string) => /^\/(provisioning|deployments|campaigns|server-keys)(\/|$)/.test(p) }, { href: '/provisioning', label: 'Operations', match: (p: string) => /^\/(provisioning|deployments|collections|campaigns|server-keys)(\/|$)/.test(p) },
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') }, { href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
{ href: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/settings') }, { href: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/settings') },
{ href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') }, { href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') },
@@ -109,7 +109,7 @@
const secondary = $derived(tabs[1].match($page.url.pathname) const secondary = $derived(tabs[1].match($page.url.pathname)
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }] ? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
: tabs[2].match($page.url.pathname) : tabs[2].match($page.url.pathname)
? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/server-keys', label: 'Keys' }] ? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/server-keys', label: 'Keys' }]
: tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }] : []); : tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }] : []);
</script> </script>
+1 -1
View File
@@ -173,7 +173,7 @@
{/if} {/if}
{#if servers.length === 0} {#if servers.length === 0}
<EmptyState title="Your first server starts here" description="Check your connections and saved keys, then create a server or use a blueprint.">{#snippet actions()}<Button href="/settings">Check system setup</Button><Button href="/provisioning?view=blueprints" variant="ghost">Browse blueprints</Button>{/snippet}</EmptyState> <EmptyState title="Your first server starts here" description="Check your connections and saved keys, then create a server or use a collection.">{#snippet actions()}<Button href="/settings">Check system setup</Button><Button href="/collections" variant="ghost">Browse collections</Button>{/snippet}</EmptyState>
{:else} {:else}
<div class="toolbar"> <div class="toolbar">
<div class="search-row"> <div class="search-row">
+1 -1
View File
@@ -6,7 +6,7 @@ export function GET() { return json({ blueprints: listBlueprints() }); }
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();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a blueprint name and a launch recipe.'); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection name and a launch recipe.');
return json({ blueprint: saveBlueprint(body) }, { status: 201 }); return json({ blueprint: saveBlueprint(body) }, { status: 201 });
} catch (err) { } catch (err) {
return json({ error: (err as Error).message }, { status: 400 }); return json({ error: (err as Error).message }, { status: 400 });
+2 -2
View File
@@ -2,13 +2,13 @@ import { json } from '@sveltejs/kit';
import { deleteBlueprint, updateBlueprint } from '$lib/server/fleet/launch-recipes.js'; import { deleteBlueprint, updateBlueprint } from '$lib/server/fleet/launch-recipes.js';
export function DELETE({ params }: { params: { id: string } }) { export function DELETE({ params }: { params: { id: string } }) {
return deleteBlueprint(params.id) ? json({ ok: true }) : json({ error: 'Blueprint not found.' }, { status: 404 }); return deleteBlueprint(params.id) ? json({ ok: true }) : json({ error: 'Collection not found.' }, { status: 404 });
} }
export async function PUT({ params, request }: { params: { id: string }; request: Request }) { export async function PUT({ params, request }: { params: { id: string }; request: Request }) {
try { try {
const body = await request.json(); const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a blueprint name and map selections.'); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection name and map selections.');
return json({ blueprint: updateBlueprint(params.id, body) }); return json({ blueprint: updateBlueprint(params.id, body) });
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); } } catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
} }
@@ -5,7 +5,7 @@ export async function POST({ request }: { request: Request }) {
try { try {
const body = await request.json(); const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a launch selection.'); 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.'); if (body.blueprintId !== undefined) throw new Error('Collections use target counts. Use /api/deployment-collections/preview to preview only missing servers.');
return json(previewCreation(body)); return json(previewCreation(body));
} catch (err) { } catch (err) {
return json({ error: (err as Error).message }, { status: 400 }); return json({ error: (err as Error).message }, { status: 400 });
+97
View File
@@ -0,0 +1,97 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { confirmAction } from '$ui/confirm.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte';
import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte';
import type { BlueprintSummary } from '$lib/deployment-plan.js';
let blueprintId = $state('');
let builderOpen = $state(false);
let editingBlueprint = $state<BlueprintSummary | null>(null);
let collectionOpen = $state(false);
let blueprints = $state<BlueprintSummary[]>([]);
let blueprintsLoading = $state(true);
let blueprintError = $state('');
let removing = $state('');
let limit = $state(25);
$effect(() => { limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25); });
function saveContext() {
const url = new URL($page.url);
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
onMount(() => { void loadBlueprints(); });
function blueprintSaved(blueprint: BlueprintSummary, launch: boolean) {
blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name));
if (launch) { blueprintId = blueprint.id; collectionOpen = true; }
}
async function loadBlueprints() {
try {
const res = await fetch('/api/blueprints');
if (!res.ok) throw new Error('Could not load your collections.');
blueprints = (await res.json()).blueprints;
blueprintError = '';
} catch (err) { blueprintError = (err as Error).message; }
finally { blueprintsLoading = false; }
}
async function removeBlueprint(id: string) {
if (!await confirmAction({title:'Remove collection?', message:'Existing servers and launch history will be kept.', confirmLabel:'Remove collection', danger:true})) return;
removing = id; blueprintError = '';
try {
const res = await fetch('/api/blueprints/' + encodeURIComponent(id), { method: 'DELETE' });
if (!res.ok) throw new Error('Could not remove collection.');
blueprints = blueprints.filter(b => b.id !== id);
} catch (err) { blueprintError = (err as Error).message; }
finally { removing = ''; }
}
</script>
<svelte:head><title>Collections | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Collections" subtitle="Save your lineup of maps and lobby counts, then preview changes to your fleet." />
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
<div class="ui-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button></div>
<section>
<h2>Your collections <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
{#if blueprintsLoading}<p>Loading collections…</p>
{: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 collection.</p><button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first collection →</button></div>
{:else}
<div class="blueprints">
{#each blueprints.slice(0,limit) as blueprint (blueprint.id)}
<article class="blueprint">
<div class="blueprint-top">
<span class="eyebrow">Server collection</span>
<span class="file-count">{blueprint.totalServers} target server{blueprint.totalServers === 1 ? '' : 's'}</span>
</div>
<h3>{blueprint.name}</h3>
<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>
<div class="blueprint-footer">
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Preview fleet changes →</Button>
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
<button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button>
</div>
</article>
{/each}
</div>
{/if}
</section>
<ListFooter total={blueprints.length} bind:limit onchange={() => saveContext()} label="collections" />
</div>
<style>
.page { display: flex; flex-direction: column; gap: 28px; }
section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
p { margin: 8px 0; }
.error { color: var(--state-danger); }
.blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: 13px; }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: 12px; cursor: pointer; }.blueprint-footer { flex-wrap: wrap; }
.eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: var(--text-xs); color: var(--text-secondary); margin-bottom: 8px; }
.blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: 19px; overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; }
.remove, .blueprint-empty button { background: none; color: var(--accent-primary-hover); border: 0; font: inherit; font-size: 12px; cursor: pointer; }.remove { color: var(--text-secondary); }.blueprint-empty { border: 1px dashed var(--border-strong); border-radius: var(--radius-md); padding: 24px; }.blueprint-empty strong { font-size: 15px; }.blueprint-empty button { padding: 8px 0 0; }
</style>
+5 -78
View File
@@ -4,7 +4,6 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import Tabs from '$ui/components/primitives/Tabs.svelte'; import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte'; import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte'; import ListFooter from '$ui/components/primitives/ListFooter.svelte';
@@ -15,10 +14,8 @@
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte'; import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte';
import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte';
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte'; import HousePartyDialog from '$ui/components/HousePartyDialog.svelte';
import type { BlueprintSummary, CollectionSummary } from '$lib/deployment-plan.js'; import type { CollectionSummary } from '$lib/deployment-plan.js';
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte'; import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
import { provisioningActions, actionLabels, actionCompleted, type ProvisioningAction } from '$lib/provisioning.js'; import { provisioningActions, actionLabels, actionCompleted, type ProvisioningAction } from '$lib/provisioning.js';
@@ -35,17 +32,9 @@
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
let open = $state(false); let open = $state(false);
let blueprintId = $state('');
let builderOpen = $state(false);
let editingBlueprint = $state<BlueprintSummary | null>(null);
let collectionOpen = $state(false);
let housePartyOpen = $state(false); let housePartyOpen = $state(false);
let housePartyNotice = $state(''); let housePartyNotice = $state('');
let collections = $state<CollectionSummary[]>([]); let collections = $state<CollectionSummary[]>([]);
let blueprints = $state<BlueprintSummary[]>([]);
let blueprintsLoading = $state(true);
let blueprintError = $state('');
let removing = $state('');
let tab = $state('active'); let tab = $state('active');
let limit = $state(25); let limit = $state(25);
let collectionLimit = $state(10); let collectionLimit = $state(10);
@@ -54,7 +43,7 @@
let cancellationNotice = $state(''); let cancellationNotice = $state('');
$effect(() => { $effect(() => {
const params = $page.url.searchParams; const params = $page.url.searchParams;
tab = ['active', 'blueprints', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'active'; tab = ['active', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'active';
action = provisioningActions.some(value => value === params.get('action')) ? params.get('action')! : 'all'; action = provisioningActions.some(value => value === params.get('action')) ? params.get('action')! : 'all';
historyState = params.get('status') === 'attention' ? 'attention' : 'all'; historyState = params.get('status') === 'attention' ? 'attention' : 'all';
search = params.get('server') ?? ''; search = params.get('server') ?? '';
@@ -79,8 +68,7 @@
cancelling = false; await load(); cancelling = false; await load();
} }
const visibleCollections = $derived(collections.filter(c => tab === 'active' ? c.active > 0 : c.active === 0)); const visibleCollections = $derived(collections.filter(c => tab === 'active' ? c.active > 0 : c.active === 0));
const tabs = $derived.by(() => [{id:'active',label:'Active',badge:active.length},{id:'blueprints',label:'Blueprints',badge:blueprintsLoading ? null : blueprints.length},{id:'history',label:'History'}]); const tabs = $derived.by(() => [{id:'active',label:'Active',badge:active.length},{id:'history',label:'History'}]);
$effect(() => { if (tab === 'blueprints') void loadBlueprints(); });
let action = $state('all'); let action = $state('all');
let search = $state(''); let search = $state('');
let refreshing = false; let refreshing = false;
@@ -102,30 +90,7 @@
} catch (err) { error = (err as Error).message; } } catch (err) { error = (err as Error).message; }
finally { loading = false; refreshing = false; } finally { loading = false; refreshing = false; }
} }
function blueprintSaved(blueprint: BlueprintSummary, launch: boolean) {
blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name));
if (launch) { blueprintId = blueprint.id; collectionOpen = true; }
}
onMount(() => pollVisible(load, 5000)); onMount(() => pollVisible(load, 5000));
async function loadBlueprints() {
try {
const res = await fetch('/api/blueprints');
if (!res.ok) throw new Error('Could not load your blueprints.');
blueprints = (await res.json()).blueprints;
blueprintError = '';
} catch (err) { blueprintError = (err as Error).message; }
finally { blueprintsLoading = false; }
}
async function removeBlueprint(id: string) {
if (!await confirmAction({title:'Remove blueprint?', message:'Existing servers and launch history will be kept.', confirmLabel:'Remove blueprint', danger:true})) return;
removing = id; blueprintError = '';
try {
const res = await fetch('/api/blueprints/' + encodeURIComponent(id), { method: 'DELETE' });
if (!res.ok) throw new Error('Could not remove blueprint.');
blueprints = blueprints.filter(b => b.id !== id);
} catch (err) { blueprintError = (err as Error).message; }
finally { removing = ''; }
}
function fmt(value: string) { return $timeFormat.dateTime(value); } function fmt(value: string) { return $timeFormat.dateTime(value); }
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } } function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
</script> </script>
@@ -137,50 +102,18 @@
</SectionHeader> </SectionHeader>
<Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" /> <Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" />
<LifecycleDialog bind:open={lifecycleOpen} /> <LifecycleDialog bind:open={lifecycleOpen} />
<DeployServerDialog bind:open onblueprint={loadBlueprints} /> <DeployServerDialog bind:open />
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in the Active tab.`; tab = 'active'; void load(); }} /> <HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in the Active tab.`; tab = 'active'; void load(); }} />
{#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if} {#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if}
{#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if} {#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
<div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels"> <div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels">
{#if tab === 'blueprints'}
<div class="ui-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create blueprint</Button></div>
<section>
<h2>Your blueprints <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
{#if blueprintsLoading}<p>Loading blueprints…</p>
{:else if !blueprints.length}<div class="blueprint-empty"><strong>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.slice(0,limit) as blueprint (blueprint.id)}
<article class="blueprint">
<div class="blueprint-top">
<span class="eyebrow">Server collection</span>
<span class="file-count">{blueprint.totalServers} target server{blueprint.totalServers === 1 ? '' : 's'}</span>
</div>
<h3>{blueprint.name}</h3>
<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>
<div class="blueprint-footer">
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Preview fleet changes →</Button>
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
<button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button>
</div>
</article>
{/each}
</div>
{/if}
</section>
<ListFooter total={blueprints.length} bind:limit onchange={() => saveContext()} label="blueprints" />
{:else}
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if} {#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
{#if visibleCollections.length} {#if visibleCollections.length}
<section> <section>
<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="/provisioning/collections/{collection.id}"> <a class="deployment-row collection-row" href="/provisioning/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 ? collection.mode === 'exact' ? 'Applying blueprint' : '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 collection' : 'Launching' : 'Queued' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></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-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 progress' : '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>
@@ -211,7 +144,6 @@
<ListFooter total={history.length} bind:limit onchange={() => saveContext()} label="operations" /> <ListFooter total={history.length} bind:limit onchange={() => saveContext()} label="operations" />
</section> </section>
{/if} {/if}
{/if}
</div> </div>
</div> </div>
@@ -237,11 +169,6 @@
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } .row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.row-head { justify-content: space-between; } .row-head { justify-content: space-between; }
.blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: 13px; }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: 12px; cursor: pointer; }.blueprint-footer { flex-wrap: wrap; }
.eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: var(--text-xs); color: var(--text-secondary); margin-bottom: 8px; }
.blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: 19px; overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; }
.remove, .blueprint-empty button { background: none; color: var(--accent-primary-hover); border: 0; font: inherit; font-size: 12px; cursor: pointer; }.remove { color: var(--text-secondary); }.blueprint-empty { border: 1px dashed var(--border-strong); border-radius: var(--radius-md); padding: 24px; }.blueprint-empty strong { font-size: 15px; }.blueprint-empty button { padding: 8px 0 0; }
section { display: grid; gap: 16px; } section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; } h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
p { margin: 8px 0; } a { color: var(--accent); } p { margin: 8px 0; } a { color: var(--accent); }
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = ({ url }) => {
if (url.searchParams.get('view') === 'blueprints') {
const params = new URLSearchParams();
const limit = url.searchParams.get('limit');
if (limit) params.set('limit', limit);
redirect(307, `/collections${params.size ? `?${params}` : ''}`);
}
};
@@ -59,7 +59,7 @@
{#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>{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> <div><span class="eyebrow">Collection progress</span><h2>{exact ? collection.active ? 'Applying your collection' : collection.failed ? 'Collection finished with issues' : 'Collection 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 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`}> <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`}>