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:
2026-09-19 21:34:34 -04:00
parent 940f81e6ca
commit 8ac420fe61
83 changed files with 2574 additions and 1190 deletions
+65 -32
View File
@@ -50,6 +50,13 @@
let artifact = $state<Artifact | null>(null);
let versions = $state<Version[]>([]);
let matchedServers = $state<string[]>([]);
let installedVersions = $state<Array<{ server: string; version: number; versionId: number; recordedAt: string }>>([]);
interface ScopeImpact { before: string[]; after: string[]; added: string[]; removed: string[]; affected: string[]; composed: boolean; detached: string[]; }
let scopeReviewOpen = $state(false);
let scopeImpact = $state<ScopeImpact | null>(null);
let scopePreviewError = $state('');
let scopePreviewLoading = $state(false);
let scopeComplete = $state<{ count: number; composed: boolean; detached: number; href: string } | null>(null);
let runningBetaId = $state<number | null>(null);
let loading = $state(true);
let flash = $state<string | null>(null);
@@ -84,9 +91,24 @@
: null
);
const latestVersion = $derived(versions[0] ?? null);
const showLatestSeparately = $derived(
latestVersion !== null && currentVersion !== null && latestVersion.id !== currentVersion.id
);
$effect(() => {
const type = scopeType; const value = editedScopeValue; const artifactId = id;
if (!scopeReviewOpen) return;
scopeImpact = null; scopePreviewError = '';
if (type !== 'none' && !value) { scopePreviewLoading = false; return; }
const controller = new AbortController(); scopePreviewLoading = true;
void (async () => {
try {
const response = await fetch(`/api/scripts/${artifactId}/scope?${new URLSearchParams({ type, value })}`, { signal: controller.signal });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not preview scope.');
if (!controller.signal.aborted) scopeImpact = body;
} catch (error) { if (!controller.signal.aborted) scopePreviewError = (error as Error).message; }
finally { if (!controller.signal.aborted) scopePreviewLoading = false; }
})();
return () => controller.abort();
});
function scopeLabel(s: Artifact): string {
if (!s.scope_type) return 'unscoped';
@@ -105,7 +127,7 @@
const body = await detailRes.json();
const scopes = scopeRes?.ok ? await scopeRes.json() : null;
if (requestedId !== id || current !== request) return;
artifact = body.script; versions = body.versions; matchedServers = body.matchedServers; runningBetaId = body.runningBetaId;
artifact = body.script; versions = body.versions; matchedServers = body.matchedServers; runningBetaId = body.runningBetaId; installedVersions = body.installedVersions ?? [];
if (scopes) scopeOptions = scopes;
if (seedForm) seedScopeForm();
loadError = '';
@@ -135,10 +157,12 @@
if (runningBetaId !== null) return;
seedScopeForm();
flash = null;
scopeReviewOpen = true;
scopeDialog?.showModal();
}
function cancelScopeModal(): void {
seedScopeForm();
scopeReviewOpen = false;
scopeDialog?.close();
}
function openUploadModal(): void {
@@ -192,7 +216,7 @@
async function saveScope(e: Event): Promise<void> {
e.preventDefault();
if (updatingScope) return;
if (updatingScope || !scopeImpact || scopePreviewLoading) return;
if (scopeType !== 'none' && !editedScopeValue) {
flash = `Pick a ${scopeType}`;
return;
@@ -207,12 +231,13 @@
scopeValue: scopeType === 'none' ? null : editedScopeValue
})
});
const body = (await res.json()) as { ok: boolean; error?: string; kind?: string };
const body = (await res.json()) as { ok: boolean; error?: string; kind?: string; detached?: string[] };
if (!res.ok || !body.ok) {
flash = body.error ?? `HTTP ${res.status}`;
return;
}
scopeDialog?.close();
scopeComplete = { count: scopeImpact.affected.length, composed: scopeImpact.composed, detached: body.detached?.length ?? 0, href: scopeImpact.affected.length === 1 ? '/campaigns?server=' + encodeURIComponent(scopeImpact.affected[0]) : '/campaigns' };
scopeReviewOpen = false; scopeDialog?.close();
await load();
} catch (error) { flash = (error as Error).message; } finally {
updatingScope = false;
@@ -244,6 +269,8 @@
];
</script>
<svelte:head><title>{artifact?.name ?? crumbLabel} · Electric Cherry</title></svelte:head>
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
{#if loading}
<div class="loading">Loading…</div>
@@ -258,6 +285,9 @@
{/snippet}
</SectionHeader>
{#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Running servers have not been changed. <a href={scopeComplete.href}>Review reconciliation →</a></div>{/if}
{#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={`/betas/${runningBetaId}`}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }])}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if}
{#if runningBetaId}
<div class="warn-banner">
Beta <a href="/betas/{runningBetaId}"><strong>#{runningBetaId}</strong></a> is running for this {family}.
@@ -278,7 +308,7 @@
</div>
</div>
<div class="kv-grid">
<details class="file-settings"><summary>Target file, scope, and eligible servers</summary><div class="kv-grid">
<div class="kv">
<span class="k">Target file</span>
<span class="v mono">{a.target_rel_path}</span>
@@ -295,7 +325,7 @@
</span>
</div>
<div class="kv">
<span class="k">Applies to</span>
<span class="k">Eligible servers</span>
<span class="v mono">
{#if !a.scope_type}
<span class="dim">Unscoped — deploy from a beta</span>
@@ -307,28 +337,21 @@
</span>
</div>
<div class="kv">
<span class="k">Current version</span>
<span class="k">Promoted version</span>
<span class="v mono">
{#if currentVersion}
{artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(0, 8)}
<Badge variant="online" size="sm">Promoted</Badge>
{:else}
<span class="dim">Never promoted — open a <a href="/betas">beta</a></span>
<span class="dim">Never promoted — test a version first</span>
{/if}
</span>
</div>
{#if showLatestSeparately && latestVersion}
<div class="kv">
<span class="k">Latest version</span>
<span class="v mono">
{artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)}
<StatusBadge state={latestVersion.state} size="sm" />
</span>
</div>
{/if}
</div>
</div></details>
</Card>
{#if installedVersions.length}<details class="installed"><summary>Recorded installed files · {installedVersions.length} servers</summary><p>Last recorded file writes, not a verification of the currently running process.</p><ul>{#each installedVersions as installed}<li><a href={'/servers/' + encodeURIComponent(installed.server)}>{installed.server}</a> · v{installed.version} · {new Date(installed.recordedAt).toLocaleString()}</li>{/each}</ul></details>{:else if family !== 'config'}<p class="dim">No installed file versions recorded. Eligibility does not mean a file is installed.</p>{/if}
{#if a.kind === 'config' || a.kind === 'baseline'}
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
{runningBetaId} scopeType={a.scope_type} scopeValue={a.scope_value} onsaved={() => load({ seedForm: false })} />
@@ -346,8 +369,6 @@
{#each versions.slice(0,limit) as v (v.id)}
<div class="version-row" class:current={a.current_version_id === v.id}>
<span class="v-num">{artifactVersionLabel(v)}</span>
<span class="v-sha">{v.sha256.slice(0, 12)}</span>
<span class="v-size">{v.size} B</span>
<span class="v-notes">{v.notes ?? ''}
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
</span>
@@ -358,6 +379,7 @@
<StatusBadge state={v.state} size="sm" />
{#if runningBetaId === null}<Button size="sm" href={betaSelectionHref([{scriptId:id,versionId:v.id}])}>Test version</Button>{/if}
{/if}
<details class="version-technical"><summary>File details</summary><span>SHA-256: {v.sha256}</span><span>Size: {v.size.toLocaleString()} bytes</span></details>
</div>
{/each}
</div>
@@ -374,10 +396,10 @@
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
</Card>
{#if flash}<div class="alert">{flash}</div>{/if}
{#if flash}<div class="alert" role="alert">{flash}</div>{/if}
<!-- Scope dialog -->
<dialog aria-label="Edit scope" oncancel={event => { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
<dialog aria-label="Edit scope" onclose={() => scopeReviewOpen = false} oncancel={event => { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
<form class="dlg" onsubmit={saveScope}>
<header class="dlg-head">
<h3>Edit scope</h3>
@@ -387,20 +409,21 @@
<div class="scope-row">
<Select ariaLabel="Scope type" bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
{#if scopeType === 'game' || scopeType === 'map'}
<Select ariaLabel="Scope game" bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
<Select ariaLabel="Scope game" bind:value={scopeGame} onchange={() => scopeMap = ''} options={gameOptions} disabled={updatingScope} />
{/if}
{#if scopeType === 'map'}
<Select ariaLabel="Scope map" bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
{/if}
</div>
</Field>
{#if scopePreviewLoading}<p role="status">Reviewing eligible servers…</p>{:else if scopePreviewError}<p role="alert">{scopePreviewError}</p>{:else if scopeImpact}<section class="scope-impact" aria-label="Scope change review"><strong>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added · {scopeImpact.removed.length} removed</strong>{#if scopeImpact.added.length}<p>Added: {scopeImpact.added.join(', ')}</p>{/if}{#if scopeImpact.removed.length}<p>Removed: {scopeImpact.removed.join(', ')}</p>{/if}<p>{scopeImpact.composed ? 'This changes intended configuration composition. Live configuration files remain unchanged until a deployment or reconciliation.' : scopeType === 'none' ? `${scopeImpact.detached.length} recorded installations will become untracked. Files remain on servers; review reconciliation to remove orphaned files.` : 'Scope describes eligibility. Saving does not install files or restart servers; review reconciliation for existing promoted files.'}</p></section>{/if}
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}>Cancel</Button>
<Button type="submit" variant="primary" disabled={updatingScope} loading={updatingScope}>
Save scope
<Button type="submit" variant="primary" disabled={updatingScope || scopePreviewLoading || !scopeImpact} loading={updatingScope}>
Save reviewed scope
</Button>
</div>
{#if flash}<div class="alert alert-danger">{flash}</div>{/if}
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
@@ -433,7 +456,7 @@
Upload version
</Button>
</div>
{#if flash}<div class="alert alert-danger">{flash}</div>{/if}
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
</div>
@@ -499,7 +522,7 @@
.version-list { display: flex; flex-direction: column; gap: var(--space-1); }
.version-row {
display: grid;
grid-template-columns: minmax(100px, 1fr) 130px 80px 1fr 220px auto;
grid-template-columns: minmax(100px, auto) minmax(0, 1fr) auto auto auto;
gap: var(--space-3);
align-items: center;
padding: var(--space-2) var(--space-3);
@@ -514,7 +537,17 @@
background: rgba(124, 212, 255, 0.04);
}
.v-num { color: var(--text-primary); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
.v-sha, .v-size, .v-when { color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
.v-when { color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
.version-technical { grid-column: 1 / -1; color: var(--text-secondary); overflow-wrap: anywhere; }
.version-technical span { display: block; margin-top: 8px; }
.scope-complete { padding: 16px; border: 1px solid var(--state-online); border-radius: var(--radius-md); font-size: var(--text-sm); }
.release-summary { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; }
.release-summary h2 { font-size: var(--text-lg); margin: 0 0 8px; }
.release-summary p, .installed p, .scope-impact p { color: var(--text-secondary); font-size: var(--text-sm); overflow-wrap: anywhere; }
.scope-impact { border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 12px; font-size: var(--text-sm); }
.installed { font-size: var(--text-sm); }
.file-settings { font-size: var(--text-sm); }
.file-settings .kv-grid { margin-top: 16px; }
.v-notes { color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-sm); }
.v-notes { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 160px; overflow-y: auto; }
.v-notes a { display: block; color: var(--accent-primary-hover); margin-top: var(--space-2); }