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:
@@ -24,6 +24,7 @@
|
||||
</a>
|
||||
<div class="tabs">
|
||||
<a href="/" class:on={$page.url.pathname === '/'}>Fleet</a>
|
||||
<a href="/scripts" class:on={$page.url.pathname.startsWith('/scripts')}>Scripts</a>
|
||||
<a href="/deployments" class:on={$page.url.pathname.startsWith('/deployments')}>Deployments</a>
|
||||
<a href="/betas" class:on={$page.url.pathname.startsWith('/betas')}>Betas</a>
|
||||
</div>
|
||||
|
||||
+9
-62
@@ -5,7 +5,7 @@
|
||||
import { statusByServer } from '$ui/stores/servers.js';
|
||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||
|
||||
interface HeldRow { target: string; slug: string; }
|
||||
interface HeldRow { target: string; betaId: number; }
|
||||
interface ServerRow {
|
||||
name: string;
|
||||
komodo_type: string;
|
||||
@@ -24,8 +24,6 @@
|
||||
}
|
||||
|
||||
let servers = $state<ServerRow[]>([]);
|
||||
let pushing = $state(false);
|
||||
let flash = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const res = await fetch('/api/servers');
|
||||
@@ -33,25 +31,6 @@
|
||||
servers = body.servers;
|
||||
});
|
||||
|
||||
async function push(dryRun: boolean) {
|
||||
pushing = true;
|
||||
flash = null;
|
||||
const res = await fetch('/api/push', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ patterns: ['*'], dryRun, create: false, delete: false })
|
||||
});
|
||||
pushing = false;
|
||||
const body = (await res.json()) as { totalChangedServers?: number; totalFiles?: number; deploymentId?: string | null };
|
||||
if (!res.ok) {
|
||||
flash = `push failed: HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
flash = dryRun
|
||||
? `dry run: ${body.totalChangedServers ?? 0} servers would change, ${body.totalFiles ?? 0} files`
|
||||
: `pushed to ${body.totalChangedServers ?? 0} servers · ${body.totalFiles ?? 0} files`;
|
||||
}
|
||||
|
||||
// Live server view — merge SSE data over the initial GET
|
||||
function liveFor(name: string, fallback: ServerRow) {
|
||||
const live = $statusByServer.get(name);
|
||||
@@ -85,12 +64,8 @@
|
||||
<h1>Fleet</h1>
|
||||
<span class="count">{servers.length} server{servers.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick={() => push(true)} disabled={pushing}>dry run</button>
|
||||
<button class="primary" onclick={() => push(false)} disabled={pushing}>push all</button>
|
||||
</div>
|
||||
<a class="scripts-link" href="/scripts">manage scripts →</a>
|
||||
</header>
|
||||
{#if flash}<p class="flash">{flash}</p>{/if}
|
||||
{#if servers.length === 0}
|
||||
<p class="empty">no servers registered yet — check SERVERS_ROOT</p>
|
||||
{:else}
|
||||
@@ -150,35 +125,17 @@
|
||||
color: var(--ink-500);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.actions { display: flex; gap: 10px; }
|
||||
button {
|
||||
background: var(--bg-card);
|
||||
color: var(--ink-100);
|
||||
.scripts-link {
|
||||
color: var(--spark-400);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.3px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--accent-glow);
|
||||
}
|
||||
button.primary {
|
||||
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
|
||||
color: var(--cream);
|
||||
border-color: var(--blue-400);
|
||||
font-weight: 600;
|
||||
}
|
||||
button.primary:hover:not(:disabled) {
|
||||
border-color: var(--spark-500);
|
||||
box-shadow: var(--spark-glow), 0 0 20px rgba(46, 134, 209, 0.45);
|
||||
}
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
.scripts-link:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: var(--gap-cards);
|
||||
@@ -193,14 +150,4 @@
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.flash {
|
||||
color: var(--spark-400);
|
||||
margin: 0 0 20px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
padding: 12px 16px;
|
||||
background: rgba(124, 212, 255, 0.06);
|
||||
border: 1px solid rgba(124, 212, 255, 0.25);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,55 +1,73 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { openBeta, updateBeta, listBetas, listBetaScripts, listBetaServers, BetaError } from '$lib/server/fleet/beta.js';
|
||||
import { stagedFiles, findTargets } from '$lib/server/fleet/files.js';
|
||||
import {
|
||||
listBetas,
|
||||
listBetaServers,
|
||||
openBeta,
|
||||
BetaError,
|
||||
type BetaTriggerKind
|
||||
} from '$lib/server/fleet/beta.js';
|
||||
import { getScript, getVersion } from '$lib/server/fleet/scripts.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export async function GET() {
|
||||
const staged = await stagedFiles();
|
||||
const withHints = await Promise.all(
|
||||
staged.map(async (base) => ({
|
||||
base,
|
||||
hits: await findTargets(base)
|
||||
}))
|
||||
);
|
||||
const betas = listBetas().map((b) => ({
|
||||
...b,
|
||||
servers: listBetaServers(b.slug),
|
||||
scripts: listBetaScripts(b.slug)
|
||||
}));
|
||||
return json({ staged: withHints, betas });
|
||||
export async function GET({ url }: { url: URL }) {
|
||||
const stateFilter = url.searchParams.get('state') as
|
||||
| 'running'
|
||||
| 'ready-promote'
|
||||
| 'promoted'
|
||||
| 'rolled-back'
|
||||
| null;
|
||||
const rows = listBetas(stateFilter ?? undefined).map((b) => {
|
||||
const script = getScript(b.script_id);
|
||||
const version = getVersion(b.script_version_id);
|
||||
return {
|
||||
...b,
|
||||
script: script ? { id: script.id, name: script.name, target_rel_path: script.target_rel_path } : null,
|
||||
version: version ? { id: version.id, version: version.version, sha256: version.sha256 } : null,
|
||||
servers: listBetaServers(b.id)
|
||||
};
|
||||
});
|
||||
return json({ betas: rows });
|
||||
}
|
||||
|
||||
// POST body: { scriptId, scriptVersionId, servers[], waitForHealth, waitForMatch, triggerKind, notes? }
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
name?: string;
|
||||
patterns?: string[];
|
||||
dryRun?: boolean;
|
||||
create?: boolean;
|
||||
scriptId?: number;
|
||||
scriptVersionId?: number;
|
||||
servers?: string[];
|
||||
waitForHealth?: boolean;
|
||||
waitForMatch?: boolean;
|
||||
triggerKind?: BetaTriggerKind;
|
||||
notes?: string;
|
||||
};
|
||||
if (!body.name) return json({ ok: false, error: 'name is required' }, { status: 400 });
|
||||
try {
|
||||
const res = await openBeta({
|
||||
name: body.name,
|
||||
patterns: body.patterns ?? ['*'],
|
||||
dryRun: body.dryRun ?? false,
|
||||
create: body.create ?? false
|
||||
});
|
||||
if (!res.dryRun) void kick();
|
||||
return json({ ok: true, result: res });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH({ request }: { request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as { name?: string; dryRun?: boolean };
|
||||
if (!body.name) return json({ ok: false, error: 'name is required' }, { status: 400 });
|
||||
if (
|
||||
typeof body.scriptId !== 'number' ||
|
||||
typeof body.scriptVersionId !== 'number' ||
|
||||
!Array.isArray(body.servers)
|
||||
) {
|
||||
return json(
|
||||
{ ok: false, error: 'scriptId, scriptVersionId, and servers[] are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (body.servers.length === 0) {
|
||||
return json({ ok: false, error: 'at least one server is required' }, { status: 400 });
|
||||
}
|
||||
const triggerKind: BetaTriggerKind = body.triggerKind ?? 'unscoped';
|
||||
|
||||
try {
|
||||
const res = await updateBeta(body.name, body.dryRun ?? false);
|
||||
return json({ ok: true, result: res });
|
||||
const result = await openBeta({
|
||||
scriptId: body.scriptId,
|
||||
scriptVersionId: body.scriptVersionId,
|
||||
servers: body.servers,
|
||||
waitForHealth: body.waitForHealth ?? true,
|
||||
waitForMatch: body.waitForMatch ?? false,
|
||||
triggerKind,
|
||||
notes: body.notes
|
||||
});
|
||||
void kick();
|
||||
return json({ ok: true, result });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getBeta, listBetaServers } from '$lib/server/fleet/beta.js';
|
||||
import { getScript, getVersion } from '$lib/server/fleet/scripts.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export async function GET({ params }: { params: { id: string } }) {
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
const beta = getBeta(id);
|
||||
if (!beta) return json({ ok: false, error: 'not found' }, { status: 404 });
|
||||
|
||||
const script = getScript(beta.script_id);
|
||||
const version = getVersion(beta.script_version_id);
|
||||
const servers = listBetaServers(id);
|
||||
|
||||
return json({ beta, script, version, servers });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { promoteBeta, BetaError } from '$lib/server/fleet/beta.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export async function POST({ params }: { params: { id: string } }) {
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
try {
|
||||
const result = await promoteBeta(id);
|
||||
return json({ ok: true, result });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { emit, type BetaStage } from '$lib/server/pipeline/bus.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// Reset one failed beta_server row back to its natural retry point. The runner
|
||||
// picks it up on the next tick. Body: { server: string }.
|
||||
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||
const betaId = parseId(params.id);
|
||||
if (betaId === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
const body = (await request.json().catch(() => ({}))) as { server?: string };
|
||||
if (!body.server) return json({ ok: false, error: 'server is required' }, { status: 400 });
|
||||
|
||||
const row = db()
|
||||
.prepare('SELECT stage, restarted_at, healthy_at FROM beta_servers WHERE beta_id = ? AND server = ?')
|
||||
.get(betaId, body.server) as
|
||||
| { stage: string; restarted_at: string | null; healthy_at: string | null }
|
||||
| undefined;
|
||||
if (!row) return json({ ok: false, error: 'no such beta_server' }, { status: 404 });
|
||||
if (row.stage !== 'failed') return json({ ok: false, error: `stage is ${row.stage}, not failed` }, { status: 409 });
|
||||
|
||||
// Best-effort resume point: if we already got past restart, go back to
|
||||
// health check; otherwise back to waiting for the server to be empty.
|
||||
const nextStage: BetaStage = row.restarted_at ? 'awaiting-health' : 'awaiting-empty';
|
||||
const now = new Date().toISOString();
|
||||
db()
|
||||
.prepare('UPDATE beta_servers SET stage = ?, error = NULL, updated_at = ? WHERE beta_id = ? AND server = ?')
|
||||
.run(nextStage, now, betaId, body.server);
|
||||
emit({ type: 'beta-server-stage', betaId, server: body.server, stage: nextStage, error: null });
|
||||
return json({ ok: true, stage: nextStage });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { rollbackBeta, BetaError } from '$lib/server/fleet/beta.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export async function POST({ params }: { params: { id: string } }) {
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
try {
|
||||
const result = await rollbackBeta(id);
|
||||
return json({ ok: true, result });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { promoteBeta, BetaError } from '$lib/server/fleet/beta.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export async function POST({ params, request }: { params: { slug: string }; request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as { dryRun?: boolean };
|
||||
try {
|
||||
const res = await promoteBeta(params.slug, body.dryRun ?? false);
|
||||
if (!res.dryRun) void kick();
|
||||
return json({ ok: true, result: res });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { rollbackBeta, BetaError } from '$lib/server/fleet/beta.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export async function POST({ params, request }: { params: { slug: string }; request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as { dryRun?: boolean };
|
||||
try {
|
||||
// callers pass name; slug in URL is the slugified name — either works
|
||||
const res = await rollbackBeta(params.slug, body.dryRun ?? false);
|
||||
if (!res.dryRun) void kick();
|
||||
return json({ ok: true, result: res });
|
||||
} catch (err) {
|
||||
if (err instanceof BetaError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { doPush } from '$lib/server/fleet/push.js';
|
||||
import { kick } from '$lib/server/pipeline/worker.js';
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
patterns?: string[];
|
||||
dryRun?: boolean;
|
||||
delete?: boolean;
|
||||
create?: boolean;
|
||||
};
|
||||
const result = await doPush({
|
||||
patterns: body.patterns ?? ['*'],
|
||||
dryRun: body.dryRun ?? false,
|
||||
delete: body.delete ?? false,
|
||||
create: body.create ?? false,
|
||||
triggeredBy: 'ui'
|
||||
});
|
||||
if (result.deploymentId) void kick();
|
||||
return json(result);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
createScript,
|
||||
listScripts,
|
||||
listVersions,
|
||||
serversMatchingScope,
|
||||
ScriptError,
|
||||
type ScopeType
|
||||
} from '$lib/server/fleet/scripts.js';
|
||||
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
|
||||
|
||||
function scopeTypeOrNull(v: FormDataEntryValue | null): ScopeType | null {
|
||||
if (v === null) return null;
|
||||
const s = String(v);
|
||||
if (s === '' || s === 'none') return null;
|
||||
if (s === 'game' || s === 'map') return s;
|
||||
throw new ScriptError(`invalid scope_type: ${s}`, 'invalid-scope');
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const scripts = listScripts().map((s) => {
|
||||
const versions = listVersions(s.id);
|
||||
const matched = s.scope_type && s.scope_value
|
||||
? serversMatchingScope(s.scope_type as ScopeType, s.scope_value)
|
||||
: [];
|
||||
const runningBeta = findRunningBetaForScript(s.id);
|
||||
return {
|
||||
...s,
|
||||
latestVersion: versions[0]
|
||||
? {
|
||||
id: versions[0].id,
|
||||
version: versions[0].version,
|
||||
sha256: versions[0].sha256,
|
||||
size: versions[0].size,
|
||||
uploaded_at: versions[0].uploaded_at
|
||||
}
|
||||
: null,
|
||||
versionCount: versions.length,
|
||||
servers: matched,
|
||||
runningBetaId: runningBeta?.id ?? null
|
||||
};
|
||||
});
|
||||
return json({ scripts });
|
||||
}
|
||||
|
||||
// Multipart POST — creates a script + version 1 from a single file upload.
|
||||
// Fields: name (text), targetRelPath (text), scopeType (optional: 'game'|'map'),
|
||||
// scopeValue (optional string), notes (optional), content (file).
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const targetRelPath = String(form.get('targetRelPath') ?? '').trim();
|
||||
const notes = form.get('notes') ? String(form.get('notes')) : undefined;
|
||||
let scopeType: ScopeType | null;
|
||||
try {
|
||||
scopeType = scopeTypeOrNull(form.get('scopeType'));
|
||||
} catch (err) {
|
||||
return json({ ok: false, error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
const scopeValueRaw = form.get('scopeValue');
|
||||
const scopeValue = scopeValueRaw ? String(scopeValueRaw).trim() : null;
|
||||
const file = form.get('content');
|
||||
if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 });
|
||||
|
||||
const content = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
try {
|
||||
const { script, version } = createScript({
|
||||
name,
|
||||
targetRelPath,
|
||||
scopeType,
|
||||
scopeValue,
|
||||
content,
|
||||
notes
|
||||
});
|
||||
return json({ ok: true, script, versionId: version.id });
|
||||
} catch (err) {
|
||||
if (err instanceof ScriptError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
getScript,
|
||||
listVersions,
|
||||
deleteScript,
|
||||
serversMatchingScope,
|
||||
ScriptError,
|
||||
type ScopeType
|
||||
} from '$lib/server/fleet/scripts.js';
|
||||
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
|
||||
|
||||
function parseId(idRaw: string): number | null {
|
||||
const n = Number.parseInt(idRaw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export async function GET({ params }: { params: { id: string } }) {
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
const script = getScript(id);
|
||||
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
|
||||
|
||||
const versions = listVersions(id).map((v) => ({
|
||||
id: v.id,
|
||||
version: v.version,
|
||||
sha256: v.sha256,
|
||||
size: v.size,
|
||||
notes: v.notes,
|
||||
uploaded_at: v.uploaded_at
|
||||
}));
|
||||
const matched = script.scope_type && script.scope_value
|
||||
? serversMatchingScope(script.scope_type as ScopeType, script.scope_value)
|
||||
: [];
|
||||
const runningBeta = findRunningBetaForScript(id);
|
||||
|
||||
return json({
|
||||
script,
|
||||
versions,
|
||||
matchedServers: matched,
|
||||
runningBetaId: runningBeta?.id ?? null
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE({ params }: { params: { id: string } }) {
|
||||
const id = parseId(params.id);
|
||||
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
try {
|
||||
deleteScript(id);
|
||||
return json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ScriptError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getScript, setScope, ScriptError, type ScopeType } from '$lib/server/fleet/scripts.js';
|
||||
import { unsetScopeAndCleanup } from '$lib/server/fleet/beta.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// POST body: { scopeType: 'game' | 'map' | null, scopeValue?: string }
|
||||
// Setting/changing scope on a script with a current_version_id returns a
|
||||
// 409 with kind='scope-requires-beta'; unsetting cleans the file off matching
|
||||
// servers.
|
||||
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||
const scriptId = parseId(params.id);
|
||||
if (scriptId === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
const script = getScript(scriptId);
|
||||
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
scopeType?: ScopeType | null;
|
||||
scopeValue?: string | null;
|
||||
};
|
||||
|
||||
const scopeType = body.scopeType ?? null;
|
||||
const scopeValue = body.scopeValue ?? null;
|
||||
|
||||
try {
|
||||
if (scopeType === null) {
|
||||
const cleaned = await unsetScopeAndCleanup(scriptId);
|
||||
return json({ ok: true, cleaned });
|
||||
}
|
||||
const updated = setScope({ scriptId, scopeType, scopeValue });
|
||||
return json({ ok: true, script: updated });
|
||||
} catch (err) {
|
||||
if (err instanceof ScriptError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js';
|
||||
|
||||
function parseId(idRaw: string): number | null {
|
||||
const n = Number.parseInt(idRaw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// Multipart POST — creates a new version from an uploaded file. Fields:
|
||||
// content (file), notes (optional). Response includes a "betaRequired" hint
|
||||
// when the script has a scope so the UI can nudge the operator to open a beta.
|
||||
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||
const scriptId = parseId(params.id);
|
||||
if (scriptId === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||
const script = getScript(scriptId);
|
||||
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
|
||||
|
||||
const form = await request.formData();
|
||||
const notes = form.get('notes') ? String(form.get('notes')) : undefined;
|
||||
const file = form.get('content');
|
||||
if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 });
|
||||
|
||||
const content = Buffer.from(await file.arrayBuffer());
|
||||
try {
|
||||
const version = uploadNewVersion(scriptId, content, notes);
|
||||
return json({
|
||||
ok: true,
|
||||
version: {
|
||||
id: version.id,
|
||||
version: version.version,
|
||||
sha256: version.sha256,
|
||||
size: version.size,
|
||||
notes: version.notes,
|
||||
uploaded_at: version.uploaded_at
|
||||
},
|
||||
betaRequired: script.scope_type !== null
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ScriptError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { deleteVersion, ScriptError } from '$lib/server/fleet/scripts.js';
|
||||
|
||||
function parseId(raw: string): number | null {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export async function DELETE({ params }: { params: { id: string; versionId: string } }) {
|
||||
const versionId = parseId(params.versionId);
|
||||
if (versionId === null) return json({ ok: false, error: 'bad version id' }, { status: 400 });
|
||||
try {
|
||||
deleteVersion(versionId);
|
||||
return json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ScriptError) {
|
||||
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export async function GET() {
|
||||
// Derived endpoint — same logic as endpointFor() but without asking
|
||||
// Komodo. If we don't have a port yet, endpoint is null.
|
||||
endpoint: s.game_port ? { host: s.rcon_host ?? rconHost, port: s.game_port } : null,
|
||||
held: [...heldByBeta(s.name).entries()].map(([target, slug]) => ({ target, slug }))
|
||||
held: [...heldByBeta(s.name).entries()].map(([target, betaId]) => ({ target, betaId }))
|
||||
}));
|
||||
return json({
|
||||
servers,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServer } from '$lib/server/fleet/servers.js';
|
||||
import { listServerScripts } from '$lib/server/fleet/inventory.js';
|
||||
|
||||
export async function GET({ params }: { params: { name: string } }) {
|
||||
const server = getServer(params.name);
|
||||
if (!server) return json({ ok: false, error: 'no such server' }, { status: 404 });
|
||||
const scripts = listServerScripts(params.name).map((r) => ({
|
||||
relPath: r.rel_path,
|
||||
scriptVersionId: r.script_version_id,
|
||||
sha256: r.sha256,
|
||||
size: r.size,
|
||||
updatedAt: r.updated_at,
|
||||
source: r.source
|
||||
}));
|
||||
return json({ scripts });
|
||||
}
|
||||
@@ -1,43 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import BetaPanel from '$ui/components/BetaPanel.svelte';
|
||||
|
||||
let staged = $state<Array<{ base: string; hits: string[] }>>([]);
|
||||
let betas = $state<Array<{
|
||||
slug: string; name: string; opened_at: string; updated_at: string; revision: number;
|
||||
servers: string[]; scripts: Array<{ staged_as: string; target: string; revision: number }>
|
||||
}>>([]);
|
||||
interface BetaListRow {
|
||||
id: number;
|
||||
script_id: number;
|
||||
script_version_id: number;
|
||||
trigger_kind: string;
|
||||
wait_for_health: number;
|
||||
wait_for_match: number;
|
||||
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
||||
opened_at: string;
|
||||
closed_at: string | null;
|
||||
script: { id: number; name: string; target_rel_path: string } | null;
|
||||
version: { id: number; version: number; sha256: string } | null;
|
||||
servers: Array<{ server: string; stage: string; error: string | null }>;
|
||||
}
|
||||
|
||||
let betas = $state<BetaListRow[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
async function load(): Promise<void> {
|
||||
const res = await fetch('/api/betas');
|
||||
const body = (await res.json()) as { staged: typeof staged; betas: typeof betas };
|
||||
staged = body.staged;
|
||||
const body = (await res.json()) as { betas: BetaListRow[] };
|
||||
betas = body.betas;
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
onMount(load);
|
||||
|
||||
const stateOrder = ['running', 'ready-promote', 'promoted', 'rolled-back'] as const;
|
||||
let grouped = $derived(
|
||||
stateOrder.map((state) => ({ state, rows: betas.filter((b) => b.state === state) }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<h1>Betas</h1>
|
||||
|
||||
{#if loading}
|
||||
<p class="empty">loading…</p>
|
||||
{:else if betas.length === 0}
|
||||
<p class="empty">no betas yet — open one from a script's page</p>
|
||||
{:else}
|
||||
<BetaPanel {staged} {betas} />
|
||||
{#each grouped as g (g.state)}
|
||||
{#if g.rows.length > 0}
|
||||
<section class="panel">
|
||||
<h2>{g.state.replace('-', ' ')} · {g.rows.length}</h2>
|
||||
<div class="rows">
|
||||
{#each g.rows as b (b.id)}
|
||||
<a class="row" href="/betas/{b.id}">
|
||||
<span class="id">#{b.id}</span>
|
||||
<span class="script">{b.script?.name ?? '(deleted)'}</span>
|
||||
<span class="mono target">{b.script?.target_rel_path ?? ''}</span>
|
||||
<span class="mono v">v{b.version?.version ?? '?'}</span>
|
||||
<span class="tag state-{b.state}">{b.state}</span>
|
||||
<span class="servers mono">{b.servers.length} server{b.servers.length === 1 ? '' : 's'}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
color: var(--cream);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-3xl);
|
||||
letter-spacing: var(--tracking-display);
|
||||
line-height: var(--leading-tight);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--ink-500);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
h1 { color: var(--cream); font-family: var(--font-display); font-size: var(--text-3xl); letter-spacing: var(--tracking-display); margin: 0 0 28px; }
|
||||
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-base); }
|
||||
.panel { background: var(--bg-nav); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--pad-panel); margin-bottom: 20px; }
|
||||
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); }
|
||||
.rows { display: flex; flex-direction: column; gap: 6px; }
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 220px 1fr 80px 130px 120px;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-100);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-mono);
|
||||
align-items: center;
|
||||
}
|
||||
.row:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
|
||||
.id { color: var(--ink-500); font-family: var(--font-mono); }
|
||||
.script { color: var(--cream); font-weight: 600; font-family: var(--font-body); }
|
||||
.target { color: var(--ink-300); font-family: var(--font-mono); word-break: break-all; }
|
||||
.v { color: var(--ink-500); }
|
||||
.servers { color: var(--ink-500); }
|
||||
.tag { padding: 3px 10px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 1px; border: 1px solid transparent; text-align: center; }
|
||||
.state-running { background: rgba(255, 176, 32, 0.15); border-color: rgba(255, 176, 32, 0.4); color: var(--amber-500); }
|
||||
.state-ready-promote { background: rgba(124, 212, 255, 0.15); border-color: rgba(124, 212, 255, 0.4); color: var(--spark-500); }
|
||||
.state-promoted { background: rgba(124, 212, 255, 0.10); border-color: rgba(124, 212, 255, 0.28); color: var(--cream); }
|
||||
.state-rolled-back { background: var(--bg-nav); border-color: var(--border); color: var(--ink-500); }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface BetaRow {
|
||||
id: number;
|
||||
script_id: number;
|
||||
script_version_id: number;
|
||||
trigger_kind: string;
|
||||
wait_for_health: number;
|
||||
wait_for_match: number;
|
||||
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
||||
opened_at: string;
|
||||
closed_at: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
interface BetaServerRow {
|
||||
beta_id: number;
|
||||
server: string;
|
||||
had_original: number;
|
||||
prior_version_id: number | null;
|
||||
stage: string;
|
||||
error: string | null;
|
||||
deployed_at: string | null;
|
||||
restarted_at: string | null;
|
||||
healthy_at: string | null;
|
||||
match_seen_at: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
interface Script { id: number; name: string; target_rel_path: string }
|
||||
interface Version { id: number; version: number; sha256: string; size: number }
|
||||
|
||||
let beta = $state<BetaRow | null>(null);
|
||||
let script = $state<Script | null>(null);
|
||||
let version = $state<Version | null>(null);
|
||||
let servers = $state<BetaServerRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
let acting = $state<string | null>(null);
|
||||
|
||||
let id = $derived(Number.parseInt($page.params.id ?? '0', 10));
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const res = await fetch(`/api/betas/${id}`);
|
||||
if (!res.ok) { loading = false; return; }
|
||||
const body = (await res.json()) as {
|
||||
beta: BetaRow;
|
||||
script: Script;
|
||||
version: Version;
|
||||
servers: BetaServerRow[];
|
||||
};
|
||||
beta = body.beta;
|
||||
script = body.script;
|
||||
version = body.version;
|
||||
servers = body.servers;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
onMount(() => {
|
||||
load();
|
||||
poll = setInterval(load, 5000);
|
||||
});
|
||||
onDestroy(() => { if (poll) clearInterval(poll); });
|
||||
|
||||
async function promote(): Promise<void> {
|
||||
acting = 'promote';
|
||||
flash = null;
|
||||
try {
|
||||
const res = await fetch(`/api/betas/${id}/promote`, { method: 'POST' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'promote failed'; return; }
|
||||
await load();
|
||||
} finally { acting = null; }
|
||||
}
|
||||
async function rollback(): Promise<void> {
|
||||
if (!confirm('Rollback this beta?')) return;
|
||||
acting = 'rollback';
|
||||
flash = null;
|
||||
try {
|
||||
const res = await fetch(`/api/betas/${id}/rollback`, { method: 'POST' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'rollback failed'; return; }
|
||||
await load();
|
||||
} finally { acting = null; }
|
||||
}
|
||||
async function retry(server: string): Promise<void> {
|
||||
acting = `retry:${server}`;
|
||||
flash = null;
|
||||
try {
|
||||
const res = await fetch(`/api/betas/${id}/retry`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ server })
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'retry failed'; return; }
|
||||
await load();
|
||||
} finally { acting = null; }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<p class="empty">loading…</p>
|
||||
{:else if !beta || !script || !version}
|
||||
<p class="empty">beta not found</p>
|
||||
{:else}
|
||||
<a class="back" href="/betas">← all betas</a>
|
||||
<div class="head-row">
|
||||
<h1>Beta #{beta.id}</h1>
|
||||
<span class="tag state-{beta.state}">{beta.state}</span>
|
||||
</div>
|
||||
<div class="sub">
|
||||
<a href="/scripts/{script.id}">{script.name}</a>
|
||||
<span class="mono">· {script.target_rel_path}</span>
|
||||
<span class="mono">· v{version.version}</span>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Options</h2>
|
||||
<div class="opts mono">
|
||||
<span>trigger: <strong>{beta.trigger_kind}</strong></span>
|
||||
<span>wait for health: <strong>{beta.wait_for_health ? 'yes' : 'no'}</strong></span>
|
||||
<span>wait for match: <strong>{beta.wait_for_match ? 'yes' : 'no'}</strong></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Server progress</h2>
|
||||
<div class="rows">
|
||||
{#each servers as bs (bs.server)}
|
||||
<div class="srv-row srv-{bs.stage}">
|
||||
<span class="srv-name mono">{bs.server}</span>
|
||||
<span class="tag stage-{bs.stage}">{bs.stage}</span>
|
||||
<div class="ticks mono">
|
||||
<span class:on={bs.deployed_at}>deploy</span>
|
||||
<span class:on={bs.stage !== 'awaiting-empty' && bs.stage !== 'deploying'}>empty</span>
|
||||
<span class:on={bs.restarted_at}>restart</span>
|
||||
<span class:on={bs.healthy_at}>healthy</span>
|
||||
{#if beta.wait_for_match}
|
||||
<span class:on={bs.match_seen_at}>match</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if bs.error}
|
||||
<span class="err mono">{bs.error}</span>
|
||||
{/if}
|
||||
{#if bs.stage === 'failed'}
|
||||
<button class="mini" onclick={() => retry(bs.server)} disabled={acting === `retry:${bs.server}`}>
|
||||
{acting === `retry:${bs.server}` ? 'retrying…' : 'retry'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Actions</h2>
|
||||
<div class="action-row">
|
||||
<button
|
||||
class="primary"
|
||||
onclick={promote}
|
||||
disabled={beta.state !== 'ready-promote' || acting !== null}
|
||||
title={beta.state !== 'ready-promote' ? 'all servers must reach ready first' : ''}
|
||||
>
|
||||
{acting === 'promote' ? 'promoting…' : 'promote'}
|
||||
</button>
|
||||
<button
|
||||
class="danger"
|
||||
onclick={rollback}
|
||||
disabled={beta.state === 'promoted' || beta.state === 'rolled-back' || acting !== null}
|
||||
>
|
||||
{acting === 'rollback' ? 'rolling back…' : 'rollback'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if flash}<p class="flash">{flash}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.back { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono-sm); text-decoration: none; }
|
||||
.back:hover { color: var(--ink-100); }
|
||||
.head-row { display: flex; align-items: center; gap: 12px; margin: 12px 0 4px; }
|
||||
h1 { color: var(--cream); font-family: var(--font-display); font-size: var(--text-2xl); letter-spacing: 1.5px; margin: 0; }
|
||||
.sub { color: var(--ink-500); font-size: var(--text-mono); margin-bottom: 24px; display: flex; gap: 8px; align-items: baseline; }
|
||||
.sub a { color: var(--spark-500); text-decoration: none; }
|
||||
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono); }
|
||||
|
||||
.panel { background: var(--bg-nav); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--pad-panel); margin-bottom: 20px; }
|
||||
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); }
|
||||
|
||||
.tag { padding: 3px 10px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 1px; border: 1px solid transparent; }
|
||||
.state-running { background: rgba(255, 176, 32, 0.15); border-color: rgba(255, 176, 32, 0.4); color: var(--amber-500); }
|
||||
.state-ready-promote { background: rgba(124, 212, 255, 0.15); border-color: rgba(124, 212, 255, 0.4); color: var(--spark-500); }
|
||||
.state-promoted { background: rgba(124, 212, 255, 0.10); border-color: rgba(124, 212, 255, 0.28); color: var(--cream); }
|
||||
.state-rolled-back { background: var(--bg-card); border-color: var(--border); color: var(--ink-500); }
|
||||
|
||||
.opts { display: flex; gap: 24px; color: var(--ink-100); font-size: var(--text-mono); }
|
||||
.opts strong { color: var(--cream); }
|
||||
|
||||
.rows { display: flex; flex-direction: column; gap: 8px; }
|
||||
.srv-row { display: grid; grid-template-columns: 220px 130px 1fr auto auto; gap: 14px; padding: 12px 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r-sm); align-items: center; }
|
||||
.srv-ready { border-left: 3px solid var(--spark-500); }
|
||||
.srv-failed { border-left: 3px solid var(--danger-500); }
|
||||
.srv-awaiting-empty, .srv-restarting, .srv-awaiting-health, .srv-awaiting-match, .srv-deploying { border-left: 3px solid var(--amber-500); }
|
||||
|
||||
.srv-name { color: var(--cream); font-size: var(--text-md); }
|
||||
.stage-deploying, .stage-awaiting-empty, .stage-restarting, .stage-awaiting-health, .stage-awaiting-match { background: rgba(255, 176, 32, 0.10); border-color: rgba(255, 176, 32, 0.3); color: var(--amber-500); }
|
||||
.stage-ready { background: rgba(124, 212, 255, 0.15); border-color: rgba(124, 212, 255, 0.4); color: var(--spark-500); }
|
||||
.stage-failed { background: rgba(255, 48, 80, 0.15); border-color: rgba(255, 48, 80, 0.4); color: var(--danger-500); }
|
||||
.ticks { display: flex; gap: 14px; font-size: var(--text-mono-sm); color: var(--ink-500); }
|
||||
.ticks span { position: relative; padding-left: 14px; }
|
||||
.ticks span::before { content: '○'; position: absolute; left: 0; color: var(--ink-500); }
|
||||
.ticks span.on { color: var(--cream); }
|
||||
.ticks span.on::before { content: '●'; color: var(--spark-500); }
|
||||
.err { color: var(--danger-500); font-size: var(--text-mono-sm); word-break: break-word; }
|
||||
.mini { background: var(--bg-nav); color: var(--cream); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 6px 12px; font-family: var(--font-body); font-size: var(--text-xs); cursor: pointer; }
|
||||
|
||||
.action-row { display: flex; gap: 12px; }
|
||||
.primary { background: linear-gradient(180deg, var(--blue-500), var(--blue-600)); border: 1px solid var(--blue-400); color: var(--cream); border-radius: var(--r-sm); padding: 12px 20px; font-family: var(--font-body); font-weight: 600; cursor: pointer; }
|
||||
.primary:disabled { opacity: 0.4; cursor: default; }
|
||||
.danger { background: var(--bg-nav); border: 1px solid rgba(255, 48, 80, 0.4); color: var(--danger-500); border-radius: var(--r-sm); padding: 12px 20px; font-family: var(--font-body); cursor: pointer; }
|
||||
.danger:disabled { opacity: 0.4; cursor: default; }
|
||||
.flash { color: var(--danger-500); font-family: var(--font-mono); font-size: var(--text-mono); margin-top: 12px; }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface ScriptRow {
|
||||
id: number;
|
||||
name: string;
|
||||
target_rel_path: string;
|
||||
scope_type: 'game' | 'map' | null;
|
||||
scope_value: string | null;
|
||||
current_version_id: number | null;
|
||||
latestVersion: {
|
||||
id: number;
|
||||
version: number;
|
||||
sha256: string;
|
||||
size: number;
|
||||
uploaded_at: string;
|
||||
} | null;
|
||||
versionCount: number;
|
||||
servers: string[];
|
||||
runningBetaId: number | null;
|
||||
}
|
||||
|
||||
let scripts = $state<ScriptRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
let uploading = $state(false);
|
||||
|
||||
// Form state for creating a new script.
|
||||
let newName = $state('');
|
||||
let newTargetRelPath = $state('');
|
||||
let newScopeType = $state<'none' | 'game' | 'map'>('none');
|
||||
let newScopeValue = $state('');
|
||||
let newFile = $state<File | null>(null);
|
||||
let newNotes = $state('');
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const res = await fetch('/api/scripts');
|
||||
const body = (await res.json()) as { scripts: ScriptRow[] };
|
||||
scripts = body.scripts;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function pickFile(e: Event): void {
|
||||
const t = e.target as HTMLInputElement;
|
||||
newFile = t.files?.[0] ?? null;
|
||||
if (!newTargetRelPath && newFile) newTargetRelPath = newFile.name;
|
||||
if (!newName && newFile) newName = newFile.name.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
|
||||
async function createScript(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (uploading || !newFile || !newName.trim() || !newTargetRelPath.trim()) return;
|
||||
uploading = true;
|
||||
flash = null;
|
||||
const form = new FormData();
|
||||
form.append('name', newName.trim());
|
||||
form.append('targetRelPath', newTargetRelPath.trim());
|
||||
form.append('scopeType', newScopeType);
|
||||
if (newScopeValue.trim()) form.append('scopeValue', newScopeValue.trim());
|
||||
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 };
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
newName = '';
|
||||
newTargetRelPath = '';
|
||||
newScopeType = 'none';
|
||||
newScopeValue = '';
|
||||
newNotes = '';
|
||||
newFile = null;
|
||||
await load();
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(s: ScriptRow): string {
|
||||
if (!s.scope_type) return 'unscoped';
|
||||
return `${s.scope_type}=${s.scope_value ?? '?'}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Scripts</h1>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Upload a script</h2>
|
||||
<form onsubmit={createScript} class="upload-form">
|
||||
<label>
|
||||
<span class="k">name</span>
|
||||
<input type="text" bind:value={newName} placeholder="hud tweaks" disabled={uploading} />
|
||||
</label>
|
||||
<label>
|
||||
<span class="k">target rel path</span>
|
||||
<input type="text" bind:value={newTargetRelPath} placeholder="maps/mp_zom_theater/hud.gsc" disabled={uploading} />
|
||||
</label>
|
||||
<label>
|
||||
<span class="k">scope</span>
|
||||
<div class="scope-row">
|
||||
<select bind:value={newScopeType} disabled={uploading}>
|
||||
<option value="none">unscoped</option>
|
||||
<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}
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span class="k">notes (optional)</span>
|
||||
<input type="text" bind:value={newNotes} placeholder="what changed" disabled={uploading} />
|
||||
</label>
|
||||
<label>
|
||||
<span class="k">file</span>
|
||||
<input type="file" onchange={pickFile} disabled={uploading} />
|
||||
</label>
|
||||
<button type="submit" class="primary" disabled={uploading || !newFile || !newName || !newTargetRelPath}>
|
||||
{uploading ? 'uploading…' : 'upload script'}
|
||||
</button>
|
||||
</form>
|
||||
{#if flash}<p class="flash">{flash}</p>{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>All scripts</h2>
|
||||
{#if loading}
|
||||
<p class="empty">loading…</p>
|
||||
{:else if scripts.length === 0}
|
||||
<p class="empty">no scripts yet — upload one above</p>
|
||||
{:else}
|
||||
<div class="scripts">
|
||||
{#each scripts as s (s.id)}
|
||||
<a class="script-row" href="/scripts/{s.id}">
|
||||
<div class="row-top">
|
||||
<span class="script-name">{s.name}</span>
|
||||
<span class="scope tag scope-{s.scope_type ?? 'none'}">{scopeLabel(s)}</span>
|
||||
{#if s.runningBetaId}
|
||||
<span class="tag beta">beta {s.runningBetaId} running</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="row-meta mono">
|
||||
<span>{s.target_rel_path}</span>
|
||||
<span>v{s.latestVersion?.version ?? '—'} · {s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span>
|
||||
<span>{s.servers.length ? `${s.servers.length} matching server${s.servers.length === 1 ? '' : 's'}` : ''}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
color: var(--cream);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-3xl);
|
||||
letter-spacing: var(--tracking-display);
|
||||
line-height: var(--leading-tight);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
.panel {
|
||||
background: var(--bg-nav);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
padding: var(--pad-panel);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
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);
|
||||
}
|
||||
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono); margin: 0; }
|
||||
|
||||
.upload-form { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; }
|
||||
.upload-form label { display: flex; flex-direction: column; gap: 6px; font-size: var(--text-xs); }
|
||||
.upload-form label .k { color: var(--ink-500); text-transform: uppercase; letter-spacing: 1px; }
|
||||
.upload-form input[type=text], .upload-form select, .upload-form input[type=file] {
|
||||
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);
|
||||
}
|
||||
.upload-form input[type=text]:focus, .upload-form select:focus {
|
||||
outline: none;
|
||||
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; }
|
||||
.primary {
|
||||
grid-column: 1 / -1;
|
||||
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
|
||||
border: 1px solid var(--blue-400);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 12px 18px;
|
||||
color: var(--cream);
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary:disabled { opacity: 0.4; cursor: default; }
|
||||
.flash { color: var(--danger-500); font-family: var(--font-mono); font-size: var(--text-mono); margin-top: 12px; }
|
||||
|
||||
.scripts { display: flex; flex-direction: column; gap: 8px; }
|
||||
.script-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 14px 16px;
|
||||
text-decoration: none;
|
||||
color: var(--ink-100);
|
||||
}
|
||||
.script-row:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
|
||||
.row-top { display: flex; gap: 12px; align-items: center; }
|
||||
.script-name { color: var(--cream); font-weight: 600; font-size: var(--text-md); }
|
||||
.row-meta { display: flex; gap: 24px; color: var(--ink-500); font-size: var(--text-mono-sm); }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.scope-none { background: var(--bg-nav); border-color: var(--border); color: var(--ink-500); }
|
||||
.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); }
|
||||
</style>
|
||||
@@ -0,0 +1,392 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface Script {
|
||||
id: number;
|
||||
name: string;
|
||||
target_rel_path: string;
|
||||
scope_type: 'game' | 'map' | null;
|
||||
scope_value: string | null;
|
||||
current_version_id: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
interface Version {
|
||||
id: number;
|
||||
version: number;
|
||||
sha256: string;
|
||||
size: number;
|
||||
notes: string | null;
|
||||
uploaded_at: string;
|
||||
}
|
||||
interface ServerRow { name: string; game: string | null; map: string | null }
|
||||
|
||||
let script = $state<Script | null>(null);
|
||||
let versions = $state<Version[]>([]);
|
||||
let matchedServers = $state<string[]>([]);
|
||||
let runningBetaId = $state<number | null>(null);
|
||||
let servers = $state<ServerRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
|
||||
// Upload-new-version form.
|
||||
let versionFile = $state<File | null>(null);
|
||||
let versionNotes = $state('');
|
||||
let uploadingVersion = $state(false);
|
||||
|
||||
// Scope form.
|
||||
let scopeType = $state<'none' | 'game' | 'map'>('none');
|
||||
let scopeValue = $state('');
|
||||
let updatingScope = $state(false);
|
||||
|
||||
// Open-beta form.
|
||||
let betaVersionId = $state<number | null>(null);
|
||||
let betaServers = $state<Set<string>>(new Set());
|
||||
let waitForHealth = $state(true);
|
||||
let waitForMatch = $state(false);
|
||||
let openingBeta = $state(false);
|
||||
|
||||
let id = $derived(Number.parseInt($page.params.id ?? '0', 10));
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const [detailRes, serversRes] = await Promise.all([
|
||||
fetch(`/api/scripts/${id}`),
|
||||
fetch('/api/servers')
|
||||
]);
|
||||
if (!detailRes.ok) { loading = false; return; }
|
||||
const body = (await detailRes.json()) as {
|
||||
script: Script;
|
||||
versions: Version[];
|
||||
matchedServers: string[];
|
||||
runningBetaId: number | null;
|
||||
};
|
||||
script = body.script;
|
||||
versions = body.versions;
|
||||
matchedServers = body.matchedServers;
|
||||
runningBetaId = body.runningBetaId;
|
||||
scopeType = script.scope_type ?? 'none';
|
||||
scopeValue = script.scope_value ?? '';
|
||||
if (versions[0]) betaVersionId = versions[0].id;
|
||||
|
||||
if (serversRes.ok) {
|
||||
const s = (await serversRes.json()) as { servers: ServerRow[] };
|
||||
servers = s.servers;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
onMount(load);
|
||||
|
||||
function pickVersionFile(e: Event): void {
|
||||
const t = e.target as HTMLInputElement;
|
||||
versionFile = t.files?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function uploadVersion(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!versionFile || uploadingVersion) return;
|
||||
uploadingVersion = true;
|
||||
flash = null;
|
||||
const form = new FormData();
|
||||
form.append('content', versionFile);
|
||||
if (versionNotes.trim()) form.append('notes', versionNotes.trim());
|
||||
try {
|
||||
const res = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string; betaRequired?: boolean };
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
versionFile = null;
|
||||
versionNotes = '';
|
||||
if (body.betaRequired) {
|
||||
flash = 'new version uploaded — open a beta below to deploy it';
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
uploadingVersion = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScope(): Promise<void> {
|
||||
if (updatingScope) return;
|
||||
updatingScope = true;
|
||||
flash = null;
|
||||
try {
|
||||
const res = await fetch(`/api/scripts/${id}/scope`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scopeType: scopeType === 'none' ? null : scopeType,
|
||||
scopeValue: scopeType === 'none' ? null : scopeValue.trim()
|
||||
})
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean; error?: string; kind?: string };
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
updatingScope = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBetaServer(name: string): void {
|
||||
const next = new Set(betaServers);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
betaServers = next;
|
||||
}
|
||||
|
||||
function candidateServers(): string[] {
|
||||
if (!script) return [];
|
||||
if (!script.scope_type) return servers.map((s) => s.name);
|
||||
return matchedServers;
|
||||
}
|
||||
|
||||
async function openBeta(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!script || betaVersionId === null || betaServers.size === 0 || openingBeta) return;
|
||||
openingBeta = true;
|
||||
flash = null;
|
||||
const triggerKind = !script.scope_type
|
||||
? 'unscoped'
|
||||
: script.current_version_id === null
|
||||
? 'mark-required'
|
||||
: 'version-update';
|
||||
try {
|
||||
const res = await fetch('/api/betas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scriptId: script.id,
|
||||
scriptVersionId: betaVersionId,
|
||||
servers: [...betaServers],
|
||||
waitForHealth,
|
||||
waitForMatch,
|
||||
triggerKind
|
||||
})
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
result?: { betaId: number };
|
||||
};
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
if (body.result) await goto(`/betas/${body.result.betaId}`);
|
||||
} finally {
|
||||
openingBeta = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteScript(): Promise<void> {
|
||||
if (!confirm('Delete this script?')) return;
|
||||
const res = await fetch(`/api/scripts/${id}`, { method: 'DELETE' });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
if (!body.ok) { flash = body.error ?? 'delete failed'; return; }
|
||||
goto('/scripts');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<p class="empty">loading…</p>
|
||||
{:else if !script}
|
||||
<p class="empty">script not found</p>
|
||||
{:else}
|
||||
<a class="back" href="/scripts">← all scripts</a>
|
||||
<h1>{script.name}</h1>
|
||||
<div class="sub mono">{script.target_rel_path}</div>
|
||||
|
||||
{#if runningBetaId}
|
||||
<section class="panel banner">
|
||||
<p>
|
||||
Beta <a href="/betas/{runningBetaId}"><strong>#{runningBetaId}</strong></a> is currently running for this script.
|
||||
Rollback or promote it before opening a new one.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Scope</h2>
|
||||
<div class="scope-form">
|
||||
<select bind:value={scopeType} disabled={updatingScope}>
|
||||
<option value="none">unscoped</option>
|
||||
<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}
|
||||
<button onclick={saveScope} disabled={updatingScope}>save scope</button>
|
||||
</div>
|
||||
{#if script.scope_type && matchedServers.length > 0}
|
||||
<p class="hint mono">applies to: {matchedServers.join(', ')}</p>
|
||||
{:else if script.scope_type}
|
||||
<p class="hint mono">no servers currently match this scope</p>
|
||||
{:else}
|
||||
<p class="hint mono">unscoped — deploy to specific servers via a beta below</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Upload new version</h2>
|
||||
<form onsubmit={uploadVersion} class="version-form">
|
||||
<input type="file" onchange={pickVersionFile} disabled={uploadingVersion} />
|
||||
<input type="text" bind:value={versionNotes} placeholder="notes (optional)" disabled={uploadingVersion} />
|
||||
<button type="submit" class="primary" disabled={uploadingVersion || !versionFile}>
|
||||
{uploadingVersion ? 'uploading…' : 'upload version'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Open beta with a version</h2>
|
||||
{#if runningBetaId}
|
||||
<p class="empty">a beta is already running for this script</p>
|
||||
{:else if versions.length === 0}
|
||||
<p class="empty">no versions yet — upload one above</p>
|
||||
{:else}
|
||||
<form onsubmit={openBeta} class="beta-form">
|
||||
<label>
|
||||
<span class="k">version</span>
|
||||
<select bind:value={betaVersionId}>
|
||||
{#each versions as v (v.id)}
|
||||
<option value={v.id}>v{v.version} · {v.sha256.slice(0, 8)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="k">test servers</span>
|
||||
<div class="server-picker">
|
||||
{#each candidateServers() as name (name)}
|
||||
<label class="chk">
|
||||
<input type="checkbox" checked={betaServers.has(name)} onchange={() => toggleBetaServer(name)} />
|
||||
{name}
|
||||
</label>
|
||||
{/each}
|
||||
{#if candidateServers().length === 0}
|
||||
<span class="empty">no candidate servers</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
<div class="opts">
|
||||
<label class="chk"><input type="checkbox" bind:checked={waitForHealth} /> wait for server start healthy</label>
|
||||
<label class="chk"><input type="checkbox" bind:checked={waitForMatch} /> wait for server match</label>
|
||||
</div>
|
||||
<button type="submit" class="primary" disabled={openingBeta || betaVersionId === null || betaServers.size === 0}>
|
||||
{openingBeta ? 'opening…' : 'open beta'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Version history</h2>
|
||||
{#if versions.length === 0}
|
||||
<p class="empty">none</p>
|
||||
{:else}
|
||||
<div class="versions">
|
||||
{#each versions as v (v.id)}
|
||||
<div class="version {script.current_version_id === v.id ? 'current' : ''}">
|
||||
<span class="v-num mono">v{v.version}</span>
|
||||
<span class="v-sha mono">{v.sha256.slice(0, 12)}</span>
|
||||
<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}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Danger</h2>
|
||||
<button class="danger" onclick={deleteScript}>delete script</button>
|
||||
</section>
|
||||
|
||||
{#if flash}<p class="flash">{flash}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.back { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono-sm); text-decoration: none; }
|
||||
.back:hover { color: var(--ink-100); }
|
||||
h1 { color: var(--cream); font-family: var(--font-display); font-size: var(--text-2xl); letter-spacing: 1.5px; margin: 12px 0 4px; }
|
||||
.sub { color: var(--ink-500); font-size: var(--text-mono); margin-bottom: 24px; }
|
||||
.empty { color: var(--ink-500); font-family: var(--font-mono); font-size: var(--text-mono); margin: 0; }
|
||||
|
||||
.panel { background: var(--bg-nav); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--pad-panel); margin-bottom: 20px; }
|
||||
.banner { background: rgba(255, 176, 32, 0.08); border-color: rgba(255, 176, 32, 0.3); }
|
||||
.banner p { margin: 0; color: var(--cream-dim); font-family: var(--font-body); font-size: var(--text-sm); }
|
||||
.banner a { color: var(--amber-500); }
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
.scope-form button, .version-form .primary, .beta-form .primary {
|
||||
background: linear-gradient(180deg, var(--blue-500), var(--blue-600));
|
||||
border: 1px solid var(--blue-400);
|
||||
color: var(--cream);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 16px;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-form button:disabled, .primary:disabled { opacity: 0.4; cursor: default; }
|
||||
.hint { color: var(--ink-500); font-size: var(--text-mono-sm); margin: 12px 0 0; }
|
||||
|
||||
.version-form { display: flex; gap: 10px; align-items: center; }
|
||||
.version-form input[type=text] { flex: 1; }
|
||||
|
||||
.beta-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.beta-form label { display: flex; flex-direction: column; gap: 6px; font-size: var(--text-xs); }
|
||||
.beta-form .k { color: var(--ink-500); text-transform: uppercase; letter-spacing: 1px; }
|
||||
.server-picker { display: flex; flex-wrap: wrap; gap: 10px; padding: 12px; background: var(--bg-page); border: 1px solid var(--border); border-radius: var(--r-sm); }
|
||||
.chk { color: var(--ink-100); font-family: var(--font-mono); font-size: var(--text-mono); display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||
.opts { display: flex; gap: 24px; }
|
||||
|
||||
.versions { display: flex; flex-direction: column; gap: 6px; }
|
||||
.version {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 130px 90px 1fr 200px auto;
|
||||
gap: 14px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
align-items: center;
|
||||
font-size: var(--text-mono);
|
||||
}
|
||||
.version.current { border-color: var(--spark-500); box-shadow: 0 0 0 1px var(--spark-500); }
|
||||
.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); }
|
||||
|
||||
.danger {
|
||||
background: var(--bg-nav);
|
||||
border: 1px solid rgba(255, 48, 80, 0.4);
|
||||
color: var(--danger-500);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 10px 16px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flash { color: var(--danger-500); font-family: var(--font-mono); font-size: var(--text-mono); margin-top: 12px; }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
</style>
|
||||
@@ -64,6 +64,8 @@
|
||||
komodo_server: string | null;
|
||||
game_port: number | null;
|
||||
rcon_host: string | null;
|
||||
game: string | null;
|
||||
map: string | null;
|
||||
last_players: number | null;
|
||||
last_checked_at: string | null;
|
||||
last_map: string | null;
|
||||
@@ -74,7 +76,15 @@
|
||||
health: Health;
|
||||
endpoint: RconEndpoint | null;
|
||||
replySnippet: string | null;
|
||||
held: Array<{ target: string; slug: string }>;
|
||||
held: Array<{ target: string; betaId: number }>;
|
||||
}
|
||||
interface InventoryEntry {
|
||||
relPath: string;
|
||||
scriptVersionId: number | null;
|
||||
sha256: string;
|
||||
size: number;
|
||||
updatedAt: string;
|
||||
source: string | null;
|
||||
}
|
||||
interface Integrations {
|
||||
rcon: boolean;
|
||||
@@ -93,6 +103,7 @@
|
||||
let server = $state<Server | null>(null);
|
||||
let integrations = $state<Integrations | null>(null);
|
||||
let history = $state<DeploymentRow[]>([]);
|
||||
let inventory = $state<InventoryEntry[]>([]);
|
||||
let name = $derived($page.params.name ?? '');
|
||||
let komodoProbe = $state<KomodoProbeResult | null>(null);
|
||||
let probing = $state(false);
|
||||
@@ -102,12 +113,20 @@
|
||||
let probingRcon = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
const [srvRes, depRes] = await Promise.all([fetch('/api/servers'), fetch('/api/deployments')]);
|
||||
const [srvRes, depRes, invRes] = await Promise.all([
|
||||
fetch('/api/servers'),
|
||||
fetch('/api/deployments'),
|
||||
fetch(`/api/servers/${encodeURIComponent(name)}/scripts`)
|
||||
]);
|
||||
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
|
||||
const depBody = (await depRes.json()) as { deployments: DeploymentRow[] };
|
||||
server = srvBody.servers.find((s) => s.name === name) ?? null;
|
||||
integrations = srvBody.integrations;
|
||||
history = depBody.deployments.filter((d) => d.servers.some((s) => s.server === name));
|
||||
if (invRes.ok) {
|
||||
const invBody = (await invRes.json()) as { scripts: InventoryEntry[] };
|
||||
inventory = invBody.scripts;
|
||||
}
|
||||
});
|
||||
|
||||
let live = $derived($statusByServer.get(name));
|
||||
@@ -420,15 +439,41 @@
|
||||
<div class="k">game_port</div>
|
||||
<div class="v mono">{server.game_port ?? '(unresolved)'}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="k">game</div>
|
||||
<div class="v mono">{server.game ?? '(unset)'}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="k">map</div>
|
||||
<div class="v mono">{server.map ?? '(unset)'}</div>
|
||||
</div>
|
||||
{#if server.held.length > 0}
|
||||
<div class="stat">
|
||||
<div class="k">held by beta</div>
|
||||
<div class="v mono">{server.held.map((h) => h.target).join(', ')}</div>
|
||||
<div class="v mono">{server.held.map((h) => `${h.target} (beta ${h.betaId})`).join(', ')}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Scripts on this server ({inventory.filter((f) => !f.relPath.endsWith('.OLD') && !/-beta\./.test(f.relPath)).length})</h2>
|
||||
{#if inventory.length === 0}
|
||||
<p class="empty">no scripts tracked — upload one from the Scripts page and open a beta to deploy it here</p>
|
||||
{:else}
|
||||
<div class="inv-list">
|
||||
{#each inventory as f (f.relPath)}
|
||||
<div class="inv-row inv-{f.relPath.endsWith('.OLD') ? 'old' : /-beta\./.test(f.relPath) ? 'beta' : 'live'}">
|
||||
<span class="rel mono">{f.relPath}</span>
|
||||
<span class="size mono">{f.size}B</span>
|
||||
<span class="src mono">{f.source ?? '—'}</span>
|
||||
<span class="sha mono">{f.sha256.slice(0, 12)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Deployment history</h2>
|
||||
{#if history.length === 0}
|
||||
@@ -843,4 +888,22 @@
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.inv-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.inv-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 90px 160px 140px;
|
||||
gap: 14px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--text-mono-sm);
|
||||
align-items: center;
|
||||
}
|
||||
.inv-row.inv-old { opacity: 0.55; border-left: 3px solid var(--ink-500); }
|
||||
.inv-row.inv-beta { border-left: 3px solid var(--amber-500); }
|
||||
.inv-row.inv-live { border-left: 3px solid var(--spark-500); }
|
||||
.inv-row .rel { color: var(--cream); word-break: break-all; }
|
||||
.inv-row .size, .inv-row .src, .inv-row .sha { color: var(--ink-500); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user