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:
@@ -6,9 +6,15 @@ export async function GET({ url }: { url: URL }) {
|
||||
|
||||
const deployments = db()
|
||||
.prepare(
|
||||
`SELECT id, action, started_at, finished_at, triggered_by
|
||||
FROM deployments
|
||||
ORDER BY started_at DESC
|
||||
`SELECT d.id, d.action, d.started_at, d.finished_at, d.triggered_by,
|
||||
d.script_id, d.script_version_id, d.prior_version_id, d.beta_id, d.rolled_back_by,
|
||||
s.name AS script_name, sv.version AS script_version,
|
||||
pv.version AS prior_version
|
||||
FROM deployments d
|
||||
LEFT JOIN scripts s ON s.id = d.script_id
|
||||
LEFT JOIN script_versions sv ON sv.id = d.script_version_id
|
||||
LEFT JOIN script_versions pv ON pv.id = d.prior_version_id
|
||||
ORDER BY d.started_at DESC
|
||||
LIMIT 500`
|
||||
)
|
||||
.all() as Array<{
|
||||
@@ -17,6 +23,14 @@ export async function GET({ url }: { url: URL }) {
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
triggered_by: string | null;
|
||||
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;
|
||||
}>;
|
||||
|
||||
const serversByDep = db()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { rollbackPromoteDeployment, RollbackError } from '$lib/server/fleet/rollback.js';
|
||||
|
||||
export async function POST({ params }: { params: { id: string } }) {
|
||||
try {
|
||||
const result = await rollbackPromoteDeployment(params.id);
|
||||
return json({ ok: true, result });
|
||||
} catch (err) {
|
||||
if (err instanceof RollbackError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listDistinctGames, listMapsByGame } from '$lib/server/fleet/scripts.js';
|
||||
|
||||
// Powers the script-upload scope dropdowns. Options are always live from the
|
||||
// fleet — no caching. This is a cheap two-query call and the servers list
|
||||
// changes infrequently.
|
||||
export async function GET() {
|
||||
return json({
|
||||
games: listDistinctGames(),
|
||||
mapsByGame: listMapsByGame()
|
||||
});
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export async function GET() {
|
||||
version: versions[0].version,
|
||||
sha256: versions[0].sha256,
|
||||
size: versions[0].size,
|
||||
state: versions[0].state,
|
||||
uploaded_at: versions[0].uploaded_at
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function GET({ params }: { params: { id: string } }) {
|
||||
sha256: v.sha256,
|
||||
size: v.size,
|
||||
notes: v.notes,
|
||||
state: v.state,
|
||||
uploaded_at: v.uploaded_at
|
||||
}));
|
||||
const matched = script.scope_type && script.scope_value
|
||||
|
||||
@@ -75,7 +75,11 @@
|
||||
} finally { acting = null; }
|
||||
}
|
||||
async function rollback(): Promise<void> {
|
||||
if (!confirm('Rollback this beta?')) return;
|
||||
const isReject = beta?.state === 'ready-promote';
|
||||
const prompt = isReject
|
||||
? `Reject beta #${id}? This restores original files on every beta server and marks the version as test-failed.`
|
||||
: `Rollback beta #${id}? This restores original files on every beta server.`;
|
||||
if (!confirm(prompt)) return;
|
||||
acting = 'rollback';
|
||||
flash = null;
|
||||
try {
|
||||
@@ -171,7 +175,13 @@
|
||||
onclick={rollback}
|
||||
disabled={beta.state === 'promoted' || beta.state === 'rolled-back' || acting !== null}
|
||||
>
|
||||
{acting === 'rollback' ? 'rolling back…' : 'rollback'}
|
||||
{#if acting === 'rollback'}
|
||||
{beta.state === 'ready-promote' ? 'rejecting…' : 'rolling back…'}
|
||||
{:else if beta.state === 'ready-promote'}
|
||||
reject
|
||||
{:else}
|
||||
rollback
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+153
-11
@@ -13,6 +13,7 @@
|
||||
version: number;
|
||||
sha256: string;
|
||||
size: number;
|
||||
state: 'untested' | 'tested' | 'test-failed';
|
||||
uploaded_at: string;
|
||||
} | null;
|
||||
versionCount: number;
|
||||
@@ -20,22 +21,69 @@
|
||||
runningBetaId: number | null;
|
||||
}
|
||||
|
||||
interface ScopeOptions {
|
||||
games: string[];
|
||||
mapsByGame: Record<string, string[]>;
|
||||
}
|
||||
|
||||
let scripts = $state<ScriptRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
let uploading = $state(false);
|
||||
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
|
||||
let successBanner = $state<{ name: string; scriptId: number; version: number } | null>(null);
|
||||
|
||||
// Form state for creating a new script.
|
||||
let newName = $state('');
|
||||
let newScopeType = $state<'none' | 'game' | 'map'>('none');
|
||||
let newScopeValue = $state('');
|
||||
let newScopeGame = $state('');
|
||||
let newScopeMap = $state('');
|
||||
let newFile = $state<File | null>(null);
|
||||
let newNotes = $state('');
|
||||
|
||||
// The scope value we send to the API — computed from the two selects so
|
||||
// scope=map ships the map (game is just a filter narrowing the map list).
|
||||
const scopeValue = $derived.by(() => {
|
||||
if (newScopeType === 'game') return newScopeGame;
|
||||
if (newScopeType === 'map') return newScopeMap;
|
||||
return '';
|
||||
});
|
||||
|
||||
// Live preview: which servers would this script land on? Empty when the
|
||||
// scope matches nothing OR when scope=none (unscoped scripts only push to
|
||||
// explicitly-chosen beta targets, so there's no promotion audience yet).
|
||||
const matchingServers = $derived.by(() => {
|
||||
if (newScopeType === 'none') return null;
|
||||
if (!scopeValue) return null;
|
||||
// scope-options carries fleet distincts, but not the (server → game/map)
|
||||
// mapping. We derive the count by checking every server in `scripts`
|
||||
// list responses — instead, use the /api/servers list. For simplicity
|
||||
// we call a scoped preview against the API on the fly. Cheaper: reuse
|
||||
// scope options — but we don't have server names. Fetch servers once.
|
||||
return matchingServersFromCache();
|
||||
});
|
||||
|
||||
let serverCache = $state<Array<{ name: string; game: string | null; map: string | null }>>([]);
|
||||
function matchingServersFromCache(): string[] {
|
||||
return serverCache
|
||||
.filter((s) => {
|
||||
const field = newScopeType === 'game' ? s.game : s.map;
|
||||
return field === scopeValue;
|
||||
})
|
||||
.map((s) => s.name);
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const res = await fetch('/api/scripts');
|
||||
const body = (await res.json()) as { scripts: ScriptRow[] };
|
||||
const [scriptsRes, scopeRes, serversRes] = await Promise.all([
|
||||
fetch('/api/scripts'),
|
||||
fetch('/api/scope-options'),
|
||||
fetch('/api/servers')
|
||||
]);
|
||||
const body = (await scriptsRes.json()) as { scripts: ScriptRow[] };
|
||||
scripts = body.scripts;
|
||||
scopeOptions = (await scopeRes.json()) as ScopeOptions;
|
||||
const sBody = (await serversRes.json()) as { servers: Array<{ name: string; game: string | null; map: string | null }> };
|
||||
serverCache = sBody.servers;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
@@ -52,24 +100,38 @@
|
||||
async function createScript(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (uploading || !newFile || !newName.trim()) return;
|
||||
// Guard: scope was set but no value picked yet.
|
||||
if (newScopeType !== 'none' && !scopeValue) {
|
||||
flash = `pick a ${newScopeType}`;
|
||||
return;
|
||||
}
|
||||
uploading = true;
|
||||
flash = null;
|
||||
const form = new FormData();
|
||||
form.append('name', newName.trim());
|
||||
form.append('scopeType', newScopeType);
|
||||
if (newScopeValue.trim()) form.append('scopeValue', newScopeValue.trim());
|
||||
if (scopeValue) form.append('scopeValue', scopeValue);
|
||||
if (newNotes.trim()) form.append('notes', newNotes.trim());
|
||||
form.append('content', newFile);
|
||||
try {
|
||||
const res = await fetch('/api/scripts', { method: 'POST', body: form });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
const body = (await res.json()) as {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
script?: { id: number; name: string };
|
||||
versionId?: number;
|
||||
};
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
successBanner = body.script
|
||||
? { name: body.script.name, scriptId: body.script.id, version: 1 }
|
||||
: null;
|
||||
newName = '';
|
||||
newScopeType = 'none';
|
||||
newScopeValue = '';
|
||||
newScopeGame = '';
|
||||
newScopeMap = '';
|
||||
newNotes = '';
|
||||
newFile = null;
|
||||
await load();
|
||||
@@ -82,12 +144,32 @@
|
||||
if (!s.scope_type) return 'unscoped';
|
||||
return `${s.scope_type}=${s.scope_value ?? '?'}`;
|
||||
}
|
||||
|
||||
// What state tag to show on a script row. Distinguishes "never promoted"
|
||||
// (whole script is untested) from "promoted but has a newer untested
|
||||
// version" (existing production is fine, but there's unreleased work).
|
||||
function scriptStateTag(s: ScriptRow): { label: string; cls: string } | null {
|
||||
if (!s.latestVersion) return null;
|
||||
if (s.current_version_id === null) return { label: 'untested', cls: 'state-untested' };
|
||||
if (s.latestVersion.id !== s.current_version_id) {
|
||||
if (s.latestVersion.state === 'untested') return { label: `v${s.latestVersion.version} untested`, cls: 'state-untested' };
|
||||
if (s.latestVersion.state === 'tested') return { label: `v${s.latestVersion.version} tested`, cls: 'state-tested' };
|
||||
if (s.latestVersion.state === 'test-failed') return { label: `v${s.latestVersion.version} test-failed`, cls: 'state-test-failed' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Scripts</h1>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Upload a script</h2>
|
||||
{#if successBanner}
|
||||
<div class="banner banner-info">
|
||||
<strong>v{successBanner.version} uploaded — untested.</strong>
|
||||
<span>Open a beta on <a href="/scripts/{successBanner.scriptId}">{successBanner.name}</a> to test it before it can be promoted.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<form onsubmit={createScript} class="upload-form">
|
||||
<label>
|
||||
<span class="k">name</span>
|
||||
@@ -101,10 +183,36 @@
|
||||
<option value="game">game</option>
|
||||
<option value="map">map</option>
|
||||
</select>
|
||||
{#if newScopeType !== 'none'}
|
||||
<input type="text" bind:value={newScopeValue} placeholder={newScopeType === 'game' ? 't5' : 'zombie_theater'} disabled={uploading} />
|
||||
{#if newScopeType === 'game'}
|
||||
<select bind:value={newScopeGame} disabled={uploading}>
|
||||
<option value="">— pick a game —</option>
|
||||
{#each scopeOptions.games as g (g)}
|
||||
<option value={g}>{g}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if newScopeType === 'map'}
|
||||
<select bind:value={newScopeGame} disabled={uploading}>
|
||||
<option value="">— pick a game —</option>
|
||||
{#each scopeOptions.games as g (g)}
|
||||
<option value={g}>{g}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={newScopeMap} disabled={uploading || !newScopeGame}>
|
||||
<option value="">— pick a map —</option>
|
||||
{#each (scopeOptions.mapsByGame[newScopeGame] ?? []) as m (m)}
|
||||
<option value={m}>{m}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{#if newScopeType !== 'none' && scopeValue}
|
||||
{@const matches = matchingServers ?? []}
|
||||
{#if matches.length === 0}
|
||||
<p class="preview warn">⚠ no matching servers in the fleet — the promoted script will land nowhere</p>
|
||||
{:else}
|
||||
<p class="preview ok">{matches.length} matching server{matches.length === 1 ? '' : 's'}: {matches.join(', ')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</label>
|
||||
<label>
|
||||
<span class="k">notes (optional)</span>
|
||||
@@ -134,6 +242,10 @@
|
||||
<div class="row-top">
|
||||
<span class="script-name">{s.name}</span>
|
||||
<span class="scope tag scope-{s.scope_type ?? 'none'}">{scopeLabel(s)}</span>
|
||||
{#if scriptStateTag(s)}
|
||||
{@const t = scriptStateTag(s)!}
|
||||
<span class="tag {t.cls}">{t.label}</span>
|
||||
{/if}
|
||||
{#if s.runningBetaId}
|
||||
<span class="tag beta">beta {s.runningBetaId} running</span>
|
||||
{/if}
|
||||
@@ -193,9 +305,8 @@
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--accent-glow);
|
||||
}
|
||||
.scope-row { display: flex; gap: 8px; }
|
||||
.scope-row select { flex: 0 0 120px; }
|
||||
.scope-row input { flex: 1; }
|
||||
.scope-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.scope-row select:nth-of-type(1) { flex: 0 0 120px; }
|
||||
.primary {
|
||||
grid-column: 1 / -1;
|
||||
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
|
||||
@@ -238,4 +349,35 @@
|
||||
.scope-game { background: rgba(124, 212, 255, 0.10); border-color: rgba(124, 212, 255, 0.3); color: var(--spark-500); }
|
||||
.scope-map { background: rgba(255, 176, 32, 0.10); border-color: rgba(255, 176, 32, 0.35); color: var(--amber-500); }
|
||||
.beta { background: rgba(255, 176, 32, 0.10); border-color: rgba(255, 176, 32, 0.3); color: var(--amber-500); }
|
||||
.state-untested { background: var(--bg-nav); border-color: var(--border); color: var(--ink-300); }
|
||||
.state-tested { background: rgba(124, 212, 255, 0.10); border-color: rgba(124, 212, 255, 0.3); color: var(--spark-500); }
|
||||
.state-test-failed { background: rgba(255, 92, 92, 0.10); border-color: rgba(255, 92, 92, 0.4); color: var(--danger-500); }
|
||||
|
||||
.banner {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border-radius: var(--r-sm);
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
font-size: var(--text-mono);
|
||||
}
|
||||
.banner-info {
|
||||
background: rgba(124, 212, 255, 0.08);
|
||||
border: 1px solid rgba(124, 212, 255, 0.28);
|
||||
color: var(--ink-100);
|
||||
}
|
||||
.banner-info a { color: var(--spark-500); text-decoration: underline; }
|
||||
|
||||
.preview {
|
||||
grid-column: 1 / -1;
|
||||
margin: 4px 0 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono-sm);
|
||||
}
|
||||
.preview.ok { color: var(--ink-300); }
|
||||
.preview.warn { color: var(--amber-500); }
|
||||
|
||||
.scope-row select:nth-of-type(2), .scope-row select:nth-of-type(3) { flex: 1; }
|
||||
</style>
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
sha256: string;
|
||||
size: number;
|
||||
notes: string | null;
|
||||
state: 'untested' | 'tested' | 'test-failed';
|
||||
uploaded_at: string;
|
||||
}
|
||||
interface ServerRow { name: string; game: string | null; map: string | null }
|
||||
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]> }
|
||||
|
||||
let script = $state<Script | null>(null);
|
||||
let versions = $state<Version[]>([]);
|
||||
@@ -37,8 +39,16 @@
|
||||
|
||||
// Scope form.
|
||||
let scopeType = $state<'none' | 'game' | 'map'>('none');
|
||||
let scopeValue = $state('');
|
||||
let scopeGame = $state('');
|
||||
let scopeMap = $state('');
|
||||
let updatingScope = $state(false);
|
||||
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
|
||||
|
||||
const editedScopeValue = $derived.by(() => {
|
||||
if (scopeType === 'game') return scopeGame;
|
||||
if (scopeType === 'map') return scopeMap;
|
||||
return '';
|
||||
});
|
||||
|
||||
// Open-beta form.
|
||||
let betaVersionId = $state<number | null>(null);
|
||||
@@ -50,9 +60,10 @@
|
||||
let id = $derived(Number.parseInt($page.params.id ?? '0', 10));
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const [detailRes, serversRes] = await Promise.all([
|
||||
const [detailRes, serversRes, scopeRes] = await Promise.all([
|
||||
fetch(`/api/scripts/${id}`),
|
||||
fetch('/api/servers')
|
||||
fetch('/api/servers'),
|
||||
fetch('/api/scope-options')
|
||||
]);
|
||||
if (!detailRes.ok) { loading = false; return; }
|
||||
const body = (await detailRes.json()) as {
|
||||
@@ -66,12 +77,30 @@
|
||||
matchedServers = body.matchedServers;
|
||||
runningBetaId = body.runningBetaId;
|
||||
scopeType = script.scope_type ?? 'none';
|
||||
scopeValue = script.scope_value ?? '';
|
||||
// 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;
|
||||
|
||||
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.
|
||||
if (scopeType === 'map' && scopeMap) {
|
||||
const owner = servers.find((sv) => sv.map === scopeMap);
|
||||
if (owner?.game) scopeGame = owner.game;
|
||||
}
|
||||
}
|
||||
if (scopeRes.ok) {
|
||||
scopeOptions = (await scopeRes.json()) as ScopeOptions;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
@@ -110,6 +139,10 @@
|
||||
|
||||
async function saveScope(): Promise<void> {
|
||||
if (updatingScope) return;
|
||||
if (scopeType !== 'none' && !editedScopeValue) {
|
||||
flash = `pick a ${scopeType}`;
|
||||
return;
|
||||
}
|
||||
updatingScope = true;
|
||||
flash = null;
|
||||
try {
|
||||
@@ -118,7 +151,7 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scopeType: scopeType === 'none' ? null : scopeType,
|
||||
scopeValue: scopeType === 'none' ? null : scopeValue.trim()
|
||||
scopeValue: scopeType === 'none' ? null : editedScopeValue
|
||||
})
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean; error?: string; kind?: string };
|
||||
@@ -218,8 +251,26 @@
|
||||
<option value="game">game</option>
|
||||
<option value="map">map</option>
|
||||
</select>
|
||||
{#if scopeType !== 'none'}
|
||||
<input type="text" bind:value={scopeValue} placeholder={scopeType === 'game' ? 't5' : 'zombie_theater'} disabled={updatingScope} />
|
||||
{#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>
|
||||
@@ -243,6 +294,15 @@
|
||||
</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}
|
||||
@@ -297,7 +357,15 @@
|
||||
<span class="v-size mono">{v.size}B</span>
|
||||
<span class="v-notes">{v.notes ?? ''}</span>
|
||||
<span class="v-when mono">{v.uploaded_at}</span>
|
||||
{#if script.current_version_id === v.id}<span class="tag cur">current</span>{/if}
|
||||
{#if script.current_version_id === v.id}
|
||||
<span class="tag cur">promoted</span>
|
||||
{:else if v.state === 'tested'}
|
||||
<span class="tag state-tested">tested</span>
|
||||
{:else if v.state === 'test-failed'}
|
||||
<span class="tag state-test-failed">test-failed</span>
|
||||
{:else}
|
||||
<span class="tag state-untested">untested</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -326,7 +394,7 @@
|
||||
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, .scope-form input, .version-form input, .beta-form select {
|
||||
.scope-form select, .version-form input, .beta-form select {
|
||||
background: var(--bg-page);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
@@ -374,8 +442,15 @@
|
||||
.v-num { color: var(--cream); font-weight: 600; }
|
||||
.v-sha, .v-size, .v-when { color: var(--ink-500); }
|
||||
.v-notes { color: var(--ink-300); font-family: var(--font-body); font-size: var(--text-sm); }
|
||||
.tag { padding: 2px 8px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
.tag.cur { background: rgba(124, 212, 255, 0.15); border: 1px solid rgba(124, 212, 255, 0.4); color: var(--spark-500); }
|
||||
.tag { padding: 2px 8px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); border: 1px solid transparent; }
|
||||
.tag.cur { background: rgba(124, 212, 255, 0.15); border-color: rgba(124, 212, 255, 0.4); color: var(--spark-500); }
|
||||
.tag.state-untested { background: var(--bg-nav); border-color: var(--border); color: var(--ink-300); }
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user