Files
electric-cherry/src/routes/betas/+page.svelte
T
2026-10-10 00:19:48 -04:00

443 lines
16 KiB
Svelte

<script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
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 Badge from '$ui/components/primitives/Badge.svelte';
import { betaStatus } from '$ui/betas.js';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
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;
server_deleted_at?: string | null;
stage: string;
error: string | null;
}>;
}
let betas = $state<BetaListRow[]>([]);
let loading = $state(true);
let error = $state('');
let tab = $state('active');
let search = $state('');
let status = $state('');
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: counts.active },
{ id: 'history', label: 'History', badge: counts.history }
]);
let now = $state(Date.now());
function deadlineLabel(beta: BetaListRow) {
if (!beta.deadlineAt || !active(beta) || beta.testOutcome === 'passed') return '';
const minutes = Math.ceil((Date.parse(beta.deadlineAt) - now) / 60000);
return minutes <= 0
? 'Testing window ended'
: minutes <= 120
? `${minutes}m remaining`
: '';
}
$effect(() => {
const params = $page.url.searchParams;
tab = params.get('view') === 'history' ? 'history' : 'active';
search = params.get('q') ?? '';
status = params.get('status') ?? '';
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 (status) url.searchParams.set('status', status);
else url.searchParams.delete('status');
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(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;
}
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>
<div class="page">
<SectionHeader title="Betas">
{#snippet actions()}{#if loading || betas.length || error}<Button
variant="primary"
href="/betas/new">New beta</Button
>{/if}{/snippet}
</SectionHeader>
<div class="toolbar">
<div class="view-tabs">
<Tabs
{tabs}
bind:value={tab}
onchange={() => {
pageNumber = 1;
status = '';
saveContext();
}}
idPrefix="beta-tabs"
panelId="beta-list"
ariaLabel="Beta views"
/>
</div>
<Field class="search" label="Search">
<Input
type="search"
bind:value={search}
oninput={() => {
pageNumber = 1;
saveContext();
}}
placeholder="Name, beta number, file, or server"
maxlength={200}
/></Field
>
<Field label="Status">
<Select
bind:value={status}
onchange={() => {
pageNumber = 1;
saveContext();
}}
>
<option value="">All statuses</option>
{#if tab === 'active'}<option value="ready">Ready to promote</option><option
value="testing">Testing</option
><option value="attention">Needs attention</option><option value="paused"
>Paused</option
>
{:else}<option value="promoted">Promoted</option><option value="rolled-back"
>Rolled back</option
>{/if}
</Select></Field
>
{#if betas.length && (search || status)}<Button
variant="ghost"
onclick={() => {
search = '';
status = '';
pageNumber = 1;
saveContext();
}}>Clear filters</Button
>{/if}
</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 !betas.length && !error}<EmptyState
title={search || status
? 'No matching betas'
: tab === 'active'
? 'No active betas'
: 'No beta history'}
description={search || status
? 'Try another search or clear the filters.'
: tab === 'active'
? 'Betas test file versions on selected servers before promotion. Choose the versions and servers to begin.'
: 'Completed and rolled-back betas appear here. Start a beta to test a file version before promotion.'}
>
{#snippet actions()}{#if search || status}<Button
onclick={() => {
search = '';
status = '';
pageNumber = 1;
saveContext();
}}>Clear filters</Button
>{:else}<Button variant="primary" href="/betas/new">New beta</Button
>{/if}{/snippet}
</EmptyState>
{:else}
<div class="table-scroll">
<table>
<thead
><tr
><th scope="col">Beta</th><th scope="col">Files</th><th scope="col"
>Status</th
><th scope="col" class="started">Started</th></tr
></thead
>
<tbody
>{#each betas as beta (beta.id)}
{@const presentation = betaStatus(beta)}
{@const expiry = deadlineLabel(beta)}
<tr>
<td
><a
class="beta-link"
href={contextHref('/betas/' + beta.id, $page.url)}
>{beta.notes || `Beta #${beta.id}`}</a
>{#if beta.notes}<span class="beta-id">#{beta.id}</span
>{/if}</td
>
<td class="files"
>{#if beta.scripts.length === 1}{beta.scripts[0].name}
<span>{artifactVersionLabel(beta.scripts[0].version)}</span
>{:else}{beta.scripts.length} files{/if}</td
>
<td
><Badge variant={presentation.tone} size="md"
>{presentation.label}</Badge
>{#if expiry}<span class="expiry">{expiry}</span>{/if}</td
>
<td class="started"
><button
type="button"
class="timestamp"
aria-label={`Started ${$timeFormat.dateTime(beta.opened_at)}`}
><RelativeTime value={beta.opened_at} /><span
class="exact-time"
aria-hidden="true"
>{$timeFormat.dateTime(beta.opened_at)}</span
></button
></td
>
</tr>
{/each}</tbody
>
</table>
</div>
{#if totalPages > 1}<div class="pagination" aria-label="Beta pagination">
<span>{total} betas · Page {pageNumber} of {totalPages}</span>
<div>
<Button
size="sm"
disabled={pageNumber <= 1}
onclick={() => {
pageNumber--;
saveContext();
}}>Previous</Button
><Button
size="sm"
disabled={pageNumber >= totalPages}
onclick={() => {
pageNumber++;
saveContext();
}}>Next</Button
>
</div>
</div>{/if}
{/if}
</div>
</div>
<style>
.page {
display: grid;
gap: 24px;
min-width: 0;
}
.toolbar {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 12px;
}
.view-tabs {
align-self: flex-end;
max-width: 100%;
}
.toolbar :global(.search) {
flex: 1;
min-width: 240px;
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
text-align: left;
}
th {
padding: 10px 16px;
border-bottom: 1px solid var(--border-strong);
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
}
td {
padding: 16px;
border-bottom: 1px solid var(--border-subtle);
}
tbody tr:hover {
background: var(--surface);
}
.beta-link {
font-weight: var(--weight-semibold);
color: var(--text-primary);
text-decoration: none;
overflow-wrap: anywhere;
}
.beta-link:hover {
color: var(--accent-primary-hover);
text-decoration: underline;
}
.beta-id {
margin-left: 8px;
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.files {
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.files span {
white-space: nowrap;
}
.expiry {
display: block;
margin-top: 6px;
font-size: var(--text-xs);
color: var(--state-warning);
}
.started {
text-align: right;
color: var(--text-secondary);
}
.timestamp {
position: relative;
display: inline-block;
white-space: nowrap;
cursor: help;
background: transparent;
border: 0;
color: inherit;
font: inherit;
padding: 0;
}
.exact-time {
display: none;
position: absolute;
right: 0;
bottom: 100%;
z-index: 1;
background: var(--surface-overlay);
color: var(--text-primary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 6px 10px;
font-size: var(--text-xs);
}
.timestamp:hover .exact-time,
.timestamp:focus .exact-time {
display: block;
}
.pagination,
.pagination > div {
display: flex;
align-items: center;
gap: 12px;
}
.pagination {
justify-content: space-between;
margin-top: 16px;
font-size: var(--text-sm);
color: var(--text-secondary);
}
@media (max-width: 720px) {
table {
min-width: 620px;
}
th,
td {
padding: 12px 8px;
}
.toolbar :global(.search) {
flex-basis: 100%;
}
.pagination {
flex-wrap: wrap;
}
}
</style>