Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -12,162 +12,723 @@
|
||||
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 {
|
||||
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 {
|
||||
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)));
|
||||
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 });
|
||||
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));
|
||||
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;
|
||||
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; });
|
||||
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 = '';
|
||||
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(() => {
|
||||
void evidenceServer;
|
||||
void evidenceKind;
|
||||
void evidenceQuery;
|
||||
evidenceLimit = 25;
|
||||
});
|
||||
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();
|
||||
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; }
|
||||
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();
|
||||
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>
|
||||
<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 !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 !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>
|
||||
<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}
|
||||
{#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>
|
||||
<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>
|
||||
<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}>{serverTitle(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}/><span class="time"><RelativeTime value={event.received_at} /></span></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}
|
||||
<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}>{serverTitle(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}
|
||||
/><span class="time"><RelativeTime value={event.received_at} /></span>
|
||||
</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('_',' ')} · <span class="time"><RelativeTime value={server.observation.lastGameplayAt} /></span> · {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 test.plan.requirements.some(needsGameTelemetry)}<p class="observation">
|
||||
{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll(
|
||||
'_',
|
||||
' '
|
||||
)} ·
|
||||
<span class="time"><RelativeTime value={server.observation.lastGameplayAt} /></span>
|
||||
· {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 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>
|
||||
<button
|
||||
class="evidence-link"
|
||||
onclick={() => showEvidence(server.server, requirement)}
|
||||
>View relevant evidence</button
|
||||
>
|
||||
</div>{/if}
|
||||
{/each}
|
||||
{/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: var(--text-md); }
|
||||
p,span,.time { font-size: var(--text-sm); 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: var(--text-base); } .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: var(--text-xs); }
|
||||
.signoff { display:flex; gap:12px; align-items:end; flex-wrap:wrap; } .signoff label { flex:1 1 240px; }
|
||||
label { display:grid; gap:6px; font-size: var(--text-sm); 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: var(--text-xs); } .evidence-section > p { margin-top:12px; }
|
||||
.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: var(--text-md);
|
||||
}
|
||||
p,
|
||||
span,
|
||||
.time {
|
||||
font-size: var(--text-sm);
|
||||
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: var(--text-base);
|
||||
}
|
||||
.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: var(--text-xs);
|
||||
}
|
||||
.signoff {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.signoff label {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: var(--text-sm);
|
||||
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: var(--text-xs);
|
||||
}
|
||||
.evidence-section > p {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user