Separate campaigns from server deployment lifecycle tracking

This commit is contained in:
2026-09-17 16:42:37 -04:00
parent 6c6e63fa80
commit 6f83700161
20 changed files with 1434 additions and 1108 deletions
+8 -2
View File
@@ -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.
+2
View File
@@ -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();
}
+13
View File
@@ -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 '[]'
);
+38 -3
View File
@@ -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<CreateServerResult> {
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<CreateServerResult> {
async function createServerUnlocked(input: CreateServerInput, begin: (name: string) => string): Promise<CreateServerResult> {
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<CreateSer
try { saveServerKey(name, plutoKey); }
catch (err) { return { ok: false, kind: 'bad-request', message: (err as Error).message }; }
const operation = begin(name);
deploymentStage(operation, 'Preparing server files');
const hostDir = `${config.serversRoot}/${name}`;
const initialConfig = getPromotedServerConfig('t5', mapEntry.canonical);
const mapConfig = getServerMapConfig('t5', mapEntry.canonical)!;
@@ -224,6 +238,7 @@ async function createServerUnlocked(input: CreateServerInput): Promise<CreateSer
}
// Step 2: Komodo CreateDeployment.
deploymentStage(operation, 'Creating Komodo deployment');
const spec = buildSpec({ name, port, hostDir, plutoKey });
const createRes = await komodoCreateDeployment(spec);
if (!createRes.ok) {
@@ -274,6 +289,7 @@ async function createServerUnlocked(input: CreateServerInput): Promise<CreateSer
void applyDeploymentTags(name, 't5', mapEntry.canonical);
// Step 5: execute/Deploy — actually start the container.
deploymentStage(operation, 'Starting container');
const deployRes = await komodoDeploy(name);
if (!deployRes.ok) {
// Container never started — undo everything so retrying is clean.
@@ -317,7 +333,23 @@ async function applyDeploymentTags(deployment: string, gameTagName: string, mapT
}
}
const destroying = new Set<string>();
export async function destroyServer(name: string): Promise<DestroyServerResult> {
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<DestroyServerResult> {
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<DestroyServerResult>
// 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<DestroyServerResult>
}
// 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<DestroyServerResult>
// 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 });
+30
View File
@@ -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());
}
@@ -18,11 +18,11 @@
<h3>Pipeline</h3>
</header>
{#if rows.length === 0}
<p class="empty">no active deployments</p>
<p class="empty">no active campaigns</p>
{:else}
<div class="rows">
{#each rows as dep (dep.id)}
<a class="row" href="/deployments/{dep.id}">
<a class="row" href="/campaigns/{dep.id}">
<div class="row-head">
<span class="action">{dep.action || dep.id}</span>
<span class="count">{dep.servers.size} server{dep.servers.size === 1 ? '' : 's'}</span>
+3 -2
View File
@@ -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<string, string> = {
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') }
];
+5 -5
View File
@@ -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 @@
</div>
</div>
<div class="hero-cell">
<a class="deploy-link" href="/deployments">
<a class="deploy-link" href="/campaigns">
<Stat
label="Deployments in flight"
value={inFlightDeployments}
label="Campaigns in flight"
value={inFlightCampaigns}
size="xl"
tone={inFlightDeployments > 0 ? 'accent' : 'primary'}
tone={inFlightCampaigns > 0 ? 'accent' : 'primary'}
tween
/>
<span class="link-hint">View →</span>
+92
View File
@@ -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<string, typeof serversByDep>();
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<string, typeof scriptRows>();
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 });
}
+42
View File
@@ -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<string, unknown> | 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 });
}
@@ -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
});
}
@@ -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;
}
}
+2 -91
View File
@@ -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<string, typeof serversByDep>();
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<string, typeof scriptRows>();
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() }); }
+667
View File
@@ -0,0 +1,667 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
interface ServerRow {
server: string;
restart_state: string;
files_changed: number;
players: number | null;
error: string | null;
}
interface DepScript {
deployment_id: string;
script_id: number;
script_name: string | null;
script_version: number | null;
prior_version: number | null;
}
interface Deployment {
id: string;
action: string;
started_at: string;
finished_at: string | null;
triggered_by: string | null;
servers: ServerRow[];
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;
scripts: DepScript[];
}
interface DriftFile {
relPath: string;
size: number;
sha256: string;
kind: 'script' | 'config' | 'plugin' | 'baseline';
driftKind: 'untracked' | 'missing' | 'replace';
}
interface DriftServer { server: string; files: DriftFile[] }
interface DriftPreview { totalDrift: number; servers: DriftServer[] }
let deployments = $state<Deployment[]>([]);
let loading = $state(true);
let drift = $state<DriftPreview | null>(null);
let scanning = $state(false);
let running = $state(false);
let rollingBack = $state<string | null>(null);
let flash = $state<string | null>(null);
// Drift panel opens automatically when there's a problem; collapses when clean.
let driftOpen = $state(false);
let driftFilter = $state('');
async function loadCampaigns(): Promise<void> {
const res = await fetch('/api/campaigns');
const body = (await res.json()) as { deployments: Deployment[] };
deployments = body.deployments;
}
async function scanDrift(): Promise<void> {
scanning = true;
try {
const res = await fetch('/api/drift');
drift = (await res.json()) as DriftPreview;
} finally {
scanning = false;
}
}
let driftCounts = $derived.by(() => {
if (!drift) return { untracked: 0, missing: 0, replace: 0 };
let u = 0, m = 0, r = 0;
for (const s of drift.servers) for (const f of s.files) {
if (f.driftKind === 'untracked') u++; else if (f.driftKind === 'replace') r++; else m++;
}
return { untracked: u, missing: m, replace: r };
});
// Auto-open on first scan if there's drift; leave user's toggle alone after.
let firstScan = $state(true);
$effect(() => {
if (drift && firstScan) {
firstScan = false;
if (drift.totalDrift > 0) driftOpen = true;
}
});
async function runDrift(): Promise<void> {
if (!drift || drift.totalDrift === 0) return;
const parts: string[] = [];
if (driftCounts.untracked > 0) parts.push(`delete ${driftCounts.untracked} untracked file(s)`);
if (driftCounts.missing > 0) parts.push(`restore ${driftCounts.missing} missing artifact(s)`);
if (driftCounts.replace > 0) parts.push(`replace ${driftCounts.replace} existing artifact(s)`);
if (!confirm(`Reconcile drift on ${drift.servers.length} server(s)? Will ${parts.join(' and ')}.`)) return;
running = true; flash = null;
try {
const res = await fetch('/api/drift', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const body = (await res.json()) as {
ok: boolean; error?: string;
result?: { deploymentId: string | null; totalRemoved: number; totalRestored?: number; totalServers: number };
};
if (!body.ok) { flash = body.error ?? 'drift run failed'; return; }
const removed = body.result?.totalRemoved ?? 0;
const restored = body.result?.totalRestored ?? 0;
const parts2: string[] = [];
if (removed > 0) parts2.push(`removed ${removed}`);
if (restored > 0) parts2.push(`installed ${restored}`);
flash = `${parts2.join(', ') || 'no'} file(s) across ${body.result?.totalServers ?? 0} server(s)`;
await Promise.all([loadCampaigns(), scanDrift()]);
} finally {
running = false;
}
}
let poll: ReturnType<typeof setInterval> | null = null;
onMount(async () => {
await Promise.all([loadCampaigns(), scanDrift()]);
loading = false;
poll = setInterval(loadCampaigns, 5000);
});
onDestroy(() => {
if (poll) clearInterval(poll);
});
function isActive(d: Deployment): boolean {
return d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed');
}
function ago(iso: string): string {
const d = Date.now() - new Date(iso).getTime();
if (d < 60_000) return 'just now';
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d ago`;
}
function stateBadgeVariant(s: string): 'online' | 'danger' | 'warning' | 'info' | 'neutral' {
return s === 'done' ? 'online' :
s === 'failed' ? 'danger' :
s === 'pending' ? 'warning' :
s === 'held' ? 'info' :
'neutral';
}
let active = $derived(deployments.filter(isActive));
let history = $derived(deployments.filter((d) => !isActive(d)));
function scriptSummary(d: Deployment): string {
if (d.scripts && d.scripts.length > 0) {
if (d.scripts.length === 1) {
const s = d.scripts[0];
return `${s.script_name ?? '(deleted)'} v${s.script_version ?? '?'}`;
}
return `${d.scripts.length} scripts`;
}
if (d.script_name) return `${d.script_name} v${d.script_version ?? '?'}`;
return '';
}
async function rollbackPromote(d: Deployment, ev: Event): Promise<void> {
ev.preventDefault();
ev.stopPropagation();
const summary = scriptSummary(d) || 'this promote';
if (!confirm(`Rollback ${summary} on ${d.servers.length} server(s)?`)) return;
rollingBack = d.id;
flash = null;
try {
const res = await fetch(`/api/campaigns/${d.id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'rollback failed'; return; }
flash = 'Rollback campaign opened';
await loadCampaigns();
} finally {
rollingBack = null;
}
}
// History grouped by day for a nice timeline read.
let historyByDay = $derived.by(() => {
const groups = new Map<string, Deployment[]>();
for (const d of history) {
const key = new Date(d.started_at).toDateString();
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(d);
}
return Array.from(groups.entries());
});
// Drift filtering — spare the operator's eyes on huge fleets.
let filteredDriftServers = $derived(
drift?.servers.filter((s) =>
!driftFilter || s.server.toLowerCase().includes(driftFilter.toLowerCase())
) ?? []
);
</script>
<div class="page">
<SectionHeader
title="Campaigns"
subtitle="Fleet campaigns, drift reconciliation, and rollbacks. Servers restart in stages as earlier servers recover."
/>
{#if loading}
<div class="loading">Loading…</div>
{:else}
<!-- Drift summary — collapsed by default when there's no problem. -->
<Card padding="lg">
<button
type="button"
class="drift-summary"
aria-expanded={driftOpen}
onclick={() => (driftOpen = !driftOpen)}
>
<div class="drift-summary-l">
<span class="chev" class:open={driftOpen} aria-hidden="true">›</span>
<span class="drift-title">Drift</span>
{#if drift === null}
<Badge variant="neutral" size="sm">Not scanned</Badge>
{:else if drift.totalDrift === 0}
<Badge variant="online" size="sm">In sync</Badge>
{:else}
{#if driftCounts.untracked > 0}
<Badge variant="danger" size="sm">{driftCounts.untracked} untracked</Badge>
{/if}
{#if driftCounts.missing > 0}
<Badge variant="warning" size="sm">{driftCounts.missing} missing</Badge>
{/if}
{#if driftCounts.replace > 0}
<Badge variant="warning" size="sm">{driftCounts.replace} to replace</Badge>
{/if}
<span class="drift-servers">
across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'}
</span>
{/if}
</div>
<div class="drift-summary-r" onclick={(e) => e.stopPropagation()} role="none">
<Button variant="ghost" size="sm" onclick={scanDrift} disabled={scanning || running} loading={scanning}>
Rescan
</Button>
<Button
variant="danger"
size="sm"
onclick={runDrift}
disabled={running || scanning || !drift || drift.totalDrift === 0}
loading={running}
>
Reconcile
</Button>
</div>
</button>
{#if driftOpen}
<div class="drift-body">
{#if drift && drift.totalDrift > 0}
<input
class="drift-filter"
type="search"
placeholder="Filter by server name…"
bind:value={driftFilter}
/>
{#each filteredDriftServers as s (s.server)}
<div class="drift-server">
<div class="drift-server-head">
<span class="server-name">{s.server}</span>
<span class="server-count">{s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}</span>
</div>
<div class="drift-files">
{#each s.files as f (f.driftKind + ':' + f.kind + ':' + f.relPath)}
<Badge
variant={f.driftKind === 'untracked' ? 'danger' : 'warning'}
size="sm"
mono
title="[{f.kind}] {f.driftKind} · {f.size}B · {f.sha256.slice(0, 12)}"
>
[{f.driftKind === 'missing' ? '+' : f.driftKind === 'replace' ? '↻' : '−'} {f.kind}] {f.relPath}
</Badge>
{/each}
</div>
</div>
{/each}
{:else if drift?.totalDrift === 0}
<EmptyState title="Inventory matches disk" description="Nothing to reconcile." />
{:else}
<EmptyState title="Scan hasn't run yet" description="Click Rescan to look for drift." />
{/if}
</div>
{/if}
{#if flash}
<div class="flash">{flash}</div>
{/if}
</Card>
<!-- Active pipelines -->
<section class="section">
<div class="section-head">
<h2>Active</h2>
<Badge variant="neutral" size="sm">{active.length}</Badge>
</div>
{#if active.length === 0}
<EmptyState
title="Nothing in flight"
description="Push scripts or open a beta to see campaigns here."
/>
{:else}
<div class="deploy-list">
{#each active as d (d.id)}
{@render deployRow(d)}
{/each}
</div>
{/if}
</section>
<!-- History timeline -->
<section class="section">
<div class="section-head">
<h2>History</h2>
<Badge variant="neutral" size="sm">{history.length}</Badge>
</div>
{#if history.length === 0}
<EmptyState
title="No completed campaigns in the retention window"
description="Retention is 7 days by default."
/>
{:else}
{#each historyByDay as [day, rows] (day)}
<div class="day-group">
<div class="day-label">{day}</div>
<div class="deploy-list">
{#each rows as d (d.id)}
{@render deployRow(d)}
{/each}
</div>
</div>
{/each}
{/if}
</section>
{/if}
</div>
{#snippet deployRow(d: Deployment)}
<a class="deploy-row" href="/campaigns/{d.id}">
<div class="row-head">
<div class="row-head-l">
<span class="action">{d.action}</span>
{#if d.action === 'promote' || d.action === 'rollback'}
<span class="target">{scriptSummary(d)}</span>
{/if}
{#if d.rolled_back_by}
<Badge variant="neutral" size="sm">Rolled back</Badge>
{/if}
</div>
<div class="row-head-r">
{#if d.action === 'promote' && !d.rolled_back_by}
<Button
variant="danger"
size="sm"
onclick={(e) => rollbackPromote(d, e)}
disabled={rollingBack === d.id}
loading={rollingBack === d.id}
>
Rollback
</Button>
{/if}
<span class="when">{ago(d.started_at)}</span>
</div>
</div>
<!-- Mini pipeline visual: one dot per server, colored by state. -->
<div class="pipeline" aria-label="Server progress">
{#each d.servers as s (s.server)}
<span
class="node n-{s.restart_state}"
title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}"
>
<span class="dot"></span>
<span class="node-label">{s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}</span>
</span>
{/each}
</div>
<div class="row-foot">
<span class="id">{d.id}</span>
<div class="counts">
{#each ['done','pending','failed','held','manual'] as state}
{@const n = d.servers.filter((s) => s.restart_state === state).length}
{#if n > 0}
<Badge variant={stateBadgeVariant(state)} size="sm">{n} {state}</Badge>
{/if}
{/each}
</div>
</div>
</a>
{/snippet}
<style>
.page {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.loading {
text-align: center;
padding: var(--space-16);
color: var(--text-tertiary);
}
/* Drift summary bar */
.drift-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
color: inherit;
text-align: left;
font: inherit;
}
.drift-summary:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-md); }
.drift-summary-l {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
flex-wrap: wrap;
}
.drift-summary-r {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.drift-title {
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.drift-servers {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.chev {
color: var(--text-tertiary);
font-size: var(--text-lg);
line-height: 1;
transition: transform var(--dur-base) var(--ease-standard);
display: inline-block;
}
.chev.open { transform: rotate(90deg); }
.drift-body {
margin-top: var(--space-5);
padding-top: var(--space-5);
border-top: 1px solid var(--border-subtle);
}
.drift-filter {
width: 100%;
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-4);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-sm);
transition: border-color var(--dur-fast) var(--ease-standard);
}
.drift-filter:focus {
outline: none;
border-color: var(--accent-primary);
}
.drift-server {
padding: var(--space-3) var(--space-4);
margin: var(--space-2) 0;
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.drift-server-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--space-2);
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.server-name { color: var(--text-primary); }
.server-count { color: var(--text-tertiary); font-size: var(--text-xs); }
.drift-files {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.flash {
margin-top: var(--space-3);
padding: var(--space-3) var(--space-4);
background: rgba(124, 212, 255, 0.06);
border: 1px solid rgba(124, 212, 255, 0.22);
border-radius: var(--radius-md);
color: var(--state-online);
font-size: var(--text-sm);
}
/* Sections */
.section {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.section-head {
display: flex;
align-items: center;
gap: var(--space-3);
}
.section-head h2 {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
}
.day-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-4);
}
.day-label {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
font-weight: var(--weight-semibold);
}
.deploy-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
/* Deployment rows */
.deploy-row {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
background: color-mix(in oklab, var(--surface) 88%, transparent);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
text-decoration: none;
transition:
background-color var(--dur-fast) var(--ease-standard),
border-color var(--dur-fast) var(--ease-standard);
}
.deploy-row:hover {
background: var(--surface-raised);
border-color: var(--border-strong);
}
.deploy-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.row-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
}
.row-head-l {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
min-width: 0;
}
.row-head-r {
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
}
.action {
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.target {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.when {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
/* Pipeline visual */
.pipeline {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
padding: var(--space-2) 0;
}
.node {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 3px 8px;
border-radius: var(--radius-pill);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
background: var(--surface-sunken);
}
.node .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-quaternary);
}
.n-done .dot { background: var(--state-online); }
.n-pending .dot { background: var(--state-warning); }
.n-failed .dot { background: var(--state-danger); }
.n-held .dot { background: var(--blue-300); }
.n-manual .dot { background: var(--text-tertiary); }
.node-label { color: var(--text-secondary); }
.row-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--border-subtle);
}
.id {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-quaternary);
}
.counts {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
@media (max-width: 640px) {
.row-head, .row-foot { flex-direction: column; align-items: flex-start; }
.drift-summary { flex-direction: column; align-items: flex-start; }
.drift-summary-r { width: 100%; justify-content: flex-end; }
}
</style>
+356
View File
@@ -0,0 +1,356 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import DiffViewer from '$ui/components/DiffViewer.svelte';
import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
interface FileRow {
id: number; server: string; verb: string; rel_path: string;
sha256_before: string | null; sha256_after: string | null;
size_before: number | null; size_after: number | null;
}
interface ServerRow {
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;
}
interface Detail {
deployment: {
id: string; action: string; started_at: string;
finished_at: string | null; triggered_by: string | null;
};
servers: ServerRow[];
files: FileRow[];
}
let detail = $state<Detail | null>(null);
let openFile = $state<{ id: number; before: string | null; after: string | null; verb: string; relPath: string } | null>(null);
async function loadDetail(): Promise<void> {
const id = $page.params.id;
const res = await fetch(`/api/campaigns/${id}`);
detail = (await res.json()) as Detail;
}
let poll: ReturnType<typeof setInterval> | null = null;
onMount(() => {
loadDetail();
poll = setInterval(loadDetail, 5000);
});
onDestroy(() => {
if (poll) clearInterval(poll);
});
async function loadDiff(f: FileRow) {
const res = await fetch(`/api/campaigns/${$page.params.id}/file/${f.id}`);
const body = (await res.json()) as { before: string | null; after: string | null; verb: string; relPath: string };
openFile = { id: f.id, ...body };
}
let byServer = $derived.by(() => {
if (!detail) return new Map<string, FileRow[]>();
const m = new Map<string, FileRow[]>();
for (const f of detail.files) {
if (!m.has(f.server)) m.set(f.server, []);
m.get(f.server)!.push(f);
}
return m;
});
function stateVariant(s: string): 'online' | 'danger' | 'warning' | 'info' | 'neutral' {
return s === 'done' ? 'online' :
s === 'failed' ? 'danger' :
s === 'pending' ? 'warning' :
s === 'held' ? 'info' :
'neutral';
}
function verbVariant(v: string): 'online' | 'info' | 'danger' | 'warning' | 'neutral' {
return v === 'added' ? 'online' :
v === 'updated' ? 'info' :
v === 'removed' ? 'danger' :
v === 'held' ? 'warning' :
'neutral';
}
function fmt(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit'
});
}
</script>
{#if detail}
{@const action = detail.deployment.action}
<div class="page">
<SectionHeader title={action} subtitle={detail.deployment.id}>
{#snippet crumbs()}
<Breadcrumbs items={[
{ label: 'Campaigns', href: '/campaigns' },
{ label: action }
]} />
{/snippet}
</SectionHeader>
<Card padding="lg">
<div class="meta-grid">
<div class="kv"><span class="k">Started</span><span class="v mono">{fmt(detail.deployment.started_at)}</span></div>
<div class="kv"><span class="k">Finished</span><span class="v mono">{fmt(detail.deployment.finished_at)}</span></div>
<div class="kv"><span class="k">Triggered by</span><span class="v mono">{detail.deployment.triggered_by ?? '—'}</span></div>
<div class="kv"><span class="k">Servers</span><span class="v mono">{detail.servers.length}</span></div>
</div>
</Card>
<section class="section">
<div class="section-head">
<h2>Servers</h2>
<Badge variant="neutral" size="sm">{detail.servers.length}</Badge>
</div>
<div class="server-list">
{#each detail.servers as s (s.server)}
<div class="srv-row">
<div class="srv-l">
<a class="srv-name" href="/servers/{s.server}">{s.server}</a>
{#if s.error}
<span class="err">{s.error}</span>
{/if}
</div>
<div class="srv-r">
<Badge variant={stateVariant(s.restart_state)} size="sm" uppercase>{s.restart_state}</Badge>
{#if s.players !== null && s.players > 0}
<span class="players">{s.players}p</span>
{/if}
<span class="attempts">{s.attempts} attempt{s.attempts === 1 ? '' : 's'}</span>
{#if s.restarted_at}
<span class="when">restarted {fmt(s.restarted_at)}</span>
{/if}
</div>
</div>
{/each}
</div>
</section>
<section class="section">
<div class="section-head">
<h2>Files</h2>
<Badge variant="neutral" size="sm">{detail.files.length}</Badge>
</div>
{#if detail.files.length === 0}
<EmptyState title="No file changes recorded" description="This campaign didn't touch any tracked artifacts." />
{:else}
{#each [...byServer.entries()] as [srv, files] (srv)}
<div class="file-group">
<h3>{srv}</h3>
<div class="file-rows">
{#each files as f (f.id)}
<button class="file-row" onclick={() => loadDiff(f)}>
<Badge variant={verbVariant(f.verb)} size="sm" uppercase>{f.verb}</Badge>
<span class="path">{f.rel_path}</span>
<span class="sizes">
{#if f.size_before !== null}{f.size_before} B{/if}
{#if f.size_before !== null && f.size_after !== null} → {/if}
{#if f.size_after !== null}{f.size_after} B{/if}
</span>
</button>
{/each}
</div>
</div>
{/each}
{/if}
</section>
{#if openFile}
<div
class="diff-scrim"
onclick={() => (openFile = null)}
onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }}
role="button"
tabindex="-1"
aria-label="Close diff"
>
<div class="diff-modal" onclick={(e) => e.stopPropagation()} role="none">
<div class="diff-head">
<span class="diff-title">{openFile.relPath}</span>
<Button variant="ghost" size="sm" onclick={() => (openFile = null)}>Close</Button>
</div>
<DiffViewer
before={openFile.before}
after={openFile.after}
verb={openFile.verb}
relPath={openFile.relPath}
/>
</div>
</div>
{/if}
</div>
{:else}
<div class="loading">Loading…</div>
{/if}
<style>
.page {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.loading {
text-align: center;
padding: var(--space-16);
color: var(--text-tertiary);
}
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-2);
}
.kv {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-3) var(--space-4);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.kv .k {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
font-weight: var(--weight-semibold);
}
.kv .v { font-size: var(--text-sm); color: var(--text-primary); }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.section { display: flex; flex-direction: column; gap: var(--space-3); }
.section-head {
display: flex;
align-items: center;
gap: var(--space-3);
}
.section-head h2 {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.server-list { display: flex; flex-direction: column; gap: var(--space-2); }
.srv-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
background: color-mix(in oklab, var(--surface) 88%, transparent);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.srv-l { display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; }
.srv-r { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.srv-name {
font-family: var(--font-body);
font-weight: var(--weight-semibold);
font-size: var(--text-sm);
color: var(--text-primary);
text-decoration: none;
}
.srv-name:hover { color: var(--accent-primary-hover); }
.err { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--state-danger); }
.players { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--state-online); }
.attempts, .when {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.file-group {
margin-top: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.file-group h3 {
margin: 0;
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-secondary);
font-weight: var(--weight-medium);
}
.file-rows { display: flex; flex-direction: column; gap: 2px; }
.file-row {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
text-align: left;
padding: var(--space-2) var(--space-3);
background: transparent;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
cursor: pointer;
font: inherit;
transition: background-color var(--dur-fast) var(--ease-standard);
}
.file-row:hover { background: var(--surface-raised); }
.file-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.path {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
word-break: break-all;
}
.sizes {
margin-left: auto;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
white-space: nowrap;
}
/* Diff modal */
.diff-scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 100;
display: grid;
place-items: center;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.diff-modal {
width: min(1000px, 92vw);
max-height: 92vh;
overflow: auto;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-raised);
}
.diff-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
.diff-title {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
word-break: break-all;
}
</style>
+74 -637
View File
@@ -1,667 +1,104 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import Card from '$ui/components/primitives/Card.svelte';
import { onMount } from 'svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
interface ServerRow {
server: string;
restart_state: string;
files_changed: number;
players: number | null;
error: string | null;
}
interface DepScript {
deployment_id: string;
script_id: number;
script_name: string | null;
script_version: number | null;
prior_version: number | null;
}
interface Deployment {
id: string;
action: string;
started_at: string;
finished_at: string | null;
triggered_by: string | null;
servers: ServerRow[];
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;
scripts: DepScript[];
id: string; server: string; action: 'create' | 'destroy';
state: 'running' | 'done' | 'failed' | 'warning' | 'interrupted';
stage: string; started_at: string; finished_at: string | null;
triggered_by: string; error: string | null; warnings: string; server_exists: number;
}
interface DriftFile {
relPath: string;
size: number;
sha256: string;
kind: 'script' | 'config' | 'plugin' | 'baseline';
driftKind: 'untracked' | 'missing' | 'replace';
}
interface DriftServer { server: string; files: DriftFile[] }
interface DriftPreview { totalDrift: number; servers: DriftServer[] }
let deployments = $state<Deployment[]>([]);
let loading = $state(true);
let drift = $state<DriftPreview | null>(null);
let scanning = $state(false);
let running = $state(false);
let rollingBack = $state<string | null>(null);
let flash = $state<string | null>(null);
// Drift panel opens automatically when there's a problem; collapses when clean.
let driftOpen = $state(false);
let driftFilter = $state('');
async function loadDeployments(): Promise<void> {
let error = $state('');
let open = $state(false);
let action = $state('all');
let search = $state('');
let refreshing = false;
let active = $derived(deployments.filter(d => d.state === 'running'));
let history = $derived(deployments.filter(d => d.state !== 'running' &&
(action === 'all' || d.action === action) && d.server.toLowerCase().includes(search.toLowerCase())));
async function load() {
if (refreshing) return;
refreshing = true;
try {
const res = await fetch('/api/deployments');
const body = (await res.json()) as { deployments: Deployment[] };
deployments = body.deployments;
if (!res.ok) throw new Error('Could not load server deployments.');
deployments = (await res.json()).deployments;
error = '';
} catch (err) { error = (err as Error).message; }
finally { loading = false; refreshing = false; }
}
async function scanDrift(): Promise<void> {
scanning = true;
try {
const res = await fetch('/api/drift');
drift = (await res.json()) as DriftPreview;
} finally {
scanning = false;
}
}
let driftCounts = $derived.by(() => {
if (!drift) return { untracked: 0, missing: 0, replace: 0 };
let u = 0, m = 0, r = 0;
for (const s of drift.servers) for (const f of s.files) {
if (f.driftKind === 'untracked') u++; else if (f.driftKind === 'replace') r++; else m++;
}
return { untracked: u, missing: m, replace: r };
onMount(() => {
void load();
const timer = setInterval(load, 2000);
return () => clearInterval(timer);
});
// Auto-open on first scan if there's drift; leave user's toggle alone after.
let firstScan = $state(true);
$effect(() => {
if (drift && firstScan) {
firstScan = false;
if (drift.totalDrift > 0) driftOpen = true;
function fmt(value: string) { return new Date(value).toLocaleString(); }
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
function status(d: Deployment) {
return { running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' }[d.state];
}
});
async function runDrift(): Promise<void> {
if (!drift || drift.totalDrift === 0) return;
const parts: string[] = [];
if (driftCounts.untracked > 0) parts.push(`delete ${driftCounts.untracked} untracked file(s)`);
if (driftCounts.missing > 0) parts.push(`restore ${driftCounts.missing} missing artifact(s)`);
if (driftCounts.replace > 0) parts.push(`replace ${driftCounts.replace} existing artifact(s)`);
if (!confirm(`Reconcile drift on ${drift.servers.length} server(s)? Will ${parts.join(' and ')}.`)) return;
running = true; flash = null;
try {
const res = await fetch('/api/drift', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const body = (await res.json()) as {
ok: boolean; error?: string;
result?: { deploymentId: string | null; totalRemoved: number; totalRestored?: number; totalServers: number };
};
if (!body.ok) { flash = body.error ?? 'drift run failed'; return; }
const removed = body.result?.totalRemoved ?? 0;
const restored = body.result?.totalRestored ?? 0;
const parts2: string[] = [];
if (removed > 0) parts2.push(`removed ${removed}`);
if (restored > 0) parts2.push(`installed ${restored}`);
flash = `${parts2.join(', ') || 'no'} file(s) across ${body.result?.totalServers ?? 0} server(s)`;
await Promise.all([loadDeployments(), scanDrift()]);
} finally {
running = false;
}
}
let poll: ReturnType<typeof setInterval> | null = null;
onMount(async () => {
await Promise.all([loadDeployments(), scanDrift()]);
loading = false;
poll = setInterval(loadDeployments, 5000);
});
onDestroy(() => {
if (poll) clearInterval(poll);
});
function isActive(d: Deployment): boolean {
return d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed');
}
function ago(iso: string): string {
const d = Date.now() - new Date(iso).getTime();
if (d < 60_000) return 'just now';
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d ago`;
}
function stateBadgeVariant(s: string): 'online' | 'danger' | 'warning' | 'info' | 'neutral' {
return s === 'done' ? 'online' :
s === 'failed' ? 'danger' :
s === 'pending' ? 'warning' :
s === 'held' ? 'info' :
'neutral';
}
let active = $derived(deployments.filter(isActive));
let history = $derived(deployments.filter((d) => !isActive(d)));
function scriptSummary(d: Deployment): string {
if (d.scripts && d.scripts.length > 0) {
if (d.scripts.length === 1) {
const s = d.scripts[0];
return `${s.script_name ?? '(deleted)'} v${s.script_version ?? '?'}`;
}
return `${d.scripts.length} scripts`;
}
if (d.script_name) return `${d.script_name} v${d.script_version ?? '?'}`;
return '';
}
async function rollbackPromote(d: Deployment, ev: Event): Promise<void> {
ev.preventDefault();
ev.stopPropagation();
const summary = scriptSummary(d) || 'this promote';
if (!confirm(`Rollback ${summary} on ${d.servers.length} server(s)?`)) return;
rollingBack = d.id;
flash = null;
try {
const res = await fetch(`/api/deployments/${d.id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'rollback failed'; return; }
flash = 'Rollback deployment opened';
await loadDeployments();
} finally {
rollingBack = null;
}
}
// History grouped by day for a nice timeline read.
let historyByDay = $derived.by(() => {
const groups = new Map<string, Deployment[]>();
for (const d of history) {
const key = new Date(d.started_at).toDateString();
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(d);
}
return Array.from(groups.entries());
});
// Drift filtering — spare the operator's eyes on huge fleets.
let filteredDriftServers = $derived(
drift?.servers.filter((s) =>
!driftFilter || s.server.toLowerCase().includes(driftFilter.toLowerCase())
) ?? []
);
</script>
<svelte:head><title>Server deployments ? Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader
title="Deployments"
subtitle="Fleet deployments, drift reconciliation, and rollbacks. Servers restart in stages as earlier servers recover."
/>
{#if loading}
<div class="loading">Loading…</div>
{:else}
<!-- Drift summary — collapsed by default when there's no problem. -->
<Card padding="lg">
<button
type="button"
class="drift-summary"
aria-expanded={driftOpen}
onclick={() => (driftOpen = !driftOpen)}
>
<div class="drift-summary-l">
<span class="chev" class:open={driftOpen} aria-hidden="true">›</span>
<span class="drift-title">Drift</span>
{#if drift === null}
<Badge variant="neutral" size="sm">Not scanned</Badge>
{:else if drift.totalDrift === 0}
<Badge variant="online" size="sm">In sync</Badge>
{:else}
{#if driftCounts.untracked > 0}
<Badge variant="danger" size="sm">{driftCounts.untracked} untracked</Badge>
{/if}
{#if driftCounts.missing > 0}
<Badge variant="warning" size="sm">{driftCounts.missing} missing</Badge>
{/if}
{#if driftCounts.replace > 0}
<Badge variant="warning" size="sm">{driftCounts.replace} to replace</Badge>
{/if}
<span class="drift-servers">
across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'}
</span>
{/if}
<SectionHeader title="Deployments" subtitle="Server creations and destructions, from work in flight to operation history." />
<div class="toolbar">
<p>Script and config rollouts live in <a href="/campaigns">Campaigns</a>.</p>
<Button variant="primary" onclick={() => open = true}>Deploy new server</Button>
</div>
<div class="drift-summary-r" onclick={(e) => e.stopPropagation()} role="none">
<Button variant="ghost" size="sm" onclick={scanDrift} disabled={scanning || running} loading={scanning}>
Rescan
</Button>
<Button
variant="danger"
size="sm"
onclick={runDrift}
disabled={running || scanning || !drift || drift.totalDrift === 0}
loading={running}
>
Reconcile
</Button>
</div>
</button>
{#if driftOpen}
<div class="drift-body">
{#if drift && drift.totalDrift > 0}
<input
class="drift-filter"
type="search"
placeholder="Filter by server name…"
bind:value={driftFilter}
/>
{#each filteredDriftServers as s (s.server)}
<div class="drift-server">
<div class="drift-server-head">
<span class="server-name">{s.server}</span>
<span class="server-count">{s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}</span>
</div>
<div class="drift-files">
{#each s.files as f (f.driftKind + ':' + f.kind + ':' + f.relPath)}
<Badge
variant={f.driftKind === 'untracked' ? 'danger' : 'warning'}
size="sm"
mono
title="[{f.kind}] {f.driftKind} · {f.size}B · {f.sha256.slice(0, 12)}"
>
[{f.driftKind === 'missing' ? '+' : f.driftKind === 'replace' ? '↻' : '−'} {f.kind}] {f.relPath}
</Badge>
{/each}
</div>
</div>
{/each}
{:else if drift?.totalDrift === 0}
<EmptyState title="Inventory matches disk" description="Nothing to reconcile." />
{:else}
<EmptyState title="Scan hasn't run yet" description="Click Rescan to look for drift." />
{/if}
</div>
{/if}
{#if flash}
<div class="flash">{flash}</div>
{/if}
</Card>
<!-- Active pipelines -->
<section class="section">
<div class="section-head">
<h2>Active</h2>
<Badge variant="neutral" size="sm">{active.length}</Badge>
</div>
{#if active.length === 0}
<EmptyState
title="Nothing in flight"
description="Push scripts or open a beta to see deployments here."
/>
{:else}
<div class="deploy-list">
{#each active as d (d.id)}
{@render deployRow(d)}
{/each}
</div>
{/if}
<DeployServerDialog bind:open />
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
<section>
<h2>In flight <span>{active.length}</span></h2>
{#if loading}<p>Loading deployments?</p>
{:else if active.length === 0}<EmptyState title="No server operations in flight" description="New creations and destructions appear here automatically." />
{:else}<div class="rows">{#each active as d (d.id)}{@render row(d)}{/each}</div>{/if}
</section>
<!-- History timeline -->
<section class="section">
<div class="section-head">
<section>
<h2>History</h2>
<Badge variant="neutral" size="sm">{history.length}</Badge>
<div class="filters">
<label>Operation <select bind:value={action}><option value="all">All operations</option><option value="create">Creations</option><option value="destroy">Destructions</option></select></label>
<label>Server <input bind:value={search} placeholder="Filter by server name" /></label>
</div>
{#if history.length === 0}
<EmptyState
title="No completed deployments in the retention window"
description="Retention is 7 days by default."
/>
{:else}
{#each historyByDay as [day, rows] (day)}
<div class="day-group">
<div class="day-label">{day}</div>
<div class="deploy-list">
{#each rows as d (d.id)}
{@render deployRow(d)}
{/each}
</div>
</div>
{/each}
{/if}
{#if !loading && history.length === 0}<EmptyState title="No matching operations" description="History is recorded for server operations performed after this update, including failures." />
{:else}<div class="rows">{#each history as d (d.id)}{@render row(d)}{/each}</div>{/if}
</section>
{/if}
</div>
{#snippet deployRow(d: Deployment)}
<a class="deploy-row" href="/deployments/{d.id}">
{#snippet row(d: Deployment)}
<article>
<div class="row-head">
<div class="row-head-l">
<span class="action">{d.action}</span>
{#if d.action === 'promote' || d.action === 'rollback'}
<span class="target">{scriptSummary(d)}</span>
{/if}
{#if d.rolled_back_by}
<Badge variant="neutral" size="sm">Rolled back</Badge>
{/if}
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{d.action === 'create' ? 'Creation' : 'Destruction'}</Badge>
{#if d.server_exists}<a class="server" href="/servers/{encodeURIComponent(d.server)}">{d.server}</a>{:else}<strong class="server">{d.server}</strong>{/if}
</div>
<div class="row-head-r">
{#if d.action === 'promote' && !d.rolled_back_by}
<Button
variant="danger"
size="sm"
onclick={(e) => rollbackPromote(d, e)}
disabled={rollingBack === d.id}
loading={rollingBack === d.id}
>
Rollback
</Button>
{/if}
<span class="when">{ago(d.started_at)}</span>
<Badge variant={d.state === 'done' ? 'online' : d.state === 'failed' ? 'danger' : 'warning'}>{status(d)}</Badge>
</div>
</div>
<!-- Mini pipeline visual: one dot per server, colored by state. -->
<div class="pipeline" aria-label="Server progress">
{#each d.servers as s (s.server)}
<span
class="node n-{s.restart_state}"
title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}"
>
<span class="dot"></span>
<span class="node-label">{s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}</span>
</span>
{/each}
</div>
<div class="row-foot">
<span class="id">{d.id}</span>
<div class="counts">
{#each ['done','pending','failed','held','manual'] as state}
{@const n = d.servers.filter((s) => s.restart_state === state).length}
{#if n > 0}
<Badge variant={stateBadgeVariant(state)} size="sm">{n} {state}</Badge>
{/if}
{/each}
</div>
</div>
</a>
<p>{d.state === 'done' ? (d.action === 'create' ? 'Server created; container start requested.' : 'Server destroyed.') : d.stage}</p>
<div class="meta"><span>Started {fmt(d.started_at)}</span>{#if d.finished_at}<span>Finished {fmt(d.finished_at)}</span>{/if}<span>By {d.triggered_by}</span></div>
{#if d.error}<p class="error">{d.error}</p>{/if}
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each}
</article>
{/snippet}
<style>
.page {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.loading {
text-align: center;
padding: var(--space-16);
color: var(--text-tertiary);
}
/* Drift summary bar */
.drift-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
color: inherit;
text-align: left;
font: inherit;
}
.drift-summary:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-md); }
.drift-summary-l {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
flex-wrap: wrap;
}
.drift-summary-r {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.drift-title {
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.drift-servers {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.chev {
color: var(--text-tertiary);
font-size: var(--text-lg);
line-height: 1;
transition: transform var(--dur-base) var(--ease-standard);
display: inline-block;
}
.chev.open { transform: rotate(90deg); }
.drift-body {
margin-top: var(--space-5);
padding-top: var(--space-5);
border-top: 1px solid var(--border-subtle);
}
.drift-filter {
width: 100%;
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-4);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-sm);
transition: border-color var(--dur-fast) var(--ease-standard);
}
.drift-filter:focus {
outline: none;
border-color: var(--accent-primary);
}
.drift-server {
padding: var(--space-3) var(--space-4);
margin: var(--space-2) 0;
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.drift-server-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--space-2);
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.server-name { color: var(--text-primary); }
.server-count { color: var(--text-tertiary); font-size: var(--text-xs); }
.drift-files {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.flash {
margin-top: var(--space-3);
padding: var(--space-3) var(--space-4);
background: rgba(124, 212, 255, 0.06);
border: 1px solid rgba(124, 212, 255, 0.22);
border-radius: var(--radius-md);
color: var(--state-online);
font-size: var(--text-sm);
}
/* Sections */
.section {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.section-head {
display: flex;
align-items: center;
gap: var(--space-3);
}
.section-head h2 {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
}
.day-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-4);
}
.day-label {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
font-weight: var(--weight-semibold);
}
.deploy-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
/* Deployment rows */
.deploy-row {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
background: color-mix(in oklab, var(--surface) 88%, transparent);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
text-decoration: none;
transition:
background-color var(--dur-fast) var(--ease-standard),
border-color var(--dur-fast) var(--ease-standard);
}
.deploy-row:hover {
background: var(--surface-raised);
border-color: var(--border-strong);
}
.deploy-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.row-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
}
.row-head-l {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
min-width: 0;
}
.row-head-r {
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
}
.action {
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.target {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.when {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
/* Pipeline visual */
.pipeline {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
padding: var(--space-2) 0;
}
.node {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 3px 8px;
border-radius: var(--radius-pill);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
background: var(--surface-sunken);
}
.node .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-quaternary);
}
.n-done .dot { background: var(--state-online); }
.n-pending .dot { background: var(--state-warning); }
.n-failed .dot { background: var(--state-danger); }
.n-held .dot { background: var(--blue-300); }
.n-manual .dot { background: var(--text-tertiary); }
.node-label { color: var(--text-secondary); }
.row-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--border-subtle);
}
.id {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-quaternary);
}
.counts {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
@media (max-width: 640px) {
.row-head, .row-foot { flex-direction: column; align-items: flex-start; }
.drift-summary { flex-direction: column; align-items: flex-start; }
.drift-summary-r { width: 100%; justify-content: flex-end; }
}
.page { display: flex; flex-direction: column; gap: 28px; }
.toolbar, .row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.toolbar, .row-head { justify-content: space-between; }
section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
p { margin: 8px 0; } a { color: var(--accent); }
.rows { display: grid; gap: 12px; }
article { padding: 20px; border: 1px solid var(--border-subtle); border-radius: 10px; background: var(--surface); overflow-wrap: anywhere; }
.server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; }
.meta, .toolbar p { font-size: 13px; color: var(--text-secondary); }
.error { color: var(--state-danger); }
label { display: flex; align-items: center; gap: 8px; font-size: 13px; }
input, select { background: var(--surface); color: inherit; border: 1px solid var(--border-subtle); border-radius: 6px; padding: 8px; max-width: 100%; }
@media (max-width: 640px) { .row-head { align-items: flex-start; } .server { display: block; margin: 10px 0; } }
</style>
@@ -0,0 +1,4 @@
import { redirect } from '@sveltejs/kit';
export function load({ params }: { params: { id: string } }) {
redirect(307, '/campaigns/' + encodeURIComponent(params.id));
}
+2 -356
View File
@@ -1,356 +1,2 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import DiffViewer from '$ui/components/DiffViewer.svelte';
import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
interface FileRow {
id: number; server: string; verb: string; rel_path: string;
sha256_before: string | null; sha256_after: string | null;
size_before: number | null; size_after: number | null;
}
interface ServerRow {
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;
}
interface Detail {
deployment: {
id: string; action: string; started_at: string;
finished_at: string | null; triggered_by: string | null;
};
servers: ServerRow[];
files: FileRow[];
}
let detail = $state<Detail | null>(null);
let openFile = $state<{ id: number; before: string | null; after: string | null; verb: string; relPath: string } | null>(null);
async function loadDetail(): Promise<void> {
const id = $page.params.id;
const res = await fetch(`/api/deployments/${id}`);
detail = (await res.json()) as Detail;
}
let poll: ReturnType<typeof setInterval> | null = null;
onMount(() => {
loadDetail();
poll = setInterval(loadDetail, 5000);
});
onDestroy(() => {
if (poll) clearInterval(poll);
});
async function loadDiff(f: FileRow) {
const res = await fetch(`/api/deployments/${$page.params.id}/file/${f.id}`);
const body = (await res.json()) as { before: string | null; after: string | null; verb: string; relPath: string };
openFile = { id: f.id, ...body };
}
let byServer = $derived.by(() => {
if (!detail) return new Map<string, FileRow[]>();
const m = new Map<string, FileRow[]>();
for (const f of detail.files) {
if (!m.has(f.server)) m.set(f.server, []);
m.get(f.server)!.push(f);
}
return m;
});
function stateVariant(s: string): 'online' | 'danger' | 'warning' | 'info' | 'neutral' {
return s === 'done' ? 'online' :
s === 'failed' ? 'danger' :
s === 'pending' ? 'warning' :
s === 'held' ? 'info' :
'neutral';
}
function verbVariant(v: string): 'online' | 'info' | 'danger' | 'warning' | 'neutral' {
return v === 'added' ? 'online' :
v === 'updated' ? 'info' :
v === 'removed' ? 'danger' :
v === 'held' ? 'warning' :
'neutral';
}
function fmt(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit'
});
}
</script>
{#if detail}
{@const action = detail.deployment.action}
<div class="page">
<SectionHeader title={action} subtitle={detail.deployment.id}>
{#snippet crumbs()}
<Breadcrumbs items={[
{ label: 'Deployments', href: '/deployments' },
{ label: action }
]} />
{/snippet}
</SectionHeader>
<Card padding="lg">
<div class="meta-grid">
<div class="kv"><span class="k">Started</span><span class="v mono">{fmt(detail.deployment.started_at)}</span></div>
<div class="kv"><span class="k">Finished</span><span class="v mono">{fmt(detail.deployment.finished_at)}</span></div>
<div class="kv"><span class="k">Triggered by</span><span class="v mono">{detail.deployment.triggered_by ?? '—'}</span></div>
<div class="kv"><span class="k">Servers</span><span class="v mono">{detail.servers.length}</span></div>
</div>
</Card>
<section class="section">
<div class="section-head">
<h2>Servers</h2>
<Badge variant="neutral" size="sm">{detail.servers.length}</Badge>
</div>
<div class="server-list">
{#each detail.servers as s (s.server)}
<div class="srv-row">
<div class="srv-l">
<a class="srv-name" href="/servers/{s.server}">{s.server}</a>
{#if s.error}
<span class="err">{s.error}</span>
{/if}
</div>
<div class="srv-r">
<Badge variant={stateVariant(s.restart_state)} size="sm" uppercase>{s.restart_state}</Badge>
{#if s.players !== null && s.players > 0}
<span class="players">{s.players}p</span>
{/if}
<span class="attempts">{s.attempts} attempt{s.attempts === 1 ? '' : 's'}</span>
{#if s.restarted_at}
<span class="when">restarted {fmt(s.restarted_at)}</span>
{/if}
</div>
</div>
{/each}
</div>
</section>
<section class="section">
<div class="section-head">
<h2>Files</h2>
<Badge variant="neutral" size="sm">{detail.files.length}</Badge>
</div>
{#if detail.files.length === 0}
<EmptyState title="No file changes recorded" description="This deployment didn't touch any tracked artifacts." />
{:else}
{#each [...byServer.entries()] as [srv, files] (srv)}
<div class="file-group">
<h3>{srv}</h3>
<div class="file-rows">
{#each files as f (f.id)}
<button class="file-row" onclick={() => loadDiff(f)}>
<Badge variant={verbVariant(f.verb)} size="sm" uppercase>{f.verb}</Badge>
<span class="path">{f.rel_path}</span>
<span class="sizes">
{#if f.size_before !== null}{f.size_before} B{/if}
{#if f.size_before !== null && f.size_after !== null} → {/if}
{#if f.size_after !== null}{f.size_after} B{/if}
</span>
</button>
{/each}
</div>
</div>
{/each}
{/if}
</section>
{#if openFile}
<div
class="diff-scrim"
onclick={() => (openFile = null)}
onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }}
role="button"
tabindex="-1"
aria-label="Close diff"
>
<div class="diff-modal" onclick={(e) => e.stopPropagation()} role="none">
<div class="diff-head">
<span class="diff-title">{openFile.relPath}</span>
<Button variant="ghost" size="sm" onclick={() => (openFile = null)}>Close</Button>
</div>
<DiffViewer
before={openFile.before}
after={openFile.after}
verb={openFile.verb}
relPath={openFile.relPath}
/>
</div>
</div>
{/if}
</div>
{:else}
<div class="loading">Loading…</div>
{/if}
<style>
.page {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.loading {
text-align: center;
padding: var(--space-16);
color: var(--text-tertiary);
}
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-2);
}
.kv {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-3) var(--space-4);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.kv .k {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
font-weight: var(--weight-semibold);
}
.kv .v { font-size: var(--text-sm); color: var(--text-primary); }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.section { display: flex; flex-direction: column; gap: var(--space-3); }
.section-head {
display: flex;
align-items: center;
gap: var(--space-3);
}
.section-head h2 {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.server-list { display: flex; flex-direction: column; gap: var(--space-2); }
.srv-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
background: color-mix(in oklab, var(--surface) 88%, transparent);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.srv-l { display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; }
.srv-r { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.srv-name {
font-family: var(--font-body);
font-weight: var(--weight-semibold);
font-size: var(--text-sm);
color: var(--text-primary);
text-decoration: none;
}
.srv-name:hover { color: var(--accent-primary-hover); }
.err { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--state-danger); }
.players { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--state-online); }
.attempts, .when {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.file-group {
margin-top: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.file-group h3 {
margin: 0;
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-secondary);
font-weight: var(--weight-medium);
}
.file-rows { display: flex; flex-direction: column; gap: 2px; }
.file-row {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
text-align: left;
padding: var(--space-2) var(--space-3);
background: transparent;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
cursor: pointer;
font: inherit;
transition: background-color var(--dur-fast) var(--ease-standard);
}
.file-row:hover { background: var(--surface-raised); }
.file-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.path {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
word-break: break-all;
}
.sizes {
margin-left: auto;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
white-space: nowrap;
}
/* Diff modal */
.diff-scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 100;
display: grid;
place-items: center;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.diff-modal {
width: min(1000px, 92vw);
max-height: 92vh;
overflow: auto;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-raised);
}
.diff-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
.diff-title {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
word-break: break-all;
}
</style>
<script lang="ts">import { page } from "$app/stores";</script>
<p>This rollout is now a campaign. <a href="/campaigns/{$page.params.id}">View campaign</a></p>
+5 -5
View File
@@ -98,7 +98,7 @@
async function loadServer(): Promise<void> {
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'}
<Card padding="lg">
<div class="panel-head">
<h2>Deployment history</h2>
<h2>Campaign history</h2>
<span class="panel-count">{history.length}</span>
</div>
{#if history.length === 0}
<EmptyState
title="No deployments yet"
title="No campaigns yet"
description="Nothing has been pushed to this server. Open a beta or push scripts to see history here."
/>
{:else}
<div class="history">
{#each history as d (d.id)}
{@const mine = d.servers.find((s) => s.server === name)!}
<a class="hist-row s-{mine.restart_state}" href="/deployments/{d.id}">
<a class="hist-row s-{mine.restart_state}" href="/campaigns/{d.id}">
<span class="hist-action">{d.action}</span>
<span class="hist-state">
<Badge
+36
View File
@@ -53,6 +53,42 @@ async function promotedConfig() {
}
describe('server creation map selection', () => {
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');