Paginate beta history and refresh pages from events

This commit is contained in:
2026-10-06 14:50:47 -04:00
parent daefb22be6
commit d99e5979fa
13 changed files with 239 additions and 35 deletions
+40
View File
@@ -0,0 +1,40 @@
import { db } from '../db/index.js';
import type { BetaRow } from './beta.js';
const positive = (value: string | null, fallback: number) => {
const number = Number(value);
return Number.isSafeInteger(number) && number > 0 ? number : fallback;
};
export function betaListPage(params: URLSearchParams) {
const state = params.get('state'), view = params.get('view'), search = (params.get('q') ?? '').slice(0, 200);
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')");
if (search) {
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);
}
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;
const pageSize = Math.min(100, positive(params.get('limit'), 25));
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const page = Math.min(totalPages, positive(params.get('page'), 1));
const betas = db().prepare('SELECT b.* FROM betas b' + where + ' ORDER BY b.id DESC LIMIT ? OFFSET ?')
.all(...args, pageSize, (page - 1) * pageSize) as BetaRow[];
const counts = db().prepare(`SELECT COUNT(CASE WHEN state IN ('running','ready-promote') THEN 1 END) active,
COUNT(CASE WHEN state IN ('promoted','rolled-back') THEN 1 END) history FROM betas`).get() as { active: number; history: number };
return { betas, page, pageSize, total, totalPages, counts };
}
export function betaActivityPage(id: number, params = new URLSearchParams()) {
const before = positive(params.get('before'), Number.MAX_SAFE_INTEGER);
const limit = Math.min(100, positive(params.get('limit'), 50));
const rows = db().prepare('SELECT id,created_at,message FROM beta_events WHERE beta_id=? AND id<? ORDER BY id DESC LIMIT ?')
.all(id, before, limit + 1) as { id: number; created_at: string; message: string }[];
const more = rows.length > limit;
const events = rows.slice(0, limit).reverse();
return { events, nextBefore: more ? events[0].id : null };
}
+3 -2
View File
@@ -7,6 +7,7 @@ import type { GameTelemetry } from '../../telemetry.js';
import { applySample, applyGameEvent, emptyMetrics, evaluateRequirements, type TestMetrics } from './beta-evaluation.js'; import { applySample, applyGameEvent, emptyMetrics, evaluateRequirements, type TestMetrics } from './beta-evaluation.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js'; import { ONLINE_MAX_MS } from '../../server-freshness.js';
import { currentBetaRevision } from './beta-revisions.js'; import { currentBetaRevision } from './beta-revisions.js';
import { invalidateBeta } from '../pipeline/bus.js';
export interface TestRun { id: string; beta_id: number; server: string; phase: number; created_at: string; observing_at: string | null; metrics: string } export interface TestRun { id: string; beta_id: number; server: string; phase: number; created_at: string; observing_at: string | null; metrics: string }
export interface StoredPlan { beta_id: number; plan: string; outcome: string; reason: string | null } export interface StoredPlan { beta_id: number; plan: string; outcome: string; reason: string | null }
@@ -15,7 +16,7 @@ const activeParticipant = (id: number, server: string) => !!db().prepare(`SELECT
WHERE beta_id = ? AND server = ? AND server_deleted_at IS NULL`).get(id, server); WHERE beta_id = ? AND server = ? AND server_deleted_at IS NULL`).get(id, server);
export const storedPlan = (id: number) => db().prepare('SELECT * FROM beta_test_plans WHERE beta_id = ?').get(id) as StoredPlan | undefined; export const storedPlan = (id: number) => db().prepare('SELECT * FROM beta_test_plans WHERE beta_id = ?').get(id) as StoredPlan | undefined;
export const currentRun = (id: number, server: string) => db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND server = ? AND retired_at IS NULL').get(id, server) as TestRun | undefined; export const currentRun = (id: number, server: string) => db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND server = ? AND retired_at IS NULL').get(id, server) as TestRun | undefined;
export function activity(id: number, message: string) { db().prepare('INSERT INTO beta_events (beta_id, message) VALUES (?, ?)').run(id, message); } export function activity(id: number, message: string) { db().prepare('INSERT INTO beta_events (beta_id, message) VALUES (?, ?)').run(id, message); invalidateBeta(id); }
export function setOutcome(id: number, outcome: string, reason: string | null): void { export function setOutcome(id: number, outcome: string, reason: string | null): void {
const previous = storedPlan(id); const previous = storedPlan(id);
if (!previous || (previous.outcome === outcome && previous.reason === reason)) return; if (!previous || (previous.outcome === outcome && previous.reason === reason)) return;
@@ -23,7 +24,7 @@ export function setOutcome(id: number, outcome: string, reason: string | null):
activity(id, `Test plan ${outcome}${reason ? `: ${reason}` : ''}`); activity(id, `Test plan ${outcome}${reason ? `: ${reason}` : ''}`);
} }
export function readMetrics(run: TestRun): TestMetrics { return { ...emptyMetrics(), ...JSON.parse(run.metrics) }; } export function readMetrics(run: TestRun): TestMetrics { return { ...emptyMetrics(), ...JSON.parse(run.metrics) }; }
export function saveMetrics(run: TestRun, metrics: TestMetrics): void { db().prepare('UPDATE beta_test_runs SET metrics = ? WHERE id = ?').run(JSON.stringify(metrics), run.id); } export function saveMetrics(run: TestRun, metrics: TestMetrics): void { db().prepare('UPDATE beta_test_runs SET metrics = ? WHERE id = ?').run(JSON.stringify(metrics), run.id); invalidateBeta(run.beta_id); }
export function evidence(run: TestRun, eventId: string, kind: string, payload: unknown): boolean { export function evidence(run: TestRun, eventId: string, kind: string, payload: unknown): boolean {
return db().prepare('INSERT OR IGNORE INTO beta_test_evidence (run_id, event_id, received_at, kind, payload) VALUES (?, ?, ?, ?, ?)') return db().prepare('INSERT OR IGNORE INTO beta_test_evidence (run_id, event_id, received_at, kind, payload) VALUES (?, ?, ?, ?, ?)')
.run(run.id, eventId, nowIso(), kind, JSON.stringify(payload)).changes > 0; .run(run.id, eventId, nowIso(), kind, JSON.stringify(payload)).changes > 0;
+11
View File
@@ -46,6 +46,7 @@ export type BetaStage =
export type BetaState = 'running' | 'ready-promote' | 'promoted' | 'rolled-back'; export type BetaState = 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
export type PipelineEvent = export type PipelineEvent =
| { type: 'beta-changed'; betaId: number }
| { type: 'fleet-invalidated' } | { type: 'fleet-invalidated' }
| { type: 'server-telemetry'; entries: GameTelemetry[] } | { type: 'server-telemetry'; entries: GameTelemetry[] }
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] } | { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
@@ -79,6 +80,15 @@ class Bus extends EventEmitter {
} }
const bus = new Bus(); const bus = new Bus();
const changedBetas = new Set<number>();
export function invalidateBeta(betaId: number): void {
const scheduled = changedBetas.size > 0;
changedBetas.add(betaId);
if (!scheduled) queueMicrotask(() => {
const ids = [...changedBetas]; changedBetas.clear();
for (const betaId of ids) bus.emit('event', { type: 'beta-changed', betaId });
});
}
let invalidationPending = false; let invalidationPending = false;
export function invalidateFleet(): void { export function invalidateFleet(): void {
if (invalidationPending) return; if (invalidationPending) return;
@@ -90,6 +100,7 @@ export function invalidateFleet(): void {
export function emit(event: PipelineEvent): void { export function emit(event: PipelineEvent): void {
bus.emit('event', event); bus.emit('event', event);
if (event.type === 'beta-opened' || event.type === 'beta-state' || event.type === 'beta-server-stage') invalidateFleet(); if (event.type === 'beta-opened' || event.type === 'beta-state' || event.type === 'beta-server-stage') invalidateFleet();
if ('betaId' in event && event.type !== 'beta-changed') invalidateBeta(event.betaId);
} }
export function subscribe(listener: (event: PipelineEvent) => void): () => void { export function subscribe(listener: (event: PipelineEvent) => void): () => void {
+30
View File
@@ -0,0 +1,30 @@
/** Coalesce pushes, serialize refreshes, and recover missed events after reconnect or tab activation. */
export function betaUpdates(load: () => Promise<unknown>, betaId?: () => number): () => void {
let timer: ReturnType<typeof setTimeout> | undefined;
let stopped = false, running = false, pending = false;
const schedule = () => {
pending = true;
if (stopped || running || timer || document.hidden) return;
timer = setTimeout(async () => {
timer = undefined;
if (stopped || document.hidden) return;
running = true; pending = false;
try { await load(); }
catch { /* The page owns its error state; later events can retry. */ }
finally { running = false; if (pending) schedule(); }
}, 200);
};
const changed = (event: Event) => { if (!betaId || (event as CustomEvent).detail?.betaId === betaId()) schedule(); };
const visible = () => { if (!document.hidden) schedule(); };
window.addEventListener('ec:beta-changed', changed);
window.addEventListener('ec:pipeline-connected', schedule);
window.addEventListener('ec:fleet-changed', schedule);
document.addEventListener('visibilitychange', visible);
return () => {
stopped = true; clearTimeout(timer);
window.removeEventListener('ec:beta-changed', changed);
window.removeEventListener('ec:pipeline-connected', schedule);
window.removeEventListener('ec:fleet-changed', schedule);
document.removeEventListener('visibilitychange', visible);
};
}
+5
View File
@@ -26,6 +26,11 @@ function updateDeployment(id: string, mutate: (d: PipelineDeployment) => void):
export function startPipelineStream(): (() => void) | undefined { export function startPipelineStream(): (() => void) | undefined {
if (!browser || source) return; if (!browser || source) return;
source = new EventSource('/api/events/pipeline'); source = new EventSource('/api/events/pipeline');
source.addEventListener('open', () => window.dispatchEvent(new Event('ec:pipeline-connected')));
source.addEventListener('beta-changed', event => {
try { window.dispatchEvent(new CustomEvent('ec:beta-changed', { detail: JSON.parse((event as MessageEvent).data) })); }
catch { /* Reconnect refreshes authoritative state if an event is malformed. */ }
});
for (const event of ['server-changed', 'server-state', 'deployment-opened', 'fleet-invalidated']) { for (const event of ['server-changed', 'server-state', 'deployment-opened', 'fleet-invalidated']) {
source.addEventListener(event, () => window.dispatchEvent(new Event('ec:fleet-changed'))); source.addEventListener(event, () => window.dispatchEvent(new Event('ec:fleet-changed')));
} }
+5 -9
View File
@@ -1,6 +1,5 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { import {
listBetas,
openBeta, openBeta,
BetaError BetaError
} from '$lib/server/fleet/beta.js'; } from '$lib/server/fleet/beta.js';
@@ -10,15 +9,12 @@ import type { BetaServerRow } from '$lib/server/fleet/beta.js';
import type { StoredPlan } from '$lib/server/fleet/beta-tests.js'; import type { StoredPlan } from '$lib/server/fleet/beta-tests.js';
import { kick } from '$lib/server/pipeline/worker.js'; import { kick } from '$lib/server/pipeline/worker.js';
import { parseBetaPlan, type BetaPlan } from '$lib/beta-plan.js'; import { parseBetaPlan, type BetaPlan } from '$lib/beta-plan.js';
import { betaListPage } from '$lib/server/fleet/beta-pages.js';
export async function GET({ url }: { url: URL }) { export async function GET({ url }: { url: URL }) {
const stateFilter = url.searchParams.get('state') as const state = url.searchParams.get('state');
| 'running' if (state && !['running','ready-promote','promoted','rolled-back'].includes(state)) return json({ error: 'Invalid beta state.' }, { status: 400 });
| 'ready-promote' const { betas, ...pagination } = betaListPage(url.searchParams);
| 'promoted'
| 'rolled-back'
| null;
const betas = listBetas(stateFilter ?? undefined);
const ids = JSON.stringify(betas.map(beta => beta.id)); const ids = JSON.stringify(betas.map(beta => beta.id));
const bundles = betaScriptMetadata(betas.map(beta => beta.id)); const bundles = betaScriptMetadata(betas.map(beta => beta.id));
const plans = new Map((db().prepare(`SELECT p.*, COALESCE((SELECT started_at FROM beta_revisions r const plans = new Map((db().prepare(`SELECT p.*, COALESCE((SELECT started_at FROM beta_revisions r
@@ -39,7 +35,7 @@ export async function GET({ url }: { url: URL }) {
servers: members.filter(member => member.beta_id === b.id) servers: members.filter(member => member.beta_id === b.id)
}; };
}); });
return json({ betas: rows }); return json({ betas: rows, ...pagination });
} }
// POST body accepts either: // POST body accepts either:
+3 -2
View File
@@ -4,6 +4,7 @@ import { getBeta, listBetaServers } from '$lib/server/fleet/beta.js';
import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js'; import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js';
import { testView } from '$lib/server/fleet/beta-tests.js'; import { testView } from '$lib/server/fleet/beta-tests.js';
import { betaRevisionHistory, currentBetaRevision } from '$lib/server/fleet/beta-revisions.js'; import { betaRevisionHistory, currentBetaRevision } from '$lib/server/fleet/beta-revisions.js';
import { betaActivityPage } from '$lib/server/fleet/beta-pages.js';
function parseId(raw: string): number | null { function parseId(raw: string): number | null {
const n = Number.parseInt(raw, 10); const n = Number.parseInt(raw, 10);
@@ -19,13 +20,13 @@ export async function GET({ params }: { params: { id: string } }) {
const scripts = betaScriptMetadata([id]).get(id) ?? []; const scripts = betaScriptMetadata([id]).get(id) ?? [];
const servers = listBetaServers(id); const servers = listBetaServers(id);
const events = db().prepare('SELECT id, created_at, message FROM beta_events WHERE beta_id = ? ORDER BY id').all(id); const { events, nextBefore: eventsNextBefore } = betaActivityPage(id);
const campaign = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1").get(id) as { id: string } | undefined; const campaign = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1").get(id) as { id: string } | undefined;
const promotion = campaign ? { ...campaign, const promotion = campaign ? { ...campaign,
pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state NOT IN ('done','failed','cancelled','skipped')").get(campaign.id) as { n: number }).n, pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state NOT IN ('done','failed','cancelled','skipped')").get(campaign.id) as { n: number }).n,
failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state = 'failed'").get(campaign.id) as { n: number }).n } : null; failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state = 'failed'").get(campaign.id) as { n: number }).n } : null;
const rollback = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'rollback' ORDER BY started_at DESC LIMIT 1") const rollback = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'rollback' ORDER BY started_at DESC LIMIT 1")
.get(id) as { id: string } | undefined; .get(id) as { id: string } | undefined;
return json({ beta, scripts, servers, events, test: testView(id), promotion, rollback: rollback ?? null, return json({ beta, scripts, servers, events, eventsNextBefore, test: testView(id), promotion, rollback: rollback ?? null,
revision: currentBetaRevision(id), revisions: betaRevisionHistory(id) }); revision: currentBetaRevision(id), revisions: betaRevisionHistory(id) });
} }
@@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import { getBeta } from '$lib/server/fleet/beta.js';
import { betaActivityPage } from '$lib/server/fleet/beta-pages.js';
export function GET({ params, url }: { params: { id: string }; url: URL }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid beta ID.' }, { status: 400 });
if (!getBeta(id)) return json({ error: 'Beta not found.' }, { status: 404 });
return json(betaActivityPage(id, url.searchParams));
}
+1 -1
View File
@@ -21,7 +21,7 @@ export async function GET() {
event.type === 'server-state' || event.type === 'server-state' ||
event.type === 'deployment-opened' || event.type === 'deployment-opened' ||
event.type === 'server-changed' || event.type === 'server-changed' ||
event.type === 'sweep-tick' || event.type === 'fleet-invalidated' event.type === 'sweep-tick' || event.type === 'fleet-invalidated' || event.type === 'beta-changed'
) { ) {
send(controller, event); send(controller, event);
} }
+33 -17
View File
@@ -4,14 +4,13 @@
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { pollVisible } from '$ui/poll.js'; import { betaUpdates } from '$ui/beta-updates.js';
import { artifactVersionLabel } from '$lib/artifact-version.js'; import { artifactVersionLabel } from '$lib/artifact-version.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte'; import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte'; import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { statusPresentation } from '$lib/ui/status.js'; import { statusPresentation } from '$lib/ui/status.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline'; type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
@@ -38,11 +37,13 @@
let error = $state(''); let error = $state('');
let tab = $state('active'); let tab = $state('active');
let search = $state(''); let search = $state('');
let limit = $state(25); let pageNumber = $state(1), totalPages = $state(1), total = $state(0);
let counts = $state({ active: 0, history: 0 });
let request = 0;
const active = (beta: BetaListRow) => beta.state === 'running' || beta.state === 'ready-promote'; const active = (beta: BetaListRow) => beta.state === 'running' || beta.state === 'ready-promote';
const tabs = $derived([{id:'active',label:'Active',badge:betas.filter(active).length},{id:'history',label:'History',badge:betas.filter(b => !active(b)).length}]); const tabs = $derived([{id:'active',label:'Active',badge:counts.active},{id:'history',label:'History',badge:counts.history}]);
let now = $state(Date.now()); let now = $state(Date.now());
const filtered = $derived(betas.filter(b => active(b) === (tab === 'active') && ('#' + b.id + ' ' + (b.notes ?? '') + ' ' + b.scripts.map(s => s.name).join(' ')).toLowerCase().includes(search.toLowerCase())));
function age(date:string) { const minutes=Math.max(0,Math.floor((now-Date.parse(date))/60000));return minutes<60?`${minutes}m ago`:minutes<1440?`${Math.floor(minutes/60)}h ago`:`${Math.floor(minutes/1440)}d ago`; } function age(date:string) { const minutes=Math.max(0,Math.floor((now-Date.parse(date))/60000));return minutes<60?`${minutes}m ago`:minutes<1440?`${Math.floor(minutes/60)}h ago`:`${Math.floor(minutes/1440)}d ago`; }
function blocker(beta:BetaListRow) { function blocker(beta:BetaListRow) {
if(beta.state==='promoted')return 'Version promoted; check its campaign for remaining activation.'; if(beta.state==='promoted')return 'Version promoted; check its campaign for remaining activation.';
@@ -58,23 +59,38 @@
const params = $page.url.searchParams; const params = $page.url.searchParams;
tab = params.get('view') === 'history' ? 'history' : 'active'; tab = params.get('view') === 'history' ? 'history' : 'active';
search = params.get('q') ?? ''; search = params.get('q') ?? '';
limit = Math.min(10000, Math.max(25, Number(params.get('limit')) || 25)); pageNumber = Math.max(1, Number(params.get('page')) || 1);
void load(params);
}); });
function saveContext() { function saveContext() {
const url = new URL($page.url); const url = new URL($page.url);
if (tab === 'history') url.searchParams.set('view', tab); else url.searchParams.delete('view'); if (tab === 'history') url.searchParams.set('view', tab); else url.searchParams.delete('view');
if (search) url.searchParams.set('q', search); else url.searchParams.delete('q'); if (search) url.searchParams.set('q', search); else url.searchParams.delete('q');
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit'); url.searchParams.delete('limit');
if (pageNumber > 1) url.searchParams.set('page', String(pageNumber)); else url.searchParams.delete('page');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true }); void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
} }
async function load() { async function load(params = new URLSearchParams(window.location.search)) {
try { const res = await fetch('/api/betas'); if (!res.ok) throw new Error('Could not refresh betas.'); betas = (await res.json()).betas; now=Date.now(); error = ''; } const current = ++request;
catch (e) { error = (e as Error).message; } finally { loading = false; } params = new URLSearchParams(params);
params.set('view', params.get('view') === 'history' ? 'history' : 'active');
params.set('limit', '25');
try {
const res = await fetch('/api/betas?' + params);
if (!res.ok) throw new Error('Could not refresh betas.');
const body = await res.json();
if (current !== request) return;
betas = body.betas; counts = body.counts; total = body.total; totalPages = body.totalPages; pageNumber = body.page;
now = Date.now(); error = '';
} catch (e) { if (current === request) error = (e as Error).message; }
finally { if (current === request) loading = false; }
} }
onMount(() => { onMount(() => {
const query = new URLSearchParams(window.location.search); const query = new URLSearchParams(window.location.search);
if (query.has('artifact') || query.has('pluginId')) { void goto('/betas/new' + window.location.search, {replaceState:true}); return; } if (query.has('artifact') || query.has('pluginId')) { void goto('/betas/new' + window.location.search, {replaceState:true}); return; }
return pollVisible(load, 5000); const stop = betaUpdates(() => load());
const clock = setInterval(() => { now = Date.now(); }, 30000);
return () => { stop(); clearInterval(clock); request++; };
}); });
</script> </script>
<svelte:head><title>Betas · Electric Cherry</title></svelte:head> <svelte:head><title>Betas · Electric Cherry</title></svelte:head>
@@ -82,18 +98,18 @@
<SectionHeader title="Betas" subtitle="Test selected versions before promoting them to the fleet."> <SectionHeader title="Betas" subtitle="Test selected versions before promoting them to the fleet.">
{#snippet actions()}<Button variant="primary" href="/betas/new">New beta</Button>{/snippet} {#snippet actions()}<Button variant="primary" href="/betas/new">New beta</Button>{/snippet}
</SectionHeader> </SectionHeader>
<Tabs {tabs} bind:value={tab} onchange={() => { limit = 25; saveContext(); }} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" /> <Tabs {tabs} bind:value={tab} onchange={() => { pageNumber = 1; saveContext(); }} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" />
<div class="ui-toolbar"><input type="search" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveContext(); }} aria-label="Search betas" placeholder="Search betas or files…" /></div> <div class="ui-toolbar"><input type="search" bind:value={search} oninput={event => { search = event.currentTarget.value; pageNumber = 1; saveContext(); }} aria-label="Search betas" placeholder="Search betas or files…" /></div>
{#if error}<div class="ui-error" role="alert">{error} {betas.length ? 'Showing the last loaded results.' : ''}<Button size="sm" onclick={load}>Retry</Button></div>{/if} {#if error}<div class="ui-error" role="alert">{error} {betas.length ? 'Showing the last loaded results.' : ''}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
<div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}> <div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}>
{#if loading}<EmptyState title="Loading betas…" /> {#if loading}<EmptyState title="Loading betas…" />
{:else if !filtered.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} /> {:else if !betas.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} />
{:else}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)} {:else}<div class="rows">{#each betas as beta (beta.id)}
<a href={contextHref('/betas/' + beta.id, $page.url)} class="row"> <a href={contextHref('/betas/' + beta.id, $page.url)} class="row">
<div class="identity"><strong>{beta.notes || `Beta #${beta.id}`}</strong>{#if beta.notes}<span class="beta-id"> · #{beta.id}</span>{/if}<span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span><span class="blocker">{blocker(beta)}</span><span class="timing"><time datetime={beta.opened_at} title={$timeFormat.dateTime(beta.opened_at)}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {$timeFormat.dateTime(beta.deadlineAt)}{:else if beta.closed_at} · Finished {$timeFormat.dateTime(beta.closed_at)}{/if}</span></div> <div class="identity"><strong>{beta.notes || `Beta #${beta.id}`}</strong>{#if beta.notes}<span class="beta-id"> · #{beta.id}</span>{/if}<span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span><span class="blocker">{blocker(beta)}</span><span class="timing"><time datetime={beta.opened_at} title={$timeFormat.dateTime(beta.opened_at)}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {$timeFormat.dateTime(beta.deadlineAt)}{:else if beta.closed_at} · Finished {$timeFormat.dateTime(beta.closed_at)}{/if}</span></div>
<div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.filter(server=>server.stage==='ready').length} / {beta.servers.length} servers passed</span></div> <div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.filter(server=>server.stage==='ready').length} / {beta.servers.length} servers passed</span></div>
</a> </a>
{/each}</div><ListFooter total={filtered.length} bind:limit onchange={saveContext} label="betas" />{/if} {/each}</div><div class="ui-toolbar" aria-label="Beta pagination"><span>{total} betas · Page {pageNumber} of {totalPages}</span><Button size="sm" disabled={pageNumber <= 1} onclick={() => { pageNumber--; saveContext(); }}>Previous</Button><Button size="sm" disabled={pageNumber >= totalPages} onclick={() => { pageNumber++; saveContext(); }}>Next</Button></div>{/if}
</div> </div>
</div> </div>
<style> <style>
+25 -4
View File
@@ -9,7 +9,7 @@
import type { BetaPromotionPreview } from '$lib/beta-promotion.js'; import type { BetaPromotionPreview } from '$lib/beta-promotion.js';
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js'; import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
import type { ActivityEvent } from '$lib/operation-flow.js'; import type { ActivityEvent } from '$lib/operation-flow.js';
import { pollVisible } from '$ui/poll.js'; import { betaUpdates } from '$ui/beta-updates.js';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
import type { BetaRevision } from '$lib/beta-revision.js'; import type { BetaRevision } from '$lib/beta-revision.js';
import BetaRevisionDialog from '$ui/components/BetaRevisionDialog.svelte'; import BetaRevisionDialog from '$ui/components/BetaRevisionDialog.svelte';
@@ -34,6 +34,8 @@
let scripts = $state<BetaFile[]>([]); let scripts = $state<BetaFile[]>([]);
let servers = $state<BetaServer[]>([]); let servers = $state<BetaServer[]>([]);
let events = $state<ActivityEvent[]>([]); let events = $state<ActivityEvent[]>([]);
let activityBefore = $state<number|null>(null), nextBefore = $state<number|null>(null), activityLoading = $state(false);
let activityRequest = 0;
let promotion = $state<Promotion|null>(null); let promotion = $state<Promotion|null>(null);
let rollbackCampaign = $state<{ id: string }|null>(null); let rollbackCampaign = $state<{ id: string }|null>(null);
let loading = $state(true), loadError = $state(''), flash = $state(''), success = $state(''); let loading = $state(true), loadError = $state(''), flash = $state(''), success = $state('');
@@ -67,15 +69,33 @@
if (!res.ok) throw new Error(res.status === 404 ? 'Beta not found.' : 'Could not refresh beta. Showing the last loaded results.'); if (!res.ok) throw new Error(res.status === 404 ? 'Beta not found.' : 'Could not refresh beta. Showing the last loaded results.');
const body = await res.json(); const body = await res.json();
if (current !== request || requestedId !== id) return; if (current !== request || requestedId !== id) return;
test = body.test; beta = body.beta; scripts = body.scripts; servers = body.servers; events = body.events; promotion = body.promotion ?? null; test = body.test; beta = body.beta; scripts = body.scripts; servers = body.servers;
if (activityBefore === null) { events = body.events; nextBefore = body.eventsNextBefore ?? null; }
promotion = body.promotion ?? null;
rollbackCampaign = body.rollback ?? null; rollbackCampaign = body.rollback ?? null;
revision = body.revision; revisions = body.revisions ?? []; revision = body.revision; revisions = body.revisions ?? [];
now = Date.now(); loadError = ''; now = Date.now(); loadError = '';
} catch (error) { if (current === request && requestedId === id) loadError = (error as Error).message; } } catch (error) { if (current === request && requestedId === id) loadError = (error as Error).message; }
finally { if (current === request && requestedId === id) loading = false; } finally { if (current === request && requestedId === id) loading = false; }
} }
$effect(() => { id; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; updateOpen = false; void load(); }); $effect(() => { id; activityRequest++; activityBefore = null; activityLoading = false; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; updateOpen = false; void load(); });
onMount(() => pollVisible(load, 5000, false)); onMount(() => {
const stop = betaUpdates(load, () => id);
const clock = setInterval(() => { now = Date.now(); }, 30000);
return () => { stop(); clearInterval(clock); request++; activityRequest++; };
});
async function loadActivity(before: number|null) {
const current = ++activityRequest, requestedId = id;
activityLoading = true;
try {
const res = await fetch(`/api/betas/${id}/events` + (before === null ? '' : `?before=${before}`));
if (!res.ok) throw new Error('Could not load beta activity.');
const body = await res.json();
if (current !== activityRequest || requestedId !== id) return;
activityBefore = before; events = body.events; nextBefore = body.nextBefore; loadError = '';
} catch (error) { if (current === activityRequest) loadError = (error as Error).message; }
finally { if (current === activityRequest) activityLoading = false; }
}
async function reviewPromotion() { async function reviewPromotion() {
reviewOpen = true; preview = null; reviewError = ''; acting = 'preview'; reviewOpen = true; preview = null; reviewError = ''; acting = 'preview';
try { try {
@@ -181,6 +201,7 @@
</div></details> </div></details>
{/if} {/if}
<OperationHistory {events} active={!closed} showOverview={false}/> <OperationHistory {events} active={!closed} showOverview={false}/>
<div class="ui-toolbar" aria-label="Activity pagination">{#if activityBefore !== null}<Button size="sm" disabled={activityLoading} onclick={() => loadActivity(null)}>Latest activity</Button>{/if}{#if nextBefore !== null}<Button size="sm" disabled={activityLoading} onclick={() => loadActivity(nextBefore)}>Older activity</Button>{/if}</div>
</div> </div>
{/if} {/if}
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
it('bounds beta pages, searches across retained history, and seeks activity without duplicates', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { betaListPage, betaActivityPage } = await import('../../src/lib/server/fleet/beta-pages.js');
db().exec(`INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'Plugin','plugin.dll');
INSERT INTO script_versions(id,script_id,version,sha256,size) VALUES(1,1,1,'hash',0)`);
db().transaction(() => {
for (let id = 1; id <= 125; id++) {
db().prepare('INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state,notes) VALUES(?,1,1,?,?,?)')
.run(id, 'ui', id <= 5 ? 'running' : 'promoted', `Test ${id}`);
db().prepare('INSERT INTO beta_scripts(beta_id,script_id,script_version_id) VALUES(?,1,1)').run(id);
}
for (let i = 0; i < 150; i++) db().prepare('INSERT INTO beta_events(beta_id,message) VALUES(1,?)').run(`Activity ${i}`);
})();
const page = betaListPage(new URLSearchParams());
expect(page.betas).toHaveLength(25);
expect(page).toMatchObject({ total: 125, page: 1, totalPages: 5, counts: { active: 5, history: 120 } });
expect(betaListPage(new URLSearchParams('view=active')).betas.map(beta => beta.id)).toEqual([5,4,3,2,1]);
expect(betaListPage(new URLSearchParams('view=history&q=Test%20101')).betas.map(beta => beta.id)).toEqual([101]);
expect(betaListPage(new URLSearchParams('q=Plugin&limit=100000')).betas).toHaveLength(100);
const second = betaListPage(new URLSearchParams('page=2'));
expect(second.betas.some(beta => page.betas.some(other => other.id === beta.id))).toBe(false);
const latest = betaActivityPage(1);
expect(latest.events).toHaveLength(50);
db().prepare("INSERT INTO beta_events(beta_id,message) VALUES(1,'New event')").run();
const previous = betaActivityPage(1, new URLSearchParams({ before: String(latest.nextBefore) }));
expect(previous.events).toHaveLength(50);
expect(previous.events.at(-1)!.id).toBeLessThan(latest.events[0].id);
const { GET } = await import('../../src/routes/api/betas/[id]/+server.js');
expect((await (await GET({ params: { id: '1' } })).json()).events).toHaveLength(50);
});
it('coalesces beta progress notifications after synchronous database work', async () => {
const { invalidateBeta, subscribe } = await import('../../src/lib/server/pipeline/bus.js');
const events: unknown[] = [];
const stop = subscribe(event => events.push(event));
try {
invalidateBeta(1); invalidateBeta(1); invalidateBeta(2);
expect(events).toEqual([]);
await Promise.resolve();
expect(events).toEqual([{ type: 'beta-changed', betaId: 1 }, { type: 'beta-changed', betaId: 2 }]);
} finally { stop(); }
});
+25
View File
@@ -0,0 +1,25 @@
import { afterEach, expect, it, vi } from 'vitest';
import { betaUpdates } from '../../src/lib/ui/beta-updates.js';
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
it('refreshes relevant pushes and reconnects without polling, pauses hidden tabs, and cleans up', async () => {
vi.useFakeTimers();
const window = new EventTarget(), document = Object.assign(new EventTarget(), { hidden: false });
vi.stubGlobal('window', window); vi.stubGlobal('document', document);
let finish!: () => void;
const load = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
const stop = betaUpdates(load, () => 7);
const change = (id: number) => window.dispatchEvent(Object.assign(new Event('ec:beta-changed'), { detail: { betaId: id } }));
try {
change(2); await vi.advanceTimersByTimeAsync(1000); expect(load).not.toHaveBeenCalled();
change(7); change(7); await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(1);
change(7); await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(1);
finish(); await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(2);
finish(); await vi.advanceTimersByTimeAsync(30000); expect(load).toHaveBeenCalledTimes(2);
document.hidden = true; change(7); await vi.advanceTimersByTimeAsync(1000); expect(load).toHaveBeenCalledTimes(2);
document.hidden = false; document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(3); finish();
window.dispatchEvent(new Event('ec:pipeline-connected'));
await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(4); finish();
stop(); change(7); await vi.advanceTimersByTimeAsync(1000); expect(load).toHaveBeenCalledTimes(4);
} finally { stop(); }
});