Simplify beta lists and combine server test details

This commit is contained in:
2026-10-06 16:38:51 -04:00
parent ba015ca4c7
commit 36c2907602
8 changed files with 379 additions and 110 deletions
+90 -23
View File
@@ -1,6 +1,10 @@
<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';
@@ -9,21 +13,38 @@
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, onUpdate }: { test:BetaTestView; betaId:number; closed:boolean; deadline?:string; retryBlocked?:string; deletedParticipants?:boolean; onUpdate:()=>Promise<void> } = $props();
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), busy = $state(false), error = $state(''), message = $state('');
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));
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`; }
async function action(action:string, extra:Record<string,string>={}) {
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 {
@@ -41,12 +62,11 @@
</script>
<section aria-label="Test plan progress">
<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="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 ['active','passed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={()=>action('pause')}>Pause 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}
@@ -55,10 +75,61 @@
{/if}
{#if error}<p role="alert" class="failed">{error}</p>{/if}
{#if message}<p role="status">{message}</p>{/if}
{#if !test.servers.length}<p>{hasDeletedServers ? 'No test run was recorded before a participating server was deleted.' : 'Waiting for the first server restart. Evidence collection begins with that run. The testing window is already running.'}</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)}
@@ -74,22 +145,18 @@
<button class="evidence-link" onclick={()=>showEvidence(server.server,requirement)}>View relevant evidence</button>
</div>{/if}
{/each}
</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"><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}
<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; }
@@ -2,7 +2,8 @@
import { timeFormat } from '$ui/stores/time-zone.js';
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
import Button from './primitives/Button.svelte';
let { steps = [], events, active = false, showOverview = true }: { steps?: OperationStep[]; events: ActivityEvent[]; active?: boolean; showOverview?: boolean } = $props();
import type { Snippet } from 'svelte';
let { steps = [], events, active = false, showOverview = true, footer }: { steps?: OperationStep[]; events: ActivityEvent[]; active?: boolean; showOverview?: boolean; footer?: Snippet } = $props();
let limit = $state(25);
let query = $state('');
let newestFirst = $state(true);
@@ -40,6 +41,7 @@
{:else}
<p class="muted">This operation predates detailed activity tracking. Its recorded status and timestamps are shown on this page.</p>
{/if}
{@render footer?.()}
</details>
</section>