93 lines
6.8 KiB
Svelte
93 lines
6.8 KiB
Svelte
<script lang="ts">
|
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
|
import { onMount } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { pollVisible } from '$ui/poll.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';
|
|
interface BetaListRow {
|
|
id: number;
|
|
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
|
testOutcome: string | null;
|
|
testReason: string | null;
|
|
deadlineAt: string | null;
|
|
notes: string | null;
|
|
wait_for_health: number;
|
|
wait_for_match: number;
|
|
opened_at: string;
|
|
closed_at: string | null;
|
|
scripts: Array<{
|
|
id: number; name: string; target_rel_path: string; kind: ArtifactKind;
|
|
version: { id: number; version: number; release_version: string | null; sha256: string } | null;
|
|
}>;
|
|
servers: Array<{ server: string; stage: string; error: string | null }>;
|
|
}
|
|
|
|
let betas = $state<BetaListRow[]>([]);
|
|
let loading = $state(true);
|
|
let error = $state('');
|
|
let tab = $state('active');
|
|
let search = $state('');
|
|
let limit = $state(25);
|
|
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}]);
|
|
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.';
|
|
if(beta.state==='rolled-back')return 'Rolled back. The files and plan can be reused for another attempt.';
|
|
if(beta.state==='ready-promote')return 'Ready for promotion review.';
|
|
if(beta.testReason)return beta.testReason;
|
|
const problem=beta.servers.find(server=>server.error);
|
|
if(problem)return `${serverTitle(problem.server)}: ${problem.error}`;
|
|
const waiting=beta.servers.find(server=>server.stage!=='ready');
|
|
return waiting?`${serverTitle(waiting.server)}: ${statusPresentation(waiting.stage).label.toLowerCase()}.`:'Waiting for test evaluation.';
|
|
}
|
|
$effect(() => { search; tab; limit = 25; });
|
|
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; }
|
|
}
|
|
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);
|
|
});
|
|
</script>
|
|
<div class="page">
|
|
<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} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" />
|
|
<div class="ui-toolbar"><input type="search" bind:value={search} 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)}
|
|
<a href={'/betas/' + beta.id} 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 label="betas" />{/if}
|
|
</div>
|
|
</div>
|
|
<style>
|
|
.page { display:grid; gap:24px; }
|
|
.rows { display:grid; gap:8px; }
|
|
.row { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:16px; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); color:var(--text-primary); text-decoration:none; }
|
|
.row:hover { border-color:var(--border-strong); }
|
|
.files { display:block; margin-top:6px; font-size:13px; color:var(--text-secondary); overflow-wrap:anywhere; }
|
|
.meta { display:flex; align-items:center; flex-wrap:wrap; gap:12px; font-size:12px; color:var(--text-secondary); }
|
|
.identity { flex:1 1 320px; min-width:0; } .blocker,.timing { display:block; margin-top:8px; line-height:1.5; font-size:13px; overflow-wrap:anywhere; } .timing,.beta-id { color:var(--text-secondary); font-size:12px; }
|
|
</style>
|