Files
electric-cherry/src/lib/ui/components/BetaTestProgress.svelte
T
2026-10-11 23:30:40 -04:00

746 lines
30 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 Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { serverTitle } from '$ui/server-labels.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
import { tick } from 'svelte';
import { page } from '$app/stores';
import { canOperate } from '$lib/auth.js';
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 editable = $derived(canOperate($page.data?.user?.role));
const hasDeletedServers = $derived(
deletedParticipants ||
test.servers.some((server) => server.server_deleted_at) ||
deployments?.some((server) => server.server_deleted_at)
);
const canSign = $derived(
editable && !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(() => {
void evidenceServer;
void evidenceKind;
void evidenceQuery;
evidenceLimit = 25;
});
export async function action(action: string, extra: Record<string, string> = {}) {
if (!editable) return;
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 editable && !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">
<Field 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
></Field
><Field 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
></Field
><Field label="Search evidence">
<Input
type="search"
bind:value={evidenceQuery}
placeholder="Round, map, tester, observation…"
/></Field
>
</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 editable && 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: 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;
}
.signoff :global(.field) {
flex: 1 1 240px;
}
label {
display: grid;
gap: 6px;
font-size: var(--text-sm);
color: var(--text-secondary);
}
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;
}
.filters :global(.field) {
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>