multi script beta

This commit is contained in:
2026-09-12 18:23:56 -04:00
parent 30e59c900c
commit 85bcb05a7d
16 changed files with 1053 additions and 516 deletions
+202 -109
View File
@@ -4,16 +4,17 @@
interface BetaListRow {
id: number;
script_id: number;
script_version_id: number;
trigger_kind: string;
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
wait_for_health: number;
wait_for_match: number;
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
opened_at: string;
closed_at: string | null;
script: { id: number; name: string; target_rel_path: string } | null;
version: { id: number; version: number; sha256: string } | null;
scripts: Array<{
id: number;
name: string;
target_rel_path: string;
version: { id: number; version: number; sha256: string } | null;
}>;
servers: Array<{ server: string; stage: string; error: string | null }>;
}
@@ -41,18 +42,30 @@
}
interface ServerRow { name: string; game: string | null; map: string | null }
interface ScriptDetail {
script: ScriptListRow;
versions: Version[];
matchedServers: string[];
}
// One row in the multi-script picker.
interface Pick {
key: number; // stable key for keyed each block
scriptId: number | null;
versionId: number | 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);
// Cache script detail (versions + matched servers) per picked scriptId so
// switching versions doesn't refetch.
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 waitForHealth = $state(true);
let waitForMatch = $state(false);
@@ -79,9 +92,8 @@
}
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.
// Scripts eligible for a new beta: has an unpromoted version and isn't in
// a running beta already.
const eligibleScripts = $derived(
scripts.filter(
(s) =>
@@ -91,32 +103,46 @@
)
);
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);
// Scripts eligible for a specific picker row: eligible AND not already
// chosen in a different row. Keeps the dropdown from letting you double-
// add the same script.
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 onPickScript(rowIdx: number): Promise<void> {
flash = null;
const pick = picks[rowIdx];
pick.versionId = null;
picks = [...picks];
if (pick.scriptId === null) return;
if (!detailById[pick.scriptId]) {
const res = await fetch(`/api/scripts/${pick.scriptId}`);
if (!res.ok) { flash = 'failed to load script detail'; return; }
const body = (await res.json()) as ScriptDetail;
detailById = { ...detailById, [pick.scriptId]: body };
}
// Default: latest non-current version.
const detail = detailById[pick.scriptId];
const eligibleVersions = detail.versions.filter((v) => v.id !== detail.script.current_version_id);
pick.versionId = eligibleVersions[0]?.id ?? null;
picks = [...picks];
// Reset server selection to the recomputed candidate set.
pickedServers = new Set(candidateServers);
}
function addRow(): void {
picks = [...picks, { key: nextKey++, scriptId: null, versionId: null }];
}
function removeRow(idx: number): void {
picks = picks.filter((_, i) => i !== idx);
pickedServers = new Set(candidateServers);
}
function toggleServer(name: string): void {
const next = new Set(pickedServers);
if (next.has(name)) next.delete(name);
@@ -124,40 +150,62 @@
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
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);
}
// Servers each pick would land on if promoted alone. Scoped scripts →
// matched servers; unscoped scripts → whole fleet.
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);
}
// Intersection of every picked script's candidate set. A beta with two
// scripts must run on servers that are in-scope for BOTH.
const candidateServers = $derived.by(() => {
const perPick: string[][] = [];
for (const p of picks) {
const c = candidatesFor(p);
if (c === null) continue; // row not yet picked — don't constrain
perPick.push(c);
}
if (perPick.length === 0) return [] as string[];
const [first, ...rest] = perPick;
return first.filter((name) => rest.every((set) => set.includes(name)));
});
// Ready to submit when every row has a script + version and at least one
// server is picked from the intersection.
const canSubmit = $derived(
!openingBeta &&
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 (!pickedScriptDetail || pickedVersionId === null || pickedServers.size === 0 || openingBeta) return;
if (!canSubmit) 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,
scripts: picks.map((p) => ({ scriptId: p.scriptId!, scriptVersionId: p.versionId! })),
servers: [...pickedServers],
waitForHealth,
waitForMatch,
triggerKind
waitForMatch
})
});
const body = (await res.json()) as {
@@ -179,6 +227,15 @@
let grouped = $derived(
stateOrder.map((state) => ({ state, rows: betas.filter((b) => b.state === state) }))
);
function summarizeScripts(b: BetaListRow): string {
if (b.scripts.length === 0) return '(no scripts)';
if (b.scripts.length === 1) {
const s = b.scripts[0];
return `${s.name} · v${s.version?.version ?? '?'}`;
}
return `${b.scripts.length} scripts`;
}
</script>
<h1>Betas</h1>
@@ -192,27 +249,45 @@
<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">
<div class="picks">
{#each picks as row, idx (row.key)}
<div class="pick-row">
<select
bind:value={row.scriptId}
onchange={() => onPickScript(idx)}
>
<option value={null}>— pick a script —</option>
{#each eligibleForRow(idx) as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
<select 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}>v{v.version} · {v.sha256.slice(0, 8)} · {v.state}</option>
{/each}
{/if}
</select>
<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'}
>×</button>
</div>
{/each}
</div>
<button type="button" class="add-row" onclick={addRow}>+ add script</button>
<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">
<span class="k">test servers</span>
<div class="server-picker">
{#if candidateServers.length === 0}
<span class="empty">no server satisfies all selected scopes — pick different scripts</span>
{:else}
{#each candidateServers as name (name)}
<label class="chk">
<input
@@ -223,29 +298,24 @@
{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>
{/if}
</div>
<button
type="submit"
class="primary"
disabled={openingBeta || pickedVersionId === null || pickedServers.size === 0}
>
{openingBeta ? 'opening…' : 'open beta'}
</button>
{/if}
</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={!canSubmit}>
{openingBeta ? 'opening…' : 'open beta'}
</button>
</form>
{#if flash}<p class="flash">{flash}</p>{/if}
{/if}
@@ -262,9 +332,7 @@
{#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="script">{summarizeScripts(b)}</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>
@@ -285,7 +353,10 @@
.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 {
.picks { display: flex; flex-direction: column; gap: 8px; }
.pick-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; }
.pick-row select {
background: var(--bg-page);
color: var(--ink-100);
border: 1px solid var(--border);
@@ -294,6 +365,30 @@
font-family: var(--font-mono);
font-size: var(--text-mono);
}
.row-x {
background: var(--bg-card);
color: var(--ink-500);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 0 14px;
font-size: var(--text-md);
cursor: pointer;
}
.row-x:hover:not(:disabled) { color: var(--danger-500); border-color: rgba(255, 48, 80, 0.4); }
.row-x:disabled { opacity: 0.4; cursor: default; }
.add-row {
align-self: flex-start;
background: transparent;
color: var(--ink-300);
border: 1px dashed var(--border);
border-radius: var(--r-sm);
padding: 8px 14px;
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
}
.add-row:hover { color: var(--cream); border-color: var(--accent); }
.server-picker {
display: flex;
flex-wrap: wrap;
@@ -330,7 +425,7 @@
.rows { display: flex; flex-direction: column; gap: 6px; }
.row {
display: grid;
grid-template-columns: 60px 220px 1fr 80px 130px 120px;
grid-template-columns: 60px 1fr 130px 120px;
gap: 14px;
padding: 12px 16px;
background: var(--bg-card);
@@ -344,8 +439,6 @@
.row:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
.id { color: var(--ink-500); font-family: var(--font-mono); }
.script { color: var(--cream); font-weight: 600; font-family: var(--font-body); }
.target { color: var(--ink-300); font-family: var(--font-mono); word-break: break-all; }
.v { color: var(--ink-500); }
.servers { color: var(--ink-500); }
.tag { padding: 3px 10px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 1px; border: 1px solid transparent; text-align: center; }
.state-running { background: rgba(255, 176, 32, 0.15); border-color: rgba(255, 176, 32, 0.4); color: var(--amber-500); }