multi script beta
This commit is contained in:
@@ -2,9 +2,9 @@ import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
listBetas,
|
||||
listBetaServers,
|
||||
listBetaScripts,
|
||||
openBeta,
|
||||
BetaError,
|
||||
type BetaTriggerKind
|
||||
BetaError
|
||||
} from '$lib/server/fleet/beta.js';
|
||||
import { getScript, getVersion } from '$lib/server/fleet/scripts.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
@@ -17,53 +17,63 @@ export async function GET({ url }: { url: URL }) {
|
||||
| 'rolled-back'
|
||||
| null;
|
||||
const rows = listBetas(stateFilter ?? undefined).map((b) => {
|
||||
const script = getScript(b.script_id);
|
||||
const version = getVersion(b.script_version_id);
|
||||
const scripts = listBetaScripts(b.id).map((bs) => {
|
||||
const script = getScript(bs.script_id);
|
||||
const version = getVersion(bs.script_version_id);
|
||||
return {
|
||||
id: bs.script_id,
|
||||
name: script?.name ?? '(deleted)',
|
||||
target_rel_path: script?.target_rel_path ?? '',
|
||||
version: version
|
||||
? { id: version.id, version: version.version, sha256: version.sha256 }
|
||||
: null
|
||||
};
|
||||
});
|
||||
return {
|
||||
...b,
|
||||
script: script ? { id: script.id, name: script.name, target_rel_path: script.target_rel_path } : null,
|
||||
version: version ? { id: version.id, version: version.version, sha256: version.sha256 } : null,
|
||||
scripts,
|
||||
servers: listBetaServers(b.id)
|
||||
};
|
||||
});
|
||||
return json({ betas: rows });
|
||||
}
|
||||
|
||||
// POST body: { scriptId, scriptVersionId, servers[], waitForHealth, waitForMatch, triggerKind, notes? }
|
||||
// POST body: { scripts: [{ scriptId, scriptVersionId }], servers[], waitForHealth, waitForMatch, notes? }
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
scriptId?: number;
|
||||
scriptVersionId?: number;
|
||||
scripts?: Array<{ scriptId?: number; scriptVersionId?: number }>;
|
||||
servers?: string[];
|
||||
waitForHealth?: boolean;
|
||||
waitForMatch?: boolean;
|
||||
triggerKind?: BetaTriggerKind;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof body.scriptId !== 'number' ||
|
||||
typeof body.scriptVersionId !== 'number' ||
|
||||
!Array.isArray(body.servers)
|
||||
) {
|
||||
if (!Array.isArray(body.scripts) || body.scripts.length === 0 || !Array.isArray(body.servers)) {
|
||||
return json(
|
||||
{ ok: false, error: 'scriptId, scriptVersionId, and servers[] are required' },
|
||||
{ ok: false, error: 'scripts[] (non-empty) and servers[] are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (body.servers.length === 0) {
|
||||
return json({ ok: false, error: 'at least one server is required' }, { status: 400 });
|
||||
}
|
||||
const triggerKind: BetaTriggerKind = body.triggerKind ?? 'unscoped';
|
||||
const normalized: Array<{ scriptId: number; scriptVersionId: number }> = [];
|
||||
for (const s of body.scripts) {
|
||||
if (typeof s.scriptId !== 'number' || typeof s.scriptVersionId !== 'number') {
|
||||
return json(
|
||||
{ ok: false, error: 'each scripts[] entry needs scriptId and scriptVersionId' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
normalized.push({ scriptId: s.scriptId, scriptVersionId: s.scriptVersionId });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await openBeta({
|
||||
scriptId: body.scriptId,
|
||||
scriptVersionId: body.scriptVersionId,
|
||||
scripts: normalized,
|
||||
servers: body.servers,
|
||||
waitForHealth: body.waitForHealth ?? true,
|
||||
waitForMatch: body.waitForMatch ?? false,
|
||||
triggerKind,
|
||||
notes: body.notes
|
||||
});
|
||||
void kick();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getBeta, listBetaServers } from '$lib/server/fleet/beta.js';
|
||||
import { getBeta, listBetaServers, listBetaScripts } from '$lib/server/fleet/beta.js';
|
||||
import { getScript, getVersion } from '$lib/server/fleet/scripts.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
@@ -13,9 +13,19 @@ export async function GET({ params }: { params: { id: string } }) {
|
||||
const beta = getBeta(id);
|
||||
if (!beta) return json({ ok: false, error: 'not found' }, { status: 404 });
|
||||
|
||||
const script = getScript(beta.script_id);
|
||||
const version = getVersion(beta.script_version_id);
|
||||
const scripts = listBetaScripts(id).map((bs) => {
|
||||
const script = getScript(bs.script_id);
|
||||
const version = getVersion(bs.script_version_id);
|
||||
return {
|
||||
id: bs.script_id,
|
||||
name: script?.name ?? '(deleted)',
|
||||
target_rel_path: script?.target_rel_path ?? '',
|
||||
version: version
|
||||
? { id: version.id, version: version.version, sha256: version.sha256 }
|
||||
: null
|
||||
};
|
||||
});
|
||||
const servers = listBetaServers(id);
|
||||
|
||||
return json({ beta, script, version, servers });
|
||||
return json({ beta, scripts, servers });
|
||||
}
|
||||
|
||||
@@ -57,8 +57,35 @@ export async function GET({ url }: { url: URL }) {
|
||||
grouped.set(s.deployment_id, arr);
|
||||
}
|
||||
|
||||
const scriptRows = db()
|
||||
.prepare(
|
||||
`SELECT ds.deployment_id, ds.script_id, s.name AS script_name,
|
||||
sv.version AS script_version, pv.version AS prior_version
|
||||
FROM deployment_scripts ds
|
||||
LEFT JOIN scripts s ON s.id = ds.script_id
|
||||
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
|
||||
LEFT JOIN script_versions pv ON pv.id = ds.prior_version_id`
|
||||
)
|
||||
.all() as Array<{
|
||||
deployment_id: string;
|
||||
script_id: number;
|
||||
script_name: string | null;
|
||||
script_version: number | null;
|
||||
prior_version: number | null;
|
||||
}>;
|
||||
const scriptsByDep = new Map<string, typeof scriptRows>();
|
||||
for (const s of scriptRows) {
|
||||
const arr = scriptsByDep.get(s.deployment_id) ?? [];
|
||||
arr.push(s);
|
||||
scriptsByDep.set(s.deployment_id, arr);
|
||||
}
|
||||
|
||||
const rows = deployments
|
||||
.map((d) => ({ ...d, servers: grouped.get(d.id) ?? [] }))
|
||||
.map((d) => ({
|
||||
...d,
|
||||
servers: grouped.get(d.id) ?? [],
|
||||
scripts: scriptsByDep.get(d.id) ?? []
|
||||
}))
|
||||
.filter((d) => (activeOnly ? d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed') : true));
|
||||
|
||||
return json({ deployments: rows });
|
||||
|
||||
@@ -24,5 +24,19 @@ export async function GET({ params }: { params: { id: string } }) {
|
||||
)
|
||||
.all(params.id);
|
||||
|
||||
return json({ deployment: dep, servers, files });
|
||||
const scripts = db()
|
||||
.prepare(
|
||||
`SELECT ds.script_id, s.name AS script_name, s.target_rel_path,
|
||||
ds.script_version_id, sv.version AS script_version,
|
||||
ds.prior_version_id, pv.version AS prior_version
|
||||
FROM deployment_scripts ds
|
||||
LEFT JOIN scripts s ON s.id = ds.script_id
|
||||
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
|
||||
LEFT JOIN script_versions pv ON pv.id = ds.prior_version_id
|
||||
WHERE ds.deployment_id = ?
|
||||
ORDER BY ds.script_id`
|
||||
)
|
||||
.all(params.id);
|
||||
|
||||
return json({ deployment: dep, servers, files, scripts });
|
||||
}
|
||||
|
||||
+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); }
|
||||
|
||||
@@ -28,12 +28,15 @@
|
||||
match_seen_at: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
interface Script { id: number; name: string; target_rel_path: string }
|
||||
interface Version { id: number; version: number; sha256: string; size: number }
|
||||
interface BetaScript {
|
||||
id: number;
|
||||
name: string;
|
||||
target_rel_path: string;
|
||||
version: { id: number; version: number; sha256: string } | null;
|
||||
}
|
||||
|
||||
let beta = $state<BetaRow | null>(null);
|
||||
let script = $state<Script | null>(null);
|
||||
let version = $state<Version | null>(null);
|
||||
let scripts = $state<BetaScript[]>([]);
|
||||
let servers = $state<BetaServerRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
@@ -46,13 +49,11 @@
|
||||
if (!res.ok) { loading = false; return; }
|
||||
const body = (await res.json()) as {
|
||||
beta: BetaRow;
|
||||
script: Script;
|
||||
version: Version;
|
||||
scripts: BetaScript[];
|
||||
servers: BetaServerRow[];
|
||||
};
|
||||
beta = body.beta;
|
||||
script = body.script;
|
||||
version = body.version;
|
||||
scripts = body.scripts;
|
||||
servers = body.servers;
|
||||
loading = false;
|
||||
}
|
||||
@@ -107,7 +108,7 @@
|
||||
|
||||
{#if loading}
|
||||
<p class="empty">loading…</p>
|
||||
{:else if !beta || !script || !version}
|
||||
{:else if !beta}
|
||||
<p class="empty">beta not found</p>
|
||||
{:else}
|
||||
<a class="back" href="/betas">← all betas</a>
|
||||
@@ -115,11 +116,24 @@
|
||||
<h1>Beta #{beta.id}</h1>
|
||||
<span class="tag state-{beta.state}">{beta.state}</span>
|
||||
</div>
|
||||
<div class="sub">
|
||||
<a href="/scripts/{script.id}">{script.name}</a>
|
||||
<span class="mono">· {script.target_rel_path}</span>
|
||||
<span class="mono">· v{version.version}</span>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Scripts · {scripts.length}</h2>
|
||||
{#if scripts.length === 0}
|
||||
<p class="empty">no scripts bundled in this beta</p>
|
||||
{:else}
|
||||
<div class="script-list">
|
||||
{#each scripts as s (s.id)}
|
||||
<div class="script-row">
|
||||
<a class="s-name" href="/scripts/{s.id}">{s.name}</a>
|
||||
<span class="s-target mono">{s.target_rel_path}</span>
|
||||
<span class="s-ver mono">v{s.version?.version ?? '?'}</span>
|
||||
<span class="s-sha mono">{s.version?.sha256.slice(0, 8) ?? ''}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Options</h2>
|
||||
@@ -193,10 +207,25 @@
|
||||
.back:hover { color: var(--ink-100); }
|
||||
.head-row { display: flex; align-items: center; gap: 12px; margin: 12px 0 4px; }
|
||||
h1 { color: var(--cream); font-family: var(--font-display); font-size: var(--text-2xl); letter-spacing: 1.5px; margin: 0; }
|
||||
.sub { color: var(--ink-500); font-size: var(--text-mono); margin-bottom: 24px; display: flex; gap: 8px; align-items: baseline; }
|
||||
.sub a { color: var(--spark-500); text-decoration: none; }
|
||||
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono); }
|
||||
|
||||
.script-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.script-row {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr 80px 100px;
|
||||
gap: 14px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
align-items: center;
|
||||
font-size: var(--text-mono);
|
||||
}
|
||||
.s-name { color: var(--spark-500); text-decoration: none; font-weight: 600; font-family: var(--font-body); }
|
||||
.s-name:hover { text-decoration: underline; }
|
||||
.s-target { color: var(--ink-300); word-break: break-all; }
|
||||
.s-ver, .s-sha { color: var(--ink-500); }
|
||||
|
||||
.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); }
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
players: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
interface DepScript {
|
||||
deployment_id: string;
|
||||
script_id: number;
|
||||
script_name: string | null;
|
||||
script_version: number | null;
|
||||
prior_version: number | null;
|
||||
}
|
||||
interface Deployment {
|
||||
id: string;
|
||||
action: string;
|
||||
@@ -23,6 +30,7 @@
|
||||
script_name: string | null;
|
||||
script_version: number | null;
|
||||
prior_version: number | null;
|
||||
scripts: DepScript[];
|
||||
}
|
||||
|
||||
interface DriftFile { relPath: string; size: number; sha256: string }
|
||||
@@ -101,11 +109,24 @@
|
||||
let active = $derived(deployments.filter(isActive));
|
||||
let history = $derived(deployments.filter((d) => !isActive(d)));
|
||||
|
||||
function scriptSummary(d: Deployment): string {
|
||||
if (d.scripts && d.scripts.length > 0) {
|
||||
if (d.scripts.length === 1) {
|
||||
const s = d.scripts[0];
|
||||
return `${s.script_name ?? '(deleted)'} v${s.script_version ?? '?'}`;
|
||||
}
|
||||
return `${d.scripts.length} scripts`;
|
||||
}
|
||||
// Legacy fallback: pre-multi-script rows still use the top-level cols.
|
||||
if (d.script_name) return `${d.script_name} v${d.script_version ?? '?'}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
async function rollbackPromote(d: Deployment, ev: Event): Promise<void> {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const priorLabel = d.prior_version === null ? 'nothing (undo the first promotion)' : `v${d.prior_version}`;
|
||||
const msg = `Rollback ${d.script_name ?? 'this script'} v${d.script_version ?? '?'} on ${d.servers.length} server(s)? Reverts to ${priorLabel}.`;
|
||||
const summary = scriptSummary(d) || 'this promote';
|
||||
const msg = `Rollback ${summary} on ${d.servers.length} server(s)?`;
|
||||
if (!confirm(msg)) return;
|
||||
rollingBack = d.id;
|
||||
flash = null;
|
||||
@@ -183,10 +204,8 @@
|
||||
<div class="head">
|
||||
<div class="head-l">
|
||||
<span class="action">{d.action}</span>
|
||||
{#if d.action === 'promote' && d.script_name}
|
||||
<span class="target mono">{d.script_name} v{d.script_version}</span>
|
||||
{:else if d.action === 'rollback' && d.script_name}
|
||||
<span class="target mono">{d.script_name} → v{d.script_version ?? '—'}</span>
|
||||
{#if d.action === 'promote' || d.action === 'rollback'}
|
||||
<span class="target mono">{scriptSummary(d)}</span>
|
||||
{/if}
|
||||
{#if d.rolled_back_by}
|
||||
<span class="tag rb">rolled back</span>
|
||||
|
||||
Reference in New Issue
Block a user