Files
electric-cherry/src/routes/betas/new/+page.svelte
T

423 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import BetaPlanBuilder from "$ui/components/BetaPlanBuilder.svelte";
import { defaultPlan, parseBetaPlan } from "$lib/beta-plan.js";
import { onMount, tick } from 'svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import { goto } from '$app/navigation';
import { fleetServers as sharedFleet, fleetError, refreshFleet } from '$ui/stores/fleet.js';
import { parseBetaSelection } from '$lib/script-library.js';
import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Field from '$ui/components/primitives/Field.svelte';
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
interface ScriptListRow {
id: number; name: string; target_rel_path: string;
scope_type: 'game' | 'map' | null; scope_value: string | null;
current_version_id: number | null; kind: ArtifactKind;
latestVersion: {
id: number; version: number; sha256: string;
state: 'untested' | 'tested' | 'test-failed';
} | null;
runningBetaId: number | null;
}
interface Version { id: number; version: number; release_version: string | null; sha256: string; size: number; state: 'untested' | 'tested' | 'test-failed'; }
interface ServerRow { name: string; game: string | null; map: string | null; }
interface ScriptDetail { script: ScriptListRow; versions: Version[]; matchedServers: string[]; }
interface Pick { key: number; scriptId: number | null; versionId: number | null; }
let loading = $state(true);
let scripts = $state<ScriptListRow[]>([]);
const fleetServers = $derived($sharedFleet);
let detailById = $state<Record<number, ScriptDetail>>({});
let nextKey = 0;
let picks = $state<Pick[]>([{ key: nextKey++, scriptId: null, versionId: null }]);
let pickedServers = $state<Set<string>>(new Set());
let testPlan = $state(defaultPlan());
let waitForHealth = $state(true);
let waitForMatch = $state(false);
let openingBeta = $state(false);
let flash = $state<string | null>(null);
async function load(): Promise<void> {
const scriptsRes = await fetch('/api/scripts');
if (!scriptsRes.ok) throw new Error('Could not load beta setup. Refresh to try again.');
if (scriptsRes.ok) {
const sBody = (await scriptsRes.json()) as { scripts: ScriptListRow[] };
scripts = sBody.scripts;
}
}
async function initialize() {
loading = true; flash = null;
try { await load(); await seedSelection(); }
catch (error) { flash = (error as Error).message; }
finally { loading = false; }
}
onMount(() => { void initialize(); });
const steps = ['Files', 'Servers', 'Test plan', 'Review'];
let step = $state(0);
let stepHeading = $state<HTMLHeadingElement>();
const filesReady = $derived(picks.length > 0 && picks.every(p => p.scriptId !== null && p.versionId !== null));
async function move(next: number) {
flash = null;
if (next > step && step === 2) {
try { parseBetaPlan(testPlan); } catch (error) { flash = (error as Error).message; return; }
}
step = next; await tick(); stepHeading?.focus();
}
const eligibleScripts = $derived(
scripts.filter(
(s) => s.runningBetaId === null && s.latestVersion !== null && (s.latestVersion.id !== s.current_version_id
|| picks.some(p => p.scriptId === s.id && p.versionId !== null && p.versionId !== s.current_version_id))
)
);
function eligibleForRow(rowIdx: number): ScriptListRow[] {
const takenIds = new Set(
picks.map((p, i) => (i === rowIdx ? null : p.scriptId)).filter((id): id is number => id !== null)
);
return eligibleScripts.filter((s) => !takenIds.has(s.id));
}
async function seedSelection(): Promise<void> {
const query = new URLSearchParams(window.location.search);
const selections = parseBetaSelection(query);
if (!selections.length) return;
// Validate all selections before populating; never substitute a newer upload.
const details = await Promise.all(selections.map(async ({ scriptId, versionId }) => {
const res = await fetch(`/api/scripts/${scriptId}`);
if (!res.ok) throw new Error('A selected artifact is no longer available. Choose artifacts below.');
const detail = await res.json() as ScriptDetail & { runningBetaId: number | null };
if (detail.runningBetaId !== null || detail.script.current_version_id === versionId
|| !detail.versions.some(v => v.id === versionId)) {
throw new Error(`${detail.script.name}: the selected version is already promoted, unavailable, or in an active beta. Review your selection below.`);
}
return detail;
}));
for (const detail of details) {
detailById[detail.script.id] = detail;
}
picks = selections.map(s => ({ key: nextKey++, ...s }));
pickedServers = new Set();
}
async function onPickScript(rowIdx: number): Promise<void> {
flash = null;
const pick = picks[rowIdx];
const scriptId = pick.scriptId;
pickedServers = new Set();
pick.versionId = null;
picks = [...picks];
if (scriptId === null) return;
if (!detailById[scriptId]) {
const res = await fetch(`/api/scripts/${scriptId}`).catch(() => null);
if (!res?.ok) { flash = 'Could not load file versions. Choose the file again to retry.'; return; }
const body = (await res.json()) as ScriptDetail;
detailById = { ...detailById, [scriptId]: body };
}
if (pick.scriptId !== scriptId) return;
const detail = detailById[scriptId];
const eligibleVersions = detail.versions.filter((v) => v.id !== detail.script.current_version_id);
pick.versionId = eligibleVersions[0]?.id ?? null;
picks = [...picks];
// Start with nothing selected — user opts in each server explicitly.
pickedServers = new Set();
}
function addRow(): void {
picks = [...picks, { key: nextKey++, scriptId: null, versionId: null }];
}
function removeRow(idx: number): void {
picks = picks.filter((_, i) => i !== idx);
// Start with nothing selected — user opts in each server explicitly.
pickedServers = new Set();
}
function toggleServer(name: string): void {
const next = new Set(pickedServers);
if (next.has(name)) next.delete(name);
else next.add(name);
pickedServers = next;
}
function versionsForRow(row: Pick): Version[] {
if (row.scriptId === null) return [];
const detail = detailById[row.scriptId];
if (!detail) return [];
return detail.versions.filter((v) => v.id !== detail.script.current_version_id);
}
function candidatesFor(row: Pick): string[] | null {
if (row.scriptId === null) return null;
const detail = detailById[row.scriptId];
if (!detail) return null;
return detail.script.scope_type ? detail.matchedServers : fleetServers.map((s) => s.name);
}
const candidateServers = $derived.by(() => {
const perPick: string[][] = [];
for (const p of picks) {
const c = candidatesFor(p);
if (c === null) continue;
perPick.push(c);
}
if (perPick.length === 0) return [] as string[];
const [first, ...rest] = perPick;
return first.filter((name) => rest.every((set) => set.includes(name)));
});
const canSubmit = $derived(
!openingBeta &&
testPlan.requirements.length > 0 &&
picks.length > 0 &&
picks.every((p) => p.scriptId !== null && p.versionId !== null) &&
candidateServers.length > 0 &&
pickedServers.size > 0
);
async function openNewBeta(e: Event): Promise<void> {
e.preventDefault();
if (step !== 3 || !canSubmit) return;
openingBeta = true;
flash = null;
try {
const res = await fetch('/api/betas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scripts: picks.map((p) => ({ scriptId: p.scriptId!, scriptVersionId: p.versionId! })),
servers: [...pickedServers],
testPlan: parseBetaPlan(testPlan),
waitForHealth,
waitForMatch
})
});
const body = (await res.json()) as { ok: boolean; error?: string; result?: { betaId: number } };
if (!res.ok || !body.ok) {
flash = body.error ?? `HTTP ${res.status}`;
return;
}
if (body.result) await goto(`/betas/${body.result.betaId}`);
} catch (error) { flash = (error as Error).message; } finally {
openingBeta = false;
}
}
</script>
<div class="page">
<SectionHeader title="New beta" subtitle="Choose files and test servers, then review before starting.">
{#snippet actions()}<Button href="/betas" variant="ghost">Back to betas</Button>{/snippet}
</SectionHeader>
{#if flash}<div class="ui-error" role="alert">{flash}{#if loading === false && scripts.length === 0}<Button size="sm" onclick={initialize}>Retry</Button>{/if}</div>{/if}
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry servers</Button></div>{/if}
{#if loading}<EmptyState title="Loading beta setup…" />
{:else if eligibleScripts.length === 0 && !flash}<EmptyState title="No versions ready to test" description="Upload a new version in the Library to start a beta." />
{:else if scripts.length}
<ol class="steps" aria-label="Beta setup progress">
{#each steps as label, index}<li aria-current={step === index ? 'step' : undefined} class:current={step === index}><span>{index + 1}</span>{label}</li>{/each}
</ol>
<form onsubmit={openNewBeta} class="beta-form">
<h2 bind:this={stepHeading} tabindex="-1">{steps[step]}</h2>
{#if step === 0} <Field label="Files">
<div class="picks">
{#each picks as row, idx (row.key)}
<div class="pick-row">
<div class="pick-select">
<select
class="native-select"
aria-label={`File ${idx + 1}`}
bind:value={row.scriptId}
onchange={() => onPickScript(idx)}
>
<option value={null}>— Pick an file —</option>
{#each eligibleForRow(idx) as s (s.id)}
<option value={s.id}>[{s.kind}] {s.name}</option>
{/each}
</select>
<span class="chev">▾</span>
</div>
<div class="pick-select">
<select
class="native-select"
aria-label={`Version for file ${idx + 1}`}
bind:value={row.versionId}
disabled={row.scriptId === null}
>
{#if row.scriptId === null}
<option value={null}>—</option>
{:else}
{#each versionsForRow(row) as v (v.id)}
<option value={v.id}>{artifactVersionLabel(v)} · {v.sha256.slice(0, 8)} · {v.state}</option>
{/each}
{/if}
</select>
<span class="chev">▾</span>
</div>
<button
type="button"
class="row-x"
onclick={() => removeRow(idx)}
disabled={picks.length === 1}
title={picks.length === 1 ? 'Need at least one script' : 'Remove this script'}
aria-label="Remove file"
>×</button>
</div>
{/each}
</div>
<button type="button" class="add-row" onclick={addRow}>+ Add file</button>
</Field>
{:else if step === 1} <Field label="Test servers" hint="Choose servers that match every selected file.">
<div class="server-picker">
{#if candidateServers.length === 0}
<span class="picker-empty">No server satisfies all selected scopes — pick different scripts.</span>
{:else}
{#each candidateServers as name (name)}
<label class="chk">
<input
type="checkbox"
checked={pickedServers.has(name)}
onchange={() => toggleServer(name)}
/>
<ServerIdentity {name} />
</label>
{/each}
{/if}
</div>
</Field>
{:else if step === 2}<BetaPlanBuilder bind:plan={testPlan} />
{:else}
<div class="ui-panel review">
<h3>Files and versions</h3>
<ul>{#each picks as pick}<li><strong>{scripts.find(s => s.id === pick.scriptId)?.name}</strong> · {artifactVersionLabel(versionsForRow(pick).find(v => v.id === pick.versionId))}</li>{/each}</ul>
<h3>Test servers ({pickedServers.size})</h3>
<ul>{#each [...pickedServers] as name}<li><ServerIdentity {name} /></li>{/each}</ul>
<h3>Pass criteria</h3><p>{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour testing window</p>
<ul>{#each testPlan.requirements as requirement}<li>{requirementSummary(requirement)}</li>{/each}</ul>
<h3>Rollout and failure handling</h3>
<p>{testPlan.waveSize ? `${testPlan.waveSize} servers per wave.` : 'Deploy to all selected servers.'} {testPlan.failureAction === 'rollback' ? 'Failed checks trigger an automatic rollback.' : testPlan.failureAction === 'pause' ? 'Failed checks pause testing for investigation.' : 'Failed checks hold the beta for review.'} {testPlan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; restart the servers manually.'}</p>
<p class="ui-muted">Starting installs these versions and restarts the selected servers. Promotion to the rest of the fleet is a separate action after testing.</p>
</div>
{/if}
<div class="ui-sticky-actions">
<Button onclick={() => move(step - 1)} disabled={step === 0 || openingBeta}>Back</Button>
{#if step < 3}<Button variant="primary" onclick={() => move(step + 1)} disabled={step === 0 ? !filesReady : step === 1 ? pickedServers.size === 0 : testPlan.requirements.length === 0}>Continue</Button>
{:else}<Button type="submit" variant="primary" disabled={!canSubmit} loading={openingBeta}>Start beta</Button>{/if}
</div>
</form>
{/if}
</div>
<style>
.page { display: flex; flex-direction: column; gap: var(--space-6); }
.beta-form { display: flex; flex-direction: column; gap: var(--space-5); }
.picks { display: flex; flex-direction: column; gap: var(--space-2); }
.pick-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: var(--space-2);
align-items: center;
}
.pick-select { position: relative; min-width: 0; }
.native-select {
appearance: none;
width: 100%;
padding: 9px 32px 9px 12px;
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
transition: border-color var(--dur-fast) var(--ease-standard);
}
.native-select:hover { border-color: var(--border-strong); }
.native-select:focus { outline: none; border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(46, 134, 209, 0.15); }
.native-select:disabled { opacity: 0.5; cursor: not-allowed; }
.chev {
position: absolute;
right: var(--space-3);
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
font-size: var(--text-xs);
}
.row-x {
background: transparent;
color: var(--text-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 0 14px;
height: 38px;
font-size: var(--text-lg);
cursor: pointer;
transition: color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
}
.row-x:hover:not(:disabled) { color: var(--state-danger); border-color: rgba(255, 86, 112, 0.4); }
.row-x:disabled { opacity: 0.3; cursor: default; }
.add-row {
align-self: flex-start;
background: transparent;
color: var(--text-secondary);
border: 1px dashed var(--border-strong);
border-radius: var(--radius-md);
padding: 8px 14px;
margin-top: var(--space-2);
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
transition: color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
}
.add-row:hover { color: var(--text-primary); border-color: var(--accent-primary); }
.server-picker {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
padding: var(--space-3);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.picker-empty {
color: var(--text-tertiary);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.chk {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text-primary);
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
user-select: none;
}
.chk input { accent-color: var(--accent-primary); cursor: pointer; }
h2 { margin: 0; font-size: var(--text-lg); }
.steps { display: flex; list-style: none; margin: 0; padding: 0; gap: 12px; flex-wrap: wrap; }
.steps li { display: flex; align-items: center; gap: 8px; color: var(--text-secondary); font-size: 13px; }
.steps li span { border: 1px solid var(--border-subtle); border-radius: 50%; padding: 4px 8px; }
.steps li.current { color: var(--text-primary); font-weight: 600; }
.steps li.current span { background: var(--accent-primary); color: var(--text-on-accent); }
.review { display: grid; gap: 16px; }
.review h3,.review p,.review ul { margin: 0; }
.review li { margin-bottom: 8px; }
@media (max-width: 720px) {
.pick-row { grid-template-columns: minmax(0,1fr) 44px; }
.pick-select:first-child { grid-column: 1 / -1; }
.chk { min-height: 44px; }
.server-picker { flex-direction: column; }
}
</style>