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