more code
This commit is contained in:
1 parent
f24fd63e9a
commit
ee72257c64
29 files changed
+5636
-440
No files matched your search
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* 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 (
|
||||
<ul className="space-y-1.5">
|
||||
{list.map((issue, index) => (
|
||||
<li key={`${issue.field}-${index}`} className="flex min-w-0 items-start gap-2 text-xs">
|
||||
{tone === 'danger' ? (
|
||||
<TriangleAlert size={13} className="mt-0.5 shrink-0 text-danger" aria-hidden="true" />
|
||||
) : (
|
||||
<Info size={13} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
{issue.field !== '' && (
|
||||
<span className="mr-1.5 font-mono text-fg-subtle">{issue.field}</span>
|
||||
)}
|
||||
<span className={tone === 'danger' ? 'text-danger' : 'text-fg-muted'}>
|
||||
{issue.message}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLDivElement | null>(null)
|
||||
const [report, setReport] = useState<Report | null>(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<HTMLTextAreaElement>): void => {
|
||||
const element = gutter.current
|
||||
if (element !== null) element.scrollTop = event.currentTarget.scrollTop
|
||||
}
|
||||
|
||||
const onValidate = async (): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 <PageError error={error} onRetry={refetch} />
|
||||
|
||||
if (loading && data === null) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Settings" description="The configuration the daemon is running." />
|
||||
<Card>
|
||||
<CardBody>
|
||||
<LoadingBlock rows={8} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const effective = data?.effective_yaml ?? ''
|
||||
const defaults = data?.defaults_yaml ?? ''
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="The configuration the daemon is running, and the file behind it."
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={writable ? 'ok' : 'warn'}>{writable ? 'writable' : 'read-only'}</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={RefreshCw}
|
||||
onClick={() => setConfirmReload(true)}
|
||||
loading={reload.pending}
|
||||
disabled={data?.reload_available === false}
|
||||
>
|
||||
Reload from disk
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-fg-muted">
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<FileCode2 size={12} aria-hidden="true" />
|
||||
<span className="truncate font-mono" title={data?.path}>
|
||||
{data?.path || 'unknown path'}
|
||||
</span>
|
||||
</span>
|
||||
<span>version {data?.version || 'unknown'}</span>
|
||||
{data?.reload_available === false && (
|
||||
<span>the daemon cannot reload this file at runtime</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data?.last_error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-lg border border-danger/40 bg-danger-soft px-4 py-3"
|
||||
>
|
||||
<ShieldAlert size={18} className="mt-0.5 shrink-0 text-danger" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-fg">The daemon reported a config error</p>
|
||||
<p className="mt-0.5 font-mono text-xs break-words text-danger">{data.last_error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<Card
|
||||
title="Configuration file"
|
||||
subtitle={`${lineCount} lines, as stored on disk`}
|
||||
padded={false}
|
||||
actions={<Badge tone={dirty ? 'warn' : 'muted'}>{dirty ? 'modified' : 'in sync'}</Badge>}
|
||||
>
|
||||
<div className="flex min-w-0 items-stretch">
|
||||
<div
|
||||
ref={gutter}
|
||||
aria-hidden="true"
|
||||
className="max-h-[520px] w-11 shrink-0 overflow-hidden border-r border-line bg-surface-2 py-2 pr-2 text-right font-mono text-xs leading-[1.625] text-fg-subtle select-none"
|
||||
>
|
||||
{gutterLines.map((line) => (
|
||||
<div key={line}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
<Textarea
|
||||
monospace
|
||||
value={value}
|
||||
onChange={(event) => setDraft({ version, text: event.currentTarget.value })}
|
||||
onScroll={onScroll}
|
||||
rows={26}
|
||||
spellCheck={false}
|
||||
wrap="off"
|
||||
aria-label="Configuration YAML"
|
||||
className="h-[520px] min-h-[520px] rounded-none border-0 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<Card
|
||||
title="Validate and save"
|
||||
subtitle="The daemon parses the exact text on the left."
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
icon={CheckCircle2}
|
||||
onClick={() => void onValidate()}
|
||||
loading={validate.pending}
|
||||
>
|
||||
Validate
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={Save}
|
||||
onClick={() => void onSave()}
|
||||
loading={save.pending}
|
||||
disabled={!writable || !dirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CardBody>
|
||||
{!writable && (
|
||||
<p className="flex items-start gap-2 rounded-lg border border-warn/40 bg-warn-soft px-3 py-2 text-xs text-fg">
|
||||
<Ban size={14} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" />
|
||||
<span>
|
||||
This daemon reports the config file as read-only, so Save is disabled. Edit{' '}
|
||||
<span className="font-mono">{data?.path || 'the file'}</span> on the host and
|
||||
use Reload from disk.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{writable && !dirty && (
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Nothing to save: the editor matches the file on disk.
|
||||
</p>
|
||||
)}
|
||||
{validate.pending && (
|
||||
<p className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<Spinner size="sm" />
|
||||
validating {lineCount} lines…
|
||||
</p>
|
||||
)}
|
||||
{validate.error !== null && <p className="text-xs text-danger">{validate.error}</p>}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Validation report"
|
||||
subtitle={
|
||||
report === null
|
||||
? 'Nothing validated yet'
|
||||
: `${report.errors.length} ${report.errors.length === 1 ? 'error' : 'errors'}, ${report.warnings.length} ${report.warnings.length === 1 ? 'warning' : 'warnings'}`
|
||||
}
|
||||
actions={
|
||||
report === null ? null : (
|
||||
<Badge tone={report.errors.length === 0 ? 'ok' : 'danger'}>
|
||||
{report.errors.length === 0 ? 'valid' : 'invalid'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
<CardBody>
|
||||
{report === null ? (
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Press Validate to parse the current text against the daemon's schema.
|
||||
</p>
|
||||
) : report.errors.length === 0 && report.warnings.length === 0 ? (
|
||||
<p className="text-xs text-ok">No errors and no warnings.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{report.errors.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-danger">Errors</p>
|
||||
<IssueList issues={report.errors} tone="danger" />
|
||||
</div>
|
||||
)}
|
||||
{report.warnings.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold text-warn">Warnings</p>
|
||||
<IssueList issues={report.warnings} tone="warn" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<Card
|
||||
title="Effective configuration"
|
||||
subtitle="Your file with every default filled in"
|
||||
actions={<CopyButton value={effective} label="Copy effective config" />}
|
||||
>
|
||||
<CardBody>
|
||||
<details>
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 text-sm text-fg-muted transition-colors hover:text-fg">
|
||||
<FileCode2 size={14} aria-hidden="true" />
|
||||
Show the effective configuration
|
||||
<span className="text-xs text-fg-subtle">
|
||||
{effective === '' ? '' : `${effective.split('\n').length} lines`}
|
||||
</span>
|
||||
</summary>
|
||||
<pre className="mt-3 max-h-80 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs whitespace-pre-wrap text-fg-muted">
|
||||
{effective || 'The server sent no effective configuration.'}
|
||||
</pre>
|
||||
</details>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Defaults"
|
||||
subtitle="What the daemon falls back to for anything you leave out"
|
||||
actions={<CopyButton value={defaults} label="Copy defaults" />}
|
||||
>
|
||||
<CardBody>
|
||||
<details>
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 text-sm text-fg-muted transition-colors hover:text-fg">
|
||||
<Info size={14} aria-hidden="true" />
|
||||
Show defaults
|
||||
<span className="text-xs text-fg-subtle">read-only</span>
|
||||
</summary>
|
||||
<pre className="mt-3 max-h-80 overflow-auto rounded-lg border border-line bg-surface-2 p-3 font-mono text-xs whitespace-pre-wrap text-fg-muted">
|
||||
{defaults || 'The server sent no defaults.'}
|
||||
</pre>
|
||||
</details>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Quick actions" subtitle="The two things operators reach for from here">
|
||||
<CardBody>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
icon={Database}
|
||||
variant="outline"
|
||||
onClick={() => void onFlush()}
|
||||
loading={flush.pending}
|
||||
>
|
||||
Flush cache
|
||||
</Button>
|
||||
<Button
|
||||
icon={RefreshCw}
|
||||
variant="outline"
|
||||
onClick={() => void onRefreshLists()}
|
||||
loading={refreshLists.pending}
|
||||
>
|
||||
Refresh blocklists
|
||||
</Button>
|
||||
<span className="text-xs text-fg-subtle">
|
||||
Both act on the running daemon; neither writes the config file.
|
||||
</span>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmReload}
|
||||
onClose={() => setConfirmReload(false)}
|
||||
onConfirm={() => void onReload()}
|
||||
title="Reload the configuration from disk?"
|
||||
message={
|
||||
<>
|
||||
The daemon re-reads <span className="font-mono">{data?.path || 'its config'}</span>{' '}
|
||||
and applies it to the running process. In-flight queries finish on the old
|
||||
configuration; the next query uses the new one.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Reload"
|
||||
pending={reload.pending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user