{betaEvidenceSummary(event)}
Raw payload · {event.kind}
{event.payload}diff --git a/src/lib/server/fleet/beta-pages.ts b/src/lib/server/fleet/beta-pages.ts
index fd87a87..7a3b87e 100644
--- a/src/lib/server/fleet/beta-pages.ts
+++ b/src/lib/server/fleet/beta-pages.ts
@@ -1,5 +1,6 @@
import { db } from '../db/index.js';
import type { BetaRow } from './beta.js';
+import { serverTitle } from '../../ui/server-labels.js';
const positive = (value: string | null, fallback: number) => {
const number = Number(value);
@@ -7,15 +8,29 @@ const positive = (value: string | null, fallback: number) => {
};
export function betaListPage(params: URLSearchParams) {
- const state = params.get('state'), view = params.get('view'), search = (params.get('q') ?? '').slice(0, 200);
+ const state = params.get('state'), view = params.get('view'), search = (params.get('q') ?? '').slice(0, 200).trim();
const predicates: string[] = [], args: (string | number)[] = [];
if (state) { predicates.push('b.state=?'); args.push(state); }
else if (view === 'active') predicates.push("b.state IN ('running','ready-promote')");
else if (view === 'history') predicates.push("b.state IN ('promoted','rolled-back')");
+ const status = params.get('status');
+ if (status && ['ready', 'testing', 'attention', 'paused', 'promoted', 'rolled-back'].includes(status)) {
+ // Match betaStatus: closed lifecycle wins over the final test outcome.
+ predicates.push(`(CASE WHEN b.state IN ('promoted','rolled-back') THEN b.state
+ WHEN EXISTS (SELECT 1 FROM beta_servers m WHERE m.beta_id=b.id AND (m.server_deleted_at IS NOT NULL OR m.stage='failed'))
+ OR EXISTS (SELECT 1 FROM beta_test_plans p WHERE p.beta_id=b.id AND p.outcome IN ('failed','inconclusive')) THEN 'attention'
+ WHEN EXISTS (SELECT 1 FROM beta_test_plans p WHERE p.beta_id=b.id AND p.outcome='paused') THEN 'paused'
+ WHEN b.state='ready-promote' THEN 'ready' ELSE 'testing' END)=?`);
+ args.push(status);
+ }
if (search) {
+ const names = (db().prepare('SELECT DISTINCT server FROM beta_servers').all() as { server: string }[])
+ .filter(row => `${row.server} ${serverTitle(row.server)}`.toLowerCase().includes(search.toLowerCase())).map(row => row.server);
predicates.push(`(instr(lower('#'||b.id||' '||COALESCE(b.notes,'')),lower(?))>0 OR EXISTS (
- SELECT 1 FROM beta_scripts bs JOIN scripts s ON s.id=bs.script_id WHERE bs.beta_id=b.id AND instr(lower(s.name),lower(?))>0))`);
- args.push(search, search);
+ SELECT 1 FROM beta_scripts bs JOIN scripts s ON s.id=bs.script_id WHERE bs.beta_id=b.id AND (instr(lower(s.name),lower(?))>0 OR instr(lower(s.target_rel_path),lower(?))>0))
+ OR EXISTS (SELECT 1 FROM scripts s WHERE s.id=b.script_id AND (instr(lower(s.name),lower(?))>0 OR instr(lower(s.target_rel_path),lower(?))>0))
+ OR EXISTS (SELECT 1 FROM beta_servers m WHERE m.beta_id=b.id AND m.server IN (SELECT value FROM json_each(?))))`);
+ args.push(search, search, search, search, search, JSON.stringify(names));
}
const where = predicates.length ? ' WHERE ' + predicates.join(' AND ') : '';
const total = (db().prepare('SELECT COUNT(*) n FROM betas b' + where).get(...args) as { n: number }).n;
diff --git a/src/lib/ui/betas.ts b/src/lib/ui/betas.ts
new file mode 100644
index 0000000..a803fe1
--- /dev/null
+++ b/src/lib/ui/betas.ts
@@ -0,0 +1,21 @@
+import type { StatusTone } from './status.js';
+
+export interface BetaDeploymentServer {
+ server: string; server_deleted_at?: string | null; stage: string; error: string | null;
+ deployed_at: string | null; restarted_at: string | null; healthy_at: string | null;
+ match_seen_at: string | null; provisioning_id: string | null;
+}
+
+export function betaStatus(beta: { state: string; testOutcome?: string | null;
+ servers: { stage: string; server_deleted_at?: string | null }[] }): {
+ key: string; label: string; tone: StatusTone;
+} {
+ if (beta.state === 'promoted') return { key: 'promoted', label: 'Promoted', tone: 'online' };
+ if (beta.state === 'rolled-back') return { key: 'rolled-back', label: 'Rolled back', tone: 'neutral' };
+ if (beta.servers.some(server => server.server_deleted_at || server.stage === 'failed') || ['failed', 'inconclusive'].includes(beta.testOutcome ?? '')) {
+ return { key: 'attention', label: 'Needs attention', tone: beta.testOutcome === 'failed' || beta.servers.some(server => server.stage === 'failed') ? 'danger' : 'warning' };
+ }
+ if (beta.testOutcome === 'paused') return { key: 'paused', label: 'Paused', tone: 'warning' };
+ if (beta.state === 'ready-promote') return { key: 'ready', label: 'Ready to promote', tone: 'online' };
+ return { key: 'testing', label: 'Testing', tone: 'info' };
+}
diff --git a/src/lib/ui/components/BetaTestProgress.svelte b/src/lib/ui/components/BetaTestProgress.svelte
index 6f23174..452cc4a 100644
--- a/src/lib/ui/components/BetaTestProgress.svelte
+++ b/src/lib/ui/components/BetaTestProgress.svelte
@@ -1,6 +1,10 @@
{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server. {retryBlocked} {error} {message} {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.'} {closed ? 'Test results were not recorded.' : hasDeletedServers ? 'No test run was recorded before a participating server was deleted.' : 'Awaiting the first test run.'} {deployment.error} {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}` : ''} {closed || deployment.server_deleted_at ? 'No test results were recorded for this server.' : 'Checks become available after the server restarts.'} {coverageSummary(test.plan)} · Every requirement must pass on each qualifying server. {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. Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt. {betaEvidenceSummary(event)} {test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}Test results
{deployments ? 'Test servers' : 'Test results'}
{#if !deployments}
+
+ {#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)}
+ Server Deployment Test result Details
+
+
+
+ {#if result?.passed}
+
+
+ {/each}
+
+ {#if deployment.error}
+ {@render checks(server)}
+ Test policy
Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''})
+ {#if evidenceOpen}
+ Raw payload · {event.kind}
{event.payload}
{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · · {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}Telemetry diagnostics{/if}
{/if} {#each server.progress as progress (progress.id)} {@const requirement = test.plan.requirements.find(r=>r.id===progress.id)} @@ -74,22 +145,18 @@ {/if} {/each} - - {/each} -{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.
Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt.
- - {#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}{betaEvidenceSummary(event)}
{event.payload}{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}
{/if} - {/if} -