import { useState, useCallback, useEffect, useMemo } from 'react';
import {
useConfig,
useRawConfig,
useSources,
useSinks,
useSanitizers,
useTerminators,
useProfiles,
} from '../api/queries/config';
import {
useAddSource,
useDeleteSource,
useAddSink,
useDeleteSink,
useAddSanitizer,
useDeleteSanitizer,
useAddTerminator,
useDeleteTerminator,
useAddProfile,
useDeleteProfile,
useActivateProfile,
useToggleTriageSync,
useSaveRawConfig,
} from '../api/mutations/config';
import { LoadingState } from '../components/ui/LoadingState';
import { ErrorState } from '../components/ui/ErrorState';
import { usePageTitle } from '../hooks/usePageTitle';
import { useToast } from '../contexts/ToastContext';
import { useTheme, type ThemePreference } from '../contexts/ThemeContext';
import type { LabelEntryView, TerminatorView, ProfileView } from '../api/types';
const THEME_OPTIONS: Array<{ value: ThemePreference; label: string }> = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' },
{ value: 'hc-light', label: 'High-contrast light' },
{ value: 'hc-dark', label: 'High-contrast dark' },
];
const LANG_OPTIONS = [
'javascript',
'typescript',
'python',
'go',
'java',
'c',
'cpp',
'php',
'ruby',
'rust',
];
const CAP_OPTIONS = [
'all',
'env_var',
'html_escape',
'shell_escape',
'url_encode',
'json_parse',
'file_io',
'sql_query',
'deserialize',
'ssrf',
'code_exec',
'crypto',
];
type Tab = 'overview' | 'rules' | 'profiles' | 'raw';
// ── Collapsible Config Section ───────────────────────────────────────────────
function ConfigSection({
title,
id,
defaultCollapsed = false,
children,
}: {
title: string;
id?: string;
defaultCollapsed?: boolean;
children: React.ReactNode;
}) {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
return (
setCollapsed(!collapsed)}
>
▼
{' '}
{title}
{children}
);
}
// ── Top-of-page settings panel (theme + triage sync) ────────────────────────
function SettingsSection({
triageSyncOn,
onToggleTriageSync,
}: {
triageSyncOn: boolean;
onToggleTriageSync: (enabled: boolean) => void;
}) {
const { preference, setPreference } = useTheme();
return (
);
}
// ── Read-only key/value grid for effective config display ───────────────────
function KvGrid({ entries }: { entries: Array<[string, React.ReactNode]> }) {
return (
{entries.map(([k, v]) => (
))}
);
}
function fmt(v: unknown): React.ReactNode {
if (v === null || v === undefined) return -;
if (typeof v === 'boolean')
return (
{v ? 'on' : 'off'}
);
if (Array.isArray(v)) {
if (v.length === 0) return [];
return (
{v.map(String).map((s, i) => (
{s}
))}
);
}
if (typeof v === 'object')
return {JSON.stringify(v)};
return {String(v)};
}
// ── Custom rules table (no built-ins; built-ins live on /rules) ─────────────
function CustomLabelSection({
title,
id,
kind,
entries,
onAdd,
onDelete,
}: {
title: string;
id: string;
kind: 'source' | 'sink' | 'sanitizer';
entries: LabelEntryView[];
onAdd: (body: { lang: string; matchers: string[]; cap: string }) => void;
onDelete: (entry: LabelEntryView) => void;
}) {
const [lang, setLang] = useState('');
const [matcher, setMatcher] = useState('');
const [cap, setCap] = useState('all');
const handleAdd = useCallback(() => {
if (!lang || !matcher) return;
onAdd({ lang, matchers: [matcher], cap });
setMatcher('');
}, [lang, matcher, cap, onAdd]);
return (
Custom {kind} rules from your nyx.local. Built-in rules are
listed on the Rules page.
setMatcher(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAdd();
}}
/>
{entries.length === 0 ? (
No custom {kind} rules yet
) : (
| Language |
Matchers |
Cap |
|
{entries.map((e, i) => (
| {e.lang} |
{e.matchers.join(', ')} |
{e.cap} |
|
))}
)}
);
}
// ── Raw TOML editor ─────────────────────────────────────────────────────────
function RawEditor() {
const { data, isLoading, error, refetch } = useRawConfig();
const save = useSaveRawConfig();
const [draft, setDraft] = useState(null);
const [saveError, setSaveError] = useState(null);
const [savedAt, setSavedAt] = useState(null);
// Seed the editor whenever we load fresh data and have no in-flight edit.
useEffect(() => {
if (data && draft === null) {
setDraft(data.content);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
if (isLoading) return ;
if (error) return ;
if (!data) return null;
const dirty = draft !== null && draft !== data.content;
const handleSave = async () => {
if (draft === null) return;
setSaveError(null);
try {
await save.mutateAsync(draft);
setSavedAt(Date.now());
// refresh disk view so {data.content} reflects what's on disk
await refetch();
} catch (e) {
setSaveError(e instanceof Error ? e.message : String(e));
}
};
const handleDiscard = () => {
setDraft(data.content);
setSaveError(null);
};
return (
nyx.local
{data.exists ? data.path : `${data.path} (will be created on save)`}
{dirty && Unsaved changes}
{savedAt && !dirty && Saved}
{saveError && (
Save failed: {saveError}
)}
);
}
// ── Effective config overview (read-only) ───────────────────────────────────
function EffectiveOverview({
cfg,
}: {
cfg: Record> | undefined;
}) {
const sections = useMemo(
() => [
{
key: 'scanner',
title: 'Scanner',
keys: [
'mode',
'min_severity',
'max_file_size_mb',
'excluded_directories',
'excluded_extensions',
'read_global_ignore',
'read_vcsignore',
'follow_symlinks',
'scan_hidden_files',
'include_nonprod',
'enable_state_analysis',
'enable_auth_analysis',
'enable_auth_as_taint',
'enable_panic_recovery',
],
},
{
key: 'output',
title: 'Output',
keys: [
'default_format',
'quiet',
'max_results',
'attack_surface_ranking',
'min_score',
'min_confidence',
'require_converged',
'include_quality',
'show_all',
'max_low',
'max_low_per_file',
'max_low_per_rule',
],
},
{
key: 'performance',
title: 'Performance',
keys: [
'max_depth',
'min_depth',
'worker_threads',
'batch_size',
'channel_multiplier',
'rayon_thread_stack_size',
'scan_timeout_secs',
'memory_limit_mb',
],
},
{
key: 'database',
title: 'Database',
keys: [
'path',
'auto_cleanup_days',
'max_db_size_mb',
'vacuum_on_startup',
],
},
{
key: 'server',
title: 'Server',
keys: [
'host',
'port',
'auto_open_browser',
'persist_runs',
'max_saved_runs',
'triage_sync',
],
},
{
key: 'runs',
title: 'Runs',
keys: [
'persist',
'max_runs',
'save_logs',
'save_stdout',
'save_code_snippets',
],
},
],
[],
);
return (
<>
The merged result of nyx.conf defaults plus your
nyx.local overrides. To change anything, edit fields below
or use the Raw tab.
{sections.map((s) => {
const sec = cfg?.[s.key] as Record | undefined;
const entries: Array<[string, React.ReactNode]> = s.keys.map((k) => [
k,
fmt(sec?.[k]),
]);
return (
);
})}
>
);
}
// ── Page ────────────────────────────────────────────────────────────────────
export function ConfigPage() {
usePageTitle('Config');
const {
data: config,
isLoading: configLoading,
error: configError,
} = useConfig();
const { data: sources } = useSources();
const { data: sinks } = useSinks();
const { data: sanitizers } = useSanitizers();
const { data: terminators } = useTerminators();
const { data: profiles } = useProfiles();
const addSource = useAddSource();
const deleteSource = useDeleteSource();
const addSink = useAddSink();
const deleteSink = useDeleteSink();
const addSanitizer = useAddSanitizer();
const deleteSanitizer = useDeleteSanitizer();
const addTerminator = useAddTerminator();
const deleteTerminator = useDeleteTerminator();
const addProfile = useAddProfile();
const deleteProfile = useDeleteProfile();
const activateProfile = useActivateProfile();
const toggleTriageSync = useToggleTriageSync();
const toast = useToast();
const ruleSummary = (b: { lang: string; matchers: string[]; cap: string }) =>
`${b.lang} · ${b.matchers.join(', ')} → ${b.cap}`;
const errMsg = (e: unknown) =>
e instanceof Error ? e.message : String(e ?? 'Unknown error');
const addRuleHandlers = (kind: 'source' | 'sink' | 'sanitizer') => ({
onSuccess: (
_d: unknown,
b: { lang: string; matchers: string[]; cap: string },
) =>
toast.success(
ruleSummary(b),
`${kind[0].toUpperCase()}${kind.slice(1)} added`,
),
onError: (e: unknown) => toast.error(errMsg(e), `Could not add ${kind}`),
});
const deleteRuleHandlers = (kind: 'source' | 'sink' | 'sanitizer') => ({
onSuccess: (
_d: unknown,
b: { lang: string; matchers: string[]; cap: string },
) =>
toast.success(
ruleSummary(b),
`${kind[0].toUpperCase()}${kind.slice(1)} removed`,
),
onError: (e: unknown) => toast.error(errMsg(e), `Could not remove ${kind}`),
});
const [tab, setTab] = useState('overview');
const [termLang, setTermLang] = useState('');
const [termName, setTermName] = useState('');
const [profileName, setProfileName] = useState('');
const handleAddTerminator = useCallback(() => {
if (!termLang || !termName) return;
addTerminator.mutate(
{ lang: termLang, name: termName },
{
onSuccess: (_d, b) =>
toast.success(`${b.lang} · ${b.name}`, 'Terminator added'),
onError: (e) =>
toast.error(
e instanceof Error ? e.message : String(e ?? 'Unknown error'),
'Could not add terminator',
),
},
);
setTermName('');
}, [termLang, termName, addTerminator, toast]);
const handleSaveProfile = useCallback(() => {
if (!profileName) return;
addProfile.mutate({ name: profileName, settings: {} });
setProfileName('');
}, [profileName, addProfile]);
if (configLoading) return ;
if (configError) return ;
const cfg = config as Record> | undefined;
const server = cfg?.server as Record | undefined;
const triageSyncOn = !!server?.triage_sync;
return (
{(
[
['overview', 'Overview'],
['rules', 'Custom Rules'],
['profiles', 'Profiles'],
['raw', 'Raw nyx.local'],
] as Array<[Tab, string]>
).map(([id, label]) => (
))}
{tab === 'overview' && (
<>
toggleTriageSync.mutate({ enabled })
}
/>
>
)}
{tab === 'rules' && (
<>
addSource.mutate(body, addRuleHandlers('source'))}
onDelete={(e) =>
deleteSource.mutate(
{ lang: e.lang, matchers: e.matchers, cap: e.cap },
deleteRuleHandlers('source'),
)
}
/>
addSink.mutate(body, addRuleHandlers('sink'))}
onDelete={(e) =>
deleteSink.mutate(
{ lang: e.lang, matchers: e.matchers, cap: e.cap },
deleteRuleHandlers('sink'),
)
}
/>
addSanitizer.mutate(body, addRuleHandlers('sanitizer'))
}
onDelete={(e) =>
deleteSanitizer.mutate(
{ lang: e.lang, matchers: e.matchers, cap: e.cap },
deleteRuleHandlers('sanitizer'),
)
}
/>
Function calls that abort control flow (e.g.{' '}
process.exit, sys.exit) so the analyzer
doesn't continue past them.
{!terminators || terminators.length === 0 ? (
No terminators configured
) : (
| Language |
Name |
|
{(terminators as TerminatorView[]).map((t, i) => (
| {t.lang} |
{t.name} |
|
))}
)}
>
)}
{tab === 'profiles' && (
Profiles bundle scanner + output settings. Activate one to apply its
settings to the current session.
{!profiles || profiles.length === 0 ? (
) : (
| Name |
Type |
Settings |
|
{(profiles as ProfileView[]).map((p) => (
|
{p.name}
|
{p.is_builtin ? (
built-in
) : (
custom
)}
|
{JSON.stringify(p.settings)}
|
{!p.is_builtin && (
)}
|
))}
)}
)}
{tab === 'raw' && }
);
}