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 @@
-

Test results

-

{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server.

- {#if !closed} +

{deployments ? 'Test servers' : 'Test results'}

{#if !deployments}{/if}
+ {#if !closed && !deployments} {#if !hasDeletedServers}
{#if test.outcome === 'paused' && !expired}{/if} - {#if ['active','passed'].includes(test.outcome)}{/if} + {#if test.outcome === 'active'}{/if} {#if ['paused','failed'].includes(test.outcome) && !expired}{/if}
{/if} {#if retryBlocked && ['paused','failed'].includes(test.outcome)}

{retryBlocked}

{/if} @@ -55,10 +75,61 @@ {/if} {#if error}{/if} {#if message}

{message}

{/if} - {#if !test.servers.length}

{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.'}

{/if} + {#if !test.servers.length}

{closed ? 'Test results were not recorded.' : hasDeletedServers ? 'No test run was recorded before a participating server was deleted.' : 'Awaiting the first test run.'}

{/if} + {#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)} + + + + + + + + {/each} +
ServerDeploymentTest resultDetails
{#if result?.passed}Passed + {:else if result?.progress.some(check => check.failed)}Failed + {:else if !result}{closed || deployment.server_deleted_at ? 'Not recorded' : 'Awaiting test run'} + {:else}{closed ? 'Incomplete' : test.outcome === 'paused' ? 'Paused' : 'Waiting'}{/if} +
+ {:else} {#each ranked as server (server.server)}
{server.progress.filter(p=>p.passed).length} / {server.progress.length} checks passed{#if test.plan.requirements.some(r=>r.kind==='recovery')}Recovery cycles: {server.phase}{/if} + {@render checks(server)} +
+ {/each} + {/if} +
Test policy

{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.

+
+ Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''}) + {#if evidenceOpen} +

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)}

Raw payload · {event.kind}
{event.payload}
{/each} + + {#if !filteredEvidence.length}

{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}

{/if} + {/if} +
+
+ +{#snippet checks(server: BetaTestView['servers'][number])} {#if test.plan.requirements.some(needsGameTelemetry)}

{#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 policy

{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.

-
- Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''}) - {#if evidenceOpen} -

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)}

Raw payload · {event.kind}
{event.payload}
{/each} - - {#if !filteredEvidence.length}

{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}

{/if} - {/if} -
- +{/snippet} diff --git a/src/routes/betas/[id]/+page.svelte b/src/routes/betas/[id]/+page.svelte index 7b09c2d..80b2d1f 100644 --- a/src/routes/betas/[id]/+page.svelte +++ b/src/routes/betas/[id]/+page.svelte @@ -1,14 +1,14 @@