Add configurable beta test plans and staged rollout

This commit is contained in:
2026-09-17 21:35:28 -04:00
parent 5b5266da3a
commit a50eae9d21
26 changed files with 1518 additions and 44 deletions
@@ -0,0 +1,82 @@
<script lang="ts">
import type { BetaTestView } from '$lib/beta-plan.js';
let { test, betaId, closed, onUpdate }: { test: BetaTestView; betaId: number; closed: boolean; onUpdate: () => Promise<void> } = $props();
let notes = $state<Record<string, string>>({});
let busy = $state(false);
let error = $state('');
async function action(action: string, extra: Record<string, string> = {}) {
busy = true; error = '';
try {
const response = await fetch(`/api/betas/${betaId}/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...extra }) });
const body = await response.json();
if (!response.ok) throw new Error(body.error);
await onUpdate();
} catch (e) { error = (e as Error).message; }
finally { busy = false; }
}
</script>
<section aria-label="Test plan progress">
<div class="heading"><h2>Test plan</h2><strong class:failed={['failed', 'inconclusive'].includes(test.outcome)}>{test.outcome}</strong></div>
{#if test.reason}<p>{test.reason}</p>{/if}
<p>Coverage: {test.plan.scope === 'quorum' ? `${test.plan.quorum} servers` : test.plan.scope}. Window: {test.plan.timeoutHours} hours.
{test.plan.waveSize ? `${test.plan.waveSize} servers per wave.` : 'All servers deployed together.'}
Failure action: {test.plan.failureAction}. Rollback restart: {test.plan.rollbackRestart}.</p>
{#if !closed}
<div class="actions">
{#if test.outcome === 'paused'}<button disabled={busy} onclick={() => action('resume')}>Resume</button>{/if}
{#if ['active', 'passed'].includes(test.outcome)}<button disabled={busy} onclick={() => action('pause')}>Pause test</button>{/if}
{#if ['paused', 'failed'].includes(test.outcome)}<button disabled={busy} onclick={() => action('retry')}>Restart testing with fresh evidence</button>{/if}
</div>
{/if}
{#if error}<p role="alert" class="failed">{error}</p>{/if}
{#if test.servers.length === 0}<p>Waiting for the first server restart. Evidence collection begins with that run.</p>{/if}
{#each test.servers as server (server.server)}
<article>
<h3>{server.server} <small>Recovery cycles: {server.phase}</small></h3>
{#each server.progress as progress (progress.id)}
{@const requirement = test.plan.requirements.find(r => r.id === progress.id)!}
<div class="requirement" class:failed={progress.failed}>
<div><strong>{progress.passed ? '✓' : progress.failed ? '✕' : '○'} {progress.label}</strong><span>{progress.detail}</span></div>
{#if ['round', 'matches', 'feature', 'populated-minutes', 'player-hours', 'sustained-population'].includes(requirement.kind)}
<span>Minimum {requirement.minPlayers} player(s){requirement.minRound ? `, round ${requirement.minRound} in the same session` : ''}{requirement.minMinutes ? `, ${requirement.minMinutes} minutes per match` : ''}{requirement.event ? ` · Event: ${requirement.event}` : ''}</span>
{/if}
{#if !['manual', 'failure-budget', 'restart-budget', 'clean-logs'].includes(requirement.kind)}
<progress aria-label={progress.label} value={Math.min(progress.current, progress.target)} max={progress.target}></progress>
{/if}
{#if requirement.kind === 'manual' && !progress.passed && !closed}
<div class="actions">
<input aria-label={`Tester and observation for ${progress.label} on ${server.server}`} placeholder="Tester name and observation" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} />
<button disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={() => action('sign-off', { server: server.server, requirementId: progress.id, note: notes[`${server.server}:${progress.id}`] })}>Sign off</button>
</div>
{/if}
</div>
{/each}
</article>
{/each}
<details>
<summary>Evidence ({test.evidence.length}{test.evidence.length === 200 ? ' most recent' : ''})</summary>
{#each test.evidence as event (event.id)}
<div class="evidence"><strong>{event.server} · {event.kind}</strong><time>{new Date(event.received_at).toLocaleString()}</time><pre>{event.payload}</pre></div>
{/each}
{#if !test.evidence.length}<p>No evidence recorded yet.</p>{/if}
</details>
</section>
<style>
section { display: grid; gap: 16px; }
h2, h3, p { margin: 0; }
h2 { font-size: 18px; } h3 { font-size: 15px; }
p, small, span, time { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.heading, .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.heading { justify-content: space-between; }
article { border: 1px solid var(--border-subtle); border-radius: 8px; padding: 16px; }
.requirement { display: grid; gap: 8px; margin-top: 16px; }
.requirement > div:first-child { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; }
progress { width: 100%; height: 8px; accent-color: var(--state-online); }
input, button { padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
input { flex: 1; min-width: 160px; } button, summary { cursor: pointer; } button:disabled { opacity: .5; }
.failed { color: var(--state-danger); }
.evidence { display: grid; gap: 4px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); }
pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; margin: 0; }
</style>