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,119 @@
<script lang="ts">
import { onMount } from 'svelte';
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
let { plan = $bindable<BetaPlan>() }: { plan: BetaPlan } = $props();
let presets = $state(builtInPlans);
let selectedPreset = $state('');
let presetName = $state('');
let addKind = $state<RequirementKind>('round');
let message = $state('');
let saving = $state(false);
onMount(async () => {
try { const response = await fetch('/api/betas/presets'); if (response.ok) presets = (await response.json()).presets; }
catch { message = 'Saved presets could not be loaded.'; }
});
async function savePreset() {
message = ''; saving = true;
try {
const validated = parseBetaPlan(plan);
const response = await fetch('/api/betas/presets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: presetName, plan: validated }) });
const result = await response.json();
if (!response.ok) throw new Error(result.error);
presets = (await (await fetch('/api/betas/presets')).json()).presets;
message = 'Preset saved.';
} catch (error) { message = (error as Error).message; }
finally { saving = false; }
}
</script>
<section class="builder" aria-label="Beta test plan">
<h3>Test plan</h3>
<p>All requirements must pass on each qualifying server. Milestones stay recorded; health and error budgets remain monitored until promotion.</p>
<label>Start from a preset
<select bind:value={selectedPreset} onchange={() => { const p = presets.find(p => p.name === selectedPreset); if (p) plan = structuredClone(p.plan); }}>
<option value="">Custom plan</option>
{#each presets as preset}<option value={preset.name}>{preset.name}</option>{/each}
</select>
</label>
{#each plan.requirements as r, index (r.id)}
<fieldset>
<legend>{requirementLabels[r.kind]}</legend>
<div class="fields">
<label>Description<input bind:value={r.label} maxlength="200" required /></label>
{#if r.kind !== 'manual'}
<label>{['failure-budget', 'restart-budget', 'clean-logs'].includes(r.kind) ? 'Maximum allowed' : 'Target'}
<input type="number" min={['failure-budget', 'restart-budget', 'clean-logs'].includes(r.kind) ? 0 : 0.01} step={['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 'any' : 1} bind:value={r.target} required />
</label>
{/if}
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population', 'feature'].includes(r.kind)}
<label>Minimum players<input type="number" min="1" step="1" bind:value={r.minPlayers} required /></label>
{/if}
{#if r.kind === 'matches' || r.kind === 'feature'}
<label>Minimum round in the same session<input type="number" min="0" step="1" bind:value={r.minRound} required /></label>
{/if}
{#if r.kind === 'matches'}
<label>Minimum match duration (minutes)<input type="number" min="0" step="any" bind:value={r.minMinutes} required /></label>
{/if}
{#if r.kind === 'feature'}
<label>Reported event name<input bind:value={r.event} placeholder="perk.purchased" maxlength="120" required /></label>
{/if}
</div>
{#if r.kind === 'round' || r.kind === 'feature' || r.kind === 'restart-budget' || (r.kind === 'matches' && (r.minRound > 0 || r.minPlayers > 1))}
<p>Requires session telemetry from the game reporter. Progress waits for events from this beta’s current run.</p>
{:else if r.kind === 'recovery'}
<p>After the other requirements pass, restart when empty and repeat them. Every cycle requires fresh evidence and checklist sign-off.</p>
{:else if r.kind === 'failure-budget'}
<p>Counts transitions from healthy to unhealthy. Missing or stale monitoring resets continuous health and does not earn playtime.</p>
{:else if r.kind === 'clean-logs'}
<p>Checks new console lines for script errors, script exceptions, unhandled exceptions and fatal errors. An unreadable log cannot pass.</p>
{:else if r.kind === 'manual'}
<p>A tester records their name and observation on the beta detail page.</p>
{/if}
<button type="button" onclick={() => plan.requirements = plan.requirements.filter((_, i) => i !== index)}>Remove requirement</button>
</fieldset>
{/each}
<div class="fields">
<label>Add requirement<select bind:value={addKind}>{#each requirementKinds as kind}<option value={kind}>{requirementLabels[kind]}</option>{/each}</select></label>
<button type="button" disabled={plan.requirements.length >= 30} onclick={() => plan.requirements = [...plan.requirements, requirement(addKind, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`)]}>Add requirement</button>
</div>
<fieldset>
<legend>Coverage and rollout</legend>
<div class="fields">
<label>Qualifying servers<select bind:value={plan.scope}>
<option value="all">Every selected server</option><option value="quorum">At least N selected servers</option>
<option value="map">At least one per selected map</option><option value="game">At least one per selected game</option>
<option value="map-game">At least one per selected game/map combination</option>
</select></label>
{#if plan.scope === 'quorum'}<label>Required servers<input type="number" min="1" step="1" bind:value={plan.quorum} required /></label>{/if}
<label>Servers per wave (0 = all at once)<input type="number" min="0" step="1" bind:value={plan.waveSize} required /></label>
</div>
<p>Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing.</p>
</fieldset>
<fieldset>
<legend>Deadline and failures</legend>
<div class="fields">
<label>Testing window (hours from opening)<input type="number" min="0.01" max="8760" step="any" bind:value={plan.timeoutHours} required /></label>
<label>On test failure<select bind:value={plan.failureAction}><option value="fail">Mark failed and hold</option><option value="pause">Pause for investigation</option><option value="rollback">Roll back automatically</option></select></label>
<label>Rollback restarts<select bind:value={plan.rollbackRestart}><option value="when-empty">Rolling restart when empty</option><option value="manual">Restore files; restart manually</option></select></label>
</div>
<p>Missing evidence at the deadline is inconclusive. Promotion always requires a manual review.</p>
</fieldset>
<div class="fields">
<label>Save as preset<input bind:value={presetName} maxlength="80" placeholder="My gameplay check" /></label>
<button type="button" disabled={saving || !presetName.trim()} onclick={savePreset}>Save preset</button>
</div>
{#if message}<p role="status">{message}</p>{/if}
</section>
<style>
.builder { display: grid; gap: 16px; }
h3, p { margin: 0; }
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
fieldset { border: 1px solid var(--border-subtle); border-radius: 8px; padding: 16px; display: grid; gap: 12px; min-width: 0; }
legend { font-weight: 600; padding: 0 6px; }
.fields { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
label { display: grid; gap: 6px; flex: 1 1 180px; font-size: 13px; }
input, select, button { color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 6px; padding: 10px; min-width: 0; font: inherit; }
button { cursor: pointer; justify-self: start; }
button:disabled { opacity: .5; cursor: default; }
</style>