Files
electric-cherry/src/lib/ui/components/ConfigInspector.svelte
T
2026-10-10 00:19:48 -04:00

622 lines
21 KiB
Svelte

<script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { onMount } from 'svelte';
import { serverTitle } from '$ui/server-labels.js';
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
import Button from './primitives/Button.svelte';
import { saveLibraryContext } from '$ui/library-context.js';
let {
artifactId,
draftText,
scopeType,
scopeValue,
fixedServer
}: {
fixedServer?: string;
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 promoted = $state<ConfigInspection | null>(null);
let expanded = $state<string[]>([]);
let inspectedServer = '';
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);
let desiredServer = '';
const eligible = $derived(
servers.filter(
(s) =>
(!fixedServer || s.name === fixedServer) &&
(!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);
const previousValues = $derived(
new Map((promoted?.settings ?? []).map((s) => [s.key, s.value]))
);
const changes = $derived(
promoted && inspection
? inspection.settings.filter((s) => previousValues.get(s.key) !== s.value)
: []
);
const removed = $derived(
promoted?.settings.filter(
(s) => inspection && !inspection.settings.some((current) => current.key === s.key)
) ?? []
);
function label(source: ConfigSource): string {
return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`;
}
function overriddenCount(source: ConfigSource) {
return (
inspection?.settings.filter(
(s) =>
(s.source.id !== source.id || s.source.kind !== source.kind) &&
s.history.some(
(h) => h.source.id === source.id && h.source.kind === source.kind
)
).length ?? 0
);
}
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(() => {
desiredServer = new URLSearchParams(window.location.search).get('server') ?? '';
void loadServers();
});
$effect(() => {
if (!eligible.some((s) => s.name === selected))
selected =
eligible.find((s) => s.name === desiredServer)?.name ?? eligible[0]?.name ?? '';
});
$effect(() => {
if (ready && selected && !fixedServer) saveLibraryContext({ server: selected });
});
$effect(() => {
const server = selected;
const text = draftText;
const id = artifactId;
void reload;
if (!ready || !server) {
inspection = null;
if (ready) loading = false;
return;
}
const controller = new AbortController();
loading = true;
error = '';
if (inspectedServer !== server) {
inspection = null;
promoted = null;
expanded = [];
inspectedServer = server;
}
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;
promoted = body.promoted ?? null;
}
} 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>
{#if !fixedServer}<span class="eyebrow"
>{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span
>{/if}
<h2>{artifactId ? 'On this server' : 'Promoted 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>
{#if !fixedServer}<Field class="server-label" label="Server">
<Select
aria-label="Server"
bind:value={selected}
disabled={!!fixedServer || !eligible.length}
>
{#each eligible as server}<option value={server.name}
>{serverTitle(server.name)}</option
>{/each}
{#if !eligible.length}<option value="">No matching servers</option>{/if}
</Select></Field
>{/if}
{#if error}<div class="error" role="alert">
{error}{inspection ? ' Showing the last completed preview.' : ''}
</div>{/if}
{#if !fixedServer || loading}<p class="preview-status" role="status">
{loading
? inspection
? 'Updating preview… Showing the last completed result.'
: 'Composing configuration…'
: inspection
? 'Preview up to date.'
: ''}
</p>{/if}
{#if !eligible.length && !loading}<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">
{fixedServer && inspection.reason.startsWith('No promoted baseline')
? `No promoted baseline for ${servers.find((item) => item.name === fixedServer)?.game === 't6' ? 'Black Ops II' : servers.find((item) => item.name === fixedServer)?.game === 't5' ? 'Black Ops I' : 'this game'}.`
: inspection.reason}
</p>
{#if fixedServer}<a href="/configs">Open config library →</a>{/if}
{:else if inspection}
<ol class="sources" aria-label="Composition order, later layers take precedence">
{#each inspection.sources as source, index}
<li>
<span class="layer-number">{index + 1}.</span>{#if source.id}<a
class:draft={source.draft}
href="/configs/{source.id}">{label(source)}</a
>
{:else}<span class="map-source">{source.name}</span>{/if}<span
>{overriddenCount(source)} settings superseded later</span
>
</li>
{/each}
</ol>
<p class="hint">
Overlays apply in creation order; later overlays win for the same key. The selected map
takes precedence over map rotation.
</p>
{#if promoted}<p class="change-summary">
Compared with promoted settings: {changes.length} changed or added, {removed.length} removed.
</p>
{#if removed.length}<details>
<summary>Removed settings</summary>
<ul>
{#each removed as setting}<li>
<code>{setting.key}</code> · previously {setting.value}
</li>{/each}
</ul>
</details>{/if}{/if}
<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">
<Field label="Search effective settings" hiddenLabel class="search"
><Input
aria-label="Search effective settings"
bind:value={query}
placeholder="Search settings or sources…"
/></Field
><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"
open={expanded.includes(setting.key)}
ontoggle={(event) => {
const open = event.currentTarget.open;
expanded = open
? [...new Set([...expanded, setting.key])]
: expanded.filter((key) => key !== setting.key);
}}
>
<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
>{#if promoted && previousValues.get(setting.key) !== setting.value}<span
class="change-summary"
>{previousValues.has(setting.key)
? `Changed from ${previousValues.get(setting.key)}`
: 'Added by draft'}</span
>{/if}</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;
}
.inspector :global(.server-label) {
display: grid;
gap: 8px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
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);
}
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: grid;
gap: 8px;
margin: 20px 0 0;
padding: 0;
border: 0;
font-size: var(--text-xs);
list-style: none;
}
.sources li {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 0;
}
.layer-number {
color: var(--text-primary);
font-weight: 600;
}
.preview-status {
font-size: var(--text-xs);
color: var(--text-secondary);
min-height: 20px;
}
.change-summary {
display: block;
font-size: var(--text-xs);
color: var(--state-warning);
margin-top: 8px;
}
.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 {
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 > :global(.search) {
flex: 1 1 180px;
}
.filters {
margin: 16px 0 8px;
}
.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(--text-primary);
}
.source-label {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: var(--text-xs);
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: var(--text-xs);
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: 8px 0;
}
.error {
color: var(--state-danger);
margin-top: 16px;
font-size: var(--text-sm);
}
@media (max-width: 600px) {
.inspector {
padding: 16px;
}
}
input:not([type='checkbox']):not([type='file']),
textarea {
border-color: var(--border-control);
}
</style>