Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements. Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
This commit is contained in:
@@ -9,11 +9,16 @@
|
||||
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;
|
||||
@@ -33,10 +38,22 @@
|
||||
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}]);
|
||||
const filtered = $derived(betas.filter(b => active(b) === (tab === 'active') && ('#' + b.id + ' ' + b.scripts.map(s => s.name).join(' ')).toLowerCase().includes(search.toLowerCase())));
|
||||
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 rollout 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; error = ''; }
|
||||
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(() => {
|
||||
@@ -57,8 +74,8 @@
|
||||
{: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><strong>Beta #{beta.id}</strong><span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span></div>
|
||||
<div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.length} servers</span></div>
|
||||
<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={new Date(beta.opened_at).toLocaleString()}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {new Date(beta.deadlineAt).toLocaleString()}{:else if beta.closed_at} · Finished {new Date(beta.closed_at).toLocaleString()}{/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>
|
||||
@@ -70,4 +87,5 @@
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user