/** * Settings — the YAML that the daemon was started from. * * The editor is deliberately plain: a line-numbered textarea rather than a * YAML widget, because the operator's file already has comments and ordering * that a form would silently destroy. Validation is server-side, so what is * reported here is exactly what the daemon would do with the file. */ import { useMemo, useRef, useState } from 'react' import type { UIEvent } from 'react' import { Ban, CheckCircle2, Database, FileCode2, Info, RefreshCw, Save, ShieldAlert, TriangleAlert, } from 'lucide-react' import { flushCache, getSettings, putSettings, refreshBlocklists, reloadSettings, validateSettings, } from '../lib/api' import type { ConfigIssue } from '../lib/types' import { useApi, useAsyncAction } from '../hooks/useApi' import { formatNumber } from '../lib/format' import { Badge, Button, Card, CardBody, ConfirmDialog, CopyButton, Spinner, Textarea, useToast, } from '../components/ui' import { LoadingBlock, PageError, PageHeader } from '../components/layout' interface Report { errors: ConfigIssue[] warnings: ConfigIssue[] } /** A validation list is only usable if every entry has a message. */ function issues(list: ConfigIssue[] | undefined): ConfigIssue[] { if (!Array.isArray(list)) return [] return list.filter((item) => item != null && typeof item.message === 'string' && item.message !== '') } function IssueList({ issues: list, tone }: { issues: ConfigIssue[]; tone: 'danger' | 'warn' }) { return ( ) } export default function Settings() { const { toast } = useToast() const notify = (title: string, description?: string, tone: 'ok' | 'fail' = 'fail'): void => toast({ title, description, tone: tone === 'ok' ? 'ok' : 'danger' }) const { data, error, loading, refetch } = useApi(() => getSettings(), []) // The draft is keyed by the version it was started from: once the server // reports a different version the edit is dropped, so a refetch can never // stomp on an editor that is mid-change. const [draft, setDraft] = useState<{ version: string; text: string } | null>(null) const gutter = useRef(null) const [report, setReport] = useState(null) const [confirmReload, setConfirmReload] = useState(false) const validate = useAsyncAction(async (yaml: string) => validateSettings(yaml)) const save = useAsyncAction(async (yaml: string) => putSettings(yaml)) const reload = useAsyncAction(async () => reloadSettings()) const flush = useAsyncAction(async () => flushCache()) const refreshLists = useAsyncAction(async () => refreshBlocklists()) const pristine = data?.config_yaml ?? '' const version = data?.version ?? '' const value = draft !== null && draft.version === version ? draft.text : pristine const dirty = data !== null && value !== pristine const writable = data?.writable !== false const lineCount = useMemo(() => value.split('\n').length, [value]) const gutterLines = useMemo(() => Array.from({ length: lineCount }, (_, i) => i + 1), [lineCount]) // The gutter is a plain div; keep its scroll offset glued to the textarea's. const onScroll = (event: UIEvent): void => { const element = gutter.current if (element !== null) element.scrollTop = event.currentTarget.scrollTop } const onValidate = async (): Promise => { const res = await validate.run(value) if (res === null) { setReport(null) notify('Validation failed', validate.error ?? 'the server could not check this file') return } const found: Report = { errors: issues(res.errors), warnings: issues(res.warnings) } setReport(found) if (found.errors.length === 0) { notify( 'Configuration is valid', found.warnings.length > 0 ? `${found.warnings.length} ${found.warnings.length === 1 ? 'warning' : 'warnings'}` : 'the daemon would accept this file', 'ok', ) return } notify( 'Configuration is not valid', `${found.errors.length} ${found.errors.length === 1 ? 'error' : 'errors'}, ${found.warnings.length} warnings`, ) } const onSave = async (): Promise => { const res = await save.run(value) if (res === null) { notify('Could not save', save.error ?? 'the daemon rejected the write') return } const list = Array.isArray(res.warnings) ? res.warnings : [] notify( 'Configuration saved', list.length > 0 ? `${list.length} ${list.length === 1 ? 'warning' : 'warnings'}: ${list.join('; ')}` : `version ${res.version || 'unknown'}`, 'ok', ) setDraft(null) setReport(null) refetch() } const onReload = async (): Promise => { const res = await reload.run() setConfirmReload(false) if (res === null) { notify('Reload failed', reload.error ?? 'the daemon kept the running configuration') return } setDraft(null) setReport(null) notify('Configuration reloaded', `running version ${res.version || 'unknown'}`, 'ok') refetch() } const onFlush = async (): Promise => { const res = await flush.run() if (res === null) { notify('Could not flush the cache', flush.error ?? 'the server rejected the request') return } notify( 'Cache flushed', `${formatNumber(res.entries ?? 0)} ${res.entries === 1 ? 'entry' : 'entries'} dropped`, 'ok', ) } const onRefreshLists = async (): Promise => { const res = await refreshLists.run() if (res === null) { notify('Refresh failed', refreshLists.error ?? 'the server rejected the request') return } notify('Refresh started', `${res.names?.length ?? 0} sources queued`, 'ok') } if (error) return if (loading && data === null) { return (
) } const effective = data?.effective_yaml ?? '' const defaults = data?.defaults_yaml ?? '' return (
{writable ? 'writable' : 'read-only'}
} />
version {data?.version || 'unknown'} {data?.reload_available === false && ( the daemon cannot reload this file at runtime )}
{data?.last_error ? (
) : null}
{dirty ? 'modified' : 'in sync'}} >