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;
|
||||
|
||||
@@ -28,37 +28,52 @@
|
||||
let versions = $state<Version[]>([]);
|
||||
let matchedServers = $state<string[]>([]);
|
||||
let runningBetaId = $state<number | null>(null);
|
||||
let servers = $state<ServerRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
|
||||
// Upload-new-version form.
|
||||
let versionFile = $state<File | null>(null);
|
||||
let versionNotes = $state('');
|
||||
let uploadingVersion = $state(false);
|
||||
|
||||
// Scope form.
|
||||
// Modal-form state (only used while the corresponding dialog is open).
|
||||
let scopeType = $state<'none' | 'game' | 'map'>('none');
|
||||
let scopeGame = $state('');
|
||||
let scopeMap = $state('');
|
||||
let updatingScope = $state(false);
|
||||
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
|
||||
|
||||
let versionFile = $state<File | null>(null);
|
||||
let versionNotes = $state('');
|
||||
let uploadingVersion = $state(false);
|
||||
|
||||
// Dialog refs so we can call .showModal()/.close() imperatively.
|
||||
let scopeDialog = $state<HTMLDialogElement | null>(null);
|
||||
let uploadDialog = $state<HTMLDialogElement | null>(null);
|
||||
// Kept around so we can reseed scopeGame after servers load (map→game backfill).
|
||||
let fleetServers: ServerRow[] = [];
|
||||
|
||||
const editedScopeValue = $derived.by(() => {
|
||||
if (scopeType === 'game') return scopeGame;
|
||||
if (scopeType === 'map') return scopeMap;
|
||||
return '';
|
||||
});
|
||||
|
||||
// Open-beta form.
|
||||
let betaVersionId = $state<number | null>(null);
|
||||
let betaServers = $state<Set<string>>(new Set());
|
||||
let waitForHealth = $state(true);
|
||||
let waitForMatch = $state(false);
|
||||
let openingBeta = $state(false);
|
||||
|
||||
let id = $derived(Number.parseInt($page.params.id ?? '0', 10));
|
||||
|
||||
// Info-panel derivations.
|
||||
const currentVersion = $derived(
|
||||
script?.current_version_id
|
||||
? versions.find((v) => v.id === script!.current_version_id) ?? null
|
||||
: null
|
||||
);
|
||||
const latestVersion = $derived(versions[0] ?? null);
|
||||
const showLatestSeparately = $derived(
|
||||
latestVersion !== null && currentVersion !== null && latestVersion.id !== currentVersion.id
|
||||
);
|
||||
function scopeLabel(s: Script): string {
|
||||
if (!s.scope_type) return 'unscoped';
|
||||
return `${s.scope_type}=${s.scope_value ?? '?'}`;
|
||||
}
|
||||
function stateClass(state: Version['state']): string {
|
||||
return `tag state-${state}`;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const [detailRes, serversRes, scopeRes] = await Promise.all([
|
||||
fetch(`/api/scripts/${id}`),
|
||||
@@ -76,26 +91,13 @@
|
||||
versions = body.versions;
|
||||
matchedServers = body.matchedServers;
|
||||
runningBetaId = body.runningBetaId;
|
||||
scopeType = script.scope_type ?? 'none';
|
||||
// Seed the two selects from the current scope. For scope=map we need
|
||||
// to figure out which game the map belongs to so the two selects show
|
||||
// consistent state; use the first matched server as ground truth.
|
||||
if (scopeType === 'game') {
|
||||
scopeGame = script.scope_value ?? '';
|
||||
scopeMap = '';
|
||||
} else if (scopeType === 'map') {
|
||||
scopeMap = script.scope_value ?? '';
|
||||
scopeGame = '';
|
||||
}
|
||||
if (versions[0]) betaVersionId = versions[0].id;
|
||||
seedScopeForm();
|
||||
|
||||
if (serversRes.ok) {
|
||||
const s = (await serversRes.json()) as { servers: ServerRow[] };
|
||||
servers = s.servers;
|
||||
// Now that we know the fleet, backfill scopeGame for scope=map so
|
||||
// the game select shows the right option.
|
||||
fleetServers = s.servers;
|
||||
if (scopeType === 'map' && scopeMap) {
|
||||
const owner = servers.find((sv) => sv.map === scopeMap);
|
||||
const owner = fleetServers.find((sv) => sv.map === scopeMap);
|
||||
if (owner?.game) scopeGame = owner.game;
|
||||
}
|
||||
}
|
||||
@@ -104,8 +106,57 @@
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
// Reset the scope modal fields to whatever the script currently has. Called
|
||||
// on load and on cancel so the modal never carries stale edits into a
|
||||
// second open.
|
||||
function seedScopeForm(): void {
|
||||
if (!script) return;
|
||||
scopeType = script.scope_type ?? 'none';
|
||||
if (scopeType === 'game') {
|
||||
scopeGame = script.scope_value ?? '';
|
||||
scopeMap = '';
|
||||
} else if (scopeType === 'map') {
|
||||
scopeMap = script.scope_value ?? '';
|
||||
// Backfill game from the fleet if we already have it.
|
||||
const owner = fleetServers.find((sv) => sv.map === scopeMap);
|
||||
scopeGame = owner?.game ?? '';
|
||||
} else {
|
||||
scopeGame = '';
|
||||
scopeMap = '';
|
||||
}
|
||||
}
|
||||
onMount(load);
|
||||
|
||||
function openScopeModal(): void {
|
||||
if (runningBetaId !== null) return;
|
||||
seedScopeForm();
|
||||
flash = null;
|
||||
scopeDialog?.showModal();
|
||||
}
|
||||
function cancelScopeModal(): void {
|
||||
seedScopeForm();
|
||||
scopeDialog?.close();
|
||||
}
|
||||
function openUploadModal(): void {
|
||||
if (runningBetaId !== null) return;
|
||||
versionFile = null;
|
||||
versionNotes = '';
|
||||
flash = null;
|
||||
uploadDialog?.showModal();
|
||||
}
|
||||
function cancelUploadModal(): void {
|
||||
versionFile = null;
|
||||
versionNotes = '';
|
||||
uploadDialog?.close();
|
||||
}
|
||||
// Standard <dialog> idiom: click on the ::backdrop bubbles up as a click on
|
||||
// the dialog element itself (target === the dialog), while clicks inside
|
||||
// the content bubble up with target inside the dialog.
|
||||
function onDialogClick(dialog: HTMLDialogElement | null, e: MouseEvent): void {
|
||||
if (!dialog) return;
|
||||
if (e.target === dialog) dialog.close();
|
||||
}
|
||||
|
||||
function pickVersionFile(e: Event): void {
|
||||
const t = e.target as HTMLInputElement;
|
||||
versionFile = t.files?.[0] ?? null;
|
||||
@@ -128,16 +179,15 @@
|
||||
}
|
||||
versionFile = null;
|
||||
versionNotes = '';
|
||||
if (body.betaRequired) {
|
||||
flash = 'new version uploaded — open a beta below to deploy it';
|
||||
}
|
||||
uploadDialog?.close();
|
||||
await load();
|
||||
} finally {
|
||||
uploadingVersion = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScope(): Promise<void> {
|
||||
async function saveScope(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (updatingScope) return;
|
||||
if (scopeType !== 'none' && !editedScopeValue) {
|
||||
flash = `pick a ${scopeType}`;
|
||||
@@ -159,63 +209,13 @@
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
scopeDialog?.close();
|
||||
await load();
|
||||
} finally {
|
||||
updatingScope = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBetaServer(name: string): void {
|
||||
const next = new Set(betaServers);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
betaServers = next;
|
||||
}
|
||||
|
||||
function candidateServers(): string[] {
|
||||
if (!script) return [];
|
||||
if (!script.scope_type) return servers.map((s) => s.name);
|
||||
return matchedServers;
|
||||
}
|
||||
|
||||
async function openBeta(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!script || betaVersionId === null || betaServers.size === 0 || openingBeta) return;
|
||||
openingBeta = true;
|
||||
flash = null;
|
||||
const triggerKind = !script.scope_type
|
||||
? 'unscoped'
|
||||
: script.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: script.id,
|
||||
scriptVersionId: betaVersionId,
|
||||
servers: [...betaServers],
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteScript(): Promise<void> {
|
||||
if (!confirm('Delete this script?')) return;
|
||||
const res = await fetch(`/api/scripts/${id}`, { method: 'DELETE' });
|
||||
@@ -238,110 +238,72 @@
|
||||
<section class="panel banner">
|
||||
<p>
|
||||
Beta <a href="/betas/{runningBetaId}"><strong>#{runningBetaId}</strong></a> is currently running for this script.
|
||||
Rollback or promote it before opening a new one.
|
||||
Rollback or promote it before making changes.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Scope</h2>
|
||||
<div class="scope-form">
|
||||
<select bind:value={scopeType} disabled={updatingScope}>
|
||||
<option value="none">unscoped</option>
|
||||
<option value="game">game</option>
|
||||
<option value="map">map</option>
|
||||
</select>
|
||||
{#if scopeType === 'game'}
|
||||
<select bind:value={scopeGame} disabled={updatingScope}>
|
||||
<option value="">— pick a game —</option>
|
||||
{#each scopeOptions.games as g (g)}
|
||||
<option value={g}>{g}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if scopeType === 'map'}
|
||||
<select bind:value={scopeGame} disabled={updatingScope}>
|
||||
<option value="">— pick a game —</option>
|
||||
{#each scopeOptions.games as g (g)}
|
||||
<option value={g}>{g}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={scopeMap} disabled={updatingScope || !scopeGame}>
|
||||
<option value="">— pick a map —</option>
|
||||
{#each (scopeOptions.mapsByGame[scopeGame] ?? []) as m (m)}
|
||||
<option value={m}>{m}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button onclick={saveScope} disabled={updatingScope}>save scope</button>
|
||||
</div>
|
||||
{#if script.scope_type && matchedServers.length > 0}
|
||||
<p class="hint mono">applies to: {matchedServers.join(', ')}</p>
|
||||
{:else if script.scope_type}
|
||||
<p class="hint mono">no servers currently match this scope</p>
|
||||
{:else}
|
||||
<p class="hint mono">unscoped — deploy to specific servers via a beta below</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Upload new version</h2>
|
||||
<form onsubmit={uploadVersion} class="version-form">
|
||||
<input type="file" onchange={pickVersionFile} disabled={uploadingVersion} />
|
||||
<input type="text" bind:value={versionNotes} placeholder="notes (optional)" disabled={uploadingVersion} />
|
||||
<button type="submit" class="primary" disabled={uploadingVersion || !versionFile}>
|
||||
{uploadingVersion ? 'uploading…' : 'upload version'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{#if script.current_version_id === null && versions.length > 0}
|
||||
<section class="panel banner-info">
|
||||
<p>
|
||||
<strong>This script has never been promoted.</strong>
|
||||
Open a beta below to test v{versions[0].version} before it can be promoted to matching servers.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Open beta with a version</h2>
|
||||
{#if runningBetaId}
|
||||
<p class="empty">a beta is already running for this script</p>
|
||||
{:else if versions.length === 0}
|
||||
<p class="empty">no versions yet — upload one above</p>
|
||||
{:else}
|
||||
<form onsubmit={openBeta} class="beta-form">
|
||||
<label>
|
||||
<span class="k">version</span>
|
||||
<select bind:value={betaVersionId}>
|
||||
{#each versions as v (v.id)}
|
||||
<option value={v.id}>v{v.version} · {v.sha256.slice(0, 8)}</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={betaServers.has(name)} onchange={() => toggleBetaServer(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>
|
||||
<h2>Details</h2>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="k">target file</div>
|
||||
<div class="v mono">{script.target_rel_path}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="k">scope</div>
|
||||
<div class="v mono">{scopeLabel(script)}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="k">applies to</div>
|
||||
<div class="v mono">
|
||||
{#if !script.scope_type}
|
||||
<span class="dim">unscoped — deploy from a beta</span>
|
||||
{:else if matchedServers.length === 0}
|
||||
<span class="warn">no matching servers</span>
|
||||
{:else}
|
||||
{matchedServers.join(', ')}
|
||||
{/if}
|
||||
</div>
|
||||
<button type="submit" class="primary" disabled={openingBeta || betaVersionId === null || betaServers.size === 0}>
|
||||
{openingBeta ? 'opening…' : 'open beta'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="k">current version</div>
|
||||
<div class="v mono">
|
||||
{#if currentVersion}
|
||||
v{currentVersion.version} · {currentVersion.sha256.slice(0, 8)} <span class="tag cur">promoted</span>
|
||||
{:else}
|
||||
<span class="dim">— never promoted (open a beta from <a href="/betas">/betas</a>)</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if showLatestSeparately && latestVersion}
|
||||
<div class="stat">
|
||||
<div class="k">latest version</div>
|
||||
<div class="v mono">
|
||||
v{latestVersion.version} · {latestVersion.sha256.slice(0, 8)}
|
||||
<span class={stateClass(latestVersion.state)}>{latestVersion.state}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="secondary"
|
||||
onclick={openScopeModal}
|
||||
disabled={runningBetaId !== null}
|
||||
title={runningBetaId !== null ? 'a beta is currently running' : ''}
|
||||
>
|
||||
edit scope
|
||||
</button>
|
||||
<button
|
||||
class="secondary"
|
||||
onclick={openUploadModal}
|
||||
disabled={runningBetaId !== null}
|
||||
title={runningBetaId !== null ? 'a beta is currently running' : ''}
|
||||
>
|
||||
upload new version
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
@@ -378,6 +340,64 @@
|
||||
</section>
|
||||
|
||||
{#if flash}<p class="flash">{flash}</p>{/if}
|
||||
|
||||
<dialog bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
|
||||
<form class="dlg" onsubmit={saveScope}>
|
||||
<h3>Edit scope</h3>
|
||||
<div class="scope-form">
|
||||
<select bind:value={scopeType} disabled={updatingScope}>
|
||||
<option value="none">unscoped</option>
|
||||
<option value="game">game</option>
|
||||
<option value="map">map</option>
|
||||
</select>
|
||||
{#if scopeType === 'game'}
|
||||
<select bind:value={scopeGame} disabled={updatingScope}>
|
||||
<option value="">— pick a game —</option>
|
||||
{#each scopeOptions.games as g (g)}
|
||||
<option value={g}>{g}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if scopeType === 'map'}
|
||||
<select bind:value={scopeGame} disabled={updatingScope}>
|
||||
<option value="">— pick a game —</option>
|
||||
{#each scopeOptions.games as g (g)}
|
||||
<option value={g}>{g}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={scopeMap} disabled={updatingScope || !scopeGame}>
|
||||
<option value="">— pick a map —</option>
|
||||
{#each (scopeOptions.mapsByGame[scopeGame] ?? []) as m (m)}
|
||||
<option value={m}>{m}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="dlg-actions">
|
||||
<button type="button" class="secondary" onclick={cancelScopeModal} disabled={updatingScope}>cancel</button>
|
||||
<button type="submit" class="primary" disabled={updatingScope}>
|
||||
{updatingScope ? 'saving…' : 'save scope'}
|
||||
</button>
|
||||
</div>
|
||||
{#if flash}<p class="flash">{flash}</p>{/if}
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog bind:this={uploadDialog} onclick={(e) => onDialogClick(uploadDialog, e)}>
|
||||
<form class="dlg" onsubmit={uploadVersion}>
|
||||
<h3>Upload new version</h3>
|
||||
<div class="version-form">
|
||||
<input type="file" onchange={pickVersionFile} disabled={uploadingVersion} />
|
||||
<input type="text" bind:value={versionNotes} placeholder="notes (optional)" disabled={uploadingVersion} />
|
||||
</div>
|
||||
<div class="dlg-actions">
|
||||
<button type="button" class="secondary" onclick={cancelUploadModal} disabled={uploadingVersion}>cancel</button>
|
||||
<button type="submit" class="primary" disabled={uploadingVersion || !versionFile}>
|
||||
{uploadingVersion ? 'uploading…' : 'upload version'}
|
||||
</button>
|
||||
</div>
|
||||
{#if flash}<p class="flash">{flash}</p>{/if}
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -393,8 +413,52 @@
|
||||
.banner a { color: var(--amber-500); }
|
||||
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); }
|
||||
|
||||
.scope-form { display: flex; gap: 10px; align-items: center; }
|
||||
.scope-form select, .version-form input, .beta-form select {
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.stat .k {
|
||||
color: var(--ink-500);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.stat .v {
|
||||
color: var(--ink-100);
|
||||
margin-top: 6px;
|
||||
font-size: var(--text-base);
|
||||
word-break: break-word;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.dim { color: var(--ink-500); }
|
||||
.warn { color: var(--amber-500); }
|
||||
|
||||
.actions { display: flex; gap: 10px; margin-top: 18px; }
|
||||
button.secondary {
|
||||
background: var(--bg-card);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 16px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary:hover:not(:disabled) { border-color: var(--accent); box-shadow: var(--accent-glow); }
|
||||
button.secondary:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.scope-form { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.scope-form select, .version-form input {
|
||||
background: var(--bg-page);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
@@ -403,29 +467,10 @@
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
}
|
||||
.scope-form button, .version-form .primary, .beta-form .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: 10px 16px;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-form button:disabled, .primary:disabled { opacity: 0.4; cursor: default; }
|
||||
.hint { color: var(--ink-500); font-size: var(--text-mono-sm); margin: 12px 0 0; }
|
||||
|
||||
.version-form { display: flex; gap: 10px; align-items: center; }
|
||||
.version-form input[type=text] { flex: 1; }
|
||||
|
||||
.beta-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.beta-form label { display: flex; flex-direction: column; gap: 6px; font-size: var(--text-xs); }
|
||||
.beta-form .k { color: var(--ink-500); text-transform: uppercase; letter-spacing: 1px; }
|
||||
.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; }
|
||||
|
||||
.versions { display: flex; flex-direction: column; gap: 6px; }
|
||||
.version {
|
||||
display: grid;
|
||||
@@ -448,10 +493,6 @@
|
||||
.tag.state-tested { background: rgba(124, 212, 255, 0.10); border-color: rgba(124, 212, 255, 0.3); color: var(--spark-500); }
|
||||
.tag.state-test-failed { background: rgba(255, 92, 92, 0.10); border-color: rgba(255, 92, 92, 0.4); color: var(--danger-500); }
|
||||
|
||||
.banner-info { background: rgba(124, 212, 255, 0.06); border-color: rgba(124, 212, 255, 0.28); }
|
||||
.banner-info p { margin: 0; color: var(--ink-100); font-family: var(--font-body); font-size: var(--text-sm); }
|
||||
.banner-info strong { color: var(--cream); }
|
||||
|
||||
.danger {
|
||||
background: var(--bg-nav);
|
||||
border: 1px solid rgba(255, 48, 80, 0.4);
|
||||
@@ -464,4 +505,37 @@
|
||||
|
||||
.flash { color: var(--danger-500); font-family: var(--font-mono); font-size: var(--text-mono); margin-top: 12px; }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
|
||||
dialog {
|
||||
background: var(--bg-nav);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
padding: 0;
|
||||
max-width: 560px;
|
||||
width: calc(100vw - 40px);
|
||||
}
|
||||
dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
|
||||
.dlg { display: flex; flex-direction: column; gap: 14px; padding: var(--pad-panel); }
|
||||
.dlg h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--ink-300);
|
||||
}
|
||||
.dlg-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 6px; }
|
||||
button.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: 10px 16px;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary:disabled { opacity: 0.4; cursor: default; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user