multi script beta

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