Preserve deleted server history

Retain historical server identities, results, and evidence after deletion. Mark archived entries as Deleted and prevent old work from targeting servers that reuse the same name.

Fixes #16.
This commit is contained in:
2026-09-27 21:48:55 -04:00
parent 53b29f726c
commit 981fb27c1c
61 changed files with 929 additions and 223 deletions
+13 -12
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { tick } from 'svelte';
import ServerIdentity from './ServerIdentity.svelte';
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
import StatusBadge from './primitives/StatusBadge.svelte';
import ListFooter from './primitives/ListFooter.svelte';
import Button from './primitives/Button.svelte';
@@ -9,12 +9,13 @@
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, onUpdate }: { test:BetaTestView; betaId:number; closed:boolean; deadline?:string; retryBlocked?:string; onUpdate:()=>Promise<void> } = $props();
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 notes = $state<Record<string,string>>({});
let evidenceOpen = $state(false), evidenceLimit = $state(25), busy = $state(false), error = $state(''), message = $state('');
let evidenceServer = $state(''), evidenceKind = $state(''), evidenceQuery = $state('');
let evidenceSection = $state<HTMLDetailsElement>();
const canSign = $derived(!closed && ['active','passed'].includes(test.outcome));
const hasDeletedServers = $derived(deletedParticipants || test.servers.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 filteredEvidence = $derived(test.evidence.filter(event => (!evidenceServer || event.server === evidenceServer)
@@ -43,29 +44,29 @@
<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="actions">
{#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 ['paused','failed'].includes(test.outcome) && !expired}<Button size="sm" disabled={busy || !!retryBlocked} onclick={()=>action('retry')}>Start fresh attempt</Button>{/if}
</div>
</div>{/if}
{#if retryBlocked && ['paused','failed'].includes(test.outcome)}<p>{retryBlocked}</p>{/if}
{#if expired}<p class="guidance">The testing window ended. Roll back this beta first, then choose “Reuse files and test plan” to open a new beta with a longer window.</p>
{:else if test.outcome === 'paused'}<p class="guidance">Resume retains completed milestones; continuous health and in-progress timing intervals start fresh. Pausing does not extend the deadline.</p>{/if}
{: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>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>{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}
{#each ranked as server (server.server)}
<details class="server" open={!server.passed}>
<summary><ServerIdentity name={server.server}/><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>
{#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} <a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a></p>{/if}
<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>
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <time datetime={server.observation.lastGameplayAt} title={$timeFormat.dateTime(server.observation.lastGameplayAt)}>{reportAge(server.observation.lastGameplayAt)}</time> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} {#if !server.server_deleted_at}<a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a>{/if}</p>{/if}
{#each server.progress as progress (progress.id)}
{@const requirement = test.plan.requirements.find(r=>r.id===progress.id)}
{#if requirement}<div class="requirement" class:failed={progress.failed}>
<div class="heading"><strong><span class="result">{progress.passed ? 'Passed' : progress.failed ? 'Failed' : 'Waiting'}</span> {requirementSummary(requirement)}</strong><span>{progress.detail}</span></div>
{#if !progress.passed && !closed}<p>{betaWaitingCondition(requirement,server.observation)}</p>{/if}
{#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}
{#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}
@@ -81,7 +82,7 @@
{#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"><ServerIdentity name={event.server}/><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}
{#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}