Files
electric-cherry/src/routes/campaigns/+page.svelte
T
josh 981fb27c1c Preserve deleted server history
Retain historical server identities, results, and evidence after deletion. Mark archived entries as Deleted and prevent old work from targeting servers that reuse the same name.

Fixes #16.
2026-09-27 21:48:55 -04:00

425 lines
16 KiB
Svelte

<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import { serverTitle } from '$ui/server-labels.js';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.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;
server_deleted_at?: string | null;
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[];
}
let loadError = $state('');
let limit = $state(25);
let deployments = $state<Deployment[]>([]);
let loading = $state(true);
let rollingBack = $state<string | null>(null);
let flash = $state<string | null>(null);
let flashError = $state(false);
let flashId = $state('');
let serverFilter = $state('');
$effect(() => {
serverFilter = $page.url.searchParams.get('server') ?? '';
limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25);
});
function saveContext(reset = false) {
if (reset) limit = 25;
const url = new URL($page.url);
if (serverFilter) url.searchParams.set('server', serverFilter); else url.searchParams.delete('server');
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
async function loadCampaigns(): Promise<void> {
try {
const res = await fetch('/api/campaigns');
if (!res.ok) throw new Error('Could not refresh campaigns.');
deployments = (await res.json()).deployments; loadError = '';
} catch (error) { loadError = (error as Error).message; }
finally { loading = false; }
}
onMount(() => pollVisible(loadCampaigns, 5000));
function isActive(d: Deployment): boolean {
return d.servers.some((s) => !s.server_deleted_at && (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`;
}
const matching = $derived(deployments.filter(d => !serverFilter || d.servers.some(server =>
(server.server + ' ' + serverTitle(server.server)).toLowerCase().includes(serverFilter.toLowerCase())
)));
let active = $derived(matching.filter(isActive));
let history = $derived(matching.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 (!await confirmAction({ title:'Roll back promotion?', message:`Restore the previous version of ${summary} on ${d.servers.filter(server => !server.server_deleted_at).length} servers.`, confirmLabel:'Roll back', danger:true })) return;
rollingBack = d.id;
flash = null; flashId = d.id; flashError = false;
try {
const res = await fetch(`/api/campaigns/${d.id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!res.ok || !body.ok) { flashError = true; flash = body.error ?? 'Rollback failed'; return; }
flash = 'Rollback started';
await loadCampaigns();
} catch (error) { flashError = true; flash = (error as Error).message; } 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.slice(0,limit)) {
const key = $timeFormat.date(d.started_at);
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(d);
}
return Array.from(groups.entries());
});
</script>
<svelte:head><title>Campaigns | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader
title="Campaigns"
subtitle="Track file campaigns, server activation, and rollbacks."
/>
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={loadCampaigns}>Retry</Button></div>{/if}
{#if loading}
<div class="loading">Loading…</div>
{:else}
<label class="server-filter">Server <input type="search" bind:value={serverFilter} oninput={event => { serverFilter = event.currentTarget.value; saveContext(true); }} placeholder="Filter campaigns by server name" /></label>
<!-- 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="Promote a beta to roll out tested files to the fleet."
/>
{: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>
<ListFooter total={history.length} bind:limit onchange={() => saveContext()} label="campaigns" />
{/if}
</div>
{#snippet deployRow(d: Deployment)}
<article class="deploy-row">
{#if flash && flashId === d.id}<div class="result" class:result-error={flashError} role={flashError ? 'alert' : 'status'}>{flash}</div>{/if}
<div class="row-head">
<div class="row-head-l">
<a class="action" href="/campaigns/{d.id}">{d.action} campaign <span class="sr-only">{d.id}</span></a>
{#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 && d.servers.some(server => !server.server_deleted_at)}
<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"><HistoricalServerIdentity name={s.server} deletedAt={s.server_deleted_at} href={`/servers/${encodeURIComponent(s.server)}?tab=diagnostics`} />{s.players !== null && s.players > 0 ? ` (${s.players} players at last check)` : ''}</span><StatusBadge state={s.restart_state} />
</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}
<span class="state-count">{n} <StatusBadge {state} size="sm" /></span>
{/if}
{/each}
</div>
</div>
</article>
{/snippet}
<style>
.server-filter { display: grid; gap: 8px; max-width: 400px; font-size: var(--text-sm); color: var(--text-secondary); }
.server-filter input { padding: 10px 12px; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); font: inherit; }
.result { padding: 16px; border: 1px solid var(--state-online); border-radius: var(--radius-md); line-height: 1.6; }
.result-error { border-color: var(--state-warning); }
.page {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.loading {
text-align: center;
padding: var(--space-16);
color: var(--text-tertiary);
}
/* 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; }
}
.state-count { display:inline-flex; align-items:center; gap:5px; }
.action { color:var(--text-primary); text-decoration:underline; text-underline-offset:4px; }
</style>