Initial commit — SvelteKit fleet manager for T5 Plutonium servers

Ports push_scripts.sh (1497 lines of bash) to a SvelteKit + SQLite web app
that manages a fleet of T5 Plutonium servers running on Komodo. Same push
and beta workflow as the shell script, with a live SSE pipeline view, a
!rt-token-gated auth modal that hard-pauses deployments on stale tokens,
per-file audit diffs, and a Komodo-container deployment target.

- Push/beta engines port the exact filter, held-by-beta, conflict, and
  stale-backup rules from the shell script
- SQLite (better-sqlite3) replaces script_metadata.json; a one-shot
  importer moves legacy state over on first boot
- 16 vitest tests cover push add/update/skip, beta open→rollback,
  open→promote, conflict guards, sweep frozen/manual branches, and IW4M
  login/status parsing
- docker/ + komodo/stack.toml deploy as a managed container on the same
  host as the game servers, /root/zombies bind-mounted at the same path
  the shell script used
This commit is contained in:
2026-09-12 00:23:51 -04:00
commit d99711563e
67 changed files with 9415 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
<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 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: #141414;
border: 1px solid #333;
border-radius: 6px;
padding: 16px;
}
h3 {
margin: 0 0 8px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #aaa;
}
h3:not(:first-of-type) { margin-top: 20px; }
.empty { color: #666; font-size: 13px; margin: 0; }
.staged { list-style: none; padding: 0; margin: 0 0 12px; }
.staged li {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px solid #222;
}
.staged code { color: #ddd; font-family: ui-monospace, monospace; font-size: 13px; }
.tag {
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
}
.tag.new { background: #2d4a2d; color: #a8e6a8; }
.tag.replaces { background: #2d3a4a; color: #a8c8e6; }
.tag.ambig { background: #4a2d2d; color: #ff8080; }
form {
display: flex;
gap: 6px;
margin-top: 8px;
}
form input {
flex: 1;
background: #000;
color: #ddd;
border: 1px solid #444;
border-radius: 3px;
padding: 6px 8px;
font-family: ui-monospace, monospace;
font-size: 12px;
}
button {
background: #ff3030;
color: #fff;
border: 0;
border-radius: 3px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: default; }
button.danger { background: #7a2020; }
.beta {
padding: 8px 10px;
margin: 6px 0;
background: #1a1a1a;
border-radius: 4px;
border: 1px solid #2a2a2a;
}
.head {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.rev { color: #666; font-size: 11px; }
.row { font-size: 12px; color: #bbb; margin: 2px 0; }
.k { color: #666; margin-right: 6px; font-family: ui-monospace, monospace; }
.actions { display: flex; gap: 4px; margin-top: 8px; }
.flash { color: #ff6060; font-size: 12px; margin: 12px 0 0; }
</style>
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
interface Props {
before: string | null;
after: string | null;
verb: string;
relPath: string;
}
let { before, after, verb, relPath }: Props = $props();
// A tiny line-by-line diff. Not LCS — just presence-based. For GSC scripts
// this reads fine; if we want proper Myers diff later, swap in `diff` npm
// package and render the same rows.
interface Row {
kind: 'ctx' | 'add' | 'del';
text: string;
}
let rows = $derived(computeRows(before, after));
function computeRows(a: string | null, b: string | null): Row[] {
const alines = (a ?? '').split(/\r?\n/);
const blines = (b ?? '').split(/\r?\n/);
const asetBase: Row[] = alines.map((t) => ({ kind: 'del' as const, text: t }));
const bsetBase: Row[] = blines.map((t) => ({ kind: 'add' as const, text: t }));
if (a === null) return bsetBase;
if (b === null) return asetBase;
// interleave shared-prefix / shared-suffix trimming for readability
let i = 0;
while (i < alines.length && i < blines.length && alines[i] === blines[i]) i++;
let j = 0;
while (
j < alines.length - i &&
j < blines.length - i &&
alines[alines.length - 1 - j] === blines[blines.length - 1 - j]
)
j++;
const prefix = alines.slice(0, i).map((t) => ({ kind: 'ctx' as const, text: t }));
const suffix = alines.slice(alines.length - j).map((t) => ({ kind: 'ctx' as const, text: t }));
const midA = alines.slice(i, alines.length - j).map((t) => ({ kind: 'del' as const, text: t }));
const midB = blines.slice(i, blines.length - j).map((t) => ({ kind: 'add' as const, text: t }));
return [...prefix, ...midA, ...midB, ...suffix];
}
</script>
<div class="diff">
<header>
<span class="verb verb-{verb}">{verb}</span>
<span class="path">{relPath}</span>
</header>
<pre>
{#each rows as r, idx (idx)}<span
class="ln ln-{r.kind}">{r.kind === 'add' ? '+' : r.kind === 'del' ? '-' : ' '} {r.text}
</span>{/each}</pre>
</div>
<style>
.diff {
background: #0f0f0f;
border: 1px solid #333;
border-radius: 4px;
margin: 8px 0;
overflow: hidden;
}
header {
display: flex;
gap: 8px;
padding: 6px 10px;
background: #1a1a1a;
border-bottom: 1px solid #333;
font-size: 12px;
}
.verb {
padding: 1px 6px;
border-radius: 3px;
font-family: ui-monospace, monospace;
}
.verb-added { background: #2d4a2d; color: #a8e6a8; }
.verb-updated { background: #2d3a4a; color: #a8c8e6; }
.verb-removed { background: #4a2d2d; color: #ff8080; }
.verb-held { background: #3a2f00; color: #ffcc55; }
.path { color: #ccc; font-family: ui-monospace, monospace; }
pre {
margin: 0;
padding: 8px 10px;
font-family: ui-monospace, monospace;
font-size: 12px;
line-height: 1.5;
color: #ccc;
max-height: 500px;
overflow: auto;
}
.ln { display: block; }
.ln-add { background: rgba(45, 74, 45, 0.4); color: #a8e6a8; }
.ln-del { background: rgba(74, 45, 45, 0.4); color: #ff8080; }
</style>
@@ -0,0 +1,108 @@
<script lang="ts">
import { deployments, frozen, frozenReason } from '$ui/stores/pipeline.js';
// Newest first, up to 8 rows on the dashboard.
let rows = $derived(
Array.from($deployments.values())
.sort((a, b) => b.openedAt - a.openedAt)
.slice(0, 8)
);
function stateColor(state: string): string {
switch (state) {
case 'done': return '#2d4a2d';
case 'failed': return '#4a2d2d';
case 'pending': return '#3a2f00';
case 'manual': return '#333';
default: return '#222';
}
}
function stateFg(state: string): string {
switch (state) {
case 'done': return '#a8e6a8';
case 'failed': return '#ff8080';
case 'pending': return '#ffcc55';
case 'manual': return '#aaa';
default: return '#888';
}
}
</script>
<div class="wrapper">
<header>
<h3>Pipeline</h3>
{#if $frozen}
<span class="frozen">frozen — {$frozenReason ?? 'no reason'}</span>
{/if}
</header>
{#if rows.length === 0}
<p class="empty">no active deployments</p>
{:else}
<div class="rows">
{#each rows as dep (dep.id)}
<a class="row" href="/deployments/{dep.id}">
<div class="row-head">
<span class="action">{dep.action || dep.id}</span>
<span class="count">{dep.servers.size} server{dep.servers.size === 1 ? '' : 's'}</span>
</div>
<div class="chips">
{#each [...dep.servers.entries()] as [srv, meta] (srv)}
<span
class="chip"
style="background:{stateColor(meta.state)};color:{stateFg(meta.state)}"
title={meta.error ?? (meta.players !== null ? `${meta.players}p` : meta.state)}
>
{srv}{meta.players !== null && meta.players > 0 ? ` ${meta.players}p` : ''}
</span>
{/each}
</div>
</a>
{/each}
</div>
{/if}
</div>
<style>
.wrapper {
background: #141414;
border: 1px solid #333;
border-radius: 6px;
padding: 12px;
}
header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
}
h3 { margin: 0; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: #aaa; }
.frozen { color: #ff6060; font-size: 12px; }
.empty { color: #666; font-size: 13px; margin: 0; }
.rows { display: grid; gap: 8px; }
.row {
display: block;
color: #ddd;
text-decoration: none;
padding: 8px;
background: #1a1a1a;
border-radius: 4px;
border: 1px solid transparent;
}
.row:hover { border-color: #444; }
.row-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 6px;
}
.action { font-family: ui-monospace, monospace; font-size: 13px; }
.count { color: #666; font-size: 12px; }
.chips { display: flex; flex-wrap: wrap; gap: 4px; }
.chip {
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-family: ui-monospace, monospace;
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
interface Held {
target: string;
slug: string;
}
interface Props {
name: string;
players?: number | null;
held?: Held[];
lastRestartedAt?: string | null;
restarting?: boolean;
}
let { name, players = null, held = [], lastRestartedAt = null, restarting = false }: Props = $props();
function ago(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 60_000) return 'just now';
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d ago`;
}
</script>
<a class="card" href="/servers/{name}" class:restarting>
<div class="head">
<strong>{name}</strong>
<span class="players" class:has-players={players !== null && players > 0}>
{players ?? '—'}
</span>
</div>
<div class="meta">
restart: {ago(lastRestartedAt)}
</div>
{#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>
{/each}
</div>
{/if}
{#if restarting}
<div class="restarting-bar" aria-label="restarting"></div>
{/if}
</a>
<style>
.card {
display: block;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 12px;
color: #ddd;
text-decoration: none;
transition: border-color 0.15s;
}
.card:hover { border-color: #555; }
.card.restarting { border-color: #ffab00; }
.head {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.players {
background: #222;
padding: 2px 8px;
border-radius: 999px;
font-family: ui-monospace, monospace;
font-size: 12px;
color: #888;
}
.players.has-players { background: #2d4a2d; color: #a8e6a8; }
.meta { color: #666; font-size: 12px; margin-top: 4px; }
.held { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
.chip {
background: #3a2f00;
color: #ffcc55;
border: 1px solid #665500;
border-radius: 3px;
padding: 1px 6px;
font-size: 11px;
font-family: ui-monospace, monospace;
}
.restarting-bar {
height: 2px;
background: linear-gradient(90deg, transparent, #ffab00, transparent);
margin-top: 8px;
animation: slide 1.2s infinite linear;
}
@keyframes slide {
from { background-position: -100% 0; }
to { background-position: 200% 0; }
}
</style>
+161
View File
@@ -0,0 +1,161 @@
<script lang="ts">
import { authState, msRemaining, shouldBlock, submitToken } from '$ui/stores/auth.js';
let value = $state('');
let submitting = $state(false);
let error = $state<string | null>(null);
async function onSubmit(e: Event) {
e.preventDefault();
if (submitting) return;
submitting = true;
error = null;
const res = await submitToken(value);
submitting = false;
if (!res.ok) {
error = res.error;
return;
}
value = '';
}
// Reactively re-open the modal when we cross the warning threshold. The
// server pushes msRemaining every 5s; that's enough for the UI to notice
// "about to expire" and coax the user in-game before things freeze.
let warnOpen = $state(false);
$effect(() => {
if ($authState.kind === 'ready' && $msRemaining < 20_000 && $msRemaining > 0) {
warnOpen = true;
} else {
warnOpen = false;
}
});
let showModal = $derived($shouldBlock || warnOpen);
</script>
{#if showModal}
<div class="scrim" role="dialog" aria-modal="true" aria-labelledby="tm-title">
<div class="modal">
<h2 id="tm-title">
{#if $shouldBlock}
Deployments paused
{:else}
Token expiring
{/if}
</h2>
<p class="explain">
{#if $authState.kind === 'missing'}
Run <code>!rt</code> in-game to generate a 4-character token, then paste it here.
{:else if $authState.kind === 'stale'}
The last token was {$authState.reason}. Generate a new one with <code>!rt</code> in-game.
{:else}
Your token expires in {Math.ceil($msRemaining / 1000)}s. Paste a new one to keep deployments flowing.
{/if}
</p>
<form onsubmit={onSubmit}>
<input
type="text"
inputmode="text"
autocomplete="off"
autocorrect="off"
spellcheck="false"
maxlength="4"
minlength="4"
pattern="[a-zA-Z0-9]{'{'}4{'}'}"
placeholder="abcd"
bind:value
disabled={submitting}
aria-label="4-character !rt token"
/>
<button type="submit" disabled={submitting || value.length !== 4}>
{submitting ? '…' : 'unfreeze'}
</button>
</form>
{#if error}
<p class="error">{error}</p>
{/if}
<p class="hint">
The <code>!rt</code> token is single-use and dies after 2 minutes. That's normal.
</p>
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: grid;
place-items: center;
z-index: 9999;
backdrop-filter: blur(2px);
}
.modal {
background: #111;
color: #eee;
border: 1px solid #444;
border-radius: 8px;
padding: 24px;
min-width: 360px;
max-width: 460px;
font-family: system-ui, -apple-system, sans-serif;
}
h2 {
margin: 0 0 8px;
font-size: 18px;
color: #ff6060;
}
.explain {
margin: 0 0 16px;
font-size: 14px;
color: #ccc;
line-height: 1.4;
}
form {
display: flex;
gap: 8px;
}
input {
flex: 1;
background: #000;
color: #eee;
border: 1px solid #666;
border-radius: 4px;
padding: 10px 12px;
font-size: 20px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
letter-spacing: 8px;
text-align: center;
text-transform: lowercase;
}
button {
background: #ff3030;
color: #fff;
border: 0;
border-radius: 4px;
padding: 0 16px;
font-weight: 600;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.error {
color: #ff6060;
margin: 12px 0 0;
font-size: 13px;
}
.hint {
margin: 16px 0 0;
font-size: 12px;
color: #888;
}
code {
background: #222;
padding: 1px 5px;
border-radius: 3px;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
export type AuthState =
| { kind: 'ready'; token: string; expiresAt: number }
| { kind: 'stale'; reason: 'expired' | 'rejected' | 'unreachable' }
| { kind: 'missing' };
interface AuthPayload {
type: 'auth-state';
state: AuthState;
msRemaining?: number;
}
export const authState = writable<AuthState>({ kind: 'missing' });
export const msRemaining = writable<number>(0);
export const isReady = derived(authState, ($s) => $s.kind === 'ready');
export const shouldBlock = derived(authState, ($s) => $s.kind !== 'ready');
let source: EventSource | null = null;
export function startAuthStream(): void {
if (!browser || source) return;
source = new EventSource('/api/events/auth');
source.addEventListener('auth-state', (evt) => {
try {
const payload = JSON.parse((evt as MessageEvent).data) as AuthPayload;
authState.set(payload.state);
if (payload.msRemaining !== undefined) msRemaining.set(payload.msRemaining);
} catch {
/* ignore malformed */
}
});
source.addEventListener('error', () => {
// Browser will auto-reconnect. If the stream stays down long enough,
// the token will expire on the server anyway and the next reconnect
// will surface the transition.
});
}
export async function submitToken(token: string): Promise<{ ok: true } | { ok: false; error: string }> {
const res = await fetch('/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
const body = (await res.json()) as { ok: boolean; error?: string };
if (!res.ok || !body.ok) return { ok: false, error: body.error ?? `HTTP ${res.status}` };
return { ok: true };
}
+60
View File
@@ -0,0 +1,60 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export type ServerState = 'pending' | 'done' | 'failed' | 'held' | 'manual';
export interface PipelineDeployment {
id: string;
action: string;
servers: Map<string, { state: ServerState; players: number | null; error: string | null }>;
openedAt: number;
}
export const deployments = writable<Map<string, PipelineDeployment>>(new Map());
export const frozen = writable<boolean>(false);
export const frozenReason = writable<string | null>(null);
let source: EventSource | null = null;
function updateDeployment(id: string, mutate: (d: PipelineDeployment) => void): void {
deployments.update((m) => {
const existing = m.get(id) ?? { id, action: '', servers: new Map(), openedAt: Date.now() };
mutate(existing);
m.set(id, existing);
return new Map(m);
});
}
export function startPipelineStream(): void {
if (!browser || source) return;
source = new EventSource('/api/events/pipeline');
source.addEventListener('deployment-opened', (evt) => {
const data = JSON.parse((evt as MessageEvent).data) as { deploymentId: string; action: string; servers: string[] };
updateDeployment(data.deploymentId, (d) => {
d.action = data.action;
for (const s of data.servers) {
if (!d.servers.has(s)) d.servers.set(s, { state: 'pending', players: null, error: null });
}
});
});
source.addEventListener('server-state', (evt) => {
const data = JSON.parse((evt as MessageEvent).data) as {
deploymentId: string;
server: string;
state: ServerState;
players: number | null;
error: string | null;
};
updateDeployment(data.deploymentId, (d) => {
d.servers.set(data.server, { state: data.state, players: data.players, error: data.error });
});
});
source.addEventListener('sweep-tick', (evt) => {
const data = JSON.parse((evt as MessageEvent).data) as { frozen: boolean; reason?: string };
frozen.set(data.frozen);
frozenReason.set(data.reason ?? null);
});
}
+25
View File
@@ -0,0 +1,25 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export interface ServerStatus {
server: string;
players: number | null;
name?: string;
}
export const statusByServer = writable<Map<string, ServerStatus>>(new Map());
let source: EventSource | null = null;
export function startServerStream(): void {
if (!browser || source) return;
source = new EventSource('/api/events/servers');
source.addEventListener('server-status', (evt) => {
const data = JSON.parse((evt as MessageEvent).data) as { entries: ServerStatus[] };
statusByServer.update(() => {
const m = new Map<string, ServerStatus>();
for (const e of data.entries) m.set(e.server, e);
return m;
});
});
}