Promote as a real deployment; version state and scope dropdowns

Turns promotion into a first-class deployment (action='promote') so it lands
in the deployments UI with before/after content, per-server restart tracking,
and one-click rollback. Non-beta matching-scope servers get restart_state=
pending so the existing sweep drains and restarts them the same way drift does.

Also adds a per-version state (untested/tested/test-failed) so the upload
flow can visibly signal that a beta must land before promotion, and swaps
the scope-value text box for game/map dropdowns populated from the fleet.
Beta rollback at ready-promote is now labelled Reject and marks the version
test-failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 14:24:35 -04:00
co-authored by Claude Opus 4.7
parent 3f934fd579
commit 1afdfddc87
16 changed files with 1014 additions and 41 deletions
+65 -3
View File
@@ -15,6 +15,14 @@
finished_at: string | null;
triggered_by: string | null;
servers: ServerRow[];
script_id: number | null;
script_version_id: number | null;
prior_version_id: number | null;
beta_id: number | null;
rolled_back_by: string | null;
script_name: string | null;
script_version: number | null;
prior_version: number | null;
}
interface DriftFile { relPath: string; size: number; sha256: string }
@@ -26,6 +34,7 @@
let drift = $state<DriftPreview | null>(null);
let scanning = $state(false);
let running = $state(false);
let rollingBack = $state<string | null>(null);
let flash = $state<string | null>(null);
async function loadDeployments(): Promise<void> {
@@ -91,6 +100,25 @@
let active = $derived(deployments.filter(isActive));
let history = $derived(deployments.filter((d) => !isActive(d)));
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}.`;
if (!confirm(msg)) return;
rollingBack = d.id;
flash = null;
try {
const res = await fetch(`/api/deployments/${d.id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'rollback failed'; return; }
flash = `rollback deployment opened`;
await loadDeployments();
} finally {
rollingBack = null;
}
}
</script>
<h1>Deployments</h1>
@@ -153,8 +181,25 @@
{#snippet deployRow(d: Deployment)}
<a class="row" href="/deployments/{d.id}">
<div class="head">
<span class="action">{d.action}</span>
<span class="when">{ago(d.started_at)}</span>
<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}
{#if d.rolled_back_by}
<span class="tag rb">rolled back</span>
{/if}
</div>
<div class="head-r">
{#if d.action === 'promote' && !d.rolled_back_by}
<button class="rb-btn" onclick={(e) => rollbackPromote(d, e)} disabled={rollingBack === d.id}>
{rollingBack === d.id ? 'rolling back…' : 'rollback'}
</button>
{/if}
<span class="when">{ago(d.started_at)}</span>
</div>
</div>
<div class="chips">
{#each d.servers as s (s.server)}
@@ -197,9 +242,26 @@
transition: border-color 0.15s, box-shadow 0.15s;
}
.row:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
.head { display: flex; justify-content: space-between; margin-bottom: 10px; align-items: baseline; }
.head { display: flex; justify-content: space-between; margin-bottom: 10px; align-items: center; gap: 12px; }
.head-l { display: flex; gap: 10px; align-items: baseline; }
.head-r { display: flex; gap: 10px; align-items: center; }
.action { font-family: var(--font-mono); font-size: var(--text-md); color: var(--cream); }
.target { color: var(--spark-500); font-size: var(--text-mono); }
.when { color: var(--ink-500); font-size: var(--text-mono); font-family: var(--font-mono); }
.tag { padding: 2px 8px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); border: 1px solid transparent; }
.tag.rb { background: var(--bg-card); border-color: var(--border); color: var(--ink-500); }
.rb-btn {
background: var(--bg-nav);
border: 1px solid rgba(255, 48, 80, 0.4);
color: var(--danger-500);
border-radius: var(--r-sm);
padding: 6px 12px;
font-family: var(--font-body);
font-size: var(--text-xs);
cursor: pointer;
}
.rb-btn:hover:not(:disabled) { border-color: var(--danger-500); }
.rb-btn:disabled { opacity: 0.5; cursor: default; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.chip {
padding: 3px 9px;