Read-only script detail; move beta creation to /betas

The script detail page is now an informational card (target, scope, matched
servers, current + latest version) with edit-scope and upload-new-version
behind native <dialog> modals. The Open Beta panel is gone from the script
page entirely.

/betas gains an Open a beta section at the top. Its script dropdown filters
to entries that actually have something to test (unpromoted latest version,
no active beta), and pre-populates version + servers on script pick.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 14:40:56 -04:00
co-authored by Claude Opus 4.7
parent c78d0c62fd
commit 30e59c900c
2 changed files with 567 additions and 232 deletions
+285 -24
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
interface BetaListRow {
id: number;
@@ -16,17 +17,164 @@
servers: Array<{ server: string; stage: string; error: string | null }>;
}
interface ScriptListRow {
id: number;
name: string;
target_rel_path: string;
scope_type: 'game' | 'map' | null;
scope_value: string | null;
current_version_id: number | null;
latestVersion: {
id: number;
version: number;
sha256: string;
state: 'untested' | 'tested' | 'test-failed';
} | null;
runningBetaId: number | null;
}
interface Version {
id: number;
version: number;
sha256: string;
size: number;
state: 'untested' | 'tested' | 'test-failed';
}
interface ServerRow { name: string; game: string | null; map: string | null }
let betas = $state<BetaListRow[]>([]);
let loading = $state(true);
let scripts = $state<ScriptListRow[]>([]);
let fleetServers = $state<ServerRow[]>([]);
let pickedScriptId = $state<number | null>(null);
let pickedScriptDetail = $state<{
script: ScriptListRow;
versions: Version[];
matchedServers: string[];
} | null>(null);
let pickedVersionId = $state<number | null>(null);
let pickedServers = $state<Set<string>>(new Set());
let waitForHealth = $state(true);
let waitForMatch = $state(false);
let openingBeta = $state(false);
let flash = $state<string | null>(null);
async function load(): Promise<void> {
const res = await fetch('/api/betas');
const body = (await res.json()) as { betas: BetaListRow[] };
betas = body.betas;
const [betasRes, scriptsRes, serversRes] = await Promise.all([
fetch('/api/betas'),
fetch('/api/scripts'),
fetch('/api/servers')
]);
const bBody = (await betasRes.json()) as { betas: BetaListRow[] };
betas = bBody.betas;
if (scriptsRes.ok) {
const sBody = (await scriptsRes.json()) as { scripts: ScriptListRow[] };
scripts = sBody.scripts;
}
if (serversRes.ok) {
const svBody = (await serversRes.json()) as { servers: ServerRow[] };
fleetServers = svBody.servers;
}
loading = false;
}
onMount(load);
// A script is eligible for a new beta when it has an unpromoted version and
// isn't already in a beta. Fully-promoted scripts have nothing to test; an
// active beta blocks concurrent runs on the same script.
const eligibleScripts = $derived(
scripts.filter(
(s) =>
s.runningBetaId === null &&
s.latestVersion !== null &&
s.latestVersion.id !== s.current_version_id
)
);
async function onScriptChange(): Promise<void> {
pickedScriptDetail = null;
pickedVersionId = null;
pickedServers = new Set();
flash = null;
if (pickedScriptId === null) return;
const res = await fetch(`/api/scripts/${pickedScriptId}`);
if (!res.ok) { flash = 'failed to load script detail'; return; }
const body = (await res.json()) as {
script: ScriptListRow;
versions: Version[];
matchedServers: string[];
};
pickedScriptDetail = body;
// Default to the newest non-current version — the one the operator
// most likely wants to test.
const eligibleVersions = body.versions.filter((v) => v.id !== body.script.current_version_id);
pickedVersionId = eligibleVersions[0]?.id ?? null;
// Default test-server selection: matched-scope servers if scoped,
// whole fleet otherwise.
const defaults = body.script.scope_type ? body.matchedServers : fleetServers.map((s) => s.name);
pickedServers = new Set(defaults);
}
function toggleServer(name: string): void {
const next = new Set(pickedServers);
if (next.has(name)) next.delete(name);
else next.add(name);
pickedServers = next;
}
const eligibleVersions = $derived(
pickedScriptDetail
? pickedScriptDetail.versions.filter((v) => v.id !== pickedScriptDetail!.script.current_version_id)
: []
);
const candidateServers = $derived.by(() => {
if (!pickedScriptDetail) return [] as string[];
return pickedScriptDetail.script.scope_type
? pickedScriptDetail.matchedServers
: fleetServers.map((s) => s.name);
});
async function openNewBeta(e: Event): Promise<void> {
e.preventDefault();
if (!pickedScriptDetail || pickedVersionId === null || pickedServers.size === 0 || openingBeta) return;
openingBeta = true;
flash = null;
const s = pickedScriptDetail.script;
const triggerKind = !s.scope_type
? 'unscoped'
: s.current_version_id === null
? 'mark-required'
: 'version-update';
try {
const res = await fetch('/api/betas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scriptId: s.id,
scriptVersionId: pickedVersionId,
servers: [...pickedServers],
waitForHealth,
waitForMatch,
triggerKind
})
});
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}`);
} finally {
openingBeta = false;
}
}
const stateOrder = ['running', 'ready-promote', 'promoted', 'rolled-back'] as const;
let grouped = $derived(
stateOrder.map((state) => ({ state, rows: betas.filter((b) => b.state === state) }))
@@ -37,35 +185,148 @@
{#if loading}
<p class="empty">loading…</p>
{:else if betas.length === 0}
<p class="empty">no betas yet — open one from a script's page</p>
{:else}
{#each grouped as g (g.state)}
{#if g.rows.length > 0}
<section class="panel">
<h2>{g.state.replace('-', ' ')} · {g.rows.length}</h2>
<div class="rows">
{#each g.rows as b (b.id)}
<a class="row" href="/betas/{b.id}">
<span class="id">#{b.id}</span>
<span class="script">{b.script?.name ?? '(deleted)'}</span>
<span class="mono target">{b.script?.target_rel_path ?? ''}</span>
<span class="mono v">v{b.version?.version ?? '?'}</span>
<span class="tag state-{b.state}">{b.state}</span>
<span class="servers mono">{b.servers.length} server{b.servers.length === 1 ? '' : 's'}</span>
</a>
{/each}
</div>
</section>
<section class="panel">
<h2>Open a beta</h2>
{#if eligibleScripts.length === 0}
<p class="empty">no scripts with untested versions — upload a new version from a script's page</p>
{:else}
<form onsubmit={openNewBeta} class="new-beta-form">
<label>
<span class="k">script</span>
<select bind:value={pickedScriptId} onchange={onScriptChange}>
<option value={null}>— pick a script —</option>
{#each eligibleScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
{#if pickedScriptDetail}
<label>
<span class="k">version</span>
<select bind:value={pickedVersionId}>
{#each eligibleVersions as v (v.id)}
<option value={v.id}>v{v.version} · {v.sha256.slice(0, 8)} · {v.state}</option>
{/each}
</select>
</label>
<label>
<span class="k">test servers</span>
<div class="server-picker">
{#each candidateServers as name (name)}
<label class="chk">
<input
type="checkbox"
checked={pickedServers.has(name)}
onchange={() => toggleServer(name)}
/>
{name}
</label>
{/each}
{#if candidateServers.length === 0}
<span class="empty">no candidate servers</span>
{/if}
</div>
</label>
<div class="opts">
<label class="chk">
<input type="checkbox" bind:checked={waitForHealth} />
wait for server start healthy
</label>
<label class="chk">
<input type="checkbox" bind:checked={waitForMatch} />
wait for server match
</label>
</div>
<button
type="submit"
class="primary"
disabled={openingBeta || pickedVersionId === null || pickedServers.size === 0}
>
{openingBeta ? 'opening…' : 'open beta'}
</button>
{/if}
</form>
{#if flash}<p class="flash">{flash}</p>{/if}
{/if}
{/each}
</section>
{#if betas.length === 0}
<p class="empty">no betas yet — open one above</p>
{:else}
{#each grouped as g (g.state)}
{#if g.rows.length > 0}
<section class="panel">
<h2>{g.state.replace('-', ' ')} · {g.rows.length}</h2>
<div class="rows">
{#each g.rows as b (b.id)}
<a class="row" href="/betas/{b.id}">
<span class="id">#{b.id}</span>
<span class="script">{b.script?.name ?? '(deleted)'}</span>
<span class="mono target">{b.script?.target_rel_path ?? ''}</span>
<span class="mono v">v{b.version?.version ?? '?'}</span>
<span class="tag state-{b.state}">{b.state}</span>
<span class="servers mono">{b.servers.length} server{b.servers.length === 1 ? '' : 's'}</span>
</a>
{/each}
</div>
</section>
{/if}
{/each}
{/if}
{/if}
<style>
h1 { color: var(--cream); font-family: var(--font-display); font-size: var(--text-3xl); letter-spacing: var(--tracking-display); margin: 0 0 28px; }
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-base); }
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-base); margin: 0; }
.panel { background: var(--bg-nav); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--pad-panel); margin-bottom: 20px; }
h2 { margin: 0 0 14px; font-family: var(--font-body); font-size: var(--text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-300); }
.new-beta-form { display: flex; flex-direction: column; gap: 14px; }
.new-beta-form label { display: flex; flex-direction: column; gap: 6px; font-size: var(--text-xs); }
.new-beta-form .k { color: var(--ink-500); text-transform: uppercase; letter-spacing: 1px; }
.new-beta-form select {
background: var(--bg-page);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 10px 12px;
font-family: var(--font-mono);
font-size: var(--text-mono);
}
.server-picker {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 12px;
background: var(--bg-page);
border: 1px solid var(--border);
border-radius: var(--r-sm);
}
.chk {
color: var(--ink-100);
font-family: var(--font-mono);
font-size: var(--text-mono);
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.opts { display: flex; gap: 24px; }
.primary {
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
border: 1px solid var(--blue-400);
color: var(--cream);
border-radius: var(--r-sm);
padding: 12px 20px;
font-family: var(--font-body);
font-weight: 600;
cursor: pointer;
align-self: flex-start;
}
.primary:disabled { opacity: 0.4; cursor: default; }
.flash { color: var(--danger-500); font-family: var(--font-mono); font-size: var(--text-mono); margin: 12px 0 0; }
.rows { display: flex; flex-direction: column; gap: 6px; }
.row {
display: grid;