From 6f83700161f78db21baabdb51cd75bb4a753a24f Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 17 Sep 2026 16:42:37 -0400 Subject: [PATCH] Separate campaigns from server deployment lifecycle tracking --- README.md | 10 +- src/hooks.server.ts | 2 + src/lib/server/db/schema.sql | 13 + src/lib/server/fleet/create.ts | 41 +- src/lib/server/fleet/deployments.ts | 30 + src/lib/ui/components/PipelineTimeline.svelte | 4 +- src/routes/+layout.svelte | 5 +- src/routes/+page.svelte | 10 +- src/routes/api/campaigns/+server.ts | 92 +++ src/routes/api/campaigns/[id]/+server.ts | 42 + .../campaigns/[id]/file/[fileId]/+server.ts | 34 + .../api/campaigns/[id]/rollback/+server.ts | 14 + src/routes/api/deployments/+server.ts | 93 +-- src/routes/campaigns/+page.svelte | 667 ++++++++++++++++ src/routes/campaigns/[id]/+page.svelte | 356 +++++++++ src/routes/deployments/+page.svelte | 721 ++---------------- src/routes/deployments/[id]/+page.server.ts | 4 + src/routes/deployments/[id]/+page.svelte | 358 +-------- src/routes/servers/[name]/+page.svelte | 10 +- tests/unit/create-server.test.ts | 36 + 20 files changed, 1434 insertions(+), 1108 deletions(-) create mode 100644 src/lib/server/fleet/deployments.ts create mode 100644 src/routes/api/campaigns/+server.ts create mode 100644 src/routes/api/campaigns/[id]/+server.ts create mode 100644 src/routes/api/campaigns/[id]/file/[fileId]/+server.ts create mode 100644 src/routes/api/campaigns/[id]/rollback/+server.ts create mode 100644 src/routes/campaigns/+page.svelte create mode 100644 src/routes/campaigns/[id]/+page.svelte create mode 100644 src/routes/deployments/[id]/+page.server.ts diff --git a/README.md b/README.md index 2e25d96..ce50f70 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Web app that manages a fleet of T5 Plutonium servers running under Komodo. Successor to `push_scripts.sh` — same job (push shared GSC scripts, run beta -tests, restart empty servers, track deployments) with a live UI and real +tests, restart empty servers, track campaigns) with a live UI and real audit diffs. ## Stack @@ -23,7 +23,7 @@ audit diffs. - `src/lib/server/integrations/` — Komodo, RCON - `src/lib/server/pipeline/` — sweep loop, event bus, worker singleton - `src/routes/api/` — REST + SSE endpoints -- `src/routes/` — UI pages (dashboard, deployments, betas, per-server) +- `src/routes/` — UI pages (dashboard, campaigns, server deployments, betas, per-server) - `Dockerfile`, `docker-compose.yml`, `entrypoint.sh` — container build - `komodo/` — Komodo stack definition - `scripts/` — legacy JSON importer, fixture seeder @@ -198,3 +198,9 @@ Every port carries a `// Ports push_scripts.sh:NN` comment pointing back to the original for the parts that had non-obvious behavior. Grep those if a behavior seems wrong — the shell script is the reference implementation until the parity check passes. + +## Campaigns and server deployments + +Campaigns (`/campaigns`) contains script/config rollouts, drift reconciliation, restart progress, file diffs, and rollbacks. Existing rollout records are preserved in the original database tables. The campaign API lives at `/api/campaigns`; legacy rollout detail API endpoints remain available for compatibility. + +Deployments (`/deployments`, `/api/deployments`) now tracks server creations and destructions with live stages and persistent history, including failures, warnings, and interrupted operations. History starts with operations performed after this update and survives server removal. Creation completion means the server was provisioned and Komodo accepted the container start request; runtime health remains on the Fleet page. diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 59e4371..da6e299 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,12 +1,14 @@ import type { Handle } from '@sveltejs/kit'; import { migrate } from '$lib/server/db/migrate.js'; import { startWorker } from '$lib/server/pipeline/worker.js'; +import { interruptDeployments } from '$lib/server/fleet/deployments.js'; let booted = false; function boot(): void { if (booted) return; booted = true; migrate(); + interruptDeployments(); startWorker(); } diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 0d2c32c..88c0b9c 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -255,3 +255,16 @@ CREATE TABLE IF NOT EXISTS server_telemetry ( players INTEGER NOT NULL, received_at TEXT NOT NULL ); +-- Lifecycle history deliberately survives removal of the server registry row. +CREATE TABLE IF NOT EXISTS server_deployments ( + id TEXT PRIMARY KEY, + server TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('create', 'destroy')), + state TEXT NOT NULL, + stage TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + triggered_by TEXT NOT NULL, + error TEXT, + warnings TEXT NOT NULL DEFAULT '[]' +); diff --git a/src/lib/server/fleet/create.ts b/src/lib/server/fleet/create.ts index 2605802..b7afb9e 100644 --- a/src/lib/server/fleet/create.ts +++ b/src/lib/server/fleet/create.ts @@ -1,3 +1,4 @@ +import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js'; import { mkdir, chmod, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { config, destOfKind } from '../config.js'; @@ -138,13 +139,24 @@ function buildSpec(args: { let creating = false; export async function createServer(input: CreateServerInput): Promise { - if (creating) return { ok: false, kind: 'no-slot', message: 'Another server is being created. Please retry shortly.' }; + if (creating || destroying.size > 0) return { ok: false, kind: 'no-slot', message: 'Another server operation is in progress. Please retry shortly.' }; creating = true; - try { return await createServerUnlocked(input); } + let operation: string | undefined; + try { + const result = await createServerUnlocked(input, (name) => { + operation = beginDeployment(name, 'create', input.createdBy); + return operation; + }); + if (operation) finishDeployment(operation, result); + return result; + } catch (err) { + if (operation) finishDeployment(operation, { ok: false, message: (err as Error).message }); + throw err; + } finally { creating = false; } } -async function createServerUnlocked(input: CreateServerInput): Promise { +async function createServerUnlocked(input: CreateServerInput, begin: (name: string) => string): Promise { if (input.gameMode !== 'zombie') { return { ok: false, kind: 'unknown-map', message: `unsupported game mode: ${input.gameMode}` }; } @@ -185,6 +197,8 @@ async function createServerUnlocked(input: CreateServerInput): Promise(); export async function destroyServer(name: string): Promise { + if (creating || destroying.has(name)) return { ok: false, kind: 'komodo', message: 'A server operation is already in progress. Retry shortly.', warnings: [] }; + if (!getServer(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] }; + const operation = beginDeployment(name, 'destroy'); + destroying.add(name); + try { + const result = await destroyServerUnlocked(name, operation); + finishDeployment(operation, result); + return result; + } catch (err) { + finishDeployment(operation, { ok: false, message: (err as Error).message }); + throw err; + } finally { destroying.delete(name); } +} + +async function destroyServerUnlocked(name: string, operation: string): Promise { const row = getServer(name); if (!row) { return { ok: false, kind: 'not-found', message: `no server named '${name}'`, warnings: [] }; @@ -327,6 +359,7 @@ export async function destroyServer(name: string): Promise // 1: stop + remove the container. execute/DestroyDeployment queues the // teardown; the response comes back before the container is actually gone. + deploymentStage(operation, 'Stopping and removing container'); const destroyRes = await komodoDestroyDeployment(name); if (!destroyRes.ok) { warnings.push(`DestroyDeployment failed: ${destroyRes.message}`); @@ -344,6 +377,7 @@ export async function destroyServer(name: string): Promise } // 3: remove the deployment entity itself. + deploymentStage(operation, 'Deleting Komodo deployment'); const deleteRes = await komodoDeleteDeployment(name); if (!deleteRes.ok) { warnings.push(`DeleteDeployment failed: ${deleteRes.message}`); @@ -351,6 +385,7 @@ export async function destroyServer(name: string): Promise // 3: host directory. Fall back to derived path if host_dir wasn't set on // legacy rows. + deploymentStage(operation, 'Removing server files and registry'); const hostDir = row.host_dir ?? `${config.serversRoot}/${name}`; try { await rm(hostDir, { recursive: true, force: true }); diff --git a/src/lib/server/fleet/deployments.ts b/src/lib/server/fleet/deployments.ts new file mode 100644 index 0000000..36563ec --- /dev/null +++ b/src/lib/server/fleet/deployments.ts @@ -0,0 +1,30 @@ +import { randomUUID } from 'node:crypto'; +import { db } from '../db/index.js'; + +export function beginDeployment(server: string, action: 'create' | 'destroy', triggeredBy = 'ui'): string { + const id = randomUUID(); + db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by) + VALUES (?, ?, ?, 'running', 'Starting', ?, ?)`).run(id, server, action, new Date().toISOString(), triggeredBy); + return id; +} + +export function deploymentStage(id: string, stage: string): void { + db().prepare('UPDATE server_deployments SET stage = ? WHERE id = ?').run(stage, id); +} + +export function finishDeployment(id: string, result: { ok: boolean; message?: string; warnings?: string[] }): void { + const state = !result.ok ? 'failed' : result.warnings?.length ? 'warning' : 'done'; + db().prepare(`UPDATE server_deployments SET state = ?, finished_at = ?, error = ?, warnings = ? WHERE id = ?`) + .run(state, new Date().toISOString(), result.message ?? null, JSON.stringify(result.warnings ?? []), id); +} + +export function listDeployments() { + return db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists + FROM server_deployments d ORDER BY started_at DESC, rowid DESC`).all(); +} + +export function interruptDeployments(): void { + db().prepare(`UPDATE server_deployments SET state = 'interrupted', finished_at = ?, + error = 'The application restarted before this operation finished. Check the server before retrying.' + WHERE state = 'running'`).run(new Date().toISOString()); +} diff --git a/src/lib/ui/components/PipelineTimeline.svelte b/src/lib/ui/components/PipelineTimeline.svelte index a59ef00..0d42426 100644 --- a/src/lib/ui/components/PipelineTimeline.svelte +++ b/src/lib/ui/components/PipelineTimeline.svelte @@ -18,11 +18,11 @@

