Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements. Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
This commit is contained in:
@@ -1,9 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
|
||||
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>() }: { plan: BetaPlan } = $props();
|
||||
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');
|
||||
@@ -45,6 +59,7 @@
|
||||
message = `${requirementLabels[addKind]} added.`;
|
||||
}
|
||||
async function savePreset() {
|
||||
if (!await validate()) return;
|
||||
message = ''; error = ''; saving = true;
|
||||
try {
|
||||
const validated = parseBetaPlan(plan);
|
||||
@@ -61,13 +76,13 @@
|
||||
|
||||
<section class="builder" aria-label="Beta test plan">
|
||||
<header class="section-heading">
|
||||
<div><span class="eyebrow">Validation</span><h3>Build your test plan</h3></div>
|
||||
<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>
|
||||
<div class="section-heading"><p>Tailor what each server needs to prove.</p><Button size="sm" onclick={() => choosingPreset = !choosingPreset}>{choosingPreset ? 'Close presets' : 'Change preset'}</Button></div>
|
||||
|
||||
{#if choosingPreset}
|
||||
<div class="presets" role="group" aria-label="Start from a preset">
|
||||
<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)}>
|
||||
@@ -90,6 +105,7 @@
|
||||
{/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">
|
||||
@@ -118,20 +134,21 @@
|
||||
<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>
|
||||
<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>{r.kind === 'failure-budget' ? 'Maximum allowed' : 'Target'}
|
||||
<input 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 />
|
||||
<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 type="number" min="1" step="1" bind:value={r.minPlayers} required /></label>
|
||||
<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 type="number" min="0" step="1" bind:value={r.minRound} required /></label>
|
||||
<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 type="number" min="0" step="any" bind:value={r.minMinutes} required /></label>
|
||||
<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)}
|
||||
@@ -157,7 +174,7 @@
|
||||
</div>
|
||||
|
||||
<div class="policy-settings">
|
||||
<details class="policy">
|
||||
<details class="policy" bind:open={coverageOpen}>
|
||||
<summary><span>Coverage & rollout<small>{coverageSummary(plan)} · {rolloutSummary}</small></span><span class="edit-hint">Configure</span></summary>
|
||||
<div class="policy-body">
|
||||
<div class="fields">
|
||||
@@ -166,21 +183,21 @@
|
||||
<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>
|
||||
{#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">
|
||||
<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 type="number" min="0.01" max="8760" step="any" bind:value={plan.timeoutHours} required /></label>
|
||||
<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>Missing evidence at the deadline is inconclusive. Health and error checks continue until you manually promote.</p>
|
||||
<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">
|
||||
@@ -195,8 +212,7 @@
|
||||
|
||||
<style>
|
||||
.builder { display: grid; gap: 16px; }
|
||||
h3, h4, h5, p, dl, dd { margin: 0; }
|
||||
h3 { font-size: 21px; font-weight: 650; letter-spacing: -.025em; }
|
||||
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; }
|
||||
@@ -243,7 +259,7 @@
|
||||
.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; }
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user