Separate campaigns from server deployment lifecycle tracking
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '[]'
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') }
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() }); }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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> {
|
||||
const res = await fetch('/api/deployments');
|
||||
const body = (await res.json()) as { deployments: Deployment[] };
|
||||
deployments = body.deployments;
|
||||
}
|
||||
async function scanDrift(): Promise<void> {
|
||||
scanning = true;
|
||||
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/drift');
|
||||
drift = (await res.json()) as DriftPreview;
|
||||
} finally {
|
||||
scanning = false;
|
||||
}
|
||||
const res = await fetch('/api/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; }
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
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];
|
||||
}
|
||||
|
||||
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}
|
||||
</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}
|
||||
</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 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}
|
||||
</section>
|
||||
{/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>
|
||||
<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>
|
||||
<section>
|
||||
<h2>History</h2>
|
||||
<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 !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>
|
||||
</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>
|
||||
<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><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>
|
||||
<Badge variant={d.state === 'done' ? 'online' : d.state === 'failed' ? 'danger' : 'warning'}>{status(d)}</Badge>
|
||||
</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));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user