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:
@@ -44,6 +44,13 @@
|
||||
let diffLoading = $state(false);
|
||||
let diffRequest = 0;
|
||||
let openFile = $state<{ id: number; before: string | null; after: string | null; verb: string; relPath: string } | null>(null);
|
||||
let retryFile = $state<FileRow | null>(null);
|
||||
const completed = $derived(detail?.servers.filter(server => server.restart_state === 'done').length ?? 0);
|
||||
const unresolved = $derived(detail?.servers.filter(server => server.restart_state !== 'done').length ?? 0);
|
||||
const rankedServers = $derived([...(detail?.servers ?? [])].sort((a, b) => {
|
||||
const rank: Record<string, number> = { failed: 0, manual: 1, held: 2, pending: 3, done: 4 };
|
||||
return (rank[a.restart_state] ?? 3) - (rank[b.restart_state] ?? 3);
|
||||
}));
|
||||
|
||||
async function loadDetail(): Promise<void> {
|
||||
const id = $page.params.id;
|
||||
@@ -64,6 +71,7 @@
|
||||
onMount(() => pollVisible(loadDetail, 5000, false));
|
||||
|
||||
async function loadDiff(f: FileRow) {
|
||||
retryFile = f;
|
||||
const id = $page.params.id;
|
||||
const current = ++diffRequest;
|
||||
diffOpen = true; diffLoading = true; diffError = ''; openFile = null;
|
||||
@@ -101,13 +109,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Rollout | Electric Cherry</title></svelte:head>
|
||||
<svelte:head><title>{detail ? `${detail.deployment.action} rollout · ${detail.deployment.id}` : 'Rollout'} | Electric Cherry</title></svelte:head>
|
||||
{#if loadError}<p role="alert" class="err">{loadError} <button onclick={loadDetail}>Retry</button></p>{/if}
|
||||
|
||||
{#if detail}
|
||||
{@const action = detail.deployment.action}
|
||||
<div class="page">
|
||||
<SectionHeader title={action} subtitle={detail.deployment.id}>
|
||||
<SectionHeader title={`${action.charAt(0).toUpperCase() + action.slice(1)} rollout`} subtitle={detail.deployment.id}>
|
||||
{#snippet crumbs()}
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Rollouts', href: '/campaigns' },
|
||||
@@ -116,6 +124,8 @@
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
<section class="outcome" aria-label="Rollout outcome"><h2>{completed} / {detail.servers.length} servers activated{unresolved ? ` · ${unresolved} unresolved` : ''}</h2><p>Files are staged before activation. Servers restart when empty, then their health is verified.</p>{#if unresolved}<p>Review failed or waiting servers below. Diagnose connectivity or inspect startup logs from the affected row.</p>{:else}<p>Every recorded server completed activation.</p>{/if}</section>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="meta-grid">
|
||||
<div class="kv"><span class="k">Started</span><span class="v mono">{fmt(detail.deployment.started_at)}</span></div>
|
||||
@@ -125,19 +135,13 @@
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<OperationHistory
|
||||
steps={campaignFlow(detail.servers, Boolean(detail.deployment.finished_at))}
|
||||
events={detail.events}
|
||||
active={detail.servers.some(s => s.restart_state === 'pending' || s.restart_state === 'failed')}
|
||||
/>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2>Servers</h2>
|
||||
<Badge variant="neutral" size="sm">{detail.servers.length}</Badge>
|
||||
</div>
|
||||
<div class="server-list">
|
||||
{#each detail.servers as s (s.server)}
|
||||
{#each rankedServers as s (s.server)}
|
||||
<div class="srv-row">
|
||||
<div class="srv-l">
|
||||
<a class="srv-name" href="/servers/{s.server}"><ServerIdentity name={s.server} /></a>
|
||||
@@ -148,12 +152,13 @@
|
||||
<div class="srv-r">
|
||||
<StatusBadge state={s.restart_state} />
|
||||
{#if s.players !== null && s.players > 0}
|
||||
<span class="players">{s.players}p</span>
|
||||
<span class="players">{s.players} players at last check</span>
|
||||
{/if}
|
||||
<span class="attempts">{s.attempts} attempt{s.attempts === 1 ? '' : 's'}</span>
|
||||
{#if s.restarted_at}
|
||||
<span class="when">restarted {fmt(s.restarted_at)}</span>
|
||||
{/if}
|
||||
{#if s.restart_state !== 'done'}<a href="/servers/{encodeURIComponent(s.server)}?tab=diagnostics">Diagnose</a><a href="/servers/{encodeURIComponent(s.server)}?tab=logs">Open logs</a>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -189,8 +194,9 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<OperationHistory steps={campaignFlow(detail.servers, Boolean(detail.deployment.finished_at))} events={detail.events} active={detail.servers.some(s => s.restart_state === 'pending' || s.restart_state === 'failed')} />
|
||||
<Dialog bind:open={diffOpen} title={openFile?.relPath ?? 'File comparison'} wide>
|
||||
{#if diffError}<p class="ui-error" role="alert">{diffError}</p>
|
||||
{#if diffError}<p class="ui-error" role="alert">{diffError}</p><Button onclick={() => { if (retryFile) void loadDiff(retryFile); }}>Retry comparison</Button>
|
||||
{:else if diffLoading}<EmptyState title="Loading comparison…" />
|
||||
{:else if openFile}<DiffViewer before={openFile.before} after={openFile.after} verb={openFile.verb} relPath={openFile.relPath} />{/if}
|
||||
</Dialog>
|
||||
@@ -200,6 +206,7 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.outcome { padding: 20px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); }.outcome h2 { font-size: 20px; margin: 0; }.outcome p { color: var(--text-secondary); line-height: 1.6; margin: 10px 0 0; }.srv-r a { color: var(--accent-primary-hover); font-size: 13px; }
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user