Add operation flows and activity logs for betas and campaigns

This commit is contained in:
2026-09-17 19:34:26 -04:00
parent 9dcf4ac1d3
commit 5b5266da3a
8 changed files with 312 additions and 16 deletions
+3 -1
View File
@@ -1,4 +1,5 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { getBeta, listBetaServers, listBetaScripts } from '$lib/server/fleet/beta.js';
import { getScript, getVersion } from '$lib/server/fleet/scripts.js';
@@ -28,5 +29,6 @@ export async function GET({ params }: { params: { id: string } }) {
});
const servers = listBetaServers(id);
return json({ beta, scripts, servers });
const events = db().prepare('SELECT id, created_at, message FROM beta_events WHERE beta_id = ? ORDER BY id').all(id);
return json({ beta, scripts, servers, events });
}
+2 -1
View File
@@ -38,5 +38,6 @@ export async function GET({ params }: { params: { id: string } }) {
)
.all(params.id);
return json({ deployment: dep, servers, files, scripts });
const events = db().prepare('SELECT id, created_at, message FROM campaign_events WHERE deployment_id = ? ORDER BY id').all(params.id);
return json({ deployment: dep, servers, files, scripts, events });
}
+33 -10
View File
@@ -7,6 +7,8 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import OperationHistory from '$ui/components/OperationHistory.svelte';
import { betaFlow, type ActivityEvent } from '$lib/operation-flow.js';
interface BetaRow {
id: number; script_id: number; script_version_id: number;
@@ -31,6 +33,9 @@
let beta = $state<BetaRow | null>(null);
let scripts = $state<BetaScript[]>([]);
let servers = $state<BetaServerRow[]>([]);
let events = $state<ActivityEvent[]>([]);
let loadError = $state('');
let request = 0;
let loading = $state(true);
let flash = $state<string | null>(null);
let acting = $state<string | null>(null);
@@ -38,18 +43,24 @@
let id = $derived(Number.parseInt($page.params.id ?? '0', 10));
async function load(): Promise<void> {
const res = await fetch(`/api/betas/${id}`);
if (!res.ok) { loading = false; return; }
const body = (await res.json()) as { beta: BetaRow; scripts: BetaScript[]; servers: BetaServerRow[]; };
beta = body.beta;
scripts = body.scripts;
servers = body.servers;
loading = false;
const current = ++request;
const requestedId = id;
try {
const res = await fetch(`/api/betas/${requestedId}`);
if (!res.ok) throw new Error(res.status === 404 ? 'Beta not found.' : 'Could not refresh beta.');
const body = await res.json();
if (current !== request || requestedId !== id) return;
beta = body.beta; scripts = body.scripts; servers = body.servers; events = body.events;
loadError = '';
} catch (err) {
if (current === request && requestedId === id) loadError = (err as Error).message;
} finally { if (current === request && requestedId === id) loading = false; }
}
$effect(() => { id; beta = null; loading = true; loadError = ''; void load(); });
let poll: ReturnType<typeof setInterval> | null = null;
onMount(() => {
load();
poll = setInterval(load, 5000);
});
onDestroy(() => { if (poll) clearInterval(poll); });
@@ -106,6 +117,9 @@
}
</script>
<svelte:head><title>Beta #{id} | Electric Cherry</title></svelte:head>
{#if loadError}<p role="alert" class="err">{loadError} <button onclick={load}>Retry</button></p>{/if}
{#if loading}
<div class="loading">Loading…</div>
{:else if !beta}
@@ -122,6 +136,15 @@
{/snippet}
</SectionHeader>
<Card padding="lg">
<div class="opts">
<div class="opt"><span class="opt-k">Started</span><span class="opt-v">{new Date(b.opened_at).toLocaleString()}</span></div>
<div class="opt"><span class="opt-k">Finished</span><span class="opt-v">{b.closed_at ? new Date(b.closed_at).toLocaleString() : 'In progress'}</span></div>
</div>
</Card>
<OperationHistory steps={betaFlow(b, servers)} {events} active={b.state === 'running' || b.state === 'ready-promote'} />
<!-- Artifacts -->
<Card padding="lg">
<div class="panel-head">
@@ -173,9 +196,9 @@
<Badge variant={stageVariant(bs.stage)} size="sm" uppercase>{bs.stage}</Badge>
<div class="ticks" aria-label="pipeline progress">
<span class:on={bs.deployed_at}>Deploy</span>
<span class:on={bs.stage !== 'awaiting-empty' && bs.stage !== 'deploying'}>Empty</span>
<span class:on={bs.restarted_at || bs.stage === 'restarting'}>Empty</span>
<span class:on={bs.restarted_at}>Restart</span>
<span class:on={bs.healthy_at}>Healthy</span>
{#if b.wait_for_health}<span class:on={bs.healthy_at}>Healthy</span>{/if}
{#if b.wait_for_match}
<span class:on={bs.match_seen_at}>Match</span>
{/if}
+27 -4
View File
@@ -8,6 +8,8 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import OperationHistory from '$ui/components/OperationHistory.svelte';
import { campaignFlow, type ActivityEvent } from '$lib/operation-flow.js';
interface FileRow {
id: number; server: string; verb: string; rel_path: string;
@@ -27,20 +29,32 @@
};
servers: ServerRow[];
files: FileRow[];
events: ActivityEvent[];
}
let detail = $state<Detail | null>(null);
let loadError = $state('');
let request = 0;
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;
const current = ++request;
try {
const res = await fetch(`/api/campaigns/${encodeURIComponent(id ?? '')}`);
if (!res.ok) throw new Error(res.status === 404 ? 'Campaign not found.' : 'Could not refresh campaign.');
const body = await res.json() as Detail;
if (current !== request || id !== $page.params.id) return;
detail = body; loadError = '';
} catch (err) {
if (current === request && id === $page.params.id) loadError = (err as Error).message;
}
}
$effect(() => { $page.params.id; detail = null; openFile = null; loadError = ''; void loadDetail(); });
let poll: ReturnType<typeof setInterval> | null = null;
onMount(() => {
loadDetail();
poll = setInterval(loadDetail, 5000);
});
onDestroy(() => {
@@ -86,6 +100,9 @@
}
</script>
<svelte:head><title>Campaign | Electric Cherry</title></svelte:head>
{#if loadError}<p role="alert" class="err">{loadError} <button onclick={loadDetail}>Retry</button></p>{/if}
{#if detail}
{@const action = detail.deployment.action}
<div class="page">
@@ -107,6 +124,12 @@
</div>
</Card>
<OperationHistory
steps={campaignFlow(detail.servers, Boolean(detail.deployment.finished_at))}
events={detail.events}
active={detail.servers.some(s => s.restart_state === 'pending' || s.restart_state === 'failed')}
/>
<section class="section">
<div class="section-head">
<h2>Servers</h2>
@@ -189,7 +212,7 @@
</div>
{/if}
</div>
{:else}
{:else if !loadError}
<div class="loading">Loading…</div>
{/if}