multi script beta
This commit is contained in:
+202
-109
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user