Fix beta preset selection and improve test plan builder UI

This commit is contained in:
2026-09-17 21:51:25 -04:00
parent a50eae9d21
commit 4237371360
4 changed files with 256 additions and 60 deletions
+206 -60
View File
@@ -1,48 +1,122 @@
<script lang="ts">
import { onMount } from 'svelte';
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
import { coverageSummary, needsSessionTelemetry, presetDescriptions, requirementCategory, requirementSummary } from '$lib/beta-plan-summary.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 error = $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.'; }
});
let editingId = $state('');
let appliedPresetName = $state('');
const matchingPreset = $derived(presets.find(p => p.name === appliedPresetName && JSON.stringify(p.plan) === JSON.stringify(plan))
?? presets.find(p => JSON.stringify(p.plan) === JSON.stringify(plan)));
const savedPresets = $derived(presets.filter(p => !builtInPlans.some(b => b.name === p.name)));
const failureSummary = $derived(plan.failureAction === 'fail' ? 'Fail and hold for review' : plan.failureAction === 'pause' ? 'Pause for investigation' : 'Roll back automatically');
const rolloutSummary = $derived(plan.waveSize ? `${plan.waveSize} server${plan.waveSize === 1 ? '' : 's'} per wave` : 'Deploy to all selected servers');
async function loadPresets() {
const response = await fetch('/api/betas/presets');
if (!response.ok) throw new Error('Saved presets could not be loaded. Built-in presets are still available.');
const body = await response.json();
presets = body.presets.map((p: { name: string; plan: unknown }) => ({ name: p.name, plan: parseBetaPlan(p.plan) }));
}
onMount(() => { void loadPresets().catch(e => error = (e as Error).message); });
function applyPreset(name: string) {
const preset = presets.find(p => p.name === name);
if (!preset) return;
try {
// Validation returns an independent plain object, even when the source is a Svelte proxy.
plan = parseBetaPlan(preset.plan);
appliedPresetName = name;
editingId = ''; error = '';
message = `${name} applied. ${plan.requirements.length} requirement${plan.requirements.length === 1 ? '' : 's'} loaded.`;
} catch (e) { error = (e as Error).message; }
}
function addRequirement() {
editingId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
plan.requirements = [...plan.requirements, requirement(addKind, editingId)];
message = `${requirementLabels[addKind]} added.`;
}
async function savePreset() {
message = ''; saving = true;
message = ''; error = ''; 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; }
await loadPresets();
appliedPresetName = presetName.trim();
message = `Preset "${presetName.trim()}" saved.`;
} catch (e) { error = (e 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>
<header class="section-heading">
<div><span class="eyebrow">Validation</span><h3>Build your test plan</h3></div>
<span class="pill">Manual promotion</span>
</header>
<p>Choose a starting point, then tailor what each server needs to prove.</p>
<div class="presets" role="group" aria-label="Start from a preset">
{#each builtInPlans as preset, index (preset.name)}
<button type="button" class="preset" class:selected={matchingPreset?.name === preset.name}
aria-pressed={matchingPreset?.name === preset.name} aria-label={`Use ${preset.name} preset`} onclick={() => applyPreset(preset.name)}>
<span class="preset-top"><span class="preset-number">0{index + 1}</span><span class="choice" aria-hidden="true">{matchingPreset?.name === preset.name ? '✓' : ''}</span></span>
<strong>{preset.name}</strong>
<span class="preset-description">{presetDescriptions[preset.name]}</span>
<ul>{#each preset.plan.requirements as r}<li>{requirementSummary(r)}</li>{/each}</ul>
<span class="preset-footer">{preset.plan.timeoutHours}-hour window <span>{matchingPreset?.name === preset.name ? 'Selected' : 'Use preset →'}</span></span>
</button>
{/each}
</div>
{#if savedPresets.length}
<label class="saved-picker">Or use a saved preset
<select value={savedPresets.some(p => p.name === matchingPreset?.name) ? matchingPreset?.name : ''} onchange={event => applyPreset(event.currentTarget.value)}>
<option value="" disabled>Choose a saved plan…</option>
{#each savedPresets as preset}<option value={preset.name}>{preset.name}</option>{/each}
</select>
</label>
{/if}
<div class="feedback" role="status" aria-live="polite">{message}</div>
{#if error}<p class="error" role="alert">{error}</p>{/if}
<div class="active-plan">
<div class="section-heading">
<div><span class="eyebrow">Your active plan</span><h4>{matchingPreset?.name ?? 'Custom plan'}</h4></div>
<span class="pill">{plan.requirements.length} requirement{plan.requirements.length === 1 ? '' : 's'}</span>
</div>
<dl class="plan-facts">
<div><dt>Must pass on</dt><dd>{coverageSummary(plan)}</dd></div>
<div><dt>Testing window</dt><dd>{plan.timeoutHours} hours <small>from opening</small></dd></div>
<div><dt>If a check fails</dt><dd>{failureSummary}</dd></div>
</dl>
<p class="plan-caption">Every requirement below must pass on each qualifying server. You can edit any check.</p>
{#if plan.requirements.some(needsSessionTelemetry)}
<p class="telemetry-note"><span aria-hidden="true">ⓘ</span> This plan needs session telemetry from the game reporter.</p>
{/if}
<div class="requirements">
{#each plan.requirements as r, index (r.id)}
<article class="requirement">
<div class="requirement-heading">
<span class="step-number">{String(index + 1).padStart(2, '0')}</span>
<div class="requirement-copy"><span class="eyebrow">{requirementCategory(r)}</span><h5>{requirementSummary(r)}</h5></div>
<button type="button" class="remove" aria-label={`Remove requirement ${index + 1}: ${r.label}`} onclick={() => { plan.requirements = plan.requirements.filter(item => item.id !== r.id); message = 'Requirement removed.'; }}>×</button>
</div>
<details class="editor" open={editingId === r.id}>
<summary>Edit requirement <span>{requirementLabels[r.kind]}</span></summary>
<div class="editor-fields">
<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 />
<input type="number" min={['failure-budget', 'restart-budget', 'clean-logs'].includes(r.kind) ? 0 : ['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 0.01 : 1} 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)}
@@ -69,51 +143,123 @@
{: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>
</details>
</article>
{:else}
<div class="empty-plan"><strong>No requirements yet</strong><p>Choose a preset above or add your first check below. A beta needs at least one requirement.</p></div>
{/each}
</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 class="add-row">
<label>Add another check<select bind:value={addKind}>{#each requirementKinds as kind}<option value={kind}>{requirementLabels[kind]}</option>{/each}</select></label>
<button type="button" class="secondary" disabled={plan.requirements.length >= 30} onclick={addRequirement}>+ Add requirement</button>
</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}
<div class="policy-settings">
<details class="policy">
<summary><span>Coverage &amp; rollout<small>{coverageSummary(plan)} · {rolloutSummary}</small></span><span class="edit-hint">Configure</span></summary>
<div class="policy-body">
<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. Restarts always wait for empty servers.</p>
</div>
</details>
<details class="policy">
<summary><span>Deadline &amp; failure handling<small>{plan.timeoutHours}-hour window · {failureSummary}</small></span><span class="edit-hint">Configure</span></summary>
<div class="policy-body">
<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. Health and error checks continue until you manually promote.</p>
</div>
</details>
<details class="policy save-policy">
<summary><span>Save this plan as a preset<small>Reuse your requirements and settings on future betas.</small></span><span class="edit-hint">Save</span></summary>
<div class="policy-body fields">
<label>Preset name<input bind:value={presetName} maxlength="80" placeholder="My gameplay check" /></label>
<button type="button" class="secondary" disabled={saving || !presetName.trim()} onclick={savePreset}>{saving ? 'Saving…' : 'Save preset'}</button>
</div>
</details>
</div>
</section>
<style>
.builder { display: grid; gap: 16px; }
h3, p { margin: 0; }
.builder { display: grid; gap: 16px; padding-top: 24px; border-top: 1px solid var(--border-strong); }
h3, h4, h5, p, dl, dd { margin: 0; }
h3 { font-size: 21px; font-weight: 650; letter-spacing: -.025em; }
h4 { font-size: 19px; font-weight: 600; }
h5 { font-size: 15px; font-weight: 550; line-height: 1.5; }
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; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.eyebrow { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .1em; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
.pill { padding: 5px 9px; border-radius: 99px; font-size: 11px; color: var(--text-secondary); border: 1px solid var(--border-strong); white-space: nowrap; }
.presets { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 4px; }
.preset { display: flex; flex-direction: column; text-align: left; padding: 18px; border-radius: 12px; background: var(--surface-sunken); border: 1px solid var(--border-strong); gap: 10px; transition: border-color .15s, background-color .15s; }
.preset:hover { border-color: var(--accent-primary-hover); }
.preset.selected { border-color: var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-sunken)); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent-primary) 30%, transparent); }
.preset-top { display: flex; align-items: center; justify-content: space-between; }
.preset-number, .step-number { font-family: var(--font-mono); color: var(--text-secondary); font-size: 12px; }
.choice { width: 19px; height: 19px; border: 1px solid var(--border-strong); border-radius: 50%; display: grid; place-items: center; font-size: 12px; }
.selected .choice { background: var(--accent-primary); color: var(--text-on-accent); border-color: var(--accent-primary); }
.preset > strong { font-size: 15px; }
.preset-description { font-size: 12px; line-height: 1.6; color: var(--text-secondary); }
.preset ul { padding-left: 16px; margin: 2px 0 10px; font-size: 12px; line-height: 1.65; flex: 1; }
.preset li + li { margin-top: 5px; }
.preset li::marker { color: var(--accent-primary-hover); }
.preset-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; border-top: 1px solid var(--border-subtle); padding-top: 12px; font-size: 11px; color: var(--text-secondary); }
.preset-footer > span { color: var(--accent-primary-hover); }
.feedback { color: var(--accent-primary-hover); font-size: 12px; min-height: 18px; }
.error { color: var(--state-danger); }
.active-plan { display: grid; gap: 18px; padding: 22px; background: color-mix(in oklab, var(--surface-raised) 25%, var(--surface)); border: 1px solid var(--border-strong); border-radius: 12px; }
.plan-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; padding: 16px 0; border-top: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle); }
dt { color: var(--text-secondary); font-size: 11px; margin-bottom: 6px; }
dd { font-size: 13px; font-weight: 550; line-height: 1.5; }
dd small { font-size: 11px; font-weight: 400; color: var(--text-secondary); }
.plan-caption { font-size: 12px; }
.telemetry-note { border-left: 2px solid var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 6%, transparent); padding: 10px 12px; font-size: 12px; }
.telemetry-note span { color: var(--accent-primary-hover); margin-right: 5px; }
.requirements { display: grid; gap: 10px; }
.requirement { border: 1px solid var(--border-subtle); border-radius: 9px; background: var(--surface-sunken); min-width: 0; overflow: hidden; }
.requirement-heading { display: flex; align-items: center; gap: 14px; padding: 16px; }
.requirement-copy { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.requirement-copy .eyebrow { font-size: 9px; margin-bottom: 4px; }
.remove { padding: 3px 8px; font-size: 21px; background: transparent; border: 0; color: var(--text-secondary); }
.remove:hover { color: var(--state-danger); }
.editor { border-top: 1px solid var(--border-subtle); }
.editor > summary { padding: 10px 16px; font-size: 11px; color: var(--accent-primary-hover); }
.editor > summary > span { color: var(--text-secondary); margin-left: 10px; }
.editor-fields { display: grid; gap: 12px; padding: 6px 16px 16px; }
.fields, .add-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
.add-row label { flex: 1 1 220px; }
label { display: grid; gap: 7px; flex: 1 1 180px; font-size: 12px; color: var(--text-secondary); }
input, select, button { color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: 6px; padding: 10px 12px; min-width: 0; font: inherit; }
input, select { width: 100%; font-size: 13px; min-height: 40px; box-sizing: border-box; }
button { cursor: pointer; }
button:disabled { opacity: .5; cursor: default; }
button:focus-visible, summary:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
.secondary { background: var(--surface-raised); font-size: 12px; min-height: 40px; }
.secondary:hover:not(:disabled) { border-color: var(--accent-primary-hover); }
.policy-settings { display: grid; gap: 8px; }
.policy { border: 1px solid var(--border-subtle); border-radius: 9px; }
.policy > summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; font-size: 13px; font-weight: 550; }
.policy small { display: block; font-size: 11px; color: var(--text-secondary); font-weight: 400; line-height: 1.6; margin-top: 5px; }
.edit-hint { color: var(--accent-primary-hover); font-size: 11px; }
.policy-body { padding: 0 18px 18px; display: grid; gap: 12px; }
.policy-body.fields { display: flex; }
summary { cursor: pointer; }
.empty-plan { padding: 22px; text-align: center; border: 1px dashed var(--border-strong); border-radius: 8px; }
.empty-plan strong { display: block; margin-bottom: 8px; font-size: 14px; }
@media (max-width: 850px) { .presets { grid-template-columns: 1fr; } .preset { padding: 16px; } .plan-facts { grid-template-columns: 1fr; gap: 12px; } }
@media (max-width: 480px) { .active-plan { padding: 14px; } .requirement-heading { padding: 12px; gap: 10px; } .editor > summary > span { display: block; margin: 5px 0 0; } .add-row { align-items: stretch; } .add-row button { width: 100%; } }
@media (prefers-reduced-motion: reduce) { .preset { transition: none; } }
</style>