Files
electric-cherry/src/routes/betas/[id]/+page.svelte
T
josh fb48f64de1 Defer server file changes until guarded restart activation
Persist beta, promotion, drift, and rollback payloads until occupancy and restart-capacity checks pass. Preserve rollback snapshots and campaign ordering, and allow held rollback campaigns to resume safely.

Fixes #28

Validation: 805 tests passed, Svelte checks clean, production build passed.
2026-09-27 23:06:16 -04:00

195 lines
18 KiB
Svelte

<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import type { BetaTestView } from '$lib/beta-plan.js';
import type { BetaPromotionPreview } from '$lib/beta-promotion.js';
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
import type { ActivityEvent } from '$lib/operation-flow.js';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import BetaTestProgress from '$ui/components/BetaTestProgress.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import OperationHistory from '$ui/components/OperationHistory.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import Card from '$ui/components/primitives/Card.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Dialog from '$ui/components/primitives/Dialog.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 BetaRow { id:number; state:string; opened_at:string; closed_at:string|null; wait_for_health:number; wait_for_match:number; notes?:string|null; }
interface BetaServer { server:string; server_deleted_at?:string|null; stage:string; error:string|null; deployed_at:string|null; restarted_at:string|null; healthy_at:string|null; match_seen_at:string|null; provisioning_id:string|null; }
interface BetaFile { id:number; name:string; kind:string; target_rel_path:string; version:{id:number; version:number; release_version:string|null; sha256:string}|null; }
interface Promotion { id:string; pending:number; failed:number; }
let test = $state<BetaTestView|null>(null);
let beta = $state<BetaRow|null>(null);
let scripts = $state<BetaFile[]>([]);
let servers = $state<BetaServer[]>([]);
let events = $state<ActivityEvent[]>([]);
let promotion = $state<Promotion|null>(null);
let rollbackCampaign = $state<{ id: string }|null>(null);
let loading = $state(true), loadError = $state(''), flash = $state(''), success = $state('');
let acting = $state<string|null>(null), request = 0;
let reviewOpen = $state(false), preview = $state<BetaPromotionPreview|null>(null), reviewError = $state('');
let now = $state(Date.now());
const id = $derived(Number($page.params.id));
const closed = $derived(beta?.state === 'promoted' || beta?.state === 'rolled-back');
const hasDeletedServers = $derived(servers.some(server => server.server_deleted_at));
const deadline = $derived(beta && test ? new Date(Date.parse(beta.opened_at) + test.plan.timeoutHours * 3600000) : null);
const remaining = $derived(deadline ? Math.max(0, Math.ceil((deadline.getTime() - now) / 60000)) : 0);
const deadlineText = $derived(remaining > 0 ? `${Math.floor(remaining / 60)}h ${remaining % 60}m remaining` : 'Testing window ended');
const passed = $derived(test?.servers.filter(server => server.passed).length ?? servers.filter(server => server.stage === 'ready').length);
const ranked = $derived([...servers].sort((a,b) => (a.stage === 'failed' ? 0 : a.stage === 'ready' ? 2 : 1) - (b.stage === 'failed' ? 0 : b.stage === 'ready' ? 2 : 1) || a.server.localeCompare(b.server)));
const waiting = $derived(ranked.filter(server => server.stage !== 'ready'));
const nextAction = $derived(closed ? beta?.state === 'promoted' ? 'Follow the campaign to check server activation.' : rollbackCampaign ? 'Follow the rollback campaign to resume held changes or check restoration and restart progress.' : 'Reuse these files and this plan when you are ready to test again.'
: hasDeletedServers ? 'A participating server was deleted. Its results remain below. Roll back, then reuse this plan to test the current servers.'
: test?.outcome === 'inconclusive' ? 'The deadline passed without enough evidence. Roll back, then reuse this plan with a longer testing window.'
: test?.outcome === 'paused' ? 'Resume to continue collecting evidence, or start a fresh attempt. The original deadline still applies.'
: test?.outcome === 'failed' ? 'Investigate the failed check before a fresh attempt, or roll back.'
: beta?.state === 'ready-promote' ? 'All required coverage passed. Review the full promotion impact before deploying to additional servers.'
: 'Review the outstanding conditions below. Promotion becomes available when the required coverage passes.');
async function load() {
const current = ++request, 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. Showing the last loaded results.');
const body = await res.json();
if (current !== request || requestedId !== id) return;
test = body.test; beta = body.beta; scripts = body.scripts; servers = body.servers; events = body.events; promotion = body.promotion ?? null;
rollbackCampaign = body.rollback ?? null;
now = Date.now(); loadError = '';
} catch (error) { if (current === request && requestedId === id) loadError = (error as Error).message; }
finally { if (current === request && requestedId === id) loading = false; }
}
$effect(() => { id; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; void load(); });
onMount(() => pollVisible(load, 5000, false));
async function reviewPromotion() {
reviewOpen = true; preview = null; reviewError = ''; acting = 'preview';
try {
const response = await fetch(`/api/betas/${id}/promote`), body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not prepare promotion review.');
preview = body.preview;
} catch (error) { reviewError = (error as Error).message; }
finally { acting = null; }
}
async function promote() {
if (!preview) return;
acting = 'promote'; reviewError = ''; flash = ''; success = '';
try {
const response = await fetch(`/api/betas/${id}/promote`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({previewToken:preview.token})});
const body = await response.json();
if (!response.ok || !body.ok) {
if (body.kind === 'preview-changed' && body.details) preview = body.details;
throw new Error(body.error ?? 'Promotion could not be started.');
}
reviewOpen = false;
success = `Promotion created. ${body.result.awaitingActivation ?? 0} servers await activation${body.result.failedServers?.length ? `; ${body.result.failedServers.length} servers need attention` : ''}.`;
await load();
} catch (error) { reviewError = (error as Error).message; }
finally { acting = null; }
}
async function rollback() {
const reject = beta?.state === 'ready-promote';
const restarts = test?.plan.rollbackRestart === 'manual' ? 'Resume the rollback campaign when ready; files stay unchanged until safety checks pass.' : 'Files are restored just before restarting, after safety checks pass.';
if (!await confirmAction({title:reject?'Reject beta?':'Roll back beta?',message:`Restore the original files on ${servers.filter(server => !server.server_deleted_at).length} beta servers${reject ? ' and mark these versions as test-failed' : ''}. ${restarts}`,confirmLabel:reject?'Reject beta':'Roll back',danger:true})) return;
acting = 'rollback'; flash = ''; success = '';
try {
const response = await fetch(`/api/betas/${id}/rollback`,{method:'POST'}), body = await response.json();
if (!response.ok || !body.ok) throw new Error(body.error ?? 'Rollback failed.');
success = `Rollback queued. ${restarts}`; await load();
} catch(error) { flash = (error as Error).message; }
finally { acting = null; }
}
async function retry(server:string) {
acting = `retry:${server}`; flash = '';
try {
const response = await fetch(`/api/betas/${id}/retry`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({server})}), body = await response.json();
if (!response.ok || !body.ok) throw new Error(body.error ?? 'Retry failed.');
await load();
} catch(error) { flash = (error as Error).message; }
finally { acting = null; }
}
function outstanding(server:string) {
const progress = test?.servers.find(row => row.server === server)?.progress.find(item => !item.passed);
const requirement = test?.plan.requirements.find(item => item.id === progress?.id);
return requirement ? requirementSummary(requirement) : 'Waiting for deployment and fresh observations.';
}
</script>
<svelte:head><title>Beta #{id} | Electric Cherry</title></svelte:head>
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
{#if loading}<EmptyState title="Loading beta…" />
{:else if !beta && !loadError}<EmptyState title="Beta not found" description="Return to Betas to find another test." />
{:else if beta}
<div class="page">
<SectionHeader title={beta.notes || `Beta #${beta.id}`} subtitle={beta.notes ? `Beta #${beta.id}` : undefined}>
{#snippet crumbs()}<Breadcrumbs items={[{label:'Betas',href:'/betas'},{label:`#${id}`}]} />{/snippet}
{#snippet actions()}<StatusBadge state={test && !closed ? test.outcome : beta?.state ?? 'running'} />{/snippet}
</SectionHeader>
<Card padding="lg">
<div class="summary-heading"><h2>{closed ? beta.state === 'promoted' ? 'Version promoted' : 'Beta rolled back' : `${passed} / ${servers.length} servers passed`}</h2>{#if test}<span>{coverageSummary(test.plan)}</span>{/if}</div>
<p>{nextAction}</p>
{#if test?.reason}<p class="reason">{test.reason}</p>{/if}
<div class="timing"><span>Started <time datetime={beta.opened_at}>{$timeFormat.dateTime(beta.opened_at)}</time></span>
{#if deadline && !closed}<strong>Ends <time datetime={deadline.toISOString()}>{$timeFormat.dateTime(deadline)}</time> · {deadlineText}</strong>{/if}
{#if beta.closed_at}<span>Finished {$timeFormat.dateTime(beta.closed_at)}</span>{/if}
</div>
{#if !closed && test}<p class="muted">Waiting for empty servers uses this window. Pausing or retrying does not extend the deadline.</p>{/if}
<div class="actions">
{#if !closed}<Button variant="primary" onclick={reviewPromotion} disabled={hasDeletedServers || beta.state !== 'ready-promote' || acting !== null}>Review promotion</Button><Button variant="danger" onclick={rollback} disabled={acting !== null} loading={acting === 'rollback'}>{beta.state === 'ready-promote' ? 'Reject beta' : 'Roll back'}</Button>{/if}
{#if closed}<Button href={`/betas/new?reuse=${id}`}>Reuse files and test plan</Button>{/if}
{#if promotion}<Button href={`/campaigns/${encodeURIComponent(promotion.id)}`}>View campaign</Button>{/if}
{#if rollbackCampaign}<Button href={`/campaigns/${encodeURIComponent(rollbackCampaign.id)}`}>View rollback campaign</Button>{/if}
</div>
{#if promotion}<p class="muted">{promotion.pending} servers awaiting activation · {promotion.failed} need attention. Promoting a version does not mean it is already running everywhere.</p>{/if}
{#if flash}<p role="alert" class="ui-error">{flash}</p>{/if}
{#if success}<p role="status">{success}</p>{/if}
</Card>
{#if !closed && waiting.length}
<section aria-label="Outstanding servers"><h2>Outstanding conditions</h2><div class="server-list">
{#each waiting as server}<div class="server-row"><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} href={`/servers/${encodeURIComponent(server.server)}`} /><StatusBadge state={server.stage} /><p>{server.error ?? (server.server_deleted_at ? 'This server was deleted. Recorded progress is retained.' : outstanding(server.server))}</p>{#if server.error && !server.server_deleted_at}<a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics`}>Diagnose</a>{:else if test}<a href="#test-results">View checks</a>{/if}</div>{/each}
</div></section>
{/if}
{#if test}<div id="test-results"><Card padding="lg"><BetaTestProgress {test} betaId={id} {closed} deletedParticipants={hasDeletedServers} deadline={deadline?.toISOString()} retryBlocked={servers.some(server=>server.error?.startsWith('deploy:')) ? 'Deployment was incomplete. Roll back before opening a fresh beta.' : undefined} onUpdate={load} /></Card></div>{/if}
<details class="ui-panel"><summary>Files and versions ({scripts.length})</summary><div class="file-list">{#each scripts as file}<div><a href={`/${file.kind === 'baseline' ? 'configs' : file.kind + 's'}/${file.id}`}>{file.name}</a><strong>{artifactVersionLabel(file.version)}</strong><code>{file.target_rel_path}</code><span>{file.version?.sha256.slice(0,12)}</span></div>{/each}</div></details>
<details class="ui-panel"><summary>Deployment progress ({servers.length} servers)</summary><div class="server-list">
{#each ranked as server}<div class="server-row"><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} href={`/servers/${encodeURIComponent(server.server)}`}/><StatusBadge state={server.stage}/><p>Files {server.deployed_at ? 'installed' : 'pending'} · Restart {server.restarted_at ? 'submitted' : 'pending'}{#if beta.wait_for_health} · Health {server.healthy_at ? 'verified' : 'pending'}{/if}{#if beta.wait_for_match} · Match {server.match_seen_at ? 'observed' : 'pending'}{/if}</p>{#if server.provisioning_id}<a href={`/provisioning/${encodeURIComponent(server.provisioning_id)}`}>View provisioning</a>{/if}{#if server.error}<p class="ui-error">{server.error}</p>{/if}{#if server.stage === 'failed' && !server.server_deleted_at && !test && !closed}<Button size="sm" onclick={() => retry(server.server)} disabled={acting !== null} loading={acting === `retry:${server.server}`}>Retry server</Button>{/if}</div>{/each}
</div></details>
<OperationHistory {events} active={!closed} showOverview={false}/>
</div>
{/if}
<Dialog bind:open={reviewOpen} title="Review promotion" description="Review the exact files and server eligibility before installing the promoted versions." busy={acting === 'promote'} wide>
{#if acting === 'preview'}<p role="status">Preparing promotion impact…</p>{/if}
{#if reviewError}<p class="ui-error" role="alert">{reviewError}</p>{/if}
{#if preview}
<p><strong>{preview.servers.filter(server => server.tested).length} tested servers</strong> and <strong>{preview.servers.filter(server => !server.tested).length} additional eligible servers</strong>.</p>
<p>Confirming queues the file changes. Each server waits until empty and restart capacity is available, then installs its files and restarts. Tested servers only need another activation if their configuration changed or earlier queued work could replace the tested files. Eligibility follows each file's current scope.</p>
<p class="muted">Reviewed {$timeFormat.dateTime(preview.createdAt)}. If targets, versions, or composed settings change, you must review again.</p>
{#each preview.blockers as blocker}<p class="ui-error" role="alert">{blocker}</p>{/each}
<div class="file-list">{#each preview.files as file}<details open><summary>{file.name} · {artifactVersionLabel(file)} · {file.servers.length} servers</summary><p><code>{file.path}</code> · {file.scope} · SHA {file.sha256.slice(0,12)}</p><ul>{#each file.servers as name}<li><ServerIdentity {name}/><span>{preview.servers.find(server => server.name === name)?.tested ? 'Already tested' : 'Additional target · restart when empty'}</span></li>{/each}</ul></details>{/each}</div>
{#if preview.configWrites.length}<details><summary>Composed configuration files ({preview.configWrites.length})</summary>{#each preview.configWrites as write}<p>{write.server} · {write.path} · {write.sha256?.slice(0,12) ?? write.error}</p>{/each}</details>{/if}
{:else if acting !== 'preview'}<Button onclick={reviewPromotion}>Retry preview</Button>{/if}
{#snippet actions()}<Button onclick={() => reviewOpen = false} disabled={acting === 'promote'}>Cancel</Button>{#if preview}<Button onclick={reviewPromotion} disabled={acting !== null}>Refresh review</Button><Button variant="primary" onclick={promote} loading={acting === 'promote'} disabled={acting !== null || !!preview.blockers.length}>Promote to {preview.servers.length} servers</Button>{/if}{/snippet}
</Dialog>
<style>
.page { display:grid; gap:24px; }
h2 { margin:0; font-size:18px; } p { line-height:1.6; overflow-wrap:anywhere; }
.summary-heading,.actions,.timing { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
.summary-heading { justify-content:space-between; } .timing,.muted,.summary-heading span { color:var(--text-secondary); font-size:13px; }
.timing strong { color:var(--text-primary); } .actions { margin-top:16px; } .reason { border-left:3px solid var(--state-warning); padding-left:12px; }
.server-list,.file-list { display:grid; gap:12px; margin-top:16px; }
.server-row { display:grid; grid-template-columns:minmax(180px,1fr) auto; gap:8px 16px; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
.server-row p { margin:0; font-size:13px; } .server-row a,.file-list a { color:var(--accent-primary-hover); }
.file-list > div { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
.file-list code { overflow-wrap:anywhere; } .file-list li { margin:10px 0; } .file-list li span { margin-left:12px; font-size:12px; color:var(--text-secondary); }
details > summary { cursor:pointer; font-weight:600; } #test-results { scroll-margin-top:80px; }
@media(max-width:600px) { .server-row { grid-template-columns:1fr; } .timing { align-items:flex-start; flex-direction:column; } }
</style>