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:
+285
-24
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user