Paginate beta history and refresh pages from events
This commit is contained in:
@@ -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++; };
|
||||
});
|
||||
</script>
|
||||
<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.">
|
||||
{#snippet actions()}<Button variant="primary" href="/betas/new">New beta</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<Tabs {tabs} bind:value={tab} onchange={() => { limit = 25; 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>
|
||||
{#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}
|
||||
<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; 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}
|
||||
<div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}>
|
||||
{#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}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)}
|
||||
{: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 betas as beta (beta.id)}
|
||||
<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="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>
|
||||
{/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>
|
||||
<style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import type { BetaPromotionPreview } from '$lib/beta-promotion.js';
|
||||
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.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 type { BetaRevision } from '$lib/beta-revision.js';
|
||||
import BetaRevisionDialog from '$ui/components/BetaRevisionDialog.svelte';
|
||||
@@ -34,6 +34,8 @@
|
||||
let scripts = $state<BetaFile[]>([]);
|
||||
let servers = $state<BetaServer[]>([]);
|
||||
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 rollbackCampaign = $state<{ id: string }|null>(null);
|
||||
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.');
|
||||
const body = await res.json();
|
||||
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;
|
||||
revision = body.revision; revisions = body.revisions ?? [];
|
||||
now = Date.now(); loadError = '';
|
||||
} catch (error) { if (current === request && requestedId === id) loadError = (error as Error).message; }
|
||||
finally { if (current === request && requestedId === id) loading = false; }
|
||||
}
|
||||
$effect(() => { id; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; updateOpen = false; void load(); });
|
||||
onMount(() => pollVisible(load, 5000, false));
|
||||
$effect(() => { id; activityRequest++; activityBefore = null; activityLoading = false; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; updateOpen = false; void load(); });
|
||||
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() {
|
||||
reviewOpen = true; preview = null; reviewError = ''; acting = 'preview';
|
||||
try {
|
||||
@@ -181,6 +201,7 @@
|
||||
</div></details>
|
||||
{/if}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user