Rework as scripts-centric, upload-only, gated beta lifecycle
Scripts become first-class: named, versioned, uploaded via the UI, optionally scoped to a game or map. Any change that would land on scope-matched servers (marking-required or new version of a scoped script) must first pass a beta. The beta runs a per-server state machine — deploy -beta.gsc → wait empty → Komodo restart → wait healthy → optionally wait for a real match end (games_sp.log 'ExitLevel: executed') — before it's eligible for promotion. Rollback is always available and restores from .OLD. The shared/scripts mirror and its betas/ staging folder are gone. Every server now has a tracked script inventory (server_scripts table), populated only by explicit upload flows. New: /scripts, /scripts/[id], /betas/[id] UI; /api/scripts + /api/betas + /api/servers/[name]/scripts endpoints; scripts.ts, inventory.ts, beta.ts (rewritten), beta-runner.ts, match-log.ts, komodoInspectHealthy(). Removed: push.ts, /api/push, BetaPanel, legacy importer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,271 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface StagedHint {
|
||||
base: string;
|
||||
hits: string[];
|
||||
}
|
||||
interface BetaSummary {
|
||||
slug: string;
|
||||
name: string;
|
||||
opened_at: string;
|
||||
updated_at: string;
|
||||
revision: number;
|
||||
servers: string[];
|
||||
scripts: Array<{ staged_as: string; target: string; revision: number }>;
|
||||
}
|
||||
interface Props {
|
||||
staged: StagedHint[];
|
||||
betas: BetaSummary[];
|
||||
}
|
||||
let { staged, betas }: Props = $props();
|
||||
|
||||
let newBetaName = $state('');
|
||||
let newBetaPatterns = $state('*');
|
||||
let submitting = $state(false);
|
||||
let flash = $state<string | null>(null);
|
||||
|
||||
async function openNewBeta() {
|
||||
if (!newBetaName.trim() || submitting) return;
|
||||
submitting = true;
|
||||
flash = null;
|
||||
const res = await fetch('/api/betas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newBetaName.trim(),
|
||||
patterns: newBetaPatterns.split(',').map((p) => p.trim()).filter(Boolean),
|
||||
create: false
|
||||
})
|
||||
});
|
||||
submitting = false;
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
newBetaName = '';
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function act(slug: string, action: 'rollback' | 'promote' | 'update') {
|
||||
const path =
|
||||
action === 'update' ? '/api/betas' : `/api/betas/${slug}/${action}`;
|
||||
const method = action === 'update' ? 'PATCH' : 'POST';
|
||||
const body = action === 'update' ? JSON.stringify({ name: slug }) : '{}';
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
flash = err.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h3>Staged</h3>
|
||||
{#if staged.length === 0}
|
||||
<p class="empty">nothing staged</p>
|
||||
{:else}
|
||||
<ul class="staged">
|
||||
{#each staged as s (s.base)}
|
||||
<li>
|
||||
<code>{s.base}</code>
|
||||
{#if s.hits.length === 0}
|
||||
<span class="tag new">new</span>
|
||||
{:else if s.hits.length === 1}
|
||||
<span class="tag replaces">replaces {s.hits[0]}</span>
|
||||
{:else}
|
||||
<span class="tag ambig">ambiguous ({s.hits.length} hits)</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); openNewBeta(); }}>
|
||||
<input type="text" placeholder="Beta name" bind:value={newBetaName} disabled={submitting} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Server pattern (*, t5_kino_*, comma-sep)"
|
||||
bind:value={newBetaPatterns}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<button type="submit" disabled={submitting || !newBetaName}>open beta</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<h3>Open betas</h3>
|
||||
{#if betas.length === 0}
|
||||
<p class="empty">no open betas</p>
|
||||
{:else}
|
||||
{#each betas as b (b.slug)}
|
||||
<div class="beta">
|
||||
<div class="head">
|
||||
<strong>{b.name}</strong>
|
||||
<span class="rev">rev {b.revision}</span>
|
||||
</div>
|
||||
<div class="row"><span class="k">scripts</span> {b.scripts.map((s) => s.target).join(', ')}</div>
|
||||
<div class="row"><span class="k">servers</span> {b.servers.join(', ')}</div>
|
||||
<div class="actions">
|
||||
<button onclick={() => act(b.slug, 'update')}>update</button>
|
||||
<button class="primary" onclick={() => act(b.slug, 'promote')}>promote</button>
|
||||
<button class="danger" onclick={() => act(b.slug, 'rollback')}>rollback</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if flash}
|
||||
<p class="flash">{flash}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
background: var(--bg-nav);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
padding: var(--pad-panel);
|
||||
}
|
||||
h3 {
|
||||
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);
|
||||
}
|
||||
h3:not(:first-of-type) { margin-top: var(--gap-section); }
|
||||
.empty {
|
||||
color: var(--ink-500);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
margin: 0;
|
||||
}
|
||||
.staged { list-style: none; padding: 0; margin: 0 0 18px; }
|
||||
.staged li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.staged code {
|
||||
color: var(--cream);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
}
|
||||
.tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.tag.new {
|
||||
background: rgba(124, 212, 255, 0.10);
|
||||
border-color: rgba(124, 212, 255, 0.3);
|
||||
color: var(--spark-500);
|
||||
}
|
||||
.tag.replaces {
|
||||
background: rgba(124, 192, 245, 0.10);
|
||||
border-color: rgba(124, 192, 245, 0.28);
|
||||
color: var(--cream);
|
||||
}
|
||||
.tag.ambig {
|
||||
background: rgba(255, 48, 80, 0.1);
|
||||
border-color: rgba(255, 48, 80, 0.3);
|
||||
color: var(--danger-500);
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
form input {
|
||||
flex: 1;
|
||||
background: var(--bg-page);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
}
|
||||
form input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--accent-glow);
|
||||
}
|
||||
button {
|
||||
background: var(--bg-card);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 16px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--accent-glow);
|
||||
}
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
button.primary {
|
||||
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
|
||||
border-color: var(--blue-400);
|
||||
color: var(--cream);
|
||||
font-weight: 600;
|
||||
}
|
||||
button.danger {
|
||||
background: var(--bg-nav);
|
||||
border-color: rgba(255, 48, 80, 0.4);
|
||||
color: var(--danger-500);
|
||||
}
|
||||
.beta {
|
||||
padding: 16px 18px;
|
||||
margin: 10px 0;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.head strong { color: var(--cream); font-weight: 600; font-size: var(--text-md); }
|
||||
.rev {
|
||||
color: var(--ink-500);
|
||||
font-size: var(--text-mono-sm);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.row {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-300);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.k {
|
||||
color: var(--ink-500);
|
||||
margin-right: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
.actions { display: flex; gap: 8px; margin-top: 14px; }
|
||||
.flash {
|
||||
color: var(--danger-500);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
margin: 18px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
interface Held {
|
||||
target: string;
|
||||
slug: string;
|
||||
betaId: number;
|
||||
}
|
||||
interface Props {
|
||||
name: string;
|
||||
@@ -96,7 +96,7 @@
|
||||
{#if held.length > 0}
|
||||
<div class="held">
|
||||
{#each held as h (h.target)}
|
||||
<span class="chip" title="held by beta: {h.slug}">β {h.target}</span>
|
||||
<span class="chip" title="held by beta {h.betaId}">β {h.target}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user