450 lines
16 KiB
Svelte
450 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 { canOperate } from '$lib/auth.js';
|
|
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[]>([]);
|
|
const editable = $derived(canOperate($page.data?.user?.role));
|
|
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 (editable && (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 editable && (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 if editable}<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,
|
|
beta.scripts[0].kind
|
|
)}</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>
|