Compare commits

...
3 Commits
17 changed files with 190 additions and 66 deletions
+10 -3
View File
@@ -171,7 +171,7 @@ game-status, 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 collections and launch previews ## Collections, provisioning, 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
@@ -189,7 +189,12 @@ repeating Komodo deletion. Requesting destruction again also resumes that cleanu
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.
**Operations → Collections** includes a builder for whole server lineups: 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. Collections also
has **Active launches** and **Launch history** tabs, with progress at
`/collections/launches/:id`.
Each server operation remains in Provisioning and links back to its collection
launch. Existing collection launch bookmarks redirect to the new location.
**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.
@@ -230,7 +235,9 @@ During deployment, choose the map. The lowest available lobby is assigned
automatically, with the exact server ID shown before deployment. Its saved key is reused automatically, automatically, with the exact server ID shown before deployment. Its saved key is reused automatically,
and any newly entered key is saved for that ID, including if deployment fails. and any newly entered key is saved for that ID, including if deployment fails.
Destroying a server preserves its saved key. Updating or removing a saved key Destroying a server preserves its saved key. Updating or removing a saved key
does not change a running server; changes apply to future deployments. does not change an existing server; changes apply when that server ID is next
created or rebuilt. Electric Cherry does not check whether Plutonium accepts a
saved key. **Last saved** shows when the key was saved, not a validation result.
Keys are stored in the application's SQLite database at `DB_PATH`, independently Keys are stored in the application's SQLite database at `DB_PATH`, independently
of server records. Preserve that database across container replacements. Key of server records. Preserve that database across container replacements. Key
+11 -4
View File
@@ -1,6 +1,8 @@
# Deployment collections # Deployment collections
Open **Operations → Collections** to manage reusable server lineups. A Open **Operations → Collections** to manage reusable server lineups. Use
**Active launches** to follow progress and **Launch history** to review completed
launches and failures. Both views refresh automatically. A
collection 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.
@@ -105,11 +107,13 @@ A lost startup response also keeps the slot because the container may already be
running. A confirmed creation failure is recorded for review, allowing later running. A confirmed creation failure is recorded for review, allowing later
queued servers to proceed on subsequent passes without undoing successful launches. queued servers to proceed on subsequent passes without undoing successful launches.
The collection page separates ready, launching, queued, and failed servers. It The collection launch page at `/collections/launches/:id` separates ready,
launching, queued, and failed servers. Its breadcrumb returns to Collections. It
highlights the current startup, shows each waiting server's queue position, and highlights the current startup, shows each waiting server's queue position, and
links to earlier launches when another collection occupies the available capacity. links to earlier launches when another collection occupies the available capacity.
Every server links to its operation timeline and recovery options, and each child Every server links to its Provisioning operation timeline and recovery options,
operation links back to its collection. and each child operation links back to its collection launch. Individual operations
remain visible in Provisioning activity and history.
## Create and review a launch ## Create and review a launch
@@ -204,6 +208,9 @@ repeats container creation or deployment as part of this recovery.
Collections retain the existing `/api/blueprints` endpoints and `blueprintId` / Collections retain the existing `/api/blueprints` endpoints and `blueprintId` /
`blueprintName` fields for compatibility. Saved data needs no migration. Old `blueprintName` fields for compatibility. Saved data needs no migration. Old
`/provisioning?view=blueprints` links redirect to `/collections`. `/provisioning?view=blueprints` links redirect to `/collections`.
Collection launch details live at `/collections/launches/:id`; old
`/provisioning/collections/:id` and `/deployments/collections/:id` links redirect
there while preserving query parameters.
- `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
+9 -4
View File
@@ -1,7 +1,9 @@
# Provisioning # Provisioning
Open **Operations → Provisioning** for instance lifecycle work, blueprints, and Open **Operations → Provisioning** for instance lifecycle work and persistent
persistent activity. Provisioning owns the queues, safety checks, execution, and activity. **Operations → Collections** owns reusable lineups and collection launch
progress; their individual operations remain visible in Provisioning.
Provisioning owns the queues, safety checks, execution, and
recovery for all six actions. Create a server using **Create server**. For an existing recovery for all six actions. Create a server using **Create server**. For an existing
server, choose **Manage server** or open its **Server actions** menu. server, choose **Manage server** or open its **Server actions** menu.
@@ -75,9 +77,10 @@ requester closes. Upgrades adopt existing restart intent for observation only.
## Routes and compatibility ## Routes and compatibility
- `/provisioning`: active operations, blueprints, and history filtered by action. - `/provisioning`: active operations and history filtered by action.
- `/provisioning/:id`: operation stages, errors, timestamps, and activity. - `/provisioning/:id`: operation stages, errors, timestamps, and activity.
- `/provisioning/collections/:id`: collection progress. - `/collections`: saved lineups, active launches, and launch history.
- `/collections/launches/:id`: collection progress, with links to each operation.
- `GET /api/provisioning`: lifecycle history (`deployments` response property). - `GET /api/provisioning`: lifecycle history (`deployments` response property).
- `POST /api/provisioning`: queue `{ "server": "t5_zombie_theater_1", "action": "start" }`. - `POST /api/provisioning`: queue `{ "server": "t5_zombie_theater_1", "action": "start" }`.
Supported actions are `start`, `restart`, `rebuild`, and `stop`. Returns HTTP 202 Supported actions are `start`, `restart`, `rebuild`, and `stop`. Returns HTTP 202
@@ -88,6 +91,8 @@ requester closes. Upgrades adopt existing restart intent for observation only.
launch preview, and fleet destruction APIs. launch preview, and fleet destruction APIs.
Old `/deployments` pages redirect to their `/provisioning` equivalents, preserving Old `/deployments` pages redirect to their `/provisioning` equivalents, preserving
query parameters. Collection launch links at `/provisioning/collections/:id` or
`/deployments/collections/:id` redirect to `/collections/launches/:id` and preserve
query parameters. `/api/deployments` remains compatible, including old campaign query parameters. `/api/deployments` remains compatible, including old campaign
detail endpoints. No history or IDs are renamed or discarded. detail endpoints. No history or IDs are renamed or discarded.
+6 -4
View File
@@ -4,6 +4,7 @@ import { komodoInspectHealthy, komodoRestart, komodoRuntimeAction } from '../int
import { probeServerStatus } from '../fleet/status-evidence.js'; import { probeServerStatus } from '../fleet/status-evidence.js';
import { deploymentStage, finishDeployment } from './history.js'; import { deploymentStage, finishDeployment } from './history.js';
import { applyStagedFilesForOperation } from '../fleet/staged-files.js'; import { applyStagedFilesForOperation } from '../fleet/staged-files.js';
import type { RuntimeAction } from '../../provisioning.js';
// Komodo queues restart work. Don't mistake the still-running old container // Komodo queues restart work. Don't mistake the still-running old container
// for a completed restart when an API kick immediately follows the request. // for a completed restart when an API kick immediately follows the request.
@@ -36,7 +37,7 @@ export function restartSettling(server: string): boolean {
&& Date.now() - Date.parse(slot.requested_at) < HEALTH_POLL_MS); && Date.now() - Date.parse(slot.requested_at) < HEALTH_POLL_MS);
} }
export function rolloutWaitReason(server: string, operationId = ''): string | null { export function rolloutWaitReason(server: string, operationId = '', action: RuntimeAction = 'restart'): string | null {
if (db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL if (db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL
AND ((action IN ('start','stop','rebuild') AND state IN ('running','awaiting-health')) AND ((action IN ('start','stop','rebuild') AND state IN ('running','awaiting-health'))
OR (action = 'destroy' AND state IN ('waiting','running')))`).get(server, operationId)) { OR (action = 'destroy' AND state IN ('waiting','running')))`).get(server, operationId)) {
@@ -47,10 +48,11 @@ export function rolloutWaitReason(server: string, operationId = ''): string | nu
WHERE d.id = ? AND d.server_deleted_at IS NULL AND d.action IN ('restart','rebuild') AND EXISTS ( WHERE d.id = ? AND d.server_deleted_at IS NULL AND d.action IN ('restart','rebuild') AND EXISTS (
SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind = 'watchdog')`).get(operationId); SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind = 'watchdog')`).get(operationId);
if (slot?.healthy_at === null && !ownHandoff) { if (slot?.healthy_at === null && !ownHandoff) {
return `Waiting for the previous restart of this server to recover${slot.error ? `: ${slot.error}` : ''}`; return `Waiting for this server to recover from its previous operation${slot.error ? `: ${slot.error}` : ''}`;
} }
const { n } = db().prepare('SELECT COUNT(*) AS n FROM restart_rollout WHERE healthy_at IS NULL').get() as { n: number }; const { n } = db().prepare('SELECT COUNT(*) AS n FROM restart_rollout WHERE healthy_at IS NULL').get() as { n: number };
return n - (ownHandoff && slot?.healthy_at === null ? 1 : 0) >= fleetSettings().restartConcurrency ? 'Queued for a rolling restart; waiting for another server to recover' : null; return n - (ownHandoff && slot?.healthy_at === null ? 1 : 0) >= fleetSettings().restartConcurrency
? `Queued to ${action}; waiting for another server to recover` : null;
} }
export type RolloutRestartResult = export type RolloutRestartResult =
@@ -63,7 +65,7 @@ export async function restartInRollout(server: string, options: { operationId: s
const operationId = options.operationId; const operationId = options.operationId;
const waitReason = db().transaction(() => { const waitReason = db().transaction(() => {
if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL').get(operationId)) return 'Original server was deleted'; if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL').get(operationId)) return 'Original server was deleted';
const reason = rolloutWaitReason(server, operationId); const reason = rolloutWaitReason(server, operationId, options.action);
if (reason) return reason; if (reason) return reason;
db().prepare(`INSERT INTO restart_rollout (server, requested_at, operation_id) VALUES (?, ?, ?) db().prepare(`INSERT INTO restart_rollout (server, requested_at, operation_id) VALUES (?, ?, ?)
ON CONFLICT(server) DO UPDATE SET requested_at = excluded.requested_at, ON CONFLICT(server) DO UPDATE SET requested_at = excluded.requested_at,
+4 -4
View File
@@ -57,7 +57,7 @@ function sourceEligibility(operation: Operation): 'ready' | 'held' | 'cancelled'
return held ? 'held' : ready ? 'ready' : 'cancelled'; return held ? 'held' : ready ? 'ready' : 'cancelled';
} }
function blocker(server: string, id: string): string | null { function blocker(server: string, id: string, action: RuntimeAction): string | null {
const sources = sourcesFor(id); const sources = sourcesFor(id);
const rank = priority(sources); const rank = priority(sources);
const others = db().prepare(`SELECT id, state, action, rowid FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL const others = db().prepare(`SELECT id, state, action, rowid FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL
@@ -76,7 +76,7 @@ function blocker(server: string, id: string): string | null {
SELECT 1 FROM server_deployments d WHERE d.id = ds.provisioning_id AND d.state IN ('queued','waiting','running','awaiting-health'))) SELECT 1 FROM server_deployments d WHERE d.id = ds.provisioning_id AND d.state IN ('queued','waiting','running','awaiting-health')))
AND NOT EXISTS (SELECT 1 FROM watchdog_actions a WHERE a.operation_id = ? AND a.campaign_id = ds.deployment_id) AND NOT EXISTS (SELECT 1 FROM watchdog_actions a WHERE a.operation_id = ? AND a.campaign_id = ds.deployment_id)
LIMIT 1`).get(server, id)) return 'Waiting for campaign work on this server'; LIMIT 1`).get(server, id)) return 'Waiting for campaign work on this server';
return rolloutWaitReason(server, id); return rolloutWaitReason(server, id, action);
} }
interface Operation { id: string; server: string; action: RuntimeAction; state: string; stage: string } interface Operation { id: string; server: string; action: RuntimeAction; state: string; stage: string }
@@ -128,7 +128,7 @@ async function processOperation(operation: Operation): Promise<void> {
} else deploymentStage(id, 'Waiting for container to stop' + (!health.ok ? ': ' + health.reason : '')); } else deploymentStage(id, 'Waiting for container to stop' + (!health.ok ? ': ' + health.reason : ''));
return; return;
} }
const reason = blocker(server, id); const reason = blocker(server, id, action);
if (reason) { deploymentStage(id, reason); return; } if (reason) { deploymentStage(id, reason); return; }
const health = await komodoInspectHealthy(server); const health = await komodoInspectHealthy(server);
if (action !== 'start' && !isStopped(health.state)) { if (action !== 'start' && !isStopped(health.state)) {
@@ -140,7 +140,7 @@ async function processOperation(operation: Operation): Promise<void> {
} }
} }
// Requests can arrive during the probes. Recheck before claiming the action. // Requests can arrive during the probes. Recheck before claiming the action.
const latestReason = blocker(server, id); const latestReason = blocker(server, id, action);
if (latestReason) { deploymentStage(id, latestReason); return; } if (latestReason) { deploymentStage(id, latestReason); return; }
if (sourceEligibility(operation) !== 'ready') return; if (sourceEligibility(operation) !== 'ready') return;
if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND state = 'waiting'").get(id)) return; if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND state = 'waiting'").get(id)) return;
@@ -60,7 +60,7 @@
const body = await res.json(); const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.'); if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.');
open = false; open = false;
if (body.collectionId) await goto(`/provisioning/collections/${encodeURIComponent(body.collectionId)}`); if (body.collectionId) await goto(`/collections/launches/${encodeURIComponent(body.collectionId)}`);
} catch (err) { error = (err as Error).message; } } catch (err) { error = (err as Error).message; }
finally { busy = false; } finally { busy = false; }
} }
@@ -136,7 +136,7 @@
<div class="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>{preview.port ? `Port ${preview.port} · ` : ''}Fresh identity, with its own server key.</p></div> <div class="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>{preview.port ? `Port ${preview.port} · ` : ''}Fresh identity, with its own server key.</p></div>
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p> {#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
{:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if} {:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if}
<p class="muted">{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Its validity is checked when the game starts. Update it in Keys.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p> <p class="muted">{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Electric Cherry does not check whether Plutonium accepts it. Update it in Keys.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p>
<div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div> <div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
{#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>
+1 -1
View File
@@ -5,7 +5,7 @@ const states: Record<string, [string, StatusTone]> = {
queued: ['Queued', 'neutral'], pending: ['Queued for restart', 'neutral'], running: ['In progress', 'info'], active: ['In progress', 'info'], queued: ['Queued', 'neutral'], pending: ['Queued for restart', 'neutral'], running: ['In progress', 'info'], active: ['In progress', 'info'],
deploying: ['Installing files', 'info'], restarting: ['Restarting', 'info'], deploying: ['Installing files', 'info'], restarting: ['Restarting', 'info'],
observing: ['Collecting test evidence', 'info'], 'rolling-back': ['Rolling back', 'info'], observing: ['Collecting test evidence', 'info'], 'rolling-back': ['Rolling back', 'info'],
'awaiting-empty': ['Waiting for empty lobby', 'neutral'], waiting: ['Waiting for empty lobby', 'neutral'], 'awaiting-empty': ['Waiting for empty lobby', 'neutral'], waiting: ['Waiting', 'neutral'],
'awaiting-health': ['Verifying health', 'info'], 'awaiting-match': ['Waiting for a match', 'neutral'], 'awaiting-health': ['Verifying health', 'info'], 'awaiting-match': ['Waiting for a match', 'neutral'],
ready: ['Ready', 'online'], 'ready-promote': ['Ready to promote', 'online'], ready: ['Ready', 'online'], 'ready-promote': ['Ready to promote', 'online'],
done: ['Completed', 'online'], passed: ['Test passed', 'online'], tested: ['Test passed', 'online'], done: ['Completed', 'online'], passed: ['Test passed', 'online'], tested: ['Test passed', 'online'],
+82 -5
View File
@@ -3,12 +3,17 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte'; import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte'; import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte';
import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte'; import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte';
import type { BlueprintSummary } from '$lib/deployment-plan.js'; import type { BlueprintSummary, CollectionSummary } from '$lib/deployment-plan.js';
let blueprintId = $state(''); let blueprintId = $state('');
let builderOpen = $state(false); let builderOpen = $state(false);
let editingBlueprint = $state<BlueprintSummary | null>(null); let editingBlueprint = $state<BlueprintSummary | null>(null);
@@ -17,14 +22,38 @@
let blueprintsLoading = $state(true); let blueprintsLoading = $state(true);
let blueprintError = $state(''); let blueprintError = $state('');
let removing = $state(''); let removing = $state('');
let launches = $state<CollectionSummary[]>([]);
let launchesLoading = $state(true);
let launchError = $state('');
let launchesRefreshing = false;
let tab = $state('collections');
let limit = $state(25); let limit = $state(25);
$effect(() => { limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25); }); let launchLimit = $state(10);
const activeLaunches = $derived(launches.filter(launch => launch.active > 0));
const launchHistory = $derived(launches.filter(launch => launch.active === 0));
const visibleLaunches = $derived(tab === 'active' ? activeLaunches : launchHistory);
const tabs = $derived([
{ id: 'collections', label: 'Your collections' },
{ id: 'active', label: 'Active launches', badge: launchesLoading ? undefined : activeLaunches.length },
{ id: 'history', label: 'Launch history' }
]);
$effect(() => {
const params = $page.url.searchParams;
tab = ['active', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'collections';
limit = Math.max(25, Number(params.get('limit')) || 25);
launchLimit = Math.max(10, Number(params.get('launches')) || 10);
});
function saveContext() { function saveContext() {
const url = new URL($page.url); const url = new URL($page.url);
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit'); if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
if (tab !== 'collections') url.searchParams.set('view', tab); else url.searchParams.delete('view');
if (launchLimit > 10) url.searchParams.set('launches', String(launchLimit)); else url.searchParams.delete('launches');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true }); void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
} }
onMount(() => { void loadBlueprints(); }); onMount(() => {
void loadBlueprints();
return pollVisible(loadLaunches, 5000);
});
function blueprintSaved(blueprint: BlueprintSummary, launch: boolean) { function blueprintSaved(blueprint: BlueprintSummary, launch: boolean) {
blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name)); blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name));
if (launch) { blueprintId = blueprint.id; collectionOpen = true; } if (launch) { blueprintId = blueprint.id; collectionOpen = true; }
@@ -38,6 +67,17 @@
} catch (err) { blueprintError = (err as Error).message; } } catch (err) { blueprintError = (err as Error).message; }
finally { blueprintsLoading = false; } finally { blueprintsLoading = false; }
} }
async function loadLaunches() {
if (launchesRefreshing) return;
launchesRefreshing = true;
try {
const res = await fetch('/api/deployment-collections');
if (!res.ok) throw new Error('Could not load collection launches.');
launches = (await res.json()).collections;
launchError = '';
} catch (err) { launchError = (err as Error).message; }
finally { launchesLoading = false; launchesRefreshing = false; }
}
async function removeBlueprint(id: string) { 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; if (!await confirmAction({title:'Remove collection?', message:'Existing servers and launch history will be kept.', confirmLabel:'Remove collection', danger:true})) return;
removing = id; blueprintError = ''; removing = id; blueprintError = '';
@@ -51,10 +91,14 @@
</script> </script>
<svelte:head><title>Collections | Electric Cherry</title></svelte:head> <svelte:head><title>Collections | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="Collections" subtitle="Save your lineup of maps and lobby counts, then preview changes to your fleet." /> <SectionHeader title="Collections" subtitle="Save your lineup, preview fleet changes, and follow launch progress and history.">
{#snippet actions()}<Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button>{/snippet}
</SectionHeader>
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} /> <BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} /> <DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
<div class="ui-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button></div> <Tabs {tabs} bind:value={tab} onchange={() => { launchLimit = 10; saveContext(); }} ariaLabel="Collection views" idPrefix="collection-tabs" panelId="collection-panel" />
<div id="collection-panel" role="tabpanel" aria-labelledby={"collection-tabs-" + tab} class="panel">
{#if tab === 'collections'}
<section> <section>
<h2>Your collections <span>{blueprints.length}</span></h2> <h2>Your collections <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if} {#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
@@ -82,13 +126,46 @@
{/if} {/if}
</section> </section>
<ListFooter total={blueprints.length} bind:limit onchange={() => saveContext()} label="collections" /> <ListFooter total={blueprints.length} bind:limit onchange={() => saveContext()} label="collections" />
{:else}
<section>
<h2>{tab === 'active' ? 'Collection launches' : 'Launch history'} <span>{visibleLaunches.length}</span></h2>
{#if launchError}<p role="alert" class="error">{launchError} <button onclick={loadLaunches}>Retry</button></p>{/if}
{#if launchesLoading}<p>Loading collection launches…</p>
{:else if visibleLaunches.length}
<div class="launches">{#each visibleLaunches.slice(0,launchLimit) as collection (collection.id)}
<a class="collection-row" href="/collections/launches/{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 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 {$timeFormat.dateTime(collection.created_at)}</span><span class="collection-link">{collection.active ? 'Follow progress' : 'View results'} →</span></div>
</a>
{/each}</div>
{:else if !launchError}
<EmptyState title={tab === 'active' ? 'No collection launches in flight' : 'No collection launch history'} description={tab === 'active' ? 'Preview fleet changes from Your collections to start a launch.' : 'Completed launches and launches needing attention appear here.'} />
{/if}
<ListFooter total={visibleLaunches.length} bind:limit={launchLimit} onchange={() => saveContext()} step={10} label="launches" />
</section>
{/if}
</div>
</div> </div>
<style> <style>
.page { display: flex; flex-direction: column; gap: 28px; } .page { display: flex; flex-direction: column; gap: 28px; }
.panel { display: grid; gap: 16px; }
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; } p { margin: 8px 0; }
.error { color: var(--state-danger); } .error { color: var(--state-danger); }
.launches { display: grid; gap: 12px; }
.collection-row { display: grid; gap: 16px; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; }
.collection-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
.collection-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
.row-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.collection-counts { display: flex; flex-wrap: wrap; gap: 10px 20px; font-size: 13px; color: var(--text-secondary); }
.collection-counts strong { color: var(--state-online); font-weight: 600; font-variant-numeric: tabular-nums; }
.collection-meter { display: flex; height: 4px; background: var(--surface-raised); overflow: hidden; border-radius: var(--radius-pill); }
.collection-meter .verified { background: var(--state-online); }.collection-meter .unsuccessful { background: var(--state-danger); }
.collection-footer { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; font-size: 12px; }
.meta { color: var(--text-secondary); }.collection-link { color: var(--accent-primary-hover); }
.blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } .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; } .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; } .eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: var(--text-xs); color: var(--text-secondary); margin-bottom: 8px; }
@@ -0,0 +1,7 @@
import { error } from '@sveltejs/kit';
import { getCollection } from '$lib/server/fleet/collections.js';
export function load({ params }: { params: { id: string } }) {
if (!getCollection(params.id)) error(404, 'Collection not found.');
return {};
}
@@ -20,7 +20,7 @@
const currentServers = $derived(detail?.servers.filter(launching) ?? []); const currentServers = $derived(detail?.servers.filter(launching) ?? []);
const otherLaunches = $derived(detail?.queue.active.filter(active => !detail?.servers.some(server => server.id === active.id)) ?? []); const otherLaunches = $derived(detail?.queue.active.filter(active => !detail?.servers.some(server => server.id === active.id)) ?? []);
const firstQueued = $derived(detail?.servers.find(server => !server.server_deleted_at && server.state === 'queued')); const firstQueued = $derived(detail?.servers.find(server => !server.server_deleted_at && server.state === 'queued'));
const labels: Record<string, string> = { queued: 'Queued', running: 'Launching', 'awaiting-health': 'Checking readiness', done: 'Ready', warning: 'Needs review', failed: 'Failed', interrupted: 'Interrupted', waiting: 'Waiting for empty server', cancelled: 'Cancelled' }; const labels: Record<string, string> = { queued: 'Queued', running: 'Launching', 'awaiting-health': 'Checking readiness', done: 'Ready', warning: 'Needs review', failed: 'Failed', interrupted: 'Interrupted', waiting: 'Waiting', cancelled: 'Cancelled' };
const waitingForPort = (server: CollectionServer) => server.state === 'queued' && (server.stage.startsWith('Waiting for removal') || server.stage.startsWith('Waiting for port')); const waitingForPort = (server: CollectionServer) => server.state === 'queued' && (server.stage.startsWith('Waiting for removal') || server.stage.startsWith('Waiting for port'));
function stateLabel(server: CollectionServer) { function stateLabel(server: CollectionServer) {
if (waitingForPort(server)) return 'Waiting for removal'; if (waitingForPort(server)) return 'Waiting for removal';
@@ -54,7 +54,7 @@
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head> <svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title={collection?.name ?? 'Collection launch'}> <SectionHeader title={collection?.name ?? 'Collection launch'}>
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: 'Collection launch' }]} />{/snippet} {#snippet crumbs()}<Breadcrumbs items={[{ label: 'Collections', href: '/collections' }, { label: 'Collection launch' }]} />{/snippet}
</SectionHeader> </SectionHeader>
{#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if} {#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if}
{#if collection && detail} {#if collection && detail}
@@ -2,5 +2,8 @@ import { redirect } from '@sveltejs/kit';
/** Preserve bookmarked operation links and list filters. */ /** Preserve bookmarked operation links and list filters. */
export function load({ url }: { url: URL }) { export function load({ url }: { url: URL }) {
if (url.pathname.startsWith('/deployments/collections/')) {
redirect(308, url.pathname.replace('/deployments/collections/', '/collections/launches/') + url.search);
}
redirect(308, url.pathname.replace(/^\/deployments(?=\/|$)/, '/provisioning') + url.search); redirect(308, url.pathname.replace(/^\/deployments(?=\/|$)/, '/provisioning') + url.search);
} }
+4 -25
View File
@@ -15,7 +15,6 @@
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 HousePartyDialog from '$ui/components/HousePartyDialog.svelte'; import HousePartyDialog from '$ui/components/HousePartyDialog.svelte';
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';
@@ -34,10 +33,8 @@
let open = $state(false); let open = $state(false);
let housePartyOpen = $state(false); let housePartyOpen = $state(false);
let housePartyNotice = $state(''); let housePartyNotice = $state('');
let collections = $state<CollectionSummary[]>([]);
let tab = $state('active'); let tab = $state('active');
let limit = $state(25); let limit = $state(25);
let collectionLimit = $state(10);
let historyState = $state('all'); let historyState = $state('all');
let cancelling = $state(false); let cancelling = $state(false);
let cancellationNotice = $state(''); let cancellationNotice = $state('');
@@ -48,12 +45,11 @@
historyState = params.get('status') === 'attention' ? 'attention' : 'all'; historyState = params.get('status') === 'attention' ? 'attention' : 'all';
search = params.get('server') ?? ''; search = params.get('server') ?? '';
limit = Math.max(25, Number(params.get('limit')) || 25); limit = Math.max(25, Number(params.get('limit')) || 25);
collectionLimit = Math.max(10, Number(params.get('collections')) || 10);
}); });
function saveContext(reset = false) { function saveContext(reset = false) {
if (reset) { limit = 25; collectionLimit = 10; } if (reset) limit = 25;
const url = new URL($page.url); const url = new URL($page.url);
for (const [key, value] of Object.entries({ view: tab === 'active' ? '' : tab, action: action === 'all' ? '' : action, status: historyState === 'all' ? '' : historyState, server: search, limit: limit > 25 ? String(limit) : '', collections: collectionLimit > 10 ? String(collectionLimit) : '' })) { for (const [key, value] of Object.entries({ view: tab === 'active' ? '' : tab, action: action === 'all' ? '' : action, status: historyState === 'all' ? '' : historyState, server: search, limit: limit > 25 ? String(limit) : '' })) {
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
} }
void goto(url, { replaceState: true, noScroll: true, keepFocus: true }); void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
@@ -67,7 +63,6 @@
cancellationNotice = `${cancelled} queued removal${cancelled === 1 ? '' : 's'} cancelled.${cancelled ? ' The servers covered by those cancelled requests and their files were kept.' : ''}${unavailable.length ? ` ${unavailable.length} could not be cancelled; refresh their operations to see whether removal has begun.` : ''}`; cancellationNotice = `${cancelled} queued removal${cancelled === 1 ? '' : 's'} cancelled.${cancelled ? ' The servers covered by those cancelled requests and their files were kept.' : ''}${unavailable.length ? ` ${unavailable.length} could not be cancelled; refresh their operations to see whether removal has begun.` : ''}`;
cancelling = false; await load(); 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:'history',label:'History'}]); const tabs = $derived.by(() => [{id:'active',label:'Active',badge:active.length},{id:'history',label:'History'}]);
let action = $state('all'); let action = $state('all');
let search = $state(''); let search = $state('');
@@ -82,10 +77,9 @@
if (refreshing) return; if (refreshing) return;
refreshing = true; refreshing = true;
try { try {
const [res, collectionRes] = await Promise.all([fetch('/api/provisioning'), fetch('/api/deployment-collections')]); const res = await fetch('/api/provisioning');
if (!res.ok || !collectionRes.ok) throw new Error('Could not load provisioning activity.'); if (!res.ok) throw new Error('Could not load provisioning activity.');
deployments = (await res.json()).deployments; deployments = (await res.json()).deployments;
collections = (await collectionRes.json()).collections;
error = ''; error = '';
} catch (err) { error = (err as Error).message; } } catch (err) { error = (err as Error).message; }
finally { loading = false; refreshing = false; } finally { loading = false; refreshing = false; }
@@ -108,20 +102,6 @@
{#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 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}
<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 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>
</a>
{/each}</div>
<ListFooter total={visibleCollections.length} bind:limit={collectionLimit} onchange={() => saveContext()} step={10} label="collections" />
</section>
{/if}
{#if tab === 'active'} {#if tab === 'active'}
{#if attention.length}<div class="ui-summary"><span>{attention.length} recorded operations need review</span><Button size="sm" onclick={() => { historyState = 'attention'; tab = 'history'; saveContext(true); }}>Review history</Button></div>{/if} {#if attention.length}<div class="ui-summary"><span>{attention.length} recorded operations need review</span><Button size="sm" onclick={() => { historyState = 'attention'; tab = 'history'; saveContext(true); }}>Review history</Button></div>{/if}
{#if active.some(d => d.canCancelRemoval)}<div class="ui-summary"><span>Waiting removals can be withdrawn until removal begins. Running and completed removals cannot be reversed.</span><Button disabled={cancelling} onclick={() => cancelRequests(active.filter(d => d.canCancelRemoval).map(d => d.id))}>Cancel all waiting removals</Button></div>{/if} {#if active.some(d => d.canCancelRemoval)}<div class="ui-summary"><span>Waiting removals can be withdrawn until removal begins. Running and completed removals cannot be reversed.</span><Button disabled={cancelling} onclick={() => cancelRequests(active.filter(d => d.canCancelRemoval).map(d => d.id))}>Cancel all waiting removals</Button></div>{/if}
@@ -176,7 +156,6 @@
.deployment-row { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; } .deployment-row { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; }
.deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); } .deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
.deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; } .deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
.collection-row { display: grid; gap: 16px; }.collection-counts { display: flex; flex-wrap: wrap; gap: 10px 20px; font-size: 13px; color: var(--text-secondary); }.collection-counts strong { color: var(--state-online); font-weight: 600; font-variant-numeric: tabular-nums; }.collection-meter { display: flex; height: 4px; background: var(--surface-raised); overflow: hidden; border-radius: var(--radius-pill); }.collection-meter .verified { background: var(--state-online); }.collection-meter .unsuccessful { background: var(--state-danger); }.collection-footer { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; }.collection-footer .meta, .collection-link { font-size: 12px; }.collection-link { color: var(--accent-primary-hover); }
.server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; } .server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; }
.meta { font-size: 13px; color: var(--text-secondary); } .meta { font-size: 13px; color: var(--text-secondary); }
.error { color: var(--state-danger); } .error { color: var(--state-danger); }
+3 -3
View File
@@ -36,7 +36,7 @@
}); });
const createSteps = ['Prepare server files', 'Create Komodo deployment', 'Start container', 'Verify Komodo health', 'Verify RCON health', 'Verify selected map']; const createSteps = ['Prepare server files', 'Create Komodo deployment', 'Start container', 'Verify Komodo health', 'Verify RCON health', 'Verify selected map'];
const createStages = ['Preparing server files', 'Creating Komodo deployment', 'Starting container', 'Waiting for Komodo health', 'Waiting for RCON health', 'Waiting for selected map']; const createStages = ['Preparing server files', 'Creating Komodo deployment', 'Starting container', 'Waiting for Komodo health', 'Waiting for RCON health', 'Waiting for selected map'];
const destroySteps = ['Wait for empty server', 'Stop and remove container', 'Delete Komodo deployment', 'Remove server files and registry']; const destroySteps = ['Wait for eligibility', 'Stop and remove container', 'Delete Komodo deployment', 'Remove server files and registry'];
const destroyStages = ['Waiting to verify', 'Stopping and removing container', 'Deleting Komodo deployment', 'Removing server files and registry']; const destroyStages = ['Waiting to verify', 'Stopping and removing container', 'Deleting Komodo deployment', 'Removing server files and registry'];
let steps = $derived(deployment?.action === 'destroy' ? ['Request destruction', ...destroySteps] let steps = $derived(deployment?.action === 'destroy' ? ['Request destruction', ...destroySteps]
: deployment?.action === 'rebuild' ? ['Request rebuild', ...destroySteps, 'Queue fresh instance', ...createSteps] : deployment?.action === 'rebuild' ? ['Request rebuild', ...destroySteps, 'Queue fresh instance', ...createSteps]
@@ -76,7 +76,7 @@
? 'The server is still registered. Inspect its status, then resume health verification without creating or restarting a container.' ? 'The server is still registered. Inspect its status, then resume health verification without creating or restarting a container.'
: 'Inspect the error and any remaining container or files before launching again. The activity below records how far this operation reached.'; : 'Inspect the error and any remaining container or files before launching again. The activity below records how far this operation reached.';
if (deployment.state === 'queued') return 'Your request is saved in the launch queue. A startup slot opens after an earlier server passes its container, RCON, and map checks.'; if (deployment.state === 'queued') return 'Your request is saved in the launch queue. A startup slot opens after an earlier server passes its container, RCON, and map checks.';
if (deployment.action === 'rebuild' && deployment.state === 'waiting') return 'Waiting until the instance is empty and a creation slot is available. The old instance and all of its files will be deleted before a fresh instance is created.'; if (deployment.action === 'rebuild' && deployment.state === 'waiting') return 'The rebuild waits for other work to finish, an empty server, and available recovery and startup capacity. The old instance and all of its files will be deleted before a fresh instance is created.';
if (deployment.state === 'waiting') return 'The server stays online while players finish. Removal begins only after a live check confirms it is empty.'; if (deployment.state === 'waiting') return 'The server stays online while players finish. Removal begins only after a live check confirms it is empty.';
if (deployment.stage.startsWith('Waiting for selected map')) return 'Container and RCON checks passed. Waiting for RCON to report the selected engine map. Inspect the server configuration if this persists.'; if (deployment.stage.startsWith('Waiting for selected map')) return 'Container and RCON checks passed. Waiting for RCON to report the selected engine map. Inspect the server configuration if this persists.';
if (deployment.stage.startsWith('Waiting for RCON')) return 'The container is healthy. Waiting for the game to answer RCON; check its startup logs, password, and network if this persists.'; if (deployment.stage.startsWith('Waiting for RCON')) return 'The container is healthy. Waiting for the game to answer RCON; check its startup logs, password, and network if this persists.';
@@ -135,7 +135,7 @@
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: deployment ? serverTitle(deployment.server) : 'Details' }]} />{/snippet} {#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: deployment ? serverTitle(deployment.server) : 'Details' }]} />{/snippet}
</SectionHeader> </SectionHeader>
{#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 detail?.collection}<a href="/provisioning/collections/{detail.collection.id}">← Back to {detail.collection.name} collection</a>{/if} {#if detail?.collection}<a href="/collections/launches/{detail.collection.id}">← Back to {detail.collection.name} collection</a>{/if}
{#if deployment} {#if deployment}
<section class="summary"> <section class="summary">
<div class="top"><StatusBadge state={deployment.state} /> <div class="top"><StatusBadge state={deployment.state} />
@@ -1,7 +1,6 @@
import { error } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { getCollection } from '$lib/server/fleet/collections.js';
export function load({ params }: { params: { id: string } }) { /** Keep bookmarked collection launches under their current Collections home. */
if (!getCollection(params.id)) error(404, 'Collection not found.'); export function load({ params, url }: { params: { id: string }; url: URL }) {
return {}; redirect(308, `/collections/launches/${encodeURIComponent(params.id)}${url.search}`);
} }
+6 -4
View File
@@ -91,6 +91,7 @@
<SectionHeader title="Keys" subtitle="Save a Plutonium key for each server ID. Keys are kept when a server is destroyed."> <SectionHeader title="Keys" subtitle="Save a Plutonium key for each server ID. Keys are kept when a server is destroyed.">
{#snippet actions()}<Button variant="primary" onclick={addKey}>Add key</Button>{/snippet} {#snippet actions()}<Button variant="primary" onclick={addKey}>Add key</Button>{/snippet}
</SectionHeader> </SectionHeader>
<p class="key-help">Saved keys are reused when a server is created or rebuilt. Electric Cherry does not check whether Plutonium accepts them. Updating or removing a saved key does not change an existing server.</p>
<div class="ui-toolbar"><input type="search" aria-label="Search keys" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveSearch(); }} placeholder="Search servers…" /></div> <div class="ui-toolbar"><input type="search" aria-label="Search keys" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveSearch(); }} placeholder="Search servers…" /></div>
{#if error && !open}<div role="alert" class="ui-error">{error}<Button size="sm" onclick={reload}>Retry</Button></div>{/if} {#if error && !open}<div role="alert" class="ui-error">{error}<Button size="sm" onclick={reload}>Retry</Button></div>{/if}
@@ -100,12 +101,12 @@
{:else if !filtered.length}<EmptyState title={keys.length ? "No matching keys" : "No saved keys"} description="Add a key here or save one when creating a server." /> {:else if !filtered.length}<EmptyState title={keys.length ? "No matching keys" : "No saved keys"} description="Add a key here or save one when creating a server." />
{:else} {:else}
<div class="table-wrap"><table> <div class="table-wrap"><table>
<thead><tr><th>Server ID</th><th>Status</th><th>Key</th><th>Actions</th></tr></thead> <thead><tr><th>Server ID</th><th>Server</th><th>Last saved</th><th>Actions</th></tr></thead>
<tbody>{#each filtered.slice(0,limit) as key (key.server_id)} <tbody>{#each filtered.slice(0,limit) as key (key.server_id)}
<tr> <tr>
<td>{#if key.deployed}<a href="/servers/{encodeURIComponent(key.server_id)}"><ServerIdentity name={key.server_id} /></a>{:else}<ServerIdentity name={key.server_id} />{/if}<code>{key.server_id}</code></td> <td>{#if key.deployed}<a href="/servers/{encodeURIComponent(key.server_id)}"><ServerIdentity name={key.server_id} /></a>{:else}<ServerIdentity name={key.server_id} />{/if}<code>{key.server_id}</code></td>
<td>{key.deployed ? 'Server exists' : 'No server deployed'}</td> <td>{key.deployed ? 'Server exists' : 'No server deployed'}</td>
<td>Stored, not validated<small>Saved {savedAt(key.updated_at)}<br />Used on next deployment</small></td> <td>{savedAt(key.updated_at)}</td>
<td><div class="actions"> <td><div class="actions">
<Button ariaLabel={`Update key for ${serverTitle(key.server_id)}`} disabled={busy} onclick={() => { serverId = key.server_id; plutoKey = ''; editing = true; error = ''; notice = ''; open = true; }}>Update</Button> <Button ariaLabel={`Update key for ${serverTitle(key.server_id)}`} disabled={busy} onclick={() => { serverId = key.server_id; plutoKey = ''; editing = true; error = ''; notice = ''; open = true; }}>Update</Button>
<Button ariaLabel={`Remove key for ${serverTitle(key.server_id)}`} disabled={busy} onclick={() => remove(key.server_id)}>Remove</Button> <Button ariaLabel={`Remove key for ${serverTitle(key.server_id)}`} disabled={busy} onclick={() => remove(key.server_id)}>Remove</Button>
@@ -125,7 +126,7 @@
<Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono /> <Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono />
</Field>{:else}<div class="id-picker"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = zombieMaps(event.currentTarget.value)[0].slug}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each zombieMaps(game) as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label><label>Lobby<input type="number" min="1" max="20" step="1" bind:value={lobby} disabled={busy} required /></label></div><p>Server ID: <code>{chosenId}</code></p>{/if} </Field>{:else}<div class="id-picker"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = zombieMaps(event.currentTarget.value)[0].slug}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each zombieMaps(game) as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label><label>Lobby<input type="number" min="1" max="20" step="1" bind:value={lobby} disabled={busy} required /></label></div><p>Server ID: <code>{chosenId}</code></p>{/if}
{#if !editing && keys.some(key => key.server_id === chosenId)}<p role="status">A key is already saved for this ID. Saving replaces it for the next deployment; the running server keeps its current key.</p>{/if} {#if !editing && keys.some(key => key.server_id === chosenId)}<p role="status">A key is already saved for this ID. Saving replaces it for the next deployment; the running server keeps its current key.</p>{/if}
<Field label="Plutonium server key" hint="Changes apply the next time this server ID is deployed."> <Field label="Plutonium server key" hint="Used when this server ID is created or rebuilt. Existing servers keep their current key.">
<Input type="password" bind:value={plutoKey} placeholder="Paste key" autocomplete="off" required disabled={busy} /> <Input type="password" bind:value={plutoKey} placeholder="Paste key" autocomplete="off" required disabled={busy} />
</Field> </Field>
<div class="actions"> <div class="actions">
@@ -136,7 +137,8 @@
{#if !validId || !plutoKey.trim()}<p role="status">Choose a valid server ID and enter its Plutonium key to save.</p>{/if} {#if !validId || !plutoKey.trim()}<p role="status">Choose a valid server ID and enter its Plutonium key to save.</p>{/if}
</Dialog> </Dialog>
<style> <style>
a { color: var(--accent-primary-hover); }small { display: block; color: var(--text-secondary); font-size: 12px; line-height: 1.6; margin-top: 5px; } a { color: var(--accent-primary-hover); }
.key-help { margin: 0; font-size: var(--text-sm); line-height: 1.65; }
.id-picker { display: grid; grid-template-columns: 1fr 1.4fr 80px; gap: 12px; }.id-picker label { display: grid; gap: 8px; font-size: 14px; }.id-picker input, .id-picker select { width: 100%; min-width: 0; padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; }.raw-toggle { display: flex; gap: 8px; align-items: center; font-size: 14px; } .id-picker { display: grid; grid-template-columns: 1fr 1.4fr 80px; gap: 12px; }.id-picker label { display: grid; gap: 8px; font-size: 14px; }.id-picker input, .id-picker select { width: 100%; min-width: 0; padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; }.raw-toggle { display: flex; gap: 8px; align-items: center; font-size: 14px; }
@media (max-width: 600px) { .id-picker { grid-template-columns: 1fr; } } @media (max-width: 600px) { .id-picker { grid-template-columns: 1fr; } }
+36
View File
@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getCollection } from '../../src/lib/server/fleet/collections.js';
import { load as collectionLoad } from '../../src/routes/collections/launches/[id]/+page.server.js';
import { load as provisioningCollectionLoad } from '../../src/routes/provisioning/collections/[id]/+page.server.js';
import { load as deploymentsLoad } from '../../src/routes/deployments/[...path]/+page.server.js';
vi.mock('../../src/lib/server/fleet/collections.js', () => ({ getCollection: vi.fn() }));
beforeEach(() => vi.resetAllMocks());
describe('collection launch routes', () => {
it('loads an existing launch at its Collections location and preserves missing-launch errors', () => {
vi.mocked(getCollection).mockReturnValue({ collection: { id: 'launch-123' } } as ReturnType<typeof getCollection>);
expect(collectionLoad({ params: { id: 'launch-123' } })).toEqual({});
expect(getCollection).toHaveBeenCalledWith('launch-123');
vi.mocked(getCollection).mockReturnValue(null);
expect(() => collectionLoad({ params: { id: 'missing' } })).toThrow(expect.objectContaining({ status: 404, body: { message: 'Collection not found.' } }));
});
it('redirects Provisioning collection bookmarks with the same ID and query parameters', () => {
expect(() => provisioningCollectionLoad({
params: { id: 'launch-123' },
url: new URL('http://localhost/provisioning/collections/launch-123?view=history&limit=50')
})).toThrow(expect.objectContaining({ status: 308, location: '/collections/launches/launch-123?view=history&limit=50' }));
expect(getCollection).not.toHaveBeenCalled();
});
it.each([
['/deployments/collections/launch-123?limit=50&from=old%20link', '/collections/launches/launch-123?limit=50&from=old%20link'],
['/deployments/operation-123?from=collection', '/provisioning/operation-123?from=collection'],
['/deployments?action=create&limit=50', '/provisioning?action=create&limit=50']
])('keeps legacy %s bookmarks compatible', (path, location) => {
expect(() => deploymentsLoad({ url: new URL(path, 'http://localhost') })).toThrow(expect.objectContaining({ status: 308, location }));
});
});