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,91 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||
import ListFooter from './primitives/ListFooter.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { coverageSummary } from '$lib/beta-plan-summary.js';
|
||||
import type { BetaTestView } from '$lib/beta-plan.js';
|
||||
let { test, betaId, closed, onUpdate }: { test: BetaTestView; betaId: number; closed: boolean; onUpdate: () => Promise<void> } = $props();
|
||||
let notes = $state<Record<string, string>>({});
|
||||
let evidenceOpen = $state(false);
|
||||
let evidenceLimit = $state(25);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
async function action(action: string, extra: Record<string, string> = {}) {
|
||||
busy = true; error = '';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { coverageSummary, requirementSummary, needsGameTelemetry } from '$lib/beta-plan-summary.js';
|
||||
import { betaWaitingCondition, betaEvidenceSummary } from '$lib/beta-evidence.js';
|
||||
import type { BetaRequirement, BetaTestView } from '$lib/beta-plan.js';
|
||||
let { test, betaId, closed, deadline, retryBlocked, onUpdate }: { test:BetaTestView; betaId:number; closed:boolean; deadline?:string; retryBlocked?:string; onUpdate:()=>Promise<void> } = $props();
|
||||
let notes = $state<Record<string,string>>({});
|
||||
let evidenceOpen = $state(false), evidenceLimit = $state(25), busy = $state(false), error = $state(''), message = $state('');
|
||||
let evidenceServer = $state(''), evidenceKind = $state(''), evidenceQuery = $state('');
|
||||
let evidenceSection = $state<HTMLDetailsElement>();
|
||||
const canSign = $derived(!closed && ['active','passed'].includes(test.outcome));
|
||||
const expired = $derived(test.outcome === 'inconclusive' || (!!deadline && test.outcome !== 'passed' && Date.parse(deadline) <= Date.now()));
|
||||
const ranked = $derived([...test.servers].sort((a,b) => (a.progress.some(r => r.failed) ? 0 : a.passed ? 2 : 1) - (b.progress.some(r => r.failed) ? 0 : b.passed ? 2 : 1) || a.server.localeCompare(b.server)));
|
||||
const filteredEvidence = $derived(test.evidence.filter(event => (!evidenceServer || event.server === evidenceServer)
|
||||
&& (!evidenceKind || (evidenceKind === 'gameplay' ? ['game_start','round_change','game_end'].includes(event.kind) : evidenceKind === 'health' ? ['health','status-sample'].includes(event.kind) : event.kind === evidenceKind))
|
||||
&& (!evidenceQuery || `${betaEvidenceSummary(event)} ${event.payload}`.toLowerCase().includes(evidenceQuery.toLowerCase()))));
|
||||
$effect(() => { evidenceServer; evidenceKind; evidenceQuery; evidenceLimit = 25; });
|
||||
function reportAge(at:string) { const minutes=Math.max(0,Math.floor((Date.now()-Date.parse(at))/60000));return minutes<1?'less than a minute ago':minutes<60?`${minutes}m ago`:`${Math.floor(minutes/60)}h ${minutes%60}m ago`; }
|
||||
async function action(action:string, extra:Record<string,string>={}) {
|
||||
if (action === 'retry' && !await confirmAction({title:'Start a fresh test attempt?',message:`Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? new Date(deadline).toLocaleString() : 'unchanged'}.`,confirmLabel:'Reset and restart testing'})) return;
|
||||
busy = true; error = ''; message = '';
|
||||
try {
|
||||
const response = await fetch(`/api/betas/${betaId}/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...extra }) });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error);
|
||||
const response = await fetch(`/api/betas/${betaId}/test`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,...extra})}), body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not update the test.');
|
||||
await onUpdate();
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
message = action === 'sign-off' ? 'Observation recorded.' : action === 'retry' ? 'Fresh attempt queued. Deployed servers restart when empty; the deadline is unchanged.' : action === 'pause' ? 'Testing paused. The original deadline still applies.' : 'Testing resumed. Continuous timing checks restart from fresh observations.';
|
||||
} catch(e) { error = (e as Error).message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
async function showEvidence(server:string, requirement:BetaRequirement) {
|
||||
evidenceServer = server; evidenceKind = needsGameTelemetry(requirement) ? 'gameplay' : requirement.kind === 'manual' ? 'manual-sign-off' : ['failure-budget','healthy-minutes'].includes(requirement.kind) ? 'health' : '';
|
||||
evidenceQuery = ''; evidenceOpen = true; await tick(); evidenceSection?.scrollIntoView({block:'start',behavior:'smooth'}); evidenceSection?.querySelector('summary')?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section aria-label="Test plan progress">
|
||||
<div class="heading"><h2>Test plan</h2><StatusBadge state={test.outcome} /></div>
|
||||
{#if test.reason}<p>{test.reason}</p>{/if}
|
||||
<p>{coverageSummary(test.plan)} · {test.plan.timeoutHours}-hour testing window · Manual promotion</p>
|
||||
<details><summary>Test policy</summary><p>{test.plan.waveSize ? test.plan.waveSize + ' servers per wave.' : 'All servers deployed together.'} {test.plan.failureAction === 'rollback' ? 'Failed checks trigger an automatic rollback.' : test.plan.failureAction === 'pause' ? 'Failed checks pause the test for investigation.' : 'Failed checks hold the test for review.'} {test.plan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; servers must be restarted manually.'}</p></details>
|
||||
<div class="heading"><h2>Test results</h2><StatusBadge state={test.outcome}/></div>
|
||||
<p>{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server.</p>
|
||||
{#if !closed}
|
||||
<div class="actions">
|
||||
{#if test.outcome === 'paused'}<Button size="sm" disabled={busy} onclick={() => action('resume')}>Resume</Button>{/if}
|
||||
{#if ['active', 'passed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={() => action('pause')}>Pause test</Button>{/if}
|
||||
{#if ['paused', 'failed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={() => action('retry')}>Restart testing with fresh evidence</Button>{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if test.outcome === 'paused' && !expired}<Button size="sm" disabled={busy} onclick={()=>action('resume')}>Resume testing</Button>{/if}
|
||||
{#if ['active','passed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={()=>action('pause')}>Pause testing</Button>{/if}
|
||||
{#if ['paused','failed'].includes(test.outcome) && !expired}<Button size="sm" disabled={busy || !!retryBlocked} onclick={()=>action('retry')}>Start fresh attempt</Button>{/if}
|
||||
</div>
|
||||
{#if retryBlocked && ['paused','failed'].includes(test.outcome)}<p>{retryBlocked}</p>{/if}
|
||||
{#if expired}<p class="guidance">The testing window ended. Roll back this beta first, then choose “Reuse files and test plan” to open a new beta with a longer window.</p>
|
||||
{:else if test.outcome === 'paused'}<p class="guidance">Resume retains completed milestones; continuous health and in-progress timing intervals start fresh. Pausing does not extend the deadline.</p>{/if}
|
||||
{/if}
|
||||
{#if error}<p role="alert" class="failed">{error}</p>{/if}
|
||||
{#if test.servers.length === 0}<p>Waiting for the first server restart. Evidence collection begins with that run.</p>{/if}
|
||||
{#each test.servers as server (server.server)}
|
||||
<article>
|
||||
<h3><ServerIdentity name={server.server} /> <small>Recovery cycles: {server.phase}</small></h3>
|
||||
{#each server.progress as progress (progress.id)}
|
||||
{@const requirement = test.plan.requirements.find(r => r.id === progress.id)!}
|
||||
<div class="requirement" class:failed={progress.failed}>
|
||||
<div><strong>{progress.passed ? '✓' : progress.failed ? '✕' : '○'} {progress.label}</strong><span>{progress.detail}</span></div>
|
||||
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(requirement.kind)}
|
||||
<span>Minimum {requirement.minPlayers} player(s){requirement.minRound ? `, round ${requirement.minRound} in the same game` : ''}{requirement.minMinutes ? `, ${requirement.minMinutes} minutes per match` : ''}</span>
|
||||
{/if}
|
||||
{#if !['manual', 'failure-budget'].includes(requirement.kind)}
|
||||
<progress aria-label={progress.label} value={Math.min(progress.current, progress.target)} max={progress.target}></progress>
|
||||
{/if}
|
||||
{#if requirement.kind === 'manual' && !progress.passed && !closed}
|
||||
<div class="actions">
|
||||
<input aria-label={`Tester and observation for ${progress.label} on ${server.server}`} placeholder="Tester name and observation" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} />
|
||||
<Button size="sm" disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={() => action('sign-off', { server: server.server, requirementId: progress.id, note: notes[`${server.server}:${progress.id}`] })}>Sign off</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</article>
|
||||
{/each}
|
||||
<details ontoggle={event => evidenceOpen = event.currentTarget.open}>
|
||||
<summary>Evidence ({test.evidence.length}{test.evidence.length === 200 ? ' most recent' : ''})</summary>
|
||||
{#if evidenceOpen}
|
||||
{#each test.evidence.slice(0,evidenceLimit) as event (event.id)}
|
||||
<div class="evidence"><strong>{event.server} · {event.kind}</strong><time>{new Date(event.received_at).toLocaleString()}</time><pre>{event.payload}</pre></div>
|
||||
{#if message}<p role="status">{message}</p>{/if}
|
||||
{#if !test.servers.length}<p>Waiting for the first server restart. Evidence collection begins with that run. The testing window is already running.</p>{/if}
|
||||
{#each ranked as server (server.server)}
|
||||
<details class="server" open={!server.passed}>
|
||||
<summary><ServerIdentity name={server.server}/><span>{server.progress.filter(p=>p.passed).length} / {server.progress.length} checks passed</span>{#if test.plan.requirements.some(r=>r.kind==='recovery')}<span>Recovery cycles: {server.phase}</span>{/if}</summary>
|
||||
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <time datetime={server.observation.lastGameplayAt} title={new Date(server.observation.lastGameplayAt).toLocaleString()}>{reportAge(server.observation.lastGameplayAt)}</time> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} <a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a></p>{/if}
|
||||
{#each server.progress as progress (progress.id)}
|
||||
{@const requirement = test.plan.requirements.find(r=>r.id===progress.id)}
|
||||
{#if requirement}<div class="requirement" class:failed={progress.failed}>
|
||||
<div class="heading"><strong><span class="result">{progress.passed ? 'Passed' : progress.failed ? 'Failed' : 'Waiting'}</span> {requirementSummary(requirement)}</strong><span>{progress.detail}</span></div>
|
||||
{#if !progress.passed && !closed}<p>{betaWaitingCondition(requirement,server.observation)}</p>{/if}
|
||||
{#if !['manual','failure-budget'].includes(requirement.kind)}<progress aria-label={requirementSummary(requirement)} value={Math.min(progress.current,progress.target)} max={progress.target}></progress>{/if}
|
||||
{#if requirement.kind === 'manual' && !progress.passed && !closed}
|
||||
{#if canSign && server.observingAt}<div class="signoff"><label>Tester name and observation<textarea aria-label={`Tester and observation for ${progress.label} on ${server.server}`} rows="2" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} placeholder="Name · what you checked and observed"></textarea></label><Button size="sm" disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={()=>action('sign-off',{server:server.server,requirementId:progress.id,note:notes[`${server.server}:${progress.id}`]})}>Sign off</Button></div>
|
||||
{:else if canSign}<p>Waiting for this server's restart and health verification to finish before tester sign-off.</p>
|
||||
{:else}<p>Sign-off is unavailable while this test is {test.outcome}. {test.outcome === 'paused' ? 'Resume testing first.' : 'Use the recovery instructions above.'}</p>{/if}
|
||||
{/if}
|
||||
<button class="evidence-link" onclick={()=>showEvidence(server.server,requirement)}>View relevant evidence</button>
|
||||
</div>{/if}
|
||||
{/each}
|
||||
<ListFooter total={test.evidence.length} bind:limit={evidenceLimit} label="events" />
|
||||
{#if !test.evidence.length}<p>No evidence recorded yet.</p>{/if}
|
||||
</details>
|
||||
{/each}
|
||||
<details><summary>Test policy</summary><p>{test.plan.waveSize ? `${test.plan.waveSize} servers per wave.` : 'All selected servers deployed together.'} {test.plan.failureAction === 'rollback' ? 'Failed checks trigger automatic rollback.' : test.plan.failureAction === 'pause' ? 'Failed checks pause testing for investigation.' : 'Failed checks hold testing for review.'} {test.plan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; restart servers manually.'} Health checks continue after passing until promotion.</p></details>
|
||||
<details bind:this={evidenceSection} bind:open={evidenceOpen} class="evidence-section">
|
||||
<summary>Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''})</summary>
|
||||
{#if evidenceOpen}
|
||||
<p>Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt.</p>
|
||||
<div class="filters"><label>Server<select bind:value={evidenceServer}><option value="">All servers</option>{#each [...new Set(test.evidence.map(event=>event.server))] as server}<option value={server}>{server}</option>{/each}</select></label><label>Event type<select bind:value={evidenceKind}><option value="">All events</option><option value="gameplay">Gameplay reports</option><option value="health">Health observations</option><option value="manual-sign-off">Tester sign-offs</option></select></label><label>Search evidence<input type="search" bind:value={evidenceQuery} placeholder="Round, map, tester, observation…"/></label></div>
|
||||
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><ServerIdentity name={event.server}/><time datetime={event.received_at}>{new Date(event.received_at).toLocaleString()}</time></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
|
||||
<ListFooter total={filteredEvidence.length} bind:limit={evidenceLimit} label="events"/>
|
||||
{#if !filteredEvidence.length}<p>{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}</p>{/if}
|
||||
{/if}
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section { display: grid; gap: 16px; }
|
||||
h2, h3, p { margin: 0; }
|
||||
h2 { font-size: 18px; } h3 { font-size: 15px; }
|
||||
p, small, span, time { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
.heading, .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||
.heading { justify-content: space-between; }
|
||||
article { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
|
||||
.requirement { display: grid; gap: 8px; margin-top: 16px; }
|
||||
.requirement > div:first-child { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; }
|
||||
progress { width: 100%; height: 8px; accent-color: var(--state-online); }
|
||||
input { padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
|
||||
input { flex: 1; min-width: 160px; } summary { cursor: pointer; }
|
||||
.failed { color: var(--state-danger); }
|
||||
.evidence { display: grid; gap: 4px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; margin: 0; }
|
||||
section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size:18px; }
|
||||
p,span,time { font-size:13px; line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
|
||||
.heading,.actions,.filters { display:flex; flex-wrap:wrap; align-items:center; gap:12px; } .heading { justify-content:space-between; }
|
||||
.server { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; }
|
||||
.server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size:14px; } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; }
|
||||
.requirement { display:grid; gap:8px; margin-top:20px; border-top:1px solid var(--border-subtle); padding-top:16px; } .result { font-weight:600; margin-right:6px; color:inherit; }
|
||||
progress { width:100%; height:8px; accent-color:var(--state-online); } .failed { color:var(--state-danger); }
|
||||
.observation,.guidance { margin-top:12px; } .evidence-link { justify-self:start; padding:6px 0; color:var(--accent-primary-hover); border:0; background:transparent; cursor:pointer; font-size:12px; }
|
||||
.signoff { display:flex; gap:12px; align-items:end; flex-wrap:wrap; } .signoff label { flex:1 1 240px; }
|
||||
label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); } input,select,textarea { min-height:40px; padding:8px 10px; border:1px solid var(--border-control); border-radius:6px; background:var(--surface-sunken); color:var(--text-primary); font:inherit; max-width:100%; } textarea { resize:vertical; width:100%; box-sizing:border-box; }
|
||||
.filters { margin:16px 0; align-items:end; } .filters label { flex:1 1 180px; min-width:0; }
|
||||
.evidence { display:grid; gap:8px; padding:16px 0; border-bottom:1px solid var(--border-subtle); } .evidence-section { scroll-margin-top:80px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; } .evidence-section > p { margin-top:12px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user