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:
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user