Files
electric-cherry/src/lib/ui/components/BetaPlanBuilder.svelte
T

287 lines
24 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount, tick } from 'svelte';
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, betaPlanIssues, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
import { coverageSummary, needsGameTelemetry, presetDescriptions, requirementCategory, requirementSummary } from '$lib/beta-plan-summary.js';
let { plan = $bindable<BetaPlan>(), serverCount, allServersRequired = false }: { plan: BetaPlan; serverCount?: number; allServersRequired?: boolean } = $props();
let validationRequested = $state(false), coverageOpen = $state(false), deadlineOpen = $state(false);
const issues = $derived(validationRequested ? betaPlanIssues(plan, serverCount) : []);
const fieldError = (field: string) => issues.find(issue => issue.field === field)?.message;
export async function validate(): Promise<boolean> {
validationRequested = true;
const issue = betaPlanIssues(plan, serverCount)[0];
if (!issue) return true;
if (issue.requirementId) editingId = issue.requirementId;
if (['quorum', 'waveSize'].includes(issue.field)) coverageOpen = true;
if (issue.field === 'timeoutHours') deadlineOpen = true;
await tick();
document.getElementById(`plan-${issue.field}`)?.focus();
return false;
}
const targetLabel = (kind: RequirementKind) => ({ round:'Target round', matches:'Matches required', 'populated-minutes':'Populated minutes', 'player-hours':'Player-hours required', population:'Players required', 'sustained-population':'Consecutive minutes', 'healthy-minutes':'Consecutive healthy minutes', 'failure-budget':'Maximum health outages', recovery:'Recovery restarts', manual:'Checklist items' })[kind];
let presets = $state(builtInPlans);
let presetName = $state('');
let addKind = $state<RequirementKind>('round');
let message = $state('');
let error = $state('');
let saving = $state(false);
let editingId = $state('');
let appliedPresetName = $state('');
let choosingPreset = $state(false);
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 waveSummary = $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);
if (allServersRequired) plan.scope = 'all';
appliedPresetName = name;
choosingPreset = false;
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() {
if (!await validate()) return;
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);
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">
<header class="section-heading">
<p>Define the checks each qualifying server must pass.</p>
<span class="pill">Manual promotion</span>
<button type="button" class="secondary" aria-expanded={choosingPreset} aria-controls="beta-presets" onclick={() => choosingPreset = !choosingPreset}>{choosingPreset ? 'Close presets' : 'Change preset'}</button>
</header>
{#if choosingPreset}
<div class="presets" id="beta-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}
{/if}
{#if message}<div class="feedback" role="status" aria-live="polite">{message}</div>{/if}
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if issues.length}<p class="error" role="alert">Correct {issues.length} highlighted {issues.length === 1 ? 'field' : 'fields'} before continuing.</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(needsGameTelemetry)}
<p class="telemetry-note"><span aria-hidden="true">ⓘ</span> Gameplay progress comes from game start, round change, and game end reports.</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 id={`plan-${r.id}-label`} aria-invalid={!!fieldError(`${r.id}-label`)} aria-describedby={`error-${r.id}-label`} bind:value={r.label} maxlength="200" required /><span class="error" id={`error-${r.id}-label`}>{fieldError(`${r.id}-label`) ?? ''}</span></label>
{#if r.kind !== 'manual'}
<label>{targetLabel(r.kind)}
<input id={`plan-${r.id}-target`} aria-invalid={!!fieldError(`${r.id}-target`)} aria-describedby={`error-${r.id}-target`} type="number" min={r.kind === 'failure-budget' ? 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 />
<span class="error" id={`error-${r.id}-target`}>{fieldError(`${r.id}-target`) ?? ''}</span>
</label>
{/if}
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind)}
<label>Minimum players<input id={`plan-${r.id}-minPlayers`} aria-invalid={!!fieldError(`${r.id}-minPlayers`)} aria-describedby={`error-${r.id}-minPlayers`} type="number" min="1" step="1" bind:value={r.minPlayers} required /><span class="error" id={`error-${r.id}-minPlayers`}>{fieldError(`${r.id}-minPlayers`) ?? ''}</span></label>
{/if}
{#if r.kind === 'matches'}
<label>Minimum round in the same game<input id={`plan-${r.id}-minRound`} aria-invalid={!!fieldError(`${r.id}-minRound`)} aria-describedby={`error-${r.id}-minRound`} type="number" min="0" step="1" bind:value={r.minRound} required /><span class="error" id={`error-${r.id}-minRound`}>{fieldError(`${r.id}-minRound`) ?? ''}</span></label>
{/if}
{#if r.kind === 'matches'}
<label>Minimum match duration (minutes)<input id={`plan-${r.id}-minMinutes`} aria-invalid={!!fieldError(`${r.id}-minMinutes`)} aria-describedby={`error-${r.id}-minMinutes`} type="number" min="0" step="any" bind:value={r.minMinutes} required /><span class="error" id={`error-${r.id}-minMinutes`}>{fieldError(`${r.id}-minMinutes`) ?? ''}</span></label>
{/if}
</div>
{#if needsGameTelemetry(r)}
<p>Uses game events reported by this server automatically.</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 healthy time.</p>
{:else if r.kind === 'manual'}
<p>A tester records their name and observation on the beta detail page.</p>
{/if}
</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>
<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>
</div>
<div class="policy-settings">
<details class="policy" bind:open={coverageOpen}>
<summary><span>Coverage &amp; waves<small>{coverageSummary(plan)} · {waveSummary}</small></span><span class="edit-hint">Configure</span></summary>
<div class="policy-body">
<div class="fields">
{#if allServersRequired}
<p>Every requested instance must pass. Change the instance counts in the Servers step.</p>
{:else}
<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 id="plan-quorum" aria-invalid={!!fieldError('quorum')} aria-describedby="error-quorum" type="number" min="1" max={serverCount ?? 1000} step="1" bind:value={plan.quorum} required /><span class="error" id="error-quorum">{fieldError('quorum') ?? `${serverCount ?? 0} servers selected`}</span></label>{/if}
{/if}
<label>Servers per wave (0 = all at once)<input id="plan-waveSize" aria-invalid={!!fieldError('waveSize')} aria-describedby="error-waveSize" type="number" min="0" step="1" bind:value={plan.waveSize} required /><span class="error" id="error-waveSize">{fieldError('waveSize') ?? ''}</span></label>
</div>
<p>Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing, and every file must pass on at least one matching instance. Restarts always wait for empty servers.</p>
</div>
</details>
<details class="policy" bind:open={deadlineOpen}>
<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 id="plan-timeoutHours" aria-invalid={!!fieldError('timeoutHours')} aria-describedby="error-timeoutHours" type="number" min="0.01" max="8760" step="any" bind:value={plan.timeoutHours} required /><span class="error" id="error-timeoutHours">{fieldError('timeoutHours') ?? ''}</span></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 activation<select bind:value={plan.rollbackRestart}><option value="when-empty">Restore and restart when empty</option><option value="manual">Hold changes for manual resume</option></select></label>
</div>
<p>Waiting for empty servers uses this window. Pausing and retrying do not extend it. Missing evidence at the deadline is inconclusive. Health 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; }
h4, h5, p, dl, dd { margin: 0; }
h4 { font-size: var(--text-lg); font-weight: 600; }
h5 { font-size: var(--text-base); font-weight: 550; line-height: 1.5; }
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.eyebrow { display: block; font-size: var(--text-xs); 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: var(--text-xs); 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: var(--radius-md); 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-numeric); color: var(--text-secondary); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.choice { width: 19px; height: 19px; border: 1px solid var(--border-strong); border-radius: 50%; display: grid; place-items: center; font-size: var(--text-xs); }
.selected .choice { background: var(--accent-primary); color: var(--text-on-accent); border-color: var(--accent-primary); }
.preset > strong { font-size: var(--text-base); }
.preset-description { font-size: var(--text-xs); line-height: 1.6; color: var(--text-secondary); }
.preset ul { padding-left: 16px; margin: 2px 0 10px; font-size: var(--text-xs); 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: var(--text-xs); color: var(--text-secondary); }
.preset-footer > span { color: var(--accent-primary-hover); }
.feedback { color: var(--accent-primary-hover); font-size: var(--text-xs); 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: var(--radius-md); }
.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: var(--text-xs); margin-bottom: 6px; }
dd { font-size: var(--text-sm); font-weight: 550; line-height: 1.5; }
dd small { font-size: var(--text-xs); font-weight: 400; color: var(--text-secondary); }
.plan-caption { font-size: var(--text-xs); }
.telemetry-note { border-left: 2px solid var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 6%, transparent); padding: 10px 12px; font-size: var(--text-xs); }
.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: var(--radius-md); 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: var(--text-xs); margin-bottom: 4px; }
.remove { padding: 3px 8px; font-size: var(--text-lg); 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: var(--text-xs); 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; min-width: 0; font-size: var(--text-xs); 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: var(--text-sm); min-height: 40px; box-sizing: border-box; border-color: var(--border-control); }
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: var(--text-xs); 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: var(--radius-md); }
.policy > summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; font-size: var(--text-sm); font-weight: 550; }
.policy small { display: block; font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; line-height: 1.6; margin-top: 5px; }
.edit-hint { color: var(--accent-primary-hover); font-size: var(--text-xs); }
.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: var(--radius-md); }
.empty-plan strong { display: block; margin-bottom: 8px; font-size: var(--text-base); }
@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>