Refresh Configs with browser editing and source inspection
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
export interface ConfigSource {
|
||||
id: number | null;
|
||||
name: string;
|
||||
version: number | null;
|
||||
kind: 'baseline' | 'config' | 'map';
|
||||
draft?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigAssignment {
|
||||
value: string;
|
||||
source: ConfigSource;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface ConfigSetting {
|
||||
key: string;
|
||||
value: string;
|
||||
source: ConfigSource;
|
||||
history: ConfigAssignment[];
|
||||
}
|
||||
|
||||
export interface ConfigInspection {
|
||||
text: string;
|
||||
settings: ConfigSetting[];
|
||||
sources: ConfigSource[];
|
||||
warnings: string[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ConfigServer { name: string; game: string | null; map: string | null; }
|
||||
|
||||
/** The same lowercase `set` grammar that the overlay composer accepts. */
|
||||
export function configLines(text: string) {
|
||||
return text.split(/\r?\n/).flatMap((line, index) => {
|
||||
const match = /^(\s*set\s+)(\S+)(\s+)(.*)$/.exec(line);
|
||||
return match ? [{ index, key: match[2], value: match[4], prefix: match[1] + match[2] + match[3] }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function changeConfigValue(text: string, index: number, value: string): string {
|
||||
// Keep every separator, comment and unrelated line byte-for-byte intact.
|
||||
const parts = text.split(/(\r?\n)/);
|
||||
const entry = configLines(text).find(line => line.index === index);
|
||||
if (entry) parts[index * 2] = entry.prefix + value.replace(/[\r\n]/g, '');
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
export function configWarnings(text: string, overlay: boolean): string[] {
|
||||
const warnings: string[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
for (const entry of configLines(text)) {
|
||||
const key = entry.key.toLowerCase();
|
||||
if (seen.has(key)) warnings.push(`Line ${entry.index + 1}: ${entry.key} is also assigned on line ${seen.get(key)}.`);
|
||||
seen.set(key, entry.index + 1);
|
||||
if (!entry.value.trim() || entry.value.trim().startsWith('//')) warnings.push(`Line ${entry.index + 1}: ${entry.key} has no value.`);
|
||||
}
|
||||
if (overlay) text.split(/\r?\n/).forEach((line, index) => {
|
||||
if (line.trim() && !/^\s*\/\//.test(line) && !/^\s*set\s+\S+\s+/.test(line)) {
|
||||
warnings.push(`Line ${index + 1} is ignored by overlays. Use set KEY VALUE.`);
|
||||
}
|
||||
});
|
||||
if (/^\s*(?:(?:set|seta)\s+)?sv_maprotation\s+/im.test(text)) {
|
||||
warnings.push('For recognized maps, the server’s selected map overrides sv_maprotation.');
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { composeServerCfg, parseCfg } from './config-compose.js';
|
||||
import { selectedZombieMap, mapRotation } from './map-aliases.js';
|
||||
import { configWarnings, type ConfigAssignment, type ConfigInspection, type ConfigSource } from '../../config-workbench.js';
|
||||
|
||||
export interface ConfigLayer { source: ConfigSource; text: string; }
|
||||
|
||||
/** Trace file composition, including assignments left in place by the composer.
|
||||
* This is an intended-file preview, not a query of live runtime dvars. */
|
||||
export function inspectConfig(baseline: ConfigLayer, overlays: ConfigLayer[], game: string | null, map: string | null, mapSlug?: string | null): ConfigInspection {
|
||||
const sorted = [...overlays].sort((a, b) => a.source.id! - b.source.id!);
|
||||
const parsed = parseCfg(baseline.text);
|
||||
type TracedLine = { line: string; history: ConfigAssignment[] };
|
||||
function assignment(line: string, source: ConfigSource, index: number): ConfigAssignment {
|
||||
const match = /^\s*(?:(?:set|seta)\s+)(\S+)\s+(.*)$/i.exec(line)
|
||||
?? /^\s*(sv_maprotation)\s+(.*)$/i.exec(line);
|
||||
return { value: match?.[2] ?? '', source, line: index + 1 };
|
||||
}
|
||||
const lines: TracedLine[] = parsed.order.map((entry, index) => ({
|
||||
line: entry.line, history: [assignment(entry.line, baseline.source, index)]
|
||||
}));
|
||||
const replacements = new Map<string, TracedLine>();
|
||||
const warnings = configWarnings(baseline.text, false).map(w => `${baseline.source.name}: ${w}`);
|
||||
for (const overlay of sorted) {
|
||||
warnings.push(...configWarnings(overlay.text, true).map(w => `${overlay.source.name}: ${w}`));
|
||||
overlay.text.split(/\r?\n/).forEach((line, index) => {
|
||||
const match = /^\s*set\s+(\S+)\s+/.exec(line);
|
||||
if (!match) return;
|
||||
const previous = replacements.get(match[1]);
|
||||
replacements.set(match[1], { line, history: [...(previous?.history ?? []), assignment(line, overlay.source, index)] });
|
||||
});
|
||||
}
|
||||
for (const [key, replacement] of replacements) {
|
||||
const index = parsed.entries.get(key);
|
||||
if (index === undefined) lines.push(replacement);
|
||||
else lines[index] = { line: replacement.line, history: [...lines[index].history, ...replacement.history] };
|
||||
}
|
||||
const sources = [baseline.source, ...sorted.map(o => o.source)];
|
||||
const selected = selectedZombieMap(game, map, mapSlug);
|
||||
if (selected) {
|
||||
const source: ConfigSource = { id: null, name: 'Server map selection', version: null, kind: 'map' };
|
||||
sources.push(source);
|
||||
const rotation = /^\s*(?:(?:set|seta)\s+)?sv_maprotation\s+/i;
|
||||
const history = lines.filter(entry => rotation.test(entry.line)).flatMap(entry => entry.history);
|
||||
for (let i = lines.length - 1; i >= 0; i--) if (rotation.test(lines[i].line)) lines.splice(i, 1);
|
||||
const line = `set sv_maprotation "${mapRotation(selected)}"`;
|
||||
lines.push({ line, history: [...history, assignment(line, source, 0)] });
|
||||
}
|
||||
const byKey = new Map<string, { key: string; history: ConfigAssignment[] }>();
|
||||
for (const entry of lines) {
|
||||
const match = /^\s*(?:set|seta)\s+(\S+)\s+/i.exec(entry.line) ?? /^\s*(sv_maprotation)\s+/i.exec(entry.line);
|
||||
if (!match) continue;
|
||||
const normalized = match[1].toLowerCase();
|
||||
const existing = byKey.get(normalized);
|
||||
byKey.set(normalized, { key: match[1], history: [...(existing?.history ?? []), ...entry.history] });
|
||||
}
|
||||
return {
|
||||
text: composeServerCfg(baseline.text, sorted.map(o => ({ id: o.source.id!, text: o.text })), game, map, mapSlug),
|
||||
sources, warnings,
|
||||
settings: [...byKey.values()].map(({ key, history }) => ({ key, history, value: history.at(-1)!.value, source: history.at(-1)!.source }))
|
||||
.sort((a, b) => a.key.localeCompare(b.key))
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import PluginGithubImport from './PluginGithubImport.svelte';
|
||||
import ConfigEditor from './ConfigEditor.svelte';
|
||||
import type { PluginSource } from '$lib/plugin-github.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import Card from './primitives/Card.svelte';
|
||||
@@ -329,6 +330,11 @@
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{#if a.kind === 'config' || a.kind === 'baseline'}
|
||||
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
|
||||
{runningBetaId} scopeType={a.scope_type} scopeValue={a.scope_value} onsaved={() => load({ seedForm: false })} />
|
||||
{/if}
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="panel-head">
|
||||
<h2>Version history</h2>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import ConfigTextEditor from './ConfigTextEditor.svelte';
|
||||
import ConfigInspector from './ConfigInspector.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
interface Version { id: number; version: number; notes: string | null; }
|
||||
let { id, kind, versions, currentVersionId, runningBetaId, scopeType, scopeValue, onsaved }: {
|
||||
id: number; kind: 'baseline' | 'config'; versions: Version[]; currentVersionId: number | null;
|
||||
runningBetaId: number | null; scopeType: string | null; scopeValue: string | null; onsaved: () => Promise<void>;
|
||||
} = $props();
|
||||
let selected = $state('');
|
||||
let text = $state('');
|
||||
let original = $state('');
|
||||
let notes = $state('');
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let success = $state('');
|
||||
let ready = $state(false);
|
||||
let retry = $state(0);
|
||||
let loadedVersion = $state('');
|
||||
let recovered = $state<{ version: string; text: string; notes: string } | null>(null);
|
||||
const dirty = $derived(text !== original || notes.trim().length > 0);
|
||||
const storageKey = $derived(`electric-cherry.config-draft.${id}`);
|
||||
onMount(() => {
|
||||
selected = String(versions[0]?.id ?? ''); ready = true;
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
|
||||
if (saved && typeof saved.text === 'string' && typeof saved.version === 'string' && typeof saved.notes === 'string') recovered = saved;
|
||||
} catch { /* Draft storage is optional. */ }
|
||||
});
|
||||
beforeNavigate(({ cancel }) => { if (dirty && !confirm('Leave this editor? Your draft is kept in this browser when storage is available.')) cancel(); });
|
||||
function beforeUnload(event: BeforeUnloadEvent) { if (dirty) { event.preventDefault(); event.returnValue = ''; } }
|
||||
$effect(() => {
|
||||
const version = selected;
|
||||
const refresh = retry;
|
||||
if (!ready || !version) return;
|
||||
const controller = new AbortController();
|
||||
loading = true; error = ''; loadedVersion = '';
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/configs/${id}?version=${version}`, { signal: controller.signal });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not load config contents.');
|
||||
if (!controller.signal.aborted) { text = body.text; original = body.text; notes = ''; loadedVersion = version; }
|
||||
} catch (err) { if (!controller.signal.aborted) error = (err as Error).message; }
|
||||
finally { if (!controller.signal.aborted) loading = false; }
|
||||
})();
|
||||
return () => controller.abort();
|
||||
});
|
||||
$effect(() => {
|
||||
if (!ready || loading || !loadedVersion || !dirty) return;
|
||||
try { localStorage.setItem(storageKey, JSON.stringify({ version: loadedVersion, text, notes })); } catch { /* Editing still works without storage. */ }
|
||||
});
|
||||
function clearDraft() { try { localStorage.removeItem(storageKey); } catch { /* Storage may be disabled. */ } recovered = null; }
|
||||
function changeVersion(event: Event) {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
if (dirty && !confirm('Discard your current edits and open this version?')) { target.value = selected; return; }
|
||||
clearDraft(); selected = target.value; success = '';
|
||||
}
|
||||
function restoreDraft() {
|
||||
if (!recovered) return;
|
||||
// Keep the current version as the comparison base, including when the
|
||||
// recovered draft was started from a version that has since been deleted.
|
||||
text = recovered.text; notes = recovered.notes; recovered = null;
|
||||
}
|
||||
function discard() { if (confirm('Discard your unsaved changes?')) { text = original; notes = ''; clearDraft(); } }
|
||||
async function importFile(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) { try { text = await file.text(); } catch { error = 'Could not read that file.'; } }
|
||||
input.value = '';
|
||||
}
|
||||
async function save() {
|
||||
if (saving || loading || runningBetaId !== null || !text.trim()) return;
|
||||
saving = true; error = ''; success = '';
|
||||
const form = new FormData();
|
||||
form.append('content', new Blob([text], { type: 'text/plain' }), 'config.cfg');
|
||||
if (notes.trim()) form.append('notes', notes.trim());
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not save this version.');
|
||||
original = text; notes = ''; clearDraft();
|
||||
success = `v${body.version.version} saved as untested. Open a beta to test and promote it.`;
|
||||
await onsaved(); selected = String(body.version.id);
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { saving = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onbeforeunload={beforeUnload} />
|
||||
<section class="workbench" aria-label="Config editor">
|
||||
<div class="editor-panel">
|
||||
<div class="heading"><div><span class="eyebrow">Edit · preview · version</span><h2>Configuration editor</h2></div>
|
||||
{#if dirty}<span class="draft-tag">Unsaved draft</span>{/if}
|
||||
</div>
|
||||
<div class="version-toolbar"><label>Starting version<select aria-label="Starting version" value={selected} onchange={changeVersion} disabled={saving || loading}>{#each versions as version}<option value={String(version.id)}>v{version.version}{version.id === currentVersionId ? ' · promoted' : ''}{version.id === versions[0]?.id ? ' · latest' : ''}</option>{/each}</select></label>
|
||||
<label class="file-button">Import .cfg<input type="file" accept=".cfg,text/plain" onchange={importFile} disabled={saving || loading || runningBetaId !== null} /></label>
|
||||
</div>
|
||||
{#if recovered && !loading}<div class="recovery"><span>A previous draft is available in this browser.</span><Button size="sm" onclick={restoreDraft} disabled={runningBetaId !== null}>Restore draft</Button><Button size="sm" variant="ghost" onclick={clearDraft}>Dismiss</Button></div>{/if}
|
||||
{#if success}<div class="success" role="status">{success} <a href="/betas">Open betas →</a></div>{/if}
|
||||
{#if error}<div class="error" role="alert">{error} {#if !loadedVersion}<Button size="sm" onclick={() => retry++}>Retry</Button>{/if}</div>{/if}
|
||||
{#if loading}<p class="muted" role="status">Loading config contents…</p>
|
||||
{:else if loadedVersion}
|
||||
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving || runningBetaId !== null} />
|
||||
<label class="notes-label">Version notes<input bind:value={notes} placeholder="What changed, and why?" disabled={saving || runningBetaId !== null} /></label>
|
||||
<div class="actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}</span>
|
||||
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}>Discard</Button>
|
||||
<Button variant="primary" onclick={save} loading={saving} disabled={!dirty || !text.trim() || runningBetaId !== null}>Save new version</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if loadedVersion && !loading}<ConfigInspector artifactId={id} draftText={text} {scopeType} {scopeValue} />{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.workbench { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: var(--space-5); align-items: start; }
|
||||
.editor-panel { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: var(--space-6); min-width: 0; }
|
||||
.heading, .version-toolbar, .actions, .recovery { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
h2 { font-size: var(--text-lg); margin: 5px 0 0; }
|
||||
.eyebrow { font-size: var(--text-xs); color: var(--accent-primary-hover); }
|
||||
.draft-tag { color: var(--state-warning); font-size: var(--text-xs); }
|
||||
.version-toolbar { margin: 20px 0; align-items: flex-end; }
|
||||
.version-toolbar > label:first-child { flex: 1; }
|
||||
label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
select, input:not([type='file']) { width: 100%; box-sizing: border-box; padding: 10px 12px; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); font: var(--text-sm) var(--font-body); }
|
||||
input:focus-visible, select:focus-visible, .file-button:focus-within { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.file-button { position: relative; display: block; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 11px; overflow: hidden; cursor: pointer; }
|
||||
.file-button input { position: absolute; inset: 0; opacity: 0; width: 100%; cursor: pointer; }
|
||||
.notes-label { margin-top: 20px; }
|
||||
.actions { margin-top: 16px; }
|
||||
.actions > span { flex: 1 1 200px; }
|
||||
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.recovery, .success, .error { font-size: var(--text-sm); padding: 12px; border-radius: var(--radius-sm); background: var(--surface-raised); margin-bottom: 16px; }
|
||||
.recovery { color: var(--state-warning); } .success { color: var(--state-online); } .error { color: var(--state-danger); }
|
||||
@media (max-width: 1100px) { .workbench { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 600px) { .editor-panel { padding: 16px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
let { artifactId, draftText, scopeType, scopeValue }: { artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props();
|
||||
let servers = $state<ConfigServer[]>([]);
|
||||
let selected = $state('');
|
||||
let inspection = $state<ConfigInspection | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let query = $state('');
|
||||
let onlyOverrides = $state(false);
|
||||
let raw = $state(false);
|
||||
let reload = $state(0);
|
||||
let ready = $state(false);
|
||||
const eligible = $derived(servers.filter(s => !artifactId || (scopeType === 'game' ? s.game === scopeValue : scopeType === 'map' ? s.map === scopeValue : false)));
|
||||
const settings = $derived((inspection?.settings ?? []).filter(s => (!onlyOverrides || s.history.length > 1) && `${s.key} ${s.value} ${s.source.name}`.toLowerCase().includes(query.toLowerCase())));
|
||||
const overlaps = $derived(inspection?.settings.filter(s => s.history.length > 1).length ?? 0);
|
||||
function label(source: ConfigSource): string { return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`; }
|
||||
|
||||
async function loadServers() {
|
||||
loading = true; error = '';
|
||||
try {
|
||||
const response = await fetch('/api/configs/inspect');
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not load servers.');
|
||||
servers = body.servers;
|
||||
ready = true;
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
onMount(loadServers);
|
||||
$effect(() => { if (!eligible.some(s => s.name === selected)) selected = eligible[0]?.name ?? ''; });
|
||||
$effect(() => {
|
||||
const server = selected;
|
||||
const text = draftText;
|
||||
const id = artifactId;
|
||||
const refresh = reload;
|
||||
if (!ready || !server) { inspection = null; if (ready) loading = false; return; }
|
||||
const controller = new AbortController();
|
||||
loading = true; error = ''; inspection = null;
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(id !== undefined && text !== undefined ? '/api/configs/inspect' : `/api/configs/inspect?server=${encodeURIComponent(server)}`, {
|
||||
signal: controller.signal,
|
||||
...(id !== undefined && text !== undefined ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server, artifactId: id, text }) } : {})
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not inspect this config.');
|
||||
if (!controller.signal.aborted) inspection = body.inspection;
|
||||
} catch (err) { if (!controller.signal.aborted) error = (err as Error).message; }
|
||||
finally { if (!controller.signal.aborted) loading = false; }
|
||||
}, text === undefined ? 0 : 350);
|
||||
return () => { clearTimeout(timer); controller.abort(); };
|
||||
});
|
||||
function download() {
|
||||
if (!inspection) return;
|
||||
const url = URL.createObjectURL(new Blob([inspection.text], { type: 'text/plain' }));
|
||||
const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${selected}${artifactId ? '-draft' : ''}.cfg`; anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="inspector" aria-label="Effective config inspector" aria-busy={loading}>
|
||||
<div class="heading"><div><span class="eyebrow">{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span><h2>{artifactId ? 'On this server' : 'Effective configuration'}</h2></div>
|
||||
<Button size="sm" variant="ghost" onclick={() => ready ? reload++ : loadServers()}>Refresh</Button>
|
||||
</div>
|
||||
<p class="intro">{artifactId ? 'Your draft composed with the other promoted configs. Saving creates an untested version.' : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}</p>
|
||||
<label class="server-label">Server<select aria-label="Server" bind:value={selected} disabled={!eligible.length}>
|
||||
{#each eligible as server}<option value={server.name}>{server.name}</option>{/each}
|
||||
{#if !eligible.length}<option value="">No matching servers</option>{/if}
|
||||
</select></label>
|
||||
{#if error}<div class="error" role="alert">{error}</div>
|
||||
{:else if loading}<p class="empty" role="status">Composing configuration…</p>
|
||||
{:else if !eligible.length}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p>
|
||||
{:else if inspection?.reason}<p class="empty">{inspection.reason}</p>
|
||||
{:else if inspection}
|
||||
<div class="sources" aria-label="Composition sources">
|
||||
{#each inspection.sources as source, index}
|
||||
{#if index}<span class="arrow" aria-hidden="true">→</span>{/if}
|
||||
{#if source.id}<a class:draft={source.draft} href="/configs/{source.id}">{label(source)}</a>
|
||||
{:else}<span class="map-source">{source.name}</span>{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<p class="hint">Overlays apply in creation order; later overlays win for the same key. The selected map takes precedence over map rotation.</p>
|
||||
<div class="toolbar"><div class="switch"><button class:active={!raw} aria-pressed={!raw} onclick={() => raw = false}>Settings <span>{inspection.settings.length}</span></button><button class:active={raw} aria-pressed={raw} onclick={() => raw = true}>Composed .cfg</button></div><Button size="sm" variant="ghost" onclick={download}>Download</Button></div>
|
||||
{#if raw}<textarea class="composed" readonly aria-label="Composed config" value={inspection.text} rows="18"></textarea>
|
||||
{:else}
|
||||
<div class="filters"><input aria-label="Search effective settings" bind:value={query} placeholder="Search settings or sources…" /><label class="check"><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label></div>
|
||||
<div class="settings">
|
||||
{#each settings as setting (setting.key)}
|
||||
<details class="setting"><summary><div class="setting-main"><code>{setting.key}</code><strong>{setting.value}</strong></div><span class="source-label">{label(setting.source)} {#if setting.history.length > 1}<b>{setting.history.length - 1} overridden</b>{/if}</span></summary>
|
||||
<ol>{#each setting.history as entry, index}<li class:winner={index === setting.history.length - 1}><div><span>{label(entry.source)}</span><small>{entry.source.kind === 'map' ? 'Automatic' : `Line ${entry.line}`} · {index === setting.history.length - 1 ? 'Final value' : 'Overridden'}</small></div><code>{entry.value}</code></li>{/each}</ol>
|
||||
</details>
|
||||
{:else}<p class="empty">No matching settings.</p>{/each}
|
||||
</div>
|
||||
<p class="hint">Shows set / seta assignments and map rotation. Other commands remain visible in the composed file.</p>
|
||||
{/if}
|
||||
{#if inspection.warnings.length}<details class="notes"><summary>{inspection.warnings.length} configuration {inspection.warnings.length === 1 ? 'note' : 'notes'}</summary><ul>{#each inspection.warnings as warning}<li>{warning}</li>{/each}</ul></details>{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.inspector { min-width: 0; padding: var(--space-6); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
.heading, .toolbar, .filters { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.eyebrow { font-size: var(--text-xs); color: var(--accent-primary-hover); }
|
||||
h2 { margin: 5px 0 0; font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
|
||||
.intro { color: var(--text-secondary); font-size: var(--text-sm); margin: 12px 0 20px; }
|
||||
.server-label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
select, input:not([type='checkbox']) { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 10px 12px; font: var(--text-sm) var(--font-body); }
|
||||
select:focus-visible, input:focus-visible, button:focus-visible, summary:focus-visible, a:focus-visible, .composed:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.sources { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 20px; font-size: var(--text-xs); }
|
||||
.sources a, .map-source { background: var(--surface-raised); border-radius: 6px; padding: 6px 9px; text-decoration: none; }
|
||||
.sources a.draft { color: var(--state-warning); }
|
||||
.map-source, .arrow { color: var(--text-secondary); }
|
||||
.hint { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }
|
||||
.toolbar { border-top: 1px solid var(--border-subtle); padding-top: 12px; margin-top: 20px; }
|
||||
.switch { display: flex; gap: 4px; }
|
||||
button { border: 0; border-radius: 6px; background: transparent; color: var(--text-secondary); font: var(--text-sm) var(--font-body); padding: 8px 10px; cursor: pointer; }
|
||||
button.active { color: var(--text-primary); background: var(--surface-raised); }
|
||||
button span { color: var(--text-secondary); margin-left: 6px; }
|
||||
.filters { margin: 16px 0 8px; }
|
||||
.filters > input { flex: 1 1 180px; }
|
||||
.check { font-size: var(--text-xs); color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
|
||||
.settings { max-height: 620px; overflow: auto; }
|
||||
.setting { border-bottom: 1px solid var(--border-subtle); padding: 14px 0; }
|
||||
.setting summary { cursor: pointer; list-style: none; }
|
||||
.setting summary::-webkit-details-marker { display: none; }
|
||||
.setting-main { display: flex; gap: 12px; justify-content: space-between; flex-wrap: wrap; }
|
||||
code, strong { font: var(--text-xs)/1.6 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
strong { color: var(--accent-primary-hover); }
|
||||
.source-label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 11px; color: var(--text-secondary); margin-top: 6px; }
|
||||
.source-label::before { content: '▸'; } .setting[open] .source-label::before { content: '▾'; }
|
||||
b { font-weight: 400; color: var(--state-warning); }
|
||||
ol { list-style: none; padding: 0 0 0 14px; margin: 16px 0 0 4px; border-left: 2px solid var(--border-strong); }
|
||||
ol li { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
ol li.winner { color: var(--text-primary); }
|
||||
small { display: block; font-size: 10px; color: var(--text-secondary); margin-top: 3px; }
|
||||
.composed { display: block; width: 100%; box-sizing: border-box; margin-top: 16px; padding: 20px; background: var(--surface-sunken); color: var(--text-primary); border: 0; border-radius: var(--radius-sm); min-height: 260px; max-height: 600px; resize: vertical; overflow: auto; font: var(--text-xs)/1.8 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.notes { margin-top: 16px; color: var(--state-warning); font-size: var(--text-xs); } .notes summary { cursor: pointer; } .notes li { margin: 8px 0; }
|
||||
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 24px 0; }
|
||||
.error { color: var(--state-danger); margin-top: 16px; font-size: var(--text-sm); }
|
||||
@media (max-width: 600px) { .inspector { padding: 16px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import SectionHeader from './primitives/SectionHeader.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
import EmptyState from './primitives/EmptyState.svelte';
|
||||
import ConfigTextEditor from './ConfigTextEditor.svelte';
|
||||
import ConfigInspector from './ConfigInspector.svelte';
|
||||
|
||||
interface Version { id: number; version: number; state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string; }
|
||||
interface Artifact {
|
||||
id: number; name: string; kind: 'baseline' | 'config'; scope_type: 'game' | 'map' | null;
|
||||
scope_value: string | null; current_version_id: number | null; currentVersion: Version | null;
|
||||
latestVersion: Version | null; versionCount: number; servers: string[]; runningBetaId: number | null;
|
||||
}
|
||||
let artifacts = $state<Artifact[]>([]);
|
||||
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({ games: [], mapsByGame: {} });
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let query = $state('');
|
||||
let game = $state('');
|
||||
let scope = $state('');
|
||||
let status = $state('');
|
||||
let view = $state<'library' | 'inspect'>('library');
|
||||
let dialog: HTMLDialogElement;
|
||||
let kind = $state<'baseline' | 'config'>('config');
|
||||
let name = $state('');
|
||||
let scopeType = $state<'game' | 'map'>('game');
|
||||
let scopeGame = $state('');
|
||||
let scopeMap = $state('');
|
||||
let text = $state('');
|
||||
let notes = $state('');
|
||||
let saving = $state(false);
|
||||
let createError = $state('');
|
||||
const baselines = $derived(artifacts.filter(a => a.kind === 'baseline'));
|
||||
const overlays = $derived(artifacts.filter(a => a.kind === 'config'));
|
||||
const pending = $derived(artifacts.filter(a => a.latestVersion && a.latestVersion.id !== a.current_version_id).length);
|
||||
const gameOptions = $derived([...new Set([...scopeOptions.games, ...artifacts.filter(a => a.scope_type === 'game').map(a => a.scope_value!).filter(Boolean)])].sort());
|
||||
const baselineExists = $derived(kind === 'baseline' && baselines.some(a => a.scope_type === 'game' && a.scope_value === scopeGame));
|
||||
const selectedScope = $derived(kind === 'baseline' || scopeType === 'game' ? scopeGame : scopeMap);
|
||||
const filtered = $derived(artifacts.filter(a => {
|
||||
if (query && !`${a.name} ${a.scope_value ?? ''} ${a.servers.join(' ')}`.toLowerCase().includes(query.toLowerCase())) return false;
|
||||
if (game && !(a.scope_type === 'game' ? a.scope_value === game : (scopeOptions.mapsByGame[game] ?? []).includes(a.scope_value ?? ''))) return false;
|
||||
if (scope && a.scope_type !== scope) return false;
|
||||
if (status === 'pending' && (!a.latestVersion || a.latestVersion.id === a.current_version_id)) return false;
|
||||
if (status === 'promoted' && !a.current_version_id) return false;
|
||||
if (status === 'beta' && !a.runningBetaId) return false;
|
||||
if (status === 'failed' && a.latestVersion?.state !== 'test-failed') return false;
|
||||
return true;
|
||||
}));
|
||||
const filteredBaselines = $derived(filtered.filter(a => a.kind === 'baseline').sort((a, b) => (a.scope_value ?? '').localeCompare(b.scope_value ?? '')));
|
||||
const filteredOverlays = $derived(filtered.filter(a => a.kind === 'config'));
|
||||
|
||||
async function load() {
|
||||
loading = true; error = '';
|
||||
try {
|
||||
const responses = await Promise.all([fetch('/api/configs?kind=baseline'), fetch('/api/configs?kind=config'), fetch('/api/scope-options')]);
|
||||
if (responses.some(r => !r.ok)) throw new Error('Could not load the config library. Please try again.');
|
||||
const [base, overlay, scopes] = await Promise.all(responses.map(r => r.json()));
|
||||
artifacts = [...base.scripts, ...overlay.scripts]; scopeOptions = scopes;
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
onMount(load);
|
||||
function openCreate(type: 'config' | 'baseline' = 'config') { kind = type; createError = ''; dialog.showModal(); }
|
||||
function resetFilters() { query = ''; game = ''; scope = ''; status = ''; }
|
||||
async function importFile(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
try { text = await file.text(); if (!name) name = file.name.replace(/\.[^.]+$/, ''); }
|
||||
catch { createError = 'Could not read that file.'; }
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
async function create(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (saving || baselineExists || !name.trim() || !selectedScope || !text.trim()) return;
|
||||
if (kind === 'config' && !/^\s*set\s+\S+\s+/m.test(text)) { createError = 'Add at least one set KEY VALUE line.'; return; }
|
||||
saving = true; createError = '';
|
||||
const form = new FormData();
|
||||
form.append('name', name.trim()); form.append('kind', kind);
|
||||
form.append('scopeType', kind === 'baseline' ? 'game' : scopeType); form.append('scopeValue', selectedScope);
|
||||
form.append('text', text); if (notes.trim()) form.append('notes', notes.trim());
|
||||
try {
|
||||
const response = await fetch('/api/configs', { method: 'POST', body: form });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not create this config.');
|
||||
dialog.close(); await goto(`/configs/${body.script.id}`);
|
||||
} catch (err) { createError = (err as Error).message; }
|
||||
finally { saving = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Configs" eyebrow="Fleet configuration" subtitle="Build on a baseline. Fine-tune with overlays. Know where every value comes from.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>+ New config</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="overview">
|
||||
<div><span class="metric">{baselines.length}</span><span>Game baselines<small>The starting point for your servers</small></span></div>
|
||||
<div><span class="metric">{overlays.length}</span><span>Scoped overlays<small>Changes for a game or map</small></span></div>
|
||||
<div><span class="metric" class:pending={pending > 0}>{pending}</span><span>Awaiting promotion<small>Latest versions outside the promoted set</small></span></div>
|
||||
</div>
|
||||
<div class="navigation"><div class="view-switch" aria-label="Configs view"><button class:active={view === 'library'} aria-pressed={view === 'library'} onclick={() => view = 'library'}>Library</button><button class:active={view === 'inspect'} aria-pressed={view === 'inspect'} onclick={() => view = 'inspect'}>Inspect a server <span aria-hidden="true">↗</span></button></div><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh library</Button></div>
|
||||
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={load}>Retry</Button></div>{/if}
|
||||
{#if view === 'inspect'}
|
||||
<ConfigInspector />
|
||||
{:else}
|
||||
<div class="filters">
|
||||
<input class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
|
||||
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select>
|
||||
<select aria-label="Filter by scope" bind:value={scope}><option value="">All scopes</option><option value="game">Game</option><option value="map">Map</option></select>
|
||||
<select aria-label="Filter by status" bind:value={status}><option value="">All statuses</option><option value="pending">Awaiting promotion</option><option value="promoted">Has promoted version</option><option value="beta">Active beta</option><option value="failed">Test failed</option></select>
|
||||
{#if query || game || scope || status}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
|
||||
</div>
|
||||
{#if loading}<div class="loading" role="status">Loading your config library…</div>
|
||||
{:else if !artifacts.length && !error}
|
||||
<div class="welcome"><span class="eyebrow">Start with a foundation</span><h2>Your fleet, configured your way.</h2><p>Add a baseline for each game, then layer on small, focused changes for a game or map. Every change gets its own version.</p><Button variant="primary" onclick={() => openCreate('baseline')}>Create your first baseline</Button></div>
|
||||
{:else if !filtered.length && !error}<EmptyState title="No matching configs" description="Try a different search or clear your filters." />
|
||||
{:else}
|
||||
{#if filteredBaselines.length}
|
||||
<section class="section"><div class="section-heading"><h2>Baselines <span>{filteredBaselines.length}</span></h2><p>One foundation per game</p></div>
|
||||
<div class="baseline-grid">{#each filteredBaselines as artifact (artifact.id)}
|
||||
<a class="baseline-card" href="/configs/{artifact.id}"><div class="card-top"><span class="game-label">{artifact.scope_value?.toUpperCase() ?? 'UNSCOPED'}</span><span class="open-arrow" aria-hidden="true">↗</span></div><h3>{artifact.name}</h3><div class="versions">{@render versionBadges(artifact)}</div><div class="card-footer"><span>{artifact.servers.length} matching server{artifact.servers.length === 1 ? '' : 's'}</span><span>{artifact.versionCount} version{artifact.versionCount === 1 ? '' : 's'}</span></div></a>
|
||||
{/each}</div>
|
||||
</section>
|
||||
{/if}
|
||||
{#if filteredOverlays.length}
|
||||
<section class="section"><div class="section-heading"><h2>Overlays <span>{filteredOverlays.length}</span></h2><p>Small changes with a clear scope</p></div>
|
||||
<div class="overlay-list">{#each filteredOverlays as artifact (artifact.id)}
|
||||
<a class="overlay-row" href="/configs/{artifact.id}"><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_value}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a>
|
||||
{/each}</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if artifacts.length}<p class="footnote">Choose a config to edit and preview a draft, or inspect a server to trace its promoted settings.</p>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet versionBadges(artifact: Artifact)}
|
||||
{#if artifact.currentVersion}<Badge variant="online" size="sm">v{artifact.currentVersion.version} promoted</Badge>{:else}<Badge variant="neutral" size="sm">Never promoted</Badge>{/if}
|
||||
{#if artifact.latestVersion && artifact.latestVersion.id !== artifact.current_version_id}<Badge variant={artifact.latestVersion.state === 'test-failed' ? 'danger' : artifact.latestVersion.state === 'tested' ? 'online' : 'neutral'} size="sm">v{artifact.latestVersion.version} {artifact.latestVersion.state.replace('-', ' ')}</Badge>{/if}
|
||||
{#if artifact.runningBetaId}<Badge variant="warning" size="sm">β{artifact.runningBetaId} active</Badge>{/if}
|
||||
{/snippet}
|
||||
|
||||
<dialog bind:this={dialog} aria-labelledby="new-config-title" oncancel={event => { if (saving) event.preventDefault(); }}>
|
||||
<form onsubmit={create}>
|
||||
<div class="dialog-heading"><div><span class="eyebrow">Create version 1</span><h2 id="new-config-title">New config</h2></div><Button variant="ghost" onclick={() => dialog.close()} disabled={saving} ariaLabel="Close new config">✕</Button></div>
|
||||
<div class="kind-picker"><button type="button" class:active={kind === 'config'} aria-pressed={kind === 'config'} onclick={() => kind = 'config'} disabled={saving}><strong>Overlay</strong><span>Override selected settings</span></button><button type="button" class:active={kind === 'baseline'} aria-pressed={kind === 'baseline'} onclick={() => kind = 'baseline'} disabled={saving}><strong>Baseline</strong><span>A complete config for a game</span></button></div>
|
||||
<div class="form-grid"><label>Name<input bind:value={name} placeholder={kind === 'config' ? 'No AFK timeout' : 'T5 baseline'} required disabled={saving} /></label><label>Game<select aria-label="Game" bind:value={scopeGame} onchange={() => scopeMap = ''} required disabled={saving}><option value="" disabled>Choose a game</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select></label>
|
||||
{#if kind === 'config'}<label>Apply to<select aria-label="Apply to" bind:value={scopeType} disabled={saving}><option value="game">Whole game</option><option value="map">One map</option></select></label>{#if scopeType === 'map'}<label>Map<select aria-label="Map" bind:value={scopeMap} required disabled={saving || !scopeGame}><option value="" disabled>Choose a map</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option value={option}>{option}</option>{/each}</select></label>{/if}{/if}
|
||||
</div>
|
||||
{#if baselineExists}<p class="warning">This game already has a baseline. Open it from the library to save a new version.</p>{/if}
|
||||
{#if !gameOptions.length}<p class="warning">Register a server first to make its game and map available here.</p>{/if}
|
||||
<label class="import">Start from a file <input type="file" accept=".cfg,text/plain" onchange={importFile} disabled={saving} /></label>
|
||||
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving} />
|
||||
<label class="notes">Notes<input bind:value={notes} placeholder="What is this config for?" disabled={saving} /></label>
|
||||
{#if createError}<div class="error" role="alert">{createError}</div>{/if}
|
||||
<div class="dialog-actions"><p>Saved as untested. Test in a beta before promotion.</p><Button variant="ghost" onclick={() => dialog.close()} disabled={saving}>Cancel</Button><Button type="submit" variant="primary" loading={saving} disabled={baselineExists || !name.trim() || !selectedScope || !text.trim()}>Create {kind === 'baseline' ? 'baseline' : 'overlay'}</Button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
.overview { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
.overview > div { display: flex; align-items: center; gap: 18px; padding: 24px; } .overview > div + div { border-left: 1px solid var(--border-subtle); }
|
||||
.metric { font-size: 32px; font-weight: 600; letter-spacing: -.04em; } .metric.pending { color: var(--state-warning); }
|
||||
.overview > div > span:last-child { font-size: var(--text-sm); } small { display: block; color: var(--text-secondary); font-size: 11px; margin-top: 5px; }
|
||||
.navigation, .view-switch, .section-heading, .card-top, .card-footer, .overlay-row, .overlay-main, .versions { display: flex; align-items: center; gap: 12px; }
|
||||
.navigation, .section-heading, .card-top, .card-footer { justify-content: space-between; }
|
||||
.navigation { border-bottom: 1px solid var(--border-subtle); padding-bottom: 12px; }
|
||||
.view-switch { gap: 4px; }
|
||||
button { background: transparent; color: var(--text-secondary); font: var(--text-sm) var(--font-body); border: 1px solid transparent; border-radius: var(--radius-sm); padding: 10px 14px; cursor: pointer; }
|
||||
button.active { color: var(--text-primary); background: var(--surface-raised); } button span { margin-left: 8px; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 10px; } .search { flex: 1 1 260px; }
|
||||
input, select { box-sizing: border-box; min-width: 0; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 11px 12px; font: var(--text-sm) var(--font-body); }
|
||||
.section { display: flex; flex-direction: column; gap: 14px; }
|
||||
h2 { font-size: var(--text-md); margin: 0; letter-spacing: var(--tracking-tight); } h2 span { font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; margin-left: 8px; }
|
||||
.section-heading p { margin: 0; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.baseline-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 16px; }
|
||||
.baseline-card { padding: 24px; background: linear-gradient(140deg, color-mix(in oklab, var(--accent-primary) 7%, var(--surface)), var(--surface)); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); text-decoration: none; color: var(--text-primary); transition: border-color var(--dur-fast); }
|
||||
.baseline-card:hover, .overlay-row:hover { border-color: var(--accent-primary); }
|
||||
.game-label { font: 600 var(--text-xs) var(--font-mono); letter-spacing: .08em; color: var(--accent-primary-hover); }
|
||||
.open-arrow { color: var(--text-secondary); }
|
||||
h3 { font-size: var(--text-md); margin: 16px 0; font-weight: 600; } .versions { flex-wrap: wrap; gap: 6px; }
|
||||
.card-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 24px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.overlay-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.overlay-row { text-decoration: none; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 18px 20px; }
|
||||
.overlay-main { flex: 1; min-width: 0; } .overlay-main h3 { margin: 0 0 5px; font-size: var(--text-sm); }
|
||||
.overlay-icon { display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; border-radius: 8px; background: var(--surface-raised); color: var(--accent-primary-hover); font-size: 24px; }
|
||||
.scope-label, .servers { color: var(--text-secondary); font-size: var(--text-xs); } .servers { min-width: 80px; text-align: right; }
|
||||
.footnote { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.loading { padding: 60px 0; text-align: center; color: var(--text-secondary); }
|
||||
.welcome { padding: 56px 32px; border-radius: var(--radius-lg); background: var(--surface); border: 1px solid var(--border-strong); text-align: center; }
|
||||
.welcome h2 { margin-top: 12px; font-size: var(--text-2xl); } .welcome p { color: var(--text-secondary); max-width: 540px; margin: 16px auto 24px; }
|
||||
.eyebrow { color: var(--accent-primary-hover); font-size: var(--text-xs); }
|
||||
.error { color: var(--state-danger); padding: 12px; background: var(--surface-raised); border-radius: var(--radius-sm); font-size: var(--text-sm); }
|
||||
.warning { color: var(--state-warning); font-size: var(--text-sm); }
|
||||
dialog { box-sizing: border-box; width: min(860px, calc(100vw - 32px)); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-raised); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(4px); }
|
||||
form { padding: 28px; display: flex; flex-direction: column; gap: 20px; }
|
||||
.dialog-heading, .dialog-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .dialog-heading h2 { margin-top: 6px; font-size: var(--text-xl); }
|
||||
.kind-picker { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.kind-picker button { padding: 16px; border: 1px solid var(--border-strong); text-align: left; }
|
||||
.kind-picker button.active { border-color: var(--accent-primary); } .kind-picker span { display: block; margin: 6px 0 0; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.import { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } .import input { flex: 1; }
|
||||
.dialog-actions { flex-wrap: wrap; } .dialog-actions p { flex: 1 1 240px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
@media (max-width: 900px) { .overview > div { padding: 16px; gap: 12px; } .overview small { display: none; } .overlay-row { flex-wrap: wrap; } .overlay-main { flex-basis: 50%; } }
|
||||
@media (max-width: 600px) { .overview { grid-template-columns: 1fr; } .overview > div + div { border-left: 0; border-top: 1px solid var(--border-subtle); } .overview > div { padding: 12px 16px; } .metric { font-size: 24px; min-width: 30px; } .overview small { display: block; } .filters select { flex: 1; } .section-heading p { display: none; } .overlay-row > .versions { order: 3; width: 100%; } .servers { min-width: auto; } .overlay-row > .open-arrow { display: none; } .form-grid { grid-template-columns: 1fr; } form { padding: 18px; } .navigation { gap: 4px; flex-wrap: wrap; } }
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { changeConfigValue, configLines, configWarnings } from '$lib/config-workbench.js';
|
||||
let { text = $bindable(''), overlay = false, disabled = false }: { text?: string; overlay?: boolean; disabled?: boolean } = $props();
|
||||
let mode = $state<'raw' | 'settings'>('raw');
|
||||
let search = $state('');
|
||||
const entries = $derived(configLines(text));
|
||||
const visible = $derived(entries.filter(e => `${e.key} ${e.value}`.toLowerCase().includes(search.toLowerCase())));
|
||||
const warnings = $derived(configWarnings(text, overlay));
|
||||
</script>
|
||||
|
||||
<div class="editor">
|
||||
<div class="toolbar">
|
||||
<div class="switch" aria-label="Editor view">
|
||||
<button type="button" class:active={mode === 'raw'} aria-pressed={mode === 'raw'} onclick={() => mode = 'raw'}>Raw .cfg</button>
|
||||
<button type="button" class:active={mode === 'settings'} aria-pressed={mode === 'settings'} onclick={() => mode = 'settings'}>Settings <span>{entries.length}</span></button>
|
||||
</div>
|
||||
<span class="hint">{text.split(/\r?\n/).length} lines</span>
|
||||
</div>
|
||||
{#if mode === 'raw'}
|
||||
<textarea aria-label="Config contents" bind:value={text} {disabled} spellcheck="false" rows="12" placeholder={'// Add your settings\nset g_inactivity "0"'}></textarea>
|
||||
{:else}
|
||||
<div class="settings">
|
||||
<input class="search" aria-label="Find a setting" bind:value={search} placeholder="Find a setting…" />
|
||||
<p class="hint">Edit values and inline comments. Other commands and formatting stay in the raw file.</p>
|
||||
{#each visible as entry (entry.index)}
|
||||
<label class="setting"><span><code>{entry.key}</code><small>Line {entry.index + 1}</small></span>
|
||||
<input value={entry.value} {disabled} aria-label={`${entry.key}, line ${entry.index + 1}`} oninput={e => text = changeConfigValue(text, entry.index, e.currentTarget.value)} />
|
||||
</label>
|
||||
{:else}<p class="hint">{entries.length ? 'No matching settings.' : 'Add set KEY VALUE lines in the raw editor to get started.'}</p>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if warnings.length}
|
||||
<details class="warnings"><summary>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'}</summary>
|
||||
<ul>{#each warnings as warning}<li>{warning}</li>{/each}</ul>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.editor { border: 1px solid var(--border-strong); border-radius: var(--radius-md); overflow: hidden; background: var(--surface-sunken); }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; gap: 12px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.switch { display: flex; gap: 4px; }
|
||||
button { font: inherit; font-size: var(--text-sm); color: var(--text-secondary); border: 0; border-radius: var(--radius-sm); padding: 8px 12px; background: transparent; cursor: pointer; }
|
||||
button.active { color: var(--text-primary); background: var(--surface-raised); }
|
||||
button span { margin-left: 8px; color: var(--text-secondary); }
|
||||
button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -2px; }
|
||||
textarea { display: block; width: 100%; height: 360px; min-height: 240px; box-sizing: border-box; resize: vertical; border: 0; padding: var(--space-5); background: transparent; color: var(--text-primary); font: var(--text-sm)/1.8 var(--font-mono); tab-size: 4; }
|
||||
textarea:disabled, input:disabled { opacity: .65; }
|
||||
.settings { padding: var(--space-4); max-height: 520px; overflow: auto; }
|
||||
input { box-sizing: border-box; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 9px 12px; font: var(--text-sm) var(--font-mono); width: 100%; }
|
||||
.setting { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr); align-items: center; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-subtle); }
|
||||
.setting span { min-width: 0; overflow-wrap: anywhere; }
|
||||
code { font: var(--text-sm) var(--font-mono); }
|
||||
small { display: block; color: var(--text-secondary); font-size: 11px; margin-top: 4px; }
|
||||
.hint { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.warnings { color: var(--state-warning); font-size: var(--text-sm); padding: 12px 0 0; }
|
||||
summary { cursor: pointer; }
|
||||
li { margin: 6px 0; }
|
||||
@media (max-width: 600px) { .setting { grid-template-columns: 1fr; gap: 8px; } .toolbar > .hint { display: none; } }
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getScript, getVersion, getLatestVersion } from '$lib/server/fleet/scripts.js';
|
||||
|
||||
export async function GET({ params, url }: { params: { id: string }; url: URL }) {
|
||||
const id = Number(params.id);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return json({ error: 'Invalid config ID.' }, { status: 400 });
|
||||
const script = getScript(id);
|
||||
if (!script || !['config', 'baseline'].includes(script.kind)) return json({ error: 'Config not found.' }, { status: 404 });
|
||||
const requested = url.searchParams.get('version');
|
||||
const version = requested === null ? getLatestVersion(id) : getVersion(Number(requested));
|
||||
if (!version || version.script_id !== id) return json({ error: 'Config version not found.' }, { status: 404 });
|
||||
return json({ text: version.content.toString('utf8'), versionId: version.id, version: version.version }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServer, listServers } from '$lib/server/fleet/servers.js';
|
||||
import { getBaselineForScope, getScript, getVersion, listPromotedOverlaysForScope, type ScriptRow } from '$lib/server/fleet/scripts.js';
|
||||
import { inspectConfig, type ConfigLayer } from '$lib/server/fleet/config-inspect.js';
|
||||
import type { ConfigInspection } from '$lib/config-workbench.js';
|
||||
|
||||
function inspect(serverName: string, draft?: { artifactId: number; text: string }): ConfigInspection {
|
||||
const server = getServer(serverName);
|
||||
if (!server || server.destroyed_at) throw new Error('Server not found.');
|
||||
const artifact = draft ? getScript(draft.artifactId) : undefined;
|
||||
if (draft && (!artifact || !['baseline', 'config'].includes(artifact.kind))) throw new Error('Config not found.');
|
||||
if (artifact && (artifact.scope_type === null || artifact.scope_value !== server[artifact.scope_type])) {
|
||||
throw new Error('This config does not apply to the selected server.');
|
||||
}
|
||||
if (artifact?.kind === 'baseline' && artifact.scope_type !== 'game') throw new Error('Server composition requires a game baseline.');
|
||||
const baseline = artifact?.kind === 'baseline' ? artifact : server.game ? getBaselineForScope('game', server.game) : undefined;
|
||||
function layer(row: ScriptRow): ConfigLayer | null {
|
||||
const isDraft = row.id === draft?.artifactId;
|
||||
const version = row.current_version_id ? getVersion(row.current_version_id) : undefined;
|
||||
if (!isDraft && !version) return null;
|
||||
return {
|
||||
source: { id: row.id, name: row.name, version: isDraft ? null : version!.version, kind: row.kind as 'baseline' | 'config', draft: isDraft },
|
||||
text: isDraft ? draft!.text : version!.content.toString('utf8')
|
||||
};
|
||||
}
|
||||
const base = baseline ? layer(baseline) : null;
|
||||
if (!base) return { text: '', sources: [], settings: [], warnings: [], reason: 'No promoted baseline for this game. Promote a baseline through a beta to inspect the managed configuration.' };
|
||||
const overlays = [
|
||||
...(server.game ? listPromotedOverlaysForScope('game', server.game) : []),
|
||||
...(server.map ? listPromotedOverlaysForScope('map', server.map) : [])
|
||||
].filter(row => row.id !== artifact?.id);
|
||||
if (artifact?.kind === 'config') overlays.push(artifact);
|
||||
return inspectConfig(base, overlays.map(layer).filter((l): l is ConfigLayer => l !== null), server.game, server.map, server.map_slug);
|
||||
}
|
||||
|
||||
export async function GET({ url }: { url: URL }) {
|
||||
const servers = listServers().filter(s => !s.destroyed_at).map(({ name, game, map }) => ({ name, game, map }));
|
||||
try {
|
||||
const server = url.searchParams.get('server');
|
||||
return json({ servers, inspection: server ? inspect(server) : null }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
let body;
|
||||
try { body = await request.json(); } catch { return json({ error: 'Invalid preview request.' }, { status: 400 }); }
|
||||
if (!body || typeof body.server !== 'string' || !Number.isSafeInteger(body.artifactId) || typeof body.text !== 'string') {
|
||||
return json({ error: 'Server, config ID and text are required.' }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
return json({ inspection: inspect(body.server, body) }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (err) {
|
||||
return json({ error: (err as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
||||
const script = getScript(scriptId);
|
||||
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
|
||||
|
||||
if ((script.kind === 'config' || script.kind === 'baseline') && findRunningBetaForScript(scriptId)) {
|
||||
return json({ ok: false, error: 'Finish the active beta before saving a config version.' }, { status: 409 });
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const notes = form.get('notes') ? String(form.get('notes')) : undefined;
|
||||
const file = form.get('content');
|
||||
|
||||
@@ -1,397 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import Textarea from '$ui/components/primitives/Textarea.svelte';
|
||||
|
||||
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
||||
interface ArtifactRow {
|
||||
id: number; name: string; target_rel_path: string;
|
||||
scope_type: 'game' | 'map' | null; scope_value: string | null;
|
||||
current_version_id: number | null; kind: ArtifactKind;
|
||||
latestVersion: {
|
||||
id: number; version: number; sha256: string; size: number;
|
||||
state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string;
|
||||
} | null;
|
||||
versionCount: number;
|
||||
servers: string[];
|
||||
runningBetaId: number | null;
|
||||
}
|
||||
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
|
||||
|
||||
let baselines = $state<ArtifactRow[]>([]);
|
||||
let overlays = $state<ArtifactRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
let uploading = $state(false);
|
||||
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
|
||||
let successBanner = $state<{ name: string; artifactId: number; version: number; kind: string } | null>(null);
|
||||
|
||||
let overlayName = $state('');
|
||||
let overlayScopeType = $state<'game' | 'map'>('game');
|
||||
let overlayScopeGame = $state('');
|
||||
let overlayScopeMap = $state('');
|
||||
let overlayText = $state('');
|
||||
let overlayNotes = $state('');
|
||||
|
||||
let baselineName = $state('');
|
||||
let baselineScopeGame = $state('');
|
||||
let baselineFile = $state<File | null>(null);
|
||||
let baselineNotes = $state('');
|
||||
|
||||
const overlayScopeValue = $derived.by(() => {
|
||||
if (overlayScopeType === 'game') return overlayScopeGame;
|
||||
return overlayScopeMap;
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const [baselineRes, overlayRes, scopeRes] = await Promise.all([
|
||||
fetch('/api/configs?kind=baseline'),
|
||||
fetch('/api/configs?kind=config'),
|
||||
fetch('/api/scope-options')
|
||||
]);
|
||||
if (!baselineRes.ok || !overlayRes.ok) {
|
||||
flash = `Configs API returned ${baselineRes.status}/${overlayRes.status}. If dev, restart to run DB migrations.`;
|
||||
baselines = []; overlays = [];
|
||||
} else {
|
||||
baselines = ((await baselineRes.json()) as { scripts?: ArtifactRow[] }).scripts ?? [];
|
||||
overlays = ((await overlayRes.json()) as { scripts?: ArtifactRow[] }).scripts ?? [];
|
||||
}
|
||||
if (scopeRes.ok) {
|
||||
scopeOptions = ((await scopeRes.json()) as ScopeOptions | undefined) ?? { games: [], mapsByGame: {} };
|
||||
}
|
||||
} catch (err) {
|
||||
flash = `Failed to load configs: ${(err as Error).message}`;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
onMount(load);
|
||||
|
||||
async function createOverlay(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (uploading) return;
|
||||
if (!overlayName.trim()) { flash = 'Name is required'; return; }
|
||||
if (!overlayScopeValue) { flash = `Pick a ${overlayScopeType}`; return; }
|
||||
if (!/^\s*set\s+\S+\s+/m.test(overlayText)) {
|
||||
flash = 'Overlay must contain at least one `set KEY VALUE` line';
|
||||
return;
|
||||
}
|
||||
uploading = true; flash = null;
|
||||
const form = new FormData();
|
||||
form.append('name', overlayName.trim());
|
||||
form.append('kind', 'config');
|
||||
form.append('scopeType', overlayScopeType);
|
||||
form.append('scopeValue', overlayScopeValue);
|
||||
form.append('text', overlayText);
|
||||
if (overlayNotes.trim()) form.append('notes', overlayNotes.trim());
|
||||
try {
|
||||
const res = await fetch('/api/configs', { method: 'POST', body: form });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string; script?: { id: number; name: string } };
|
||||
if (!res.ok || !body.ok) { flash = body.error ?? `HTTP ${res.status}`; return; }
|
||||
successBanner = body.script
|
||||
? { name: body.script.name, artifactId: body.script.id, version: 1, kind: 'config overlay' }
|
||||
: null;
|
||||
overlayName = ''; overlayText = ''; overlayNotes = '';
|
||||
await load();
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickBaselineFile(e: Event): void {
|
||||
const t = e.target as HTMLInputElement;
|
||||
baselineFile = t.files?.[0] ?? null;
|
||||
if (!baselineName && baselineFile) baselineName = baselineFile.name.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
|
||||
async function createBaseline(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (uploading) return;
|
||||
if (!baselineName.trim() || !baselineFile || !baselineScopeGame) {
|
||||
flash = 'Name, game, and cfg file are required';
|
||||
return;
|
||||
}
|
||||
uploading = true; flash = null;
|
||||
const form = new FormData();
|
||||
form.append('name', baselineName.trim());
|
||||
form.append('kind', 'baseline');
|
||||
form.append('scopeType', 'game');
|
||||
form.append('scopeValue', baselineScopeGame);
|
||||
form.append('content', baselineFile);
|
||||
if (baselineNotes.trim()) form.append('notes', baselineNotes.trim());
|
||||
try {
|
||||
const res = await fetch('/api/configs', { method: 'POST', body: form });
|
||||
const body = (await res.json()) as { ok: boolean; error?: string; script?: { id: number; name: string } };
|
||||
if (!res.ok || !body.ok) { flash = body.error ?? `HTTP ${res.status}`; return; }
|
||||
successBanner = body.script
|
||||
? { name: body.script.name, artifactId: body.script.id, version: 1, kind: 'baseline' }
|
||||
: null;
|
||||
baselineName = ''; baselineScopeGame = ''; baselineFile = null; baselineNotes = '';
|
||||
await load();
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(a: ArtifactRow): string {
|
||||
if (!a.scope_type) return 'unscoped';
|
||||
return `${a.scope_type} · ${a.scope_value ?? '?'}`;
|
||||
}
|
||||
function stateVariantFor(a: ArtifactRow): 'online' | 'warning' | 'danger' | 'neutral' | null {
|
||||
if (!a.latestVersion) return null;
|
||||
if (a.current_version_id === null) return 'neutral';
|
||||
if (a.latestVersion.id !== a.current_version_id) {
|
||||
if (a.latestVersion.state === 'untested') return 'neutral';
|
||||
if (a.latestVersion.state === 'tested') return 'online';
|
||||
if (a.latestVersion.state === 'test-failed') return 'danger';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function stateLabel(a: ArtifactRow): string {
|
||||
if (!a.latestVersion) return '';
|
||||
if (a.current_version_id === null) return 'untested';
|
||||
if (a.latestVersion.id !== a.current_version_id) {
|
||||
return `v${a.latestVersion.version} ${a.latestVersion.state.replace('-', ' ')}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
let gameOptions = $derived([
|
||||
{ value: '', label: '— Pick a game —', disabled: true },
|
||||
...scopeOptions.games.map((g) => ({ value: g, label: g }))
|
||||
]);
|
||||
let mapOptions = $derived([
|
||||
{ value: '', label: '— Pick a map —', disabled: true },
|
||||
...(scopeOptions.mapsByGame[overlayScopeGame] ?? []).map((m) => ({ value: m, label: m }))
|
||||
]);
|
||||
let scopeTypeOptions = [
|
||||
{ value: 'game', label: 'Game' },
|
||||
{ value: 'map', label: 'Map' }
|
||||
];
|
||||
<script lang="ts">
|
||||
import ConfigLibrary from '$ui/components/ConfigLibrary.svelte';
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader
|
||||
title="Configs"
|
||||
subtitle="One baseline per game, plus scoped overlays. Each server's selected map is applied automatically."
|
||||
/>
|
||||
|
||||
{#if successBanner}
|
||||
<div class="banner">
|
||||
<strong>v{successBanner.version} of "{successBanner.name}" ({successBanner.kind}) uploaded — untested.</strong>
|
||||
<span>Open a beta on <a href="/betas">the betas page</a> to test it before promotion.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Baseline upload -->
|
||||
<Card padding="lg">
|
||||
<h2 class="panel-title">Upload a baseline <span class="dim">(one per game)</span></h2>
|
||||
<form onsubmit={createBaseline} class="upload-form">
|
||||
<Field label="Name">
|
||||
<Input bind:value={baselineName} placeholder="Game baseline" disabled={uploading} />
|
||||
</Field>
|
||||
<Field label="Game">
|
||||
<Select bind:value={baselineScopeGame} options={gameOptions} disabled={uploading} />
|
||||
</Field>
|
||||
<Field label="Notes" hint="Optional. Describe what this baseline covers.">
|
||||
<Input bind:value={baselineNotes} placeholder="Server config baseline" disabled={uploading} />
|
||||
</Field>
|
||||
<Field label="Server cfg file">
|
||||
<Input type="file" accept=".cfg,text/plain" onchange={pickBaselineFile} disabled={uploading} />
|
||||
</Field>
|
||||
<div class="form-actions">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={uploading || !baselineFile || !baselineName || !baselineScopeGame}
|
||||
loading={uploading}
|
||||
>
|
||||
Upload baseline
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<!-- Overlay creation -->
|
||||
<Card padding="lg">
|
||||
<h2 class="panel-title">Create a config overlay</h2>
|
||||
<form onsubmit={createOverlay} class="upload-form">
|
||||
<Field label="Name">
|
||||
<Input bind:value={overlayName} placeholder="disable AFK" disabled={uploading} />
|
||||
</Field>
|
||||
<Field label="Scope">
|
||||
<div class="scope-row">
|
||||
<Select bind:value={overlayScopeType} options={scopeTypeOptions} disabled={uploading} />
|
||||
<Select bind:value={overlayScopeGame} options={gameOptions} disabled={uploading} />
|
||||
{#if overlayScopeType === 'map'}
|
||||
<Select bind:value={overlayScopeMap} options={mapOptions} disabled={uploading || !overlayScopeGame} />
|
||||
{/if}
|
||||
</div>
|
||||
</Field>
|
||||
<div class="full">
|
||||
<Field label="Overlay lines" hint="One or more `set KEY "VALUE"` lines. The server's selected map takes precedence over sv_maprotation; no map-choice overlay is needed.">
|
||||
<Textarea
|
||||
bind:value={overlayText}
|
||||
rows={4}
|
||||
mono
|
||||
placeholder={'set g_inactivity "0"'}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Notes" hint="Optional. Explain the reason for this overlay.">
|
||||
<Input bind:value={overlayNotes} placeholder="why this change" disabled={uploading} />
|
||||
</Field>
|
||||
<div class="form-actions">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={uploading || !overlayName || !overlayText || !overlayScopeValue}
|
||||
loading={uploading}
|
||||
>
|
||||
Create overlay
|
||||
</Button>
|
||||
{#if flash}<span class="flash">{flash}</span>{/if}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<!-- Baselines list -->
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2>Baselines</h2>
|
||||
<Badge variant="neutral" size="sm">{baselines.length}</Badge>
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else if baselines.length === 0}
|
||||
<EmptyState title="No baseline yet" description="Upload a per-game baseline above." />
|
||||
{:else}
|
||||
<div class="rows">
|
||||
{#each baselines as a (a.id)}
|
||||
{@render artifactRow(a)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Overlays list -->
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2>Overlays</h2>
|
||||
<Badge variant="neutral" size="sm">{overlays.length}</Badge>
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
{:else if overlays.length === 0}
|
||||
<EmptyState title="No overlays yet" description="Create a scoped overlay above." />
|
||||
{:else}
|
||||
<div class="rows">
|
||||
{#each overlays as a (a.id)}
|
||||
{@render artifactRow(a)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#snippet artifactRow(a: ArtifactRow)}
|
||||
<a class="row" href="/configs/{a.id}">
|
||||
<div class="row-top">
|
||||
<span class="name">{a.name}</span>
|
||||
<Badge
|
||||
variant={a.scope_type === 'game' ? 'info' : a.scope_type === 'map' ? 'warning' : 'neutral'}
|
||||
size="sm"
|
||||
>
|
||||
{scopeLabel(a)}
|
||||
</Badge>
|
||||
{#if stateVariantFor(a)}
|
||||
{@const v = stateVariantFor(a)!}
|
||||
<Badge variant={v} size="sm">{stateLabel(a)}</Badge>
|
||||
{/if}
|
||||
{#if a.runningBetaId}
|
||||
<Badge variant="warning" size="sm">β{a.runningBetaId} running</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
<span class="path">{a.target_rel_path}</span>
|
||||
<span>v{a.latestVersion?.version ?? '—'} · {a.versionCount} version{a.versionCount === 1 ? '' : 's'}</span>
|
||||
{#if a.servers.length}
|
||||
<span>{a.servers.length} matching server{a.servers.length === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); }
|
||||
|
||||
.banner {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: rgba(124, 212, 255, 0.06);
|
||||
border: 1px solid rgba(124, 212, 255, 0.22);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.banner a { color: var(--state-online); text-decoration: underline; }
|
||||
|
||||
.panel-title {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.panel-title .dim {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: var(--weight-regular);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.upload-form { display: grid; gap: var(--space-4); grid-template-columns: 1fr 1fr; }
|
||||
.upload-form .full { grid-column: 1 / -1; }
|
||||
.scope-row { display: flex; gap: var(--space-2); flex-wrap: wrap; }
|
||||
.scope-row :global(.wrap) { flex: 1 1 140px; min-width: 140px; }
|
||||
.form-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
|
||||
.flash { color: var(--state-danger); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
|
||||
.section { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.section-head { display: flex; align-items: center; gap: var(--space-3); }
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rows { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.row {
|
||||
display: flex; flex-direction: column; gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: color-mix(in oklab, var(--surface) 88%, transparent);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: background-color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.row:hover { background: var(--surface-raised); border-color: var(--border-strong); }
|
||||
.row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.row-top { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; }
|
||||
.name { font-size: var(--text-md); font-weight: var(--weight-semibold); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.row-meta { display: flex; gap: var(--space-5); color: var(--text-tertiary); font-family: var(--font-mono); font-size: var(--text-xs); flex-wrap: wrap; }
|
||||
.path { color: var(--text-secondary); }
|
||||
|
||||
@media (max-width: 720px) { .upload-form { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
<svelte:head><title>Configs · Electric Cherry</title></svelte:head>
|
||||
<ConfigLibrary />
|
||||
|
||||
Reference in New Issue
Block a user