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