diff --git a/src/lib/ui/campaigns.ts b/src/lib/ui/campaigns.ts index db97e61..6c99ff9 100644 --- a/src/lib/ui/campaigns.ts +++ b/src/lib/ui/campaigns.ts @@ -2,6 +2,40 @@ import { statusPresentation, type StatusTone } from './status.js'; export type CampaignServerGroup = 'attention' | 'processing' | 'queued' | 'complete' | 'inactive'; +export interface CampaignSummary { + action: string; + beta_id: number | null; + rolled_back_by: string | null; + script_name: string | null; + script_version: number | null; + scripts: { script_name: string | null; script_version: number | null }[]; + servers: { restart_state: string; server_deleted_at?: string | null }[]; +} + +export function campaignTitle(campaign: CampaignSummary): string { + const action = campaign.action === 'drift' ? 'Maintenance repair' + : campaign.action.charAt(0).toUpperCase() + campaign.action.slice(1); + if (campaign.action === 'drift') return action; + if (campaign.action === 'rollback' && campaign.beta_id) return `${action} · Beta #${campaign.beta_id}`; + if (campaign.scripts.length > 1) return `${action} · ${campaign.scripts.length} scripts`; + const script = campaign.scripts[0] ?? campaign; + if (script.script_name) return `${action} · ${script.script_name}${script.script_version !== null ? ` v${script.script_version}` : ''}`; + return campaign.beta_id ? `${action} · Beta #${campaign.beta_id}` : action; +} + +export function campaignOutcome(campaign: Pick): { + status: 'attention' | 'progress' | 'rolled-back' | 'complete' | 'inactive'; label: string; tone: StatusTone; completed: number; +} { + const completed = campaign.servers.filter(server => server.restart_state === 'done').length; + const live = campaign.servers.filter(server => !server.server_deleted_at); + const attention = live.filter(server => ['failed', 'held', 'manual'].includes(server.restart_state)); + if (attention.length) return { status: 'attention', label: `${attention.length} need attention`, tone: attention.some(server => server.restart_state === 'failed') ? 'danger' : 'warning', completed }; + if (live.some(server => server.restart_state === 'pending')) return { status: 'progress', label: `${completed}/${campaign.servers.length} complete`, tone: 'info', completed }; + if (campaign.rolled_back_by) return { status: 'rolled-back', label: 'Rolled back', tone: 'neutral', completed }; + if (campaign.servers.length && completed === campaign.servers.length) return { status: 'complete', label: 'Complete', tone: 'online', completed }; + return { status: 'inactive', label: completed ? `${completed}/${campaign.servers.length} complete` : 'Inactive', tone: 'neutral', completed }; +} + /** Progress comes from recorded state, not the error field, which also contains routine stage messages. */ export function campaignServerStatus(server: { restart_state: string; server_deleted_at?: string | null; restarted_at: string | null; diff --git a/src/routes/api/campaigns/+server.ts b/src/routes/api/campaigns/+server.ts index dabf17f..b498c18 100644 --- a/src/routes/api/campaigns/+server.ts +++ b/src/routes/api/campaigns/+server.ts @@ -6,16 +6,38 @@ export async function GET({ url }: { url: URL }) { const activeOnly = url.searchParams.get('active') === 'true'; const historyOnly = url.searchParams.get('view') === 'history'; const server = url.searchParams.get('server'); - const query = (url.searchParams.get('q') ?? '').slice(0, 100).toLowerCase(); + const query = (url.searchParams.get('q') ?? '').slice(0, 100).trim().toLowerCase(); + const action = url.searchParams.get('action'); + const status = url.searchParams.get('status'); const limit = Math.min(500, Math.max(1, Math.floor(Number(url.searchParams.get('limit')) || 25))); const offset = Math.min(1_000_000, Math.max(0, Math.floor(Number(url.searchParams.get('offset')) || 0))); - const active = `EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.server_deleted_at IS NULL AND a.restart_state IN ('pending','failed'))`; + const attention = `EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.server_deleted_at IS NULL AND a.restart_state IN ('failed','held','manual'))`; + const pending = `EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.server_deleted_at IS NULL AND a.restart_state='pending')`; + const active = `(${attention} OR ${pending})`; + // Keep these outcomes aligned with campaignOutcome, including historical deleted servers. + const outcome = `CASE WHEN ${attention} THEN 'attention' WHEN ${pending} THEN 'progress' + WHEN d.rolled_back_by IS NOT NULL THEN 'rolled-back' + WHEN EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id) + AND NOT EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.restart_state != 'done') THEN 'complete' + ELSE 'inactive' END`; const names = query ? (db().prepare('SELECT DISTINCT server FROM deployment_servers').all() as { server: string }[]) .map(row => row.server).filter(name => `${name} ${serverTitle(name)}`.toLowerCase().includes(query)) : []; - const where = `(? IS NULL OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id=d.id AND target.server=?)) - ${activeOnly ? `AND ${active}` : historyOnly ? `AND NOT ${active}` : ''} - ${query ? `AND EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id=d.id AND target.server IN (${names.map(() => '?').join(',') || 'NULL'}))` : ''}`; - const parameters = [server, server, ...names]; + const conditions = ['(? IS NULL OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id=d.id AND target.server=?))']; + const parameters: (string | null)[] = [server, server]; + if (activeOnly) conditions.push(active); + else if (historyOnly) conditions.push(`NOT ${active}`); + if (action && ['promote', 'rollback', 'drift'].includes(action)) { conditions.push('d.action=?'); parameters.push(action); } + if (status && ['attention', 'progress', 'complete', 'rolled-back', 'inactive'].includes(status)) { conditions.push(`(${outcome})=?`); parameters.push(status); } + if (query) { + conditions.push(`(instr(lower(d.id), ?) > 0 + OR instr(lower(CASE WHEN d.action='drift' THEN 'maintenance repair' ELSE d.action END), ?) > 0 + OR instr(lower('Beta #' || d.beta_id), ?) > 0 + OR EXISTS (SELECT 1 FROM scripts s WHERE s.id=d.script_id AND instr(lower(s.name), ?) > 0) + OR EXISTS (SELECT 1 FROM deployment_scripts ds JOIN scripts s ON s.id=ds.script_id WHERE ds.deployment_id=d.id AND instr(lower(s.name), ?) > 0) + OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id=d.id AND target.server IN (${names.map(() => '?').join(',') || 'NULL'})))`); + parameters.push(query, query, query, query, query, ...names); + } + const where = conditions.join(' AND '); const { total } = db().prepare(`SELECT COUNT(*) AS total FROM deployments d WHERE ${where}`).get(...parameters) as { total: number }; const deployments = db() @@ -105,8 +127,7 @@ export async function GET({ url }: { url: URL }) { ...d, servers: grouped.get(d.id) ?? [], scripts: scriptsByDep.get(d.id) ?? [] - })) - .filter((d) => (activeOnly ? d.servers.some((s) => !s.server_deleted_at && (s.restart_state === 'pending' || s.restart_state === 'failed')) : true)); + })); return json({ deployments: rows, total, limit, offset }); } diff --git a/src/routes/campaigns/+page.svelte b/src/routes/campaigns/+page.svelte index a05716c..e274f1a 100644 --- a/src/routes/campaigns/+page.svelte +++ b/src/routes/campaigns/+page.svelte @@ -1,438 +1,187 @@ Campaigns | Electric Cherry
- - + +
+ + + + {#if filtered}{/if} +
{#if loadError}{/if} {#if loading} -
Loading…
+
Loading campaigns…
{:else} - - - -
-
-

Active

- {active.length} -
- {#if active.length === 0} - +
+ {#if active.length} +
+

Active {active.length}

+ {@render campaignTable(active, true)} +
{:else} -
- {#each active as d (d.id)} - {@render deployRow(d)} - {/each} -
+

{filtered ? 'No active campaigns match these filters.' : 'No active campaigns.'}

{/if} -
- - -
-
-

History

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

History {historyTotal}

+ {#if history.length} + {@render campaignTable(history)} + {#if historyTotal > 25} + -
- {/each} - {/if} -
- saveContext()} label="campaigns" /> + {/if} + {:else} + + {/if} + +
{/if} -{#snippet deployRow(d: Deployment)} -
- {#if flash && flashId === d.id}
{flash}
{/if} -
-
- {d.action} campaign {d.id} - {#if d.action === 'promote' || d.action === 'rollback'} - {scriptSummary(d)} - {/if} - {#if d.rolled_back_by} - Rolled back - {/if} -
-
- {#if d.action === 'promote' && !d.rolled_back_by && d.servers.some(server => !server.server_deleted_at)} - - {/if} - {ago(d.started_at)} -
-
- -
- {#each d.servers as s (s.server)} - - - {s.players !== null && s.players > 0 ? ` (${s.players} players at last check)` : ''} - - {/each} -
-
- {d.id} -
- {#each ['done','pending','failed','held','manual'] as state} - {@const n = d.servers.filter((s) => s.restart_state === state).length} - {#if n > 0} - {n} - {/if} +{#snippet campaignTable(rows: Deployment[], showProgress = false)} +
+ + + + + + {#each rows as campaign (campaign.id)} + {@const outcome = campaignOutcome(campaign)} + + + + + + {/each} - - - + +
{showProgress ? 'Active campaigns' : 'Campaign history'}
CampaignServersStatusStarted
{campaignTitle(campaign)}{campaign.servers.length}
{outcome.label} + {#if showProgress}{/if} +
+
{/snippet} diff --git a/src/routes/campaigns/[id]/+page.svelte b/src/routes/campaigns/[id]/+page.svelte index 4df3c99..8ac84fb 100644 --- a/src/routes/campaigns/[id]/+page.svelte +++ b/src/routes/campaigns/[id]/+page.svelte @@ -6,6 +6,8 @@ import Dialog from '$ui/components/primitives/Dialog.svelte'; import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte'; import { page } from '$app/stores'; + import { goto } from '$app/navigation'; + import { confirmAction } from '$ui/confirm.js'; import DiffViewer from '$ui/components/DiffViewer.svelte'; import Badge from '$ui/components/primitives/Badge.svelte'; import Button from '$ui/components/primitives/Button.svelte'; @@ -35,6 +37,7 @@ deployment: { id: string; action: string; started_at: string; finished_at: string | null; triggered_by: string | null; beta_id?: number | null; + rolled_back_by: string | null; }; servers: ServerRow[]; files: FileRow[]; @@ -46,6 +49,8 @@ let loadError = $state(''); let resuming = $state(false); let resumeError = $state(''); + let rollingBack = $state(false); + let rollbackError = $state(''); let request = 0; let diffOpen = $state(false); let diffError = $state(''); @@ -96,7 +101,7 @@ } } - $effect(() => { $page.params.id; detail = null; openFile = null; diffOpen = false; loadError = ''; filter = 'all'; expandedServers = []; copyStatus = ''; void loadDetail(); }); + $effect(() => { $page.params.id; detail = null; openFile = null; diffOpen = false; loadError = ''; rollbackError = ''; filter = 'all'; expandedServers = []; copyStatus = ''; void loadDetail(); }); onMount(() => pollVisible(loadDetail, 5000, false)); @@ -110,6 +115,24 @@ finally { resuming = false; } } + async function rollback() { + if (!detail || rollingBack) return; + const id = detail.deployment.id; + const servers = detail.servers.filter(server => !server.server_deleted_at).length; + if (!await confirmAction({ title: 'Roll back promotion?', message: `Restore the previous files on ${servers} server${servers === 1 ? '' : 's'}. Changes will wait for safe activation.`, confirmLabel: 'Roll back', danger: true })) return; + if ($page.params.id !== id) return; + rollingBack = true; rollbackError = ''; + try { + const response = await fetch(`/api/campaigns/${encodeURIComponent(id)}/rollback`, { method: 'POST' }); + const body = await response.json(); + if (!response.ok || !body.ok) throw new Error(body.error ?? 'Could not roll back promotion.'); + if ($page.params.id !== id) return; + await goto(contextHref(`/campaigns/${encodeURIComponent(body.result.deploymentId)}`, $page.url)); + } catch (error) { + if ($page.params.id === id) rollbackError = (error as Error).message; + } finally { rollingBack = false; } + } + async function loadDiff(f: FileRow) { retryFile = f; const id = $page.params.id; @@ -158,7 +181,15 @@ { label: action } ]} /> {/snippet} + {#snippet actions()} + {#if detail?.deployment.rolled_back_by} + + {:else if detail && action === 'promote' && !detail.fileContentsExpired && detail.servers.some(server => !server.server_deleted_at)} + + {/if} + {/snippet} + {#if rollbackError}{/if}