@@ -62,7 +62,7 @@ function collectionPlan(entries: BlueprintEntry[], mode: CollectionMode) {
|
||||
const retained = new Set(retainedServers);
|
||||
const matching = new Set(counts.flatMap(entry => entry.existingServers));
|
||||
const removals = mode === 'exact' ? housePartyTargets().filter(name => !retained.has(name)).map(name => ({ name,
|
||||
reason: matching.has(name) ? 'Exceeds the target lobby count' : 'Outside this blueprint or already awaiting removal'
|
||||
reason: matching.has(name) ? 'Exceeds the target lobby count' : 'Outside this collection or already awaiting removal'
|
||||
})) : [];
|
||||
return { counts, retainedServers, removals };
|
||||
}
|
||||
@@ -93,8 +93,8 @@ function blockersFor(servers: CollectionPreview['servers']): string[] {
|
||||
}
|
||||
|
||||
export function previewCollection(blueprintId: unknown, mode: unknown = 'fill'): CollectionPreview {
|
||||
if (typeof blueprintId !== 'string' || !blueprintId) throw new Error('Choose a blueprint to launch.');
|
||||
if (mode !== 'fill' && mode !== 'exact') throw new Error('Choose fill or exact blueprint mode.');
|
||||
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 collection mode.');
|
||||
return db().transaction((): CollectionPreview => {
|
||||
const { entries, blueprintName } = blueprintEntries(blueprintId);
|
||||
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');
|
||||
if (!result.ok || !result.deploymentId) throw new Error('Could not queue removal for ' + server.name);
|
||||
id = result.deploymentId;
|
||||
db().prepare("UPDATE server_deployments SET state = 'cancelled', stage = 'Cancelled by exact blueprint', finished_at = ? WHERE id = ?").run(new Date().toISOString(), queued.id);
|
||||
recordDeploymentEvent(queued.id, 'Queued creation cancelled by exact blueprint; saved key retained.');
|
||||
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 collection; saved key retained.');
|
||||
deploymentStage(id, 'Cancelled queued creation; no server was started');
|
||||
finishDeployment(id, { ok: true });
|
||||
} else {
|
||||
|
||||
@@ -43,7 +43,7 @@ function summarizeBlueprint(row: BlueprintRow): BlueprintSummary {
|
||||
|
||||
export function blueprintEntries(id: string): { entries: BlueprintEntry[]; blueprintName: string } {
|
||||
const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined;
|
||||
if (!row) throw new Error('This blueprint is no longer available. Choose another starting point.');
|
||||
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) }));
|
||||
return { entries, blueprintName: row.name };
|
||||
}
|
||||
@@ -60,7 +60,7 @@ function buildEntries(input: unknown): BlueprintSelection[] {
|
||||
seen.add(key);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export function recipeForDeployment(id: string): { recipe: LaunchRecipe; bluepri
|
||||
|
||||
export function blueprintRecipe(id: string): { recipe: LaunchRecipe; blueprintName: string } {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -112,9 +112,9 @@ export function listBlueprints(): BlueprintSummary[] {
|
||||
}
|
||||
|
||||
function blueprintName(input: { name: unknown; description?: unknown }, exceptId = ''): string {
|
||||
if (typeof input.name !== 'string' || !input.name.trim() || input.name.trim().length > 80) throw new Error('Give the blueprint a name of 1–80 characters.');
|
||||
if (typeof input.name !== 'string' || !input.name.trim() || input.name.trim().length > 80) throw new Error('Give the 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 (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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
return db().transaction(() => {
|
||||
const row = db().prepare('SELECT * FROM server_blueprints WHERE id = ?').get(id) as BlueprintRow | undefined;
|
||||
if (!row) throw new Error('Blueprint not found.');
|
||||
if (!row) throw new Error('Collection not found.');
|
||||
const name = blueprintName(input, id);
|
||||
const entries = buildEntries(input.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));
|
||||
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 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 wasOpen = false;
|
||||
let session = 0;
|
||||
@@ -60,22 +60,22 @@
|
||||
method: blueprint ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description, entries: rows.map(({ game, mapSlug, count }) => ({ game, mapSlug, count })) }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not save blueprint.');
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not save collection.');
|
||||
open = false; onsaved(body.blueprint, launch);
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-label="Blueprint builder" onclick={event => { if (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">
|
||||
<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 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>
|
||||
<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 collection.</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if !loaded}<p class="muted">Loading maps…</p>{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
||||
{:else}
|
||||
<form onsubmit={event => { event.preventDefault(); void save(true); }}>
|
||||
<div class="identity"><label>Blueprint name<input bind:value={name} maxlength="80" placeholder="Friday Zombies" required disabled={busy} /></label><label>Description (optional)<input bind:value={description} maxlength="500" placeholder="Our weekend lineup" disabled={busy} /></label></div>
|
||||
<div class="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="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)}
|
||||
@@ -89,9 +89,9 @@
|
||||
{/each}
|
||||
<Button disabled={busy || !canAdd} onclick={addMap}>+ Add another map</Button>
|
||||
</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}
|
||||
<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>
|
||||
{/if}
|
||||
</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}>
|
||||
<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>
|
||||
<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(); }}>
|
||||
<option value="fill">Fill missing servers</option>
|
||||
<option value="exact">Match blueprint exactly</option>
|
||||
<option value="exact">Match collection exactly</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this blueprint across both games.' : 'Create missing lobbies and keep all existing servers.'}</p>
|
||||
<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 !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}
|
||||
{#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}
|
||||
<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>
|
||||
<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}
|
||||
@@ -102,7 +102,7 @@
|
||||
<h3>Remove {preview.removals.length} server{preview.removals.length === 1 ? '' : 's'}</h3>
|
||||
<p class="muted">Containers, files, and registrations will be removed after a fresh zero-player check. Busy or unreachable servers wait. Unstarted creations are cancelled. Saved keys are kept. New servers reusing these ports wait for removal to finish.</p>
|
||||
<ul>{#each preview.removals as server}<li><strong><ServerIdentity name={server.name} /></strong><span class="server-id">{server.name}</span><span class="muted">{server.reason}</span></li>{/each}</ul>
|
||||
<label class="confirm-removals"><input type="checkbox" bind:checked={confirmRemovals} disabled={busy || preview.mode !== mode} />Remove the {preview.removals.length} listed servers and apply this blueprint.</label>
|
||||
<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>
|
||||
{/if}
|
||||
{#if preview.servers.length}
|
||||
@@ -121,7 +121,7 @@
|
||||
{/if}
|
||||
<div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Key validity is checked at startup. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div>
|
||||
</form>
|
||||
{: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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -52,13 +52,13 @@
|
||||
mapSlug = initialMapSlug;
|
||||
try {
|
||||
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()]);
|
||||
if (current !== session) return;
|
||||
catalogs = { t5: mapBody.t5Zombie, t6: mapBody.t6Zombie };
|
||||
blueprints = blueprintBody.blueprints;
|
||||
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;
|
||||
if (sourceDeploymentId) await review();
|
||||
} 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' },
|
||||
body: JSON.stringify({ name: blueprintName, description, previewId: preview.id }) });
|
||||
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?.(); }
|
||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||
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>
|
||||
{#if error}<div class="error" role="alert">{error}</div>{/if}
|
||||
{#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}
|
||||
{:else if preview}
|
||||
{#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}
|
||||
<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>
|
||||
<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>
|
||||
@@ -141,9 +141,9 @@
|
||||
{#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>
|
||||
</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>
|
||||
{#if savedName}<p class="success" role="status">“{savedName}” is ready in your blueprint 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>
|
||||
<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 collection library.</p>{:else}
|
||||
<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}
|
||||
</details>
|
||||
{:else if sourceDeploymentId}
|
||||
@@ -152,7 +152,7 @@
|
||||
<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>
|
||||
{#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="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>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<div class="content">
|
||||
<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>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>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if servers}
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
const tabs = [
|
||||
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') },
|
||||
{ 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: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/settings') },
|
||||
{ href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') },
|
||||
@@ -109,7 +109,7 @@
|
||||
const secondary = $derived(tabs[1].match($page.url.pathname)
|
||||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
|
||||
: 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' }] : []);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
{/if}
|
||||
|
||||
{#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}
|
||||
<div class="toolbar">
|
||||
<div class="search-row">
|
||||
|
||||
@@ -6,7 +6,7 @@ export function GET() { return json({ blueprints: listBlueprints() }); }
|
||||
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 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 });
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
|
||||
@@ -2,13 +2,13 @@ import { json } from '@sveltejs/kit';
|
||||
import { deleteBlueprint, updateBlueprint } from '$lib/server/fleet/launch-recipes.js';
|
||||
|
||||
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 }) {
|
||||
try {
|
||||
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) });
|
||||
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
|
||||
}
|
||||
|
||||
@@ -5,7 +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.');
|
||||
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));
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
|
||||
@@ -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>
|
||||
@@ -4,7 +4,6 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
@@ -15,10 +14,8 @@
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.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 type { BlueprintSummary, CollectionSummary } from '$lib/deployment-plan.js';
|
||||
import type { CollectionSummary } from '$lib/deployment-plan.js';
|
||||
|
||||
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
|
||||
import { provisioningActions, actionLabels, actionCompleted, type ProvisioningAction } from '$lib/provisioning.js';
|
||||
@@ -35,17 +32,9 @@
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
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 housePartyNotice = $state('');
|
||||
let collections = $state<CollectionSummary[]>([]);
|
||||
let blueprints = $state<BlueprintSummary[]>([]);
|
||||
let blueprintsLoading = $state(true);
|
||||
let blueprintError = $state('');
|
||||
let removing = $state('');
|
||||
let tab = $state('active');
|
||||
let limit = $state(25);
|
||||
let collectionLimit = $state(10);
|
||||
@@ -54,7 +43,7 @@
|
||||
let cancellationNotice = $state('');
|
||||
$effect(() => {
|
||||
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';
|
||||
historyState = params.get('status') === 'attention' ? 'attention' : 'all';
|
||||
search = params.get('server') ?? '';
|
||||
@@ -79,8 +68,7 @@
|
||||
cancelling = false; await load();
|
||||
}
|
||||
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'}]);
|
||||
$effect(() => { if (tab === 'blueprints') void loadBlueprints(); });
|
||||
const tabs = $derived.by(() => [{id:'active',label:'Active',badge:active.length},{id:'history',label:'History'}]);
|
||||
let action = $state('all');
|
||||
let search = $state('');
|
||||
let refreshing = false;
|
||||
@@ -102,30 +90,7 @@
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
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));
|
||||
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 warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
|
||||
</script>
|
||||
@@ -137,50 +102,18 @@
|
||||
</SectionHeader>
|
||||
<Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" />
|
||||
<LifecycleDialog bind:open={lifecycleOpen} />
|
||||
<DeployServerDialog bind:open onblueprint={loadBlueprints} />
|
||||
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
|
||||
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
|
||||
<DeployServerDialog bind:open />
|
||||
<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 cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
|
||||
<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 visibleCollections.length}
|
||||
<section>
|
||||
<h2>Collection launches</h2>
|
||||
<div class="rows">{#each visibleCollections.slice(0,collectionLimit) as collection (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-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>
|
||||
@@ -211,7 +144,6 @@
|
||||
<ListFooter total={history.length} bind:limit onchange={() => saveContext()} label="operations" />
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -237,11 +169,6 @@
|
||||
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.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; }
|
||||
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
|
||||
p { margin: 8px 0; } a { color: var(--accent); }
|
||||
|
||||
@@ -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}
|
||||
<section class="summary" aria-label="Collection progress">
|
||||
<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>
|
||||
</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`}>
|
||||
|
||||
Reference in New Issue
Block a user