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

173 lines
19 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 { timeFormat } from '$ui/stores/time-zone.js';
import { tick } from 'svelte';
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import type { BetaDeploymentServer } from '$ui/betas.js';
import Badge from './primitives/Badge.svelte';
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
import StatusBadge from './primitives/StatusBadge.svelte';
import ListFooter from './primitives/ListFooter.svelte';
import Button from './primitives/Button.svelte';
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, deletedParticipants = false, deployments, busy = $bindable(false), onUpdate }: { test:BetaTestView; betaId:number; closed:boolean; deadline?:string; retryBlocked?:string; deletedParticipants?:boolean; deployments?:BetaDeploymentServer[]; busy?:boolean; onUpdate:()=>Promise<void> } = $props();
let notes = $state<Record<string,string>>({});
let evidenceOpen = $state(false), evidenceLimit = $state(25), error = $state(''), message = $state('');
let evidenceServer = $state(''), evidenceKind = $state(''), evidenceQuery = $state('');
let evidenceSection = $state<HTMLDetailsElement>();
const hasDeletedServers = $derived(deletedParticipants || test.servers.some(server => server.server_deleted_at) || deployments?.some(server => server.server_deleted_at));
const canSign = $derived(!closed && !hasDeletedServers && ['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)));
let expanded = $state<Record<string, boolean>>({});
const combined = $derived.by(() => {
const rows = [...(deployments ?? [])];
for (const server of test.servers) {
if (!rows.some(row => row.server === server.server)) rows.push({ server: server.server, server_deleted_at: server.server_deleted_at,
stage: 'unknown', error: null, deployed_at: null, restarted_at: null, healthy_at: null, match_seen_at: null, provisioning_id: null });
}
return rows.map(deployment => {
const result = test.servers.find(server => server.server === deployment.server);
const deletedAt = deployment.server_deleted_at ?? result?.server_deleted_at ?? null;
return { deployment: { ...deployment, server_deleted_at: deletedAt }, result: result ? { ...result, server_deleted_at: deletedAt } : undefined };
})
.sort((a,b) => rank(a) - rank(b) || a.deployment.server.localeCompare(b.deployment.server));
});
function rank(row: { deployment: BetaDeploymentServer; result?: BetaTestView['servers'][number] }) {
return row.deployment.stage === 'failed' || row.result?.progress.some(check => check.failed) ? 0 : row.result?.passed ? 2 : 1;
}
const filteredEvidence = $derived(test.evidence.filter(event => (!evidenceServer || event.server === evidenceServer)
&& (!evidenceKind || (evidenceKind === 'gameplay' ? ['game_start','round_change','heartbeat','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`; }
export 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 ? $timeFormat.dateTime(deadline) : '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})}), body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not update the test.');
await onUpdate();
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>{deployments ? 'Test servers' : 'Test results'}</h2>{#if !deployments}<StatusBadge state={test.outcome}/>{/if}</div>
{#if !closed && !deployments}
{#if !hasDeletedServers}<div class="actions">
{#if test.outcome === 'paused' && !expired}<Button size="sm" disabled={busy} onclick={()=>action('resume')}>Resume testing</Button>{/if}
{#if test.outcome === 'active'}<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}
{#if retryBlocked && ['paused','failed'].includes(test.outcome)}<p>{retryBlocked}</p>{/if}
{#if expired}<p class="guidance">The testing window ended. Use “Update beta” to test changed files in a fresh revision, or roll back and reuse the plan with a longer window.</p>
{:else if test.outcome === 'paused' && !hasDeletedServers}<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 message}<p role="status">{message}</p>{/if}
{#if !test.servers.length}<p>{closed ? 'Test results were not recorded.' : hasDeletedServers ? 'No test run was recorded before a participating server was deleted.' : 'Awaiting the first test run.'}</p>{/if}
{#if deployments}
<div class="table-scroll"><table>
<thead><tr><th scope="col">Server</th><th scope="col">Deployment</th><th scope="col">Test result</th><th scope="col" class="align-right">Details</th></tr></thead>
<tbody>{#each combined as { deployment, result } (deployment.server)}
{@const failed = deployment.stage === 'failed' || result?.progress.some(check => check.failed)}
{@const open = expanded[deployment.server] ?? Boolean(failed)}
<tr>
<td><HistoricalServerIdentity name={deployment.server} deletedAt={deployment.server_deleted_at} href={contextHref(`/servers/${encodeURIComponent(deployment.server)}`, $page.url)} /></td>
<td><StatusBadge state={deployment.stage === 'observing' ? 'ready' : deployment.stage} /></td>
<td>{#if result?.passed}<Badge variant="online">Passed</Badge>
{:else if result?.progress.some(check => check.failed)}<Badge variant="danger">Failed</Badge>
{:else if !result}<span>{closed || deployment.server_deleted_at ? 'Not recorded' : 'Awaiting test run'}</span>
{:else}<Badge variant="neutral">{closed ? 'Incomplete' : test.outcome === 'paused' ? 'Paused' : 'Waiting'}</Badge>{/if}
</td>
<td class="align-right"><button class="disclosure" aria-expanded={open} aria-controls={`beta-server-${betaId}-${deployment.server}`} aria-label={`${open ? 'Hide' : 'Show'} details for ${deployment.server}`} onclick={() => expanded = { ...expanded, [deployment.server]: !open }}>{open ? 'Hide' : 'Details'} <span aria-hidden="true">{open ? '−' : '+'}</span></button></td>
</tr>
<tr hidden={!open} id={`beta-server-${betaId}-${deployment.server}`} class="server-detail"><td colspan="4">
{#if deployment.error}<p class:failed={deployment.stage === 'failed'}>{deployment.error}</p>{/if}
<div class="deployment-details"><span>Files: {deployment.deployed_at ? 'installed' : 'pending'}</span><span>Restart: {deployment.restarted_at ? 'submitted' : 'pending'}</span><span>Health: {deployment.healthy_at ? 'verified' : 'not recorded'}</span>
{#if deployment.match_seen_at}<span>Match observed {$timeFormat.dateTime(deployment.match_seen_at)}</span>{/if}
</div>
<div class="deployment-links">
{#if !deployment.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(deployment.server)}?tab=diagnostics`, $page.url)}>Diagnostics</a><a href={contextHref(`/servers/${encodeURIComponent(deployment.server)}?tab=logs`, $page.url)}>Open logs</a>{/if}
{#if deployment.provisioning_id}<a href={contextHref(`/provisioning/${encodeURIComponent(deployment.provisioning_id)}`, $page.url)}>View provisioning</a>{/if}
</div>
{#if result}
{#if result.progress.length}<p>{result.progress.filter(check => check.passed).length} of {result.progress.length} checks passed{test.plan.requirements.some(requirement => requirement.kind === 'recovery') ? ` · Recovery cycle ${result.phase}` : ''}</p>{/if}
{@render checks(result)}
{:else}<p>{closed || deployment.server_deleted_at ? 'No test results were recorded for this server.' : 'Checks become available after the server restarts.'}</p>{/if}
</td></tr>
{/each}</tbody>
</table></div>
{:else}
{#each ranked as server (server.server)}
<details class="server" open={!server.passed}>
<summary><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at}/><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>
{@render checks(server)}
</details>
{/each}
{/if}
<details><summary>Test policy</summary><p>{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server.</p><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 changes wait until the rollback campaign is resumed.'} 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"><HistoricalServerIdentity name={event.server} deletedAt={event.server_deleted_at}/><time datetime={event.received_at}>{$timeFormat.dateTime(event.received_at)}</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>
{#snippet checks(server: BetaTestView['servers'][number])}
{#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={$timeFormat.dateTime(server.observation.lastGameplayAt)}>{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} {#if !server.server_deleted_at}<a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a>{/if}</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 && !hasDeletedServers}<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 && !hasDeletedServers}
{#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}
{/snippet}
<style>
.table-scroll { overflow-x:auto; }
table { width:100%; border-collapse:collapse; font-size:var(--text-sm); text-align:left; }
th { padding:10px 12px; border-bottom:1px solid var(--border-strong); color:var(--text-tertiary); font-size:var(--text-xs); font-weight:var(--weight-medium); }
td { padding:16px 12px; border-bottom:1px solid var(--border-subtle); }
.align-right { text-align:right; }
.disclosure { background:transparent; border:0; padding:8px 0; color:var(--text-secondary); cursor:pointer; font:inherit; white-space:nowrap; }
.server-detail { background:var(--surface); }
.deployment-details,.deployment-links { display:flex; flex-wrap:wrap; gap:8px 20px; margin:12px 0; font-size:var(--text-sm); }
@media(max-width:720px) { table { min-width:620px; } }
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>