282 lines
23 KiB
Svelte
282 lines
23 KiB
Svelte
<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 }: { plan: BetaPlan; serverCount?: number } = $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);
|
||
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 & waves<small>{coverageSummary(plan)} · {waveSummary}</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 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}
|
||
<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. Restarts always wait for empty servers.</p>
|
||
</div>
|
||
</details>
|
||
<details class="policy" bind:open={deadlineOpen}>
|
||
<summary><span>Deadline & 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 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>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: 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; }
|
||
.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-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: var(--text-xs); 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: 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: 13px; 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: 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: 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: 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: 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: 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; 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: 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: var(--radius-md); }
|
||
.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: 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: 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>
|