622 lines
21 KiB
Svelte
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>
|