From d99e5979fa1881cf6c72033f72a17f436a0378c0 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Tue, 6 Oct 2026 14:50:47 -0400 Subject: [PATCH] Paginate beta history and refresh pages from events --- src/lib/server/fleet/beta-pages.ts | 40 +++++++++++++++++ src/lib/server/fleet/beta-tests.ts | 5 ++- src/lib/server/pipeline/bus.ts | 11 +++++ src/lib/ui/beta-updates.ts | 30 +++++++++++++ src/lib/ui/stores/pipeline.ts | 5 +++ src/routes/api/betas/+server.ts | 14 +++--- src/routes/api/betas/[id]/+server.ts | 5 ++- src/routes/api/betas/[id]/events/+server.ts | 10 +++++ src/routes/api/events/pipeline/+server.ts | 2 +- src/routes/betas/+page.svelte | 50 ++++++++++++++------- src/routes/betas/[id]/+page.svelte | 29 ++++++++++-- tests/unit/beta-pages.test.ts | 48 ++++++++++++++++++++ tests/unit/beta-updates.test.ts | 25 +++++++++++ 13 files changed, 239 insertions(+), 35 deletions(-) create mode 100644 src/lib/server/fleet/beta-pages.ts create mode 100644 src/lib/ui/beta-updates.ts create mode 100644 src/routes/api/betas/[id]/events/+server.ts create mode 100644 tests/unit/beta-pages.test.ts create mode 100644 tests/unit/beta-updates.test.ts diff --git a/src/lib/server/fleet/beta-pages.ts b/src/lib/server/fleet/beta-pages.ts new file mode 100644 index 0000000..fd87a87 --- /dev/null +++ b/src/lib/server/fleet/beta-pages.ts @@ -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 limit; + const events = rows.slice(0, limit).reverse(); + return { events, nextBefore: more ? events[0].id : null }; +} diff --git a/src/lib/server/fleet/beta-tests.ts b/src/lib/server/fleet/beta-tests.ts index 886a4cf..6b7b4c3 100644 --- a/src/lib/server/fleet/beta-tests.ts +++ b/src/lib/server/fleet/beta-tests.ts @@ -7,6 +7,7 @@ import type { GameTelemetry } from '../../telemetry.js'; import { applySample, applyGameEvent, emptyMetrics, evaluateRequirements, type TestMetrics } from './beta-evaluation.js'; import { ONLINE_MAX_MS } from '../../server-freshness.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 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); 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 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 { const previous = storedPlan(id); 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}` : ''}`); } 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 { 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; diff --git a/src/lib/server/pipeline/bus.ts b/src/lib/server/pipeline/bus.ts index 6ddf95c..666e75a 100644 --- a/src/lib/server/pipeline/bus.ts +++ b/src/lib/server/pipeline/bus.ts @@ -46,6 +46,7 @@ export type BetaStage = export type BetaState = 'running' | 'ready-promote' | 'promoted' | 'rolled-back'; export type PipelineEvent = + | { type: 'beta-changed'; betaId: number } | { type: 'fleet-invalidated' } | { type: 'server-telemetry'; entries: GameTelemetry[] } | { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] } @@ -79,6 +80,15 @@ class Bus extends EventEmitter { } const bus = new Bus(); +const changedBetas = new Set(); +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; export function invalidateFleet(): void { if (invalidationPending) return; @@ -90,6 +100,7 @@ export function invalidateFleet(): void { export function emit(event: PipelineEvent): void { bus.emit('event', event); 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 { diff --git a/src/lib/ui/beta-updates.ts b/src/lib/ui/beta-updates.ts new file mode 100644 index 0000000..650ed01 --- /dev/null +++ b/src/lib/ui/beta-updates.ts @@ -0,0 +1,30 @@ +/** Coalesce pushes, serialize refreshes, and recover missed events after reconnect or tab activation. */ +export function betaUpdates(load: () => Promise, betaId?: () => number): () => void { + let timer: ReturnType | 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); + }; +} diff --git a/src/lib/ui/stores/pipeline.ts b/src/lib/ui/stores/pipeline.ts index f883d19..96f0cfe 100644 --- a/src/lib/ui/stores/pipeline.ts +++ b/src/lib/ui/stores/pipeline.ts @@ -26,6 +26,11 @@ function updateDeployment(id: string, mutate: (d: PipelineDeployment) => void): export function startPipelineStream(): (() => void) | undefined { if (!browser || source) return; 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']) { source.addEventListener(event, () => window.dispatchEvent(new Event('ec:fleet-changed'))); } diff --git a/src/routes/api/betas/+server.ts b/src/routes/api/betas/+server.ts index 74ee536..cab0760 100644 --- a/src/routes/api/betas/+server.ts +++ b/src/routes/api/betas/+server.ts @@ -1,6 +1,5 @@ import { json } from '@sveltejs/kit'; import { - listBetas, openBeta, BetaError } 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 { kick } from '$lib/server/pipeline/worker.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 }) { - const stateFilter = url.searchParams.get('state') as - | 'running' - | 'ready-promote' - | 'promoted' - | 'rolled-back' - | null; - const betas = listBetas(stateFilter ?? undefined); + const state = url.searchParams.get('state'); + if (state && !['running','ready-promote','promoted','rolled-back'].includes(state)) return json({ error: 'Invalid beta state.' }, { status: 400 }); + const { betas, ...pagination } = betaListPage(url.searchParams); const ids = JSON.stringify(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 @@ -39,7 +35,7 @@ export async function GET({ url }: { url: URL }) { servers: members.filter(member => member.beta_id === b.id) }; }); - return json({ betas: rows }); + return json({ betas: rows, ...pagination }); } // POST body accepts either: diff --git a/src/routes/api/betas/[id]/+server.ts b/src/routes/api/betas/[id]/+server.ts index e8c8f69..7e077e0 100644 --- a/src/routes/api/betas/[id]/+server.ts +++ b/src/routes/api/betas/[id]/+server.ts @@ -4,6 +4,7 @@ import { getBeta, listBetaServers } from '$lib/server/fleet/beta.js'; import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js'; import { testView } from '$lib/server/fleet/beta-tests.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 { 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 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 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, 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") .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) }); } diff --git a/src/routes/api/betas/[id]/events/+server.ts b/src/routes/api/betas/[id]/events/+server.ts new file mode 100644 index 0000000..913521a --- /dev/null +++ b/src/routes/api/betas/[id]/events/+server.ts @@ -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)); +} diff --git a/src/routes/api/events/pipeline/+server.ts b/src/routes/api/events/pipeline/+server.ts index 7aa1696..6a89845 100644 --- a/src/routes/api/events/pipeline/+server.ts +++ b/src/routes/api/events/pipeline/+server.ts @@ -21,7 +21,7 @@ export async function GET() { event.type === 'server-state' || event.type === 'deployment-opened' || 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); } diff --git a/src/routes/betas/+page.svelte b/src/routes/betas/+page.svelte index 1cf9c2f..bc46c0b 100644 --- a/src/routes/betas/+page.svelte +++ b/src/routes/betas/+page.svelte @@ -4,14 +4,13 @@ import { timeFormat } from '$ui/stores/time-zone.js'; import { onMount } from 'svelte'; 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 SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import Button from '$ui/components/primitives/Button.svelte'; import Tabs from '$ui/components/primitives/Tabs.svelte'; import StatusBadge from '$ui/components/primitives/StatusBadge.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 { serverTitle } from '$ui/server-labels.js'; type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline'; @@ -38,11 +37,13 @@ let error = $state(''); let tab = $state('active'); 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 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()); - 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 blocker(beta:BetaListRow) { if(beta.state==='promoted')return 'Version promoted; check its campaign for remaining activation.'; @@ -58,23 +59,38 @@ const params = $page.url.searchParams; tab = params.get('view') === 'history' ? 'history' : 'active'; 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() { const url = new URL($page.url); 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 (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 }); } - async function load() { - 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 = ''; } - catch (e) { error = (e as Error).message; } finally { loading = false; } + async function load(params = new URLSearchParams(window.location.search)) { + const current = ++request; + 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(() => { const query = new URLSearchParams(window.location.search); 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++; }; }); Betas · Electric Cherry @@ -82,18 +98,18 @@ {#snippet actions()}{/snippet} - { limit = 25; saveContext(); }} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" /> -
{ search = event.currentTarget.value; limit = 25; saveContext(); }} aria-label="Search betas" placeholder="Search betas or files…" />
- {#if error}{/if} + { pageNumber = 1; saveContext(); }} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" /> +
{ search = event.currentTarget.value; pageNumber = 1; saveContext(); }} aria-label="Search betas" placeholder="Search betas or files…" />
+ {#if error}{/if}
{#if loading} - {:else if !filtered.length && !error} - {:else}
{total} betas · Page {pageNumber} of {totalPages}
{/if}