@@ -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.' : 'W aiting 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.' : 'Aw aiting 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 : 10 px 12 px ; border-bottom : 1 px solid var ( -- border - strong ); color : var ( -- text - tertiary ); font-size : var ( -- text - xs ); font-weight : var ( -- weight - medium ); }
td { padding : 16 px 12 px ; border-bottom : 1 px solid var ( -- border - subtle ); }
. align-right { text-align : right ; }
. disclosure { background : transparent ; border : 0 ; padding : 8 px 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 : 8 px 20 px ; margin : 12 px 0 ; font-size : var ( -- text - sm ); }
@ media ( max-width : 720px ) { table { min-width : 620 px ; } }
section { display : grid ; gap : 16 px ; } h2 , p { margin : 0 ; } h2 { font-size : 18 px ; }
p , span , time { font-size : 13 px ; 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 : 12 px ; } . heading { justify-content : space-between ; }