Pipeline

{#if rows.length === 0} -

no active deployments

+

no active campaigns

{:else}
{#each rows as dep (dep.id)} - +
{dep.action || dep.id} {dep.servers.size} server{dep.servers.size === 1 ? '' : 's'} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 95e1a18..60716fb 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -21,7 +21,7 @@ // Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` deployments, // `g b` betas, `g s` scripts. Ignored while typing in inputs. const shortcuts: Record = { - f: '/', d: '/deployments', b: '/betas', + f: '/', d: '/deployments', a: '/campaigns', b: '/betas', k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins' }; let armed = false; @@ -64,7 +64,8 @@ { href: '/scripts', label: 'Scripts', match: (p: string) => p.startsWith('/scripts') }, { href: '/configs', label: 'Configs', match: (p: string) => p.startsWith('/configs') }, { href: '/plugins', label: 'Plugins', match: (p: string) => p.startsWith('/plugins') }, - { href: '/deployments', label: 'Deployments', match: (p: string) => p.startsWith('/deployments') }, + { href: '/deployments', label: 'Deployments', match: (p: string) => p === '/deployments' }, + { href: '/campaigns', label: 'Campaigns', match: (p: string) => p.startsWith('/campaigns') }, { href: '/server-keys', label: 'Server keys', match: (p: string) => p.startsWith('/server-keys') }, { href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') } ]; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 263199e..2feb573 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -90,7 +90,7 @@ liveViews.filter((v) => v.health === 'online').length ); let totalCount = $derived(servers.length); - let inFlightDeployments = $derived( + let inFlightCampaigns = $derived( Array.from($deployments.values()).filter((d) => { for (const s of d.servers.values()) if (s.state === 'pending') return true; return false; @@ -127,12 +127,12 @@
- + 0 ? 'accent' : 'primary'} + tone={inFlightCampaigns > 0 ? 'accent' : 'primary'} tween /> View → diff --git a/src/routes/api/campaigns/+server.ts b/src/routes/api/campaigns/+server.ts new file mode 100644 index 0000000..956f2bd --- /dev/null +++ b/src/routes/api/campaigns/+server.ts @@ -0,0 +1,92 @@ +import { json } from '@sveltejs/kit'; +import { db } from '$lib/server/db/index.js'; + +export async function GET({ url }: { url: URL }) { + const activeOnly = url.searchParams.get('active') === 'true'; + + const deployments = db() + .prepare( + `SELECT d.id, d.action, d.started_at, d.finished_at, d.triggered_by, + d.script_id, d.script_version_id, d.prior_version_id, d.beta_id, d.rolled_back_by, + s.name AS script_name, sv.version AS script_version, + pv.version AS prior_version + FROM deployments d + LEFT JOIN scripts s ON s.id = d.script_id + LEFT JOIN script_versions sv ON sv.id = d.script_version_id + LEFT JOIN script_versions pv ON pv.id = d.prior_version_id + ORDER BY d.started_at DESC + LIMIT 500` + ) + .all() as Array<{ + id: string; + action: string; + started_at: string; + finished_at: string | null; + triggered_by: string | null; + script_id: number | null; + script_version_id: number | null; + prior_version_id: number | null; + beta_id: number | null; + rolled_back_by: string | null; + script_name: string | null; + script_version: number | null; + prior_version: number | null; + }>; + + const serversByDep = db() + .prepare( + `SELECT deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error + FROM deployment_servers` + ) + .all() as Array<{ + deployment_id: string; + server: string; + files_changed: number; + restart_state: string; + players: number | null; + checked_at: string | null; + restarted_at: string | null; + attempts: number; + error: string | null; + }>; + + const grouped = new Map(); + for (const s of serversByDep) { + const arr = grouped.get(s.deployment_id) ?? []; + arr.push(s); + grouped.set(s.deployment_id, arr); + } + + const scriptRows = db() + .prepare( + `SELECT ds.deployment_id, ds.script_id, s.name AS script_name, + sv.version AS script_version, pv.version AS prior_version + FROM deployment_scripts ds + LEFT JOIN scripts s ON s.id = ds.script_id + LEFT JOIN script_versions sv ON sv.id = ds.script_version_id + LEFT JOIN script_versions pv ON pv.id = ds.prior_version_id` + ) + .all() as Array<{ + deployment_id: string; + script_id: number; + script_name: string | null; + script_version: number | null; + prior_version: number | null; + }>; + const scriptsByDep = new Map(); + for (const s of scriptRows) { + const arr = scriptsByDep.get(s.deployment_id) ?? []; + arr.push(s); + scriptsByDep.set(s.deployment_id, arr); + } + + const rows = deployments + .map((d) => ({ + ...d, + servers: grouped.get(d.id) ?? [], + scripts: scriptsByDep.get(d.id) ?? [] + })) + .filter((d) => (activeOnly ? d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed') : true)); + + return json({ deployments: rows }); +} diff --git a/src/routes/api/campaigns/[id]/+server.ts b/src/routes/api/campaigns/[id]/+server.ts new file mode 100644 index 0000000..7aed1fc --- /dev/null +++ b/src/routes/api/campaigns/[id]/+server.ts @@ -0,0 +1,42 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db/index.js'; + +export async function GET({ params }: { params: { id: string } }) { + const dep = db() + .prepare('SELECT * FROM deployments WHERE id = ?') + .get(params.id) as Record | undefined; + if (!dep) throw error(404, 'deployment not found'); + + const servers = db() + .prepare('SELECT * FROM deployment_servers WHERE deployment_id = ? ORDER BY server') + .all(params.id); + + // Don't ship blobs in the summary — the client asks for a single file when + // it opens the diff viewer. + const files = db() + .prepare( + `SELECT id, server, verb, rel_path, sha256_before, sha256_after, + LENGTH(content_before) AS size_before, + LENGTH(content_after) AS size_after + FROM deployment_files + WHERE deployment_id = ? + ORDER BY server, rel_path` + ) + .all(params.id); + + const scripts = db() + .prepare( + `SELECT ds.script_id, s.name AS script_name, s.target_rel_path, + ds.script_version_id, sv.version AS script_version, + ds.prior_version_id, pv.version AS prior_version + FROM deployment_scripts ds + LEFT JOIN scripts s ON s.id = ds.script_id + LEFT JOIN script_versions sv ON sv.id = ds.script_version_id + LEFT JOIN script_versions pv ON pv.id = ds.prior_version_id + WHERE ds.deployment_id = ? + ORDER BY ds.script_id` + ) + .all(params.id); + + return json({ deployment: dep, servers, files, scripts }); +} diff --git a/src/routes/api/campaigns/[id]/file/[fileId]/+server.ts b/src/routes/api/campaigns/[id]/file/[fileId]/+server.ts new file mode 100644 index 0000000..b2e86cf --- /dev/null +++ b/src/routes/api/campaigns/[id]/file/[fileId]/+server.ts @@ -0,0 +1,34 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db/index.js'; + +// Streams a single deployment_files row's before/after blobs as UTF-8 text +// so the DiffViewer can render a unified diff without a second query. + +export async function GET({ params }: { params: { id: string; fileId: string } }) { + const row = db() + .prepare( + `SELECT verb, rel_path, sha256_before, sha256_after, content_before, content_after + FROM deployment_files + WHERE deployment_id = ? AND id = ?` + ) + .get(params.id, Number(params.fileId)) as + | { + verb: string; + rel_path: string; + sha256_before: string | null; + sha256_after: string | null; + content_before: Buffer | null; + content_after: Buffer | null; + } + | undefined; + if (!row) throw error(404, 'file not found'); + + return json({ + verb: row.verb, + relPath: row.rel_path, + sha256Before: row.sha256_before, + sha256After: row.sha256_after, + before: row.content_before ? row.content_before.toString('utf8') : null, + after: row.content_after ? row.content_after.toString('utf8') : null + }); +} diff --git a/src/routes/api/campaigns/[id]/rollback/+server.ts b/src/routes/api/campaigns/[id]/rollback/+server.ts new file mode 100644 index 0000000..f687ca2 --- /dev/null +++ b/src/routes/api/campaigns/[id]/rollback/+server.ts @@ -0,0 +1,14 @@ +import { json } from '@sveltejs/kit'; +import { rollbackPromoteDeployment, RollbackError } from '$lib/server/fleet/rollback.js'; + +export async function POST({ params }: { params: { id: string } }) { + try { + const result = await rollbackPromoteDeployment(params.id); + return json({ ok: true, result }); + } catch (err) { + if (err instanceof RollbackError) { + return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 }); + } + throw err; + } +} diff --git a/src/routes/api/deployments/+server.ts b/src/routes/api/deployments/+server.ts index 956f2bd..2236807 100644 --- a/src/routes/api/deployments/+server.ts +++ b/src/routes/api/deployments/+server.ts @@ -1,92 +1,3 @@ import { json } from '@sveltejs/kit'; -import { db } from '$lib/server/db/index.js'; - -export async function GET({ url }: { url: URL }) { - const activeOnly = url.searchParams.get('active') === 'true'; - - const deployments = db() - .prepare( - `SELECT d.id, d.action, d.started_at, d.finished_at, d.triggered_by, - d.script_id, d.script_version_id, d.prior_version_id, d.beta_id, d.rolled_back_by, - s.name AS script_name, sv.version AS script_version, - pv.version AS prior_version - FROM deployments d - LEFT JOIN scripts s ON s.id = d.script_id - LEFT JOIN script_versions sv ON sv.id = d.script_version_id - LEFT JOIN script_versions pv ON pv.id = d.prior_version_id - ORDER BY d.started_at DESC - LIMIT 500` - ) - .all() as Array<{ - id: string; - action: string; - started_at: string; - finished_at: string | null; - triggered_by: string | null; - script_id: number | null; - script_version_id: number | null; - prior_version_id: number | null; - beta_id: number | null; - rolled_back_by: string | null; - script_name: string | null; - script_version: number | null; - prior_version: number | null; - }>; - - const serversByDep = db() - .prepare( - `SELECT deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error - FROM deployment_servers` - ) - .all() as Array<{ - deployment_id: string; - server: string; - files_changed: number; - restart_state: string; - players: number | null; - checked_at: string | null; - restarted_at: string | null; - attempts: number; - error: string | null; - }>; - - const grouped = new Map(); - for (const s of serversByDep) { - const arr = grouped.get(s.deployment_id) ?? []; - arr.push(s); - grouped.set(s.deployment_id, arr); - } - - const scriptRows = db() - .prepare( - `SELECT ds.deployment_id, ds.script_id, s.name AS script_name, - sv.version AS script_version, pv.version AS prior_version - FROM deployment_scripts ds - LEFT JOIN scripts s ON s.id = ds.script_id - LEFT JOIN script_versions sv ON sv.id = ds.script_version_id - LEFT JOIN script_versions pv ON pv.id = ds.prior_version_id` - ) - .all() as Array<{ - deployment_id: string; - script_id: number; - script_name: string | null; - script_version: number | null; - prior_version: number | null; - }>; - const scriptsByDep = new Map(); - for (const s of scriptRows) { - const arr = scriptsByDep.get(s.deployment_id) ?? []; - arr.push(s); - scriptsByDep.set(s.deployment_id, arr); - } - - const rows = deployments - .map((d) => ({ - ...d, - servers: grouped.get(d.id) ?? [], - scripts: scriptsByDep.get(d.id) ?? [] - })) - .filter((d) => (activeOnly ? d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed') : true)); - - return json({ deployments: rows }); -} +import { listDeployments } from '$lib/server/fleet/deployments.js'; +export function GET() { return json({ deployments: listDeployments() }); } diff --git a/src/routes/campaigns/+page.svelte b/src/routes/campaigns/+page.svelte new file mode 100644 index 0000000..9067b4e --- /dev/null +++ b/src/routes/campaigns/+page.svelte @@ -0,0 +1,667 @@ + + +
+ + + {#if loading} +
Loading…
+ {:else} + + + + +
+ + + {#if driftOpen} +
+ {#if drift && drift.totalDrift > 0} + + {#each filteredDriftServers as s (s.server)} +
+
+ {s.server} + {s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'} +
+
+ {#each s.files as f (f.driftKind + ':' + f.kind + ':' + f.relPath)} + + [{f.driftKind === 'missing' ? '+' : f.driftKind === 'replace' ? '↻' : '−'} {f.kind}] {f.relPath} + + {/each} +
+
+ {/each} + {:else if drift?.totalDrift === 0} + + {:else} + + {/if} +
+ {/if} + + {#if flash} +
{flash}
+ {/if} + + + +
+
+

Active

+ {active.length} +
+ {#if active.length === 0} + + {:else} +
+ {#each active as d (d.id)} + {@render deployRow(d)} + {/each} +
+ {/if} +
+ + +
+
+

History

+ {history.length} +
+ {#if history.length === 0} + + {:else} + {#each historyByDay as [day, rows] (day)} +
+
{day}
+
+ {#each rows as d (d.id)} + {@render deployRow(d)} + {/each} +
+
+ {/each} + {/if} +
+ {/if} +
+ +{#snippet deployRow(d: Deployment)} + +
+
+ {d.action} + {#if d.action === 'promote' || d.action === 'rollback'} + {scriptSummary(d)} + {/if} + {#if d.rolled_back_by} + Rolled back + {/if} +
+
+ {#if d.action === 'promote' && !d.rolled_back_by} + + {/if} + {ago(d.started_at)} +
+
+ +
+ {#each d.servers as s (s.server)} + + + {s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''} + + {/each} +
+
+ {d.id} +
+ {#each ['done','pending','failed','held','manual'] as state} + {@const n = d.servers.filter((s) => s.restart_state === state).length} + {#if n > 0} + {n} {state} + {/if} + {/each} +
+
+
+{/snippet} + + diff --git a/src/routes/campaigns/[id]/+page.svelte b/src/routes/campaigns/[id]/+page.svelte new file mode 100644 index 0000000..43b094d --- /dev/null +++ b/src/routes/campaigns/[id]/+page.svelte @@ -0,0 +1,356 @@ + + +{#if detail} + {@const action = detail.deployment.action} +
+ + {#snippet crumbs()} + + {/snippet} + + + +
+
Started{fmt(detail.deployment.started_at)}
+
Finished{fmt(detail.deployment.finished_at)}
+
Triggered by{detail.deployment.triggered_by ?? '—'}
+
Servers{detail.servers.length}
+
+
+ +
+
+

Servers

+ {detail.servers.length} +
+
+ {#each detail.servers as s (s.server)} +
+
+ {s.server} + {#if s.error} + {s.error} + {/if} +
+
+ {s.restart_state} + {#if s.players !== null && s.players > 0} + {s.players}p + {/if} + {s.attempts} attempt{s.attempts === 1 ? '' : 's'} + {#if s.restarted_at} + restarted {fmt(s.restarted_at)} + {/if} +
+
+ {/each} +
+
+ +
+
+

Files

+ {detail.files.length} +
+ {#if detail.files.length === 0} + + {:else} + {#each [...byServer.entries()] as [srv, files] (srv)} +
+

{srv}

+
+ {#each files as f (f.id)} + + {/each} +
+
+ {/each} + {/if} +
+ + {#if openFile} +
(openFile = null)} + onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }} + role="button" + tabindex="-1" + aria-label="Close diff" + > +
e.stopPropagation()} role="none"> +
+ {openFile.relPath} + +
+ +
+
+ {/if} +
+{:else} +
Loading…
+{/if} + + diff --git a/src/routes/deployments/+page.svelte b/src/routes/deployments/+page.svelte index e820c00..93a49a2 100644 --- a/src/routes/deployments/+page.svelte +++ b/src/routes/deployments/+page.svelte @@ -1,667 +1,104 @@ +Server deployments ? Electric Cherry
- - - {#if loading} -
Loading…
- {:else} - - - - -
- - - {#if driftOpen} -
- {#if drift && drift.totalDrift > 0} - - {#each filteredDriftServers as s (s.server)} -
-
- {s.server} - {s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'} -
-
- {#each s.files as f (f.driftKind + ':' + f.kind + ':' + f.relPath)} - - [{f.driftKind === 'missing' ? '+' : f.driftKind === 'replace' ? '↻' : '−'} {f.kind}] {f.relPath} - - {/each} -
-
- {/each} - {:else if drift?.totalDrift === 0} - - {:else} - - {/if} -
- {/if} - - {#if flash} -
{flash}
- {/if} - - - -
-
-

Active

- {active.length} -
- {#if active.length === 0} - - {:else} -
- {#each active as d (d.id)} - {@render deployRow(d)} - {/each} -
- {/if} -
- - -
-
-

History

- {history.length} -
- {#if history.length === 0} - - {:else} - {#each historyByDay as [day, rows] (day)} -
-
{day}
-
- {#each rows as d (d.id)} - {@render deployRow(d)} - {/each} -
-
- {/each} - {/if} -
- {/if} + +
+

Script and config rollouts live in Campaigns.

+ +
+ + {#if error}{/if} +
+

In flight {active.length}

+ {#if loading}

Loading deployments?

+ {:else if active.length === 0} + {:else}
{#each active as d (d.id)}{@render row(d)}{/each}
{/if} +
+
+

History

+
+ + +
+ {#if !loading && history.length === 0} + {:else}
{#each history as d (d.id)}{@render row(d)}{/each}
{/if} +
-{#snippet deployRow(d: Deployment)} - +{#snippet row(d: Deployment)} +
-
- {d.action} - {#if d.action === 'promote' || d.action === 'rollback'} - {scriptSummary(d)} - {/if} - {#if d.rolled_back_by} - Rolled back - {/if} -
-
- -
- {#each d.servers as s (s.server)} - - - {s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''} - - {/each} -
-
- {d.id} -
- {#each ['done','pending','failed','held','manual'] as state} - {@const n = d.servers.filter((s) => s.restart_state === state).length} - {#if n > 0} - {n} {state} - {/if} - {/each} -
-
- +

{d.state === 'done' ? (d.action === 'create' ? 'Server created; container start requested.' : 'Server destroyed.') : d.stage}

+
Started {fmt(d.started_at)}{#if d.finished_at}Finished {fmt(d.finished_at)}{/if}By {d.triggered_by}
+ {#if d.error}

{d.error}

{/if} + {#each warnings(d) as warning}

{warning}

{/each} +
{/snippet} diff --git a/src/routes/deployments/[id]/+page.server.ts b/src/routes/deployments/[id]/+page.server.ts new file mode 100644 index 0000000..3f8e156 --- /dev/null +++ b/src/routes/deployments/[id]/+page.server.ts @@ -0,0 +1,4 @@ +import { redirect } from '@sveltejs/kit'; +export function load({ params }: { params: { id: string } }) { + redirect(307, '/campaigns/' + encodeURIComponent(params.id)); +} diff --git a/src/routes/deployments/[id]/+page.svelte b/src/routes/deployments/[id]/+page.svelte index dc52de9..6062853 100644 --- a/src/routes/deployments/[id]/+page.svelte +++ b/src/routes/deployments/[id]/+page.svelte @@ -1,356 +1,2 @@ - - -{#if detail} - {@const action = detail.deployment.action} -
- - {#snippet crumbs()} - - {/snippet} - - - -
-
Started{fmt(detail.deployment.started_at)}
-
Finished{fmt(detail.deployment.finished_at)}
-
Triggered by{detail.deployment.triggered_by ?? '—'}
-
Servers{detail.servers.length}
-
-
- -
-
-

Servers

- {detail.servers.length} -
-
- {#each detail.servers as s (s.server)} -
-
- {s.server} - {#if s.error} - {s.error} - {/if} -
-
- {s.restart_state} - {#if s.players !== null && s.players > 0} - {s.players}p - {/if} - {s.attempts} attempt{s.attempts === 1 ? '' : 's'} - {#if s.restarted_at} - restarted {fmt(s.restarted_at)} - {/if} -
-
- {/each} -
-
- -
-
-

Files

- {detail.files.length} -
- {#if detail.files.length === 0} - - {:else} - {#each [...byServer.entries()] as [srv, files] (srv)} -
-

{srv}

-
- {#each files as f (f.id)} - - {/each} -
-
- {/each} - {/if} -
- - {#if openFile} -
(openFile = null)} - onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }} - role="button" - tabindex="-1" - aria-label="Close diff" - > -
e.stopPropagation()} role="none"> -
- {openFile.relPath} - -
- -
-
- {/if} -
-{:else} -
Loading…
-{/if} - - + +

This rollout is now a campaign. View campaign

diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index 2bef5a5..d395bde 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -98,7 +98,7 @@ async function loadServer(): Promise { const [srvRes, depRes, invRes] = await Promise.all([ fetch('/api/servers'), - fetch('/api/deployments'), + fetch('/api/campaigns'), fetch(`/api/servers/${encodeURIComponent(name)}/scripts`) ]); const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations }; @@ -211,7 +211,7 @@ if (destroying || !name) return; const confirmed = confirm( `Destroy ${name}?\n\nThis stops the container, deletes the Komodo deployment, ` + - `removes the host directory, and drops all deployment history for this server. Cannot be undone.` + `removes the host directory, and removes this server?s campaign details. Server deployment history is retained. Cannot be undone.` ); if (!confirmed) return; destroying = true; destroyError = null; destroyWarnings = []; @@ -520,19 +520,19 @@ {:else if activeTab === 'history'}
-

Deployment history

+

Campaign history

{history.length}
{#if history.length === 0} {:else}
{#each history as d (d.id)} {@const mine = d.servers.find((s) => s.server === name)!} - + {d.action} { + it('records in-flight creation and retains both operations after destruction', async () => { + const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js'); + const { listDeployments } = await import('../../src/lib/server/fleet/deployments.js'); + const { komodoDeploy, komodoInspectHealthy, komodoDestroyDeployment } = await import('../../src/lib/server/integrations/komodo.js'); + vi.mocked(komodoDeploy).mockImplementationOnce(async () => { + expect(listDeployments()).toContainEqual(expect.objectContaining({ server: serverName, action: 'create', state: 'running', stage: 'Starting container' })); + return { ok: true }; + }); + expect((await createServer(input)).ok).toBe(true); + vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'gone' }); + vi.mocked(komodoDestroyDeployment).mockImplementationOnce(async () => { + expect(listDeployments()).toContainEqual(expect.objectContaining({ action: 'destroy', state: 'running' })); + return { ok: true }; + }); + expect((await destroyServer(serverName)).ok).toBe(true); + expect(listDeployments()).toEqual(expect.arrayContaining([ + expect.objectContaining({ server: serverName, action: 'create', state: 'done', server_exists: 0 }), + expect.objectContaining({ server: serverName, action: 'destroy', state: 'done', server_exists: 0 }) + ])); + }); + + it('records failed and unexpectedly interrupted creation attempts', async () => { + const { createServer } = await import('../../src/lib/server/fleet/create.js'); + const { listDeployments, beginDeployment, interruptDeployments } = await import('../../src/lib/server/fleet/deployments.js'); + const { komodoDeploy } = await import('../../src/lib/server/integrations/komodo.js'); + vi.mocked(komodoDeploy).mockResolvedValueOnce({ ok: false, kind: 'http-error', message: 'test failure' }); + expect((await createServer(input)).ok).toBe(false); + expect(listDeployments()).toContainEqual(expect.objectContaining({ state: 'failed', error: 'Deploy: test failure' })); + vi.mocked(komodoDeploy).mockRejectedValueOnce(new Error('connection lost')); + await expect(createServer(input)).rejects.toThrow('connection lost'); + expect(listDeployments()).toContainEqual(expect.objectContaining({ state: 'failed', error: 'connection lost' })); + const id = beginDeployment('interrupted-server', 'destroy'); + interruptDeployments(); + expect(listDeployments()).toContainEqual(expect.objectContaining({ id, state: 'interrupted', finished_at: expect.any(String) })); + }); + it('installs only promoted scope-matching scripts and plugins before first boot', async () => { const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js');