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,393 @@
|
||||
/**
|
||||
* Cache — what the resolver keeps in memory between queries.
|
||||
*
|
||||
* Everything here is a read of `/api/cache` plus the top-50 ranking, refreshed
|
||||
* on a slow 15s tick. The only mutation is the flush, which is destructive
|
||||
* enough to sit behind a confirmation: it costs a cold cache, not a restart.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip as ChartTooltip,
|
||||
} from 'recharts'
|
||||
import { Database, FileClock, Gauge, HardDrive, Layers, RefreshCw, Timer } from 'lucide-react'
|
||||
|
||||
import { flushCache, getCache, getCacheTop } from '../lib/api'
|
||||
import type { CachePersisted, CacheTopEntry } from '../lib/types'
|
||||
import { useApi, useAsyncAction } from '../hooks/useApi'
|
||||
import { useAutoRefresh } from '../hooks/useAutoRefresh'
|
||||
import { formatBytes, formatMs, formatNumber, formatRate, formatRelative } from '../lib/format'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
StatCard,
|
||||
Table,
|
||||
useToast,
|
||||
} from '../components/ui'
|
||||
import { PageError, PageHeader } from '../components/layout'
|
||||
|
||||
type Row = Record<string, ReactNode>
|
||||
|
||||
const REFRESH_MS = 15000
|
||||
const TOP_LIMIT = 50
|
||||
|
||||
/** Fixed hex so the donut reads on both themes, like the dashboard charts. */
|
||||
const HITS = '#34d399'
|
||||
const MISSES = '#f87171'
|
||||
|
||||
/** `persisted` gained fields over time; read defensively. */
|
||||
type PersistedView = Partial<CachePersisted> & {
|
||||
errors?: number
|
||||
error?: string
|
||||
last_error?: string
|
||||
}
|
||||
|
||||
type PieSlice = { name: string; value: number; fill: string }
|
||||
|
||||
type TipProps = {
|
||||
active?: boolean
|
||||
payload?: { name?: string; value?: number | string; payload?: PieSlice }[]
|
||||
}
|
||||
|
||||
function ChartTip({ active, payload }: TipProps) {
|
||||
if (!active || payload === undefined || payload.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-xs shadow-lg">
|
||||
<ul className="space-y-0.5">
|
||||
{payload.map((item) => (
|
||||
<li key={item.name} className="flex items-center gap-2 text-slate-100">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ background: item.payload?.fill }}
|
||||
/>
|
||||
<span className="text-slate-400">{item.name}</span>
|
||||
<span className="ml-auto font-mono">{formatNumber(Number(item.value ?? 0))}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Cache() {
|
||||
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(() => getCache(), [])
|
||||
const {
|
||||
data: top,
|
||||
error: topError,
|
||||
loading: topLoading,
|
||||
refetch: refetchTop,
|
||||
} = useApi(() => getCacheTop(TOP_LIMIT), [])
|
||||
|
||||
useAutoRefresh(() => {
|
||||
refetch()
|
||||
refetchTop()
|
||||
}, REFRESH_MS)
|
||||
|
||||
const [confirmFlush, setConfirmFlush] = useState(false)
|
||||
const flush = useAsyncAction(async () => flushCache())
|
||||
|
||||
const hits = data?.hits ?? 0
|
||||
const misses = data?.misses ?? 0
|
||||
const lookups = hits + misses
|
||||
const hitRate = data?.hit_rate ?? 0
|
||||
|
||||
const pie: PieSlice[] = useMemo(
|
||||
() => [
|
||||
{ name: 'hits', value: hits, fill: HITS },
|
||||
{ name: 'misses', value: misses, fill: MISSES },
|
||||
],
|
||||
[hits, misses],
|
||||
)
|
||||
|
||||
const persisted: PersistedView = (data?.persisted ?? {}) as PersistedView
|
||||
const persistErrors = persisted.errors ?? 0
|
||||
const persistMessage = persisted.last_error ?? persisted.error ?? ''
|
||||
|
||||
const entries: CacheTopEntry[] = useMemo(() => top?.items ?? [], [top])
|
||||
|
||||
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
|
||||
}
|
||||
setConfirmFlush(false)
|
||||
notify(
|
||||
'Cache flushed',
|
||||
`${formatNumber(res.entries ?? 0)} ${res.entries === 1 ? 'entry' : 'entries'} dropped`,
|
||||
'ok',
|
||||
)
|
||||
refetch()
|
||||
refetchTop()
|
||||
}
|
||||
|
||||
if (error) return <PageError error={error} onRetry={refetch} />
|
||||
|
||||
const topColumns = [
|
||||
{ key: 'name', header: 'Name', width: '48%' },
|
||||
{ key: 'qtype', header: 'Type', width: '10%' },
|
||||
{ key: 'count', header: 'Lookups', width: '14%', align: 'right' as const },
|
||||
{ key: 'hits', header: 'Served from cache', width: '14%', align: 'right' as const },
|
||||
{ key: 'last_seen', header: 'Last seen', width: '14%', align: 'right' as const },
|
||||
]
|
||||
|
||||
const topRows: Row[] = entries.map((entry) => ({
|
||||
__key: `${entry.name}-${entry.qtype}`,
|
||||
name: (
|
||||
<span className="block max-w-[420px] truncate font-mono text-xs" title={entry.name}>
|
||||
{entry.name}
|
||||
</span>
|
||||
),
|
||||
qtype: <span className="font-mono text-xs">{entry.qtype || '—'}</span>,
|
||||
count: <span className="font-mono text-xs">{formatNumber(entry.count)}</span>,
|
||||
hits: <span className="font-mono text-xs">{formatNumber(entry.hits)}</span>,
|
||||
last_seen: <span className="text-xs text-fg-muted">{formatRelative(entry.last_seen)}</span>,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Cache"
|
||||
description="Answer reuse, its lifetime, and what survives a restart."
|
||||
actions={
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={RefreshCw}
|
||||
onClick={() => setConfirmFlush(true)}
|
||||
loading={flush.pending}
|
||||
>
|
||||
Flush cache
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<StatCard
|
||||
label="Entries"
|
||||
value={formatNumber(data?.entries ?? 0)}
|
||||
icon={Database}
|
||||
hint={data?.enabled === false ? 'cache disabled' : undefined}
|
||||
loading={loading && data === null}
|
||||
/>
|
||||
<StatCard
|
||||
label="Size"
|
||||
value={formatBytes(data?.size ?? 0)}
|
||||
icon={HardDrive}
|
||||
hint={`${formatNumber(lookups)} lookups`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Shards"
|
||||
value={formatNumber(data?.shards ?? 0)}
|
||||
icon={Layers}
|
||||
/>
|
||||
<StatCard
|
||||
label="Max TTL"
|
||||
value={formatMs(data?.max_ttl_ms ?? 0)}
|
||||
icon={Timer}
|
||||
hint={`min ${formatMs(data?.min_ttl_ms ?? 0)}`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Serve stale"
|
||||
value={formatMs(data?.serve_stale_ms ?? 0)}
|
||||
icon={FileClock}
|
||||
hint={data?.serve_stale_ms ? 'upstream timeouts tolerated' : 'never serve stale'}
|
||||
tone={data?.serve_stale_ms ? 'default' : 'warn'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Evictions"
|
||||
value={formatNumber(data?.evictions ?? 0)}
|
||||
icon={Gauge}
|
||||
hint={`${formatNumber(data?.stale_hits ?? 0)} stale hits`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<Card
|
||||
title="Hit rate"
|
||||
subtitle={`${formatRate(hitRate)} of ${formatNumber(lookups)} lookups served from memory`}
|
||||
>
|
||||
<CardBody>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<div className="relative h-48 w-48 shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pie}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius="62%"
|
||||
outerRadius="92%"
|
||||
paddingAngle={1}
|
||||
stroke="none"
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{pie.map((slice) => (
|
||||
<Cell key={slice.name} fill={slice.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip content={<ChartTip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center">
|
||||
<div className="text-center">
|
||||
<p className="tabular text-xl font-semibold text-fg">{formatRate(hitRate, 0)}</p>
|
||||
<p className="text-xs text-fg-muted">hit rate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="w-full min-w-0 space-y-2 text-sm">
|
||||
<li className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ background: HITS }}
|
||||
/>
|
||||
<span className="text-fg-muted">Hits</span>
|
||||
<span className="tabular ml-auto font-mono text-xs">
|
||||
{formatNumber(hits)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ background: MISSES }}
|
||||
/>
|
||||
<span className="text-fg-muted">Misses</span>
|
||||
<span className="tabular ml-auto font-mono text-xs">
|
||||
{formatNumber(misses)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 border-t border-line pt-2 text-xs text-fg-muted">
|
||||
<span>Negative TTL</span>
|
||||
<span className="tabular ml-auto font-mono">
|
||||
{formatMs(data?.negative_ttl_ms ?? 0)}
|
||||
</span>
|
||||
</li>
|
||||
{lookups === 0 && (
|
||||
<li className="text-xs text-fg-subtle">
|
||||
Nothing has been looked up since the daemon started.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Persisted file"
|
||||
subtitle={persisted.enabled ? 'Snapshots survive a restart' : 'Snapshots are turned off'}
|
||||
actions={
|
||||
<Badge tone={persisted.enabled ? 'ok' : 'muted'}>
|
||||
{persisted.enabled ? 'enabled' : 'disabled'}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<CardBody>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
|
||||
<div className="col-span-2 min-w-0 sm:col-span-3">
|
||||
<dt className="text-xs tracking-wide text-fg-muted uppercase">Path</dt>
|
||||
<dd className="mt-0.5 truncate font-mono text-xs" title={persisted.path}>
|
||||
{persisted.path || '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs tracking-wide text-fg-muted uppercase">Size</dt>
|
||||
<dd className="tabular mt-0.5 font-mono text-sm">
|
||||
{formatBytes(persisted.bytes ?? 0)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs tracking-wide text-fg-muted uppercase">Entries</dt>
|
||||
<dd className="tabular mt-0.5 font-mono text-sm">
|
||||
{formatNumber(persisted.entries ?? 0)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs tracking-wide text-fg-muted uppercase">Writes</dt>
|
||||
<dd className="tabular mt-0.5 font-mono text-sm">
|
||||
{formatNumber(persisted.writes ?? 0)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs tracking-wide text-fg-muted uppercase">Errors</dt>
|
||||
<dd
|
||||
className={
|
||||
persistErrors > 0
|
||||
? 'tabular mt-0.5 font-mono text-sm text-danger'
|
||||
: 'tabular mt-0.5 font-mono text-sm'
|
||||
}
|
||||
>
|
||||
{formatNumber(persistErrors)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<dt className="text-xs tracking-wide text-fg-muted uppercase">Last snapshot</dt>
|
||||
<dd className="mt-0.5 text-sm">{formatRelative(persisted.last_snapshot)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{persistMessage !== '' && (
|
||||
<p className="mt-3 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-xs text-danger">
|
||||
{persistMessage}
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="Most requested"
|
||||
subtitle={`Top ${TOP_LIMIT} names by lookups`}
|
||||
padded={false}
|
||||
actions={<Badge tone="muted">auto-refresh every 15s</Badge>}
|
||||
>
|
||||
{topError !== null ? (
|
||||
<CardBody>
|
||||
<p className="text-sm text-danger">{topError}</p>
|
||||
</CardBody>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
columns={topColumns}
|
||||
rows={topRows}
|
||||
rowKey={(row) => String(row.__key)}
|
||||
loading={topLoading && entries.length === 0}
|
||||
empty={
|
||||
<EmptyState
|
||||
icon={Database}
|
||||
title="The cache is empty"
|
||||
description="Nothing has been cached yet, so there is no ranking to show."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmFlush}
|
||||
onClose={() => setConfirmFlush(false)}
|
||||
onConfirm={() => void onFlush()}
|
||||
title="Flush the cache?"
|
||||
message="Every cached answer is dropped. The next query for each name goes back to the upstreams, so expect a burst of upstream traffic."
|
||||
confirmLabel="Flush cache"
|
||||
destructive
|
||||
pending={flush.pending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+11
-26
@@ -23,11 +23,11 @@ import {
|
||||
} from 'recharts'
|
||||
import { Activity, Ban, Gauge, RefreshCw, Timer } from 'lucide-react'
|
||||
|
||||
import { getDashboard } from '../../lib/api'
|
||||
import type { Dashboard as DashboardData, LogItem, UpstreamStat } from '../../lib/types'
|
||||
import { useApi } from '../../hooks/useApi'
|
||||
import { useAutoRefresh } from '../../hooks/useAutoRefresh'
|
||||
import { TIME_RANGES, useTimeRange } from '../../hooks/useTimeRange'
|
||||
import { getDashboard } from '../lib/api'
|
||||
import type { Dashboard as DashboardData, LogItem, UpstreamStat } from '../lib/types'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { useAutoRefresh } from '../hooks/useAutoRefresh'
|
||||
import { TIME_RANGES, useTimeRange } from '../hooks/useTimeRange'
|
||||
import {
|
||||
formatClock,
|
||||
formatCompact,
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
formatPercent,
|
||||
formatRate,
|
||||
formatUptime,
|
||||
} from '../../lib/format'
|
||||
} from '../lib/format'
|
||||
import { Badge, Button, Card, CardBody, EmptyState, Progress, StatCard, Table } from '../components/ui'
|
||||
import { HealthPill, PageError, PageHeader } from '../components/layout'
|
||||
|
||||
@@ -47,7 +47,6 @@ const QUERIES = '#38bdf8'
|
||||
const CACHED = '#34d399'
|
||||
const BLOCKED = '#f59e0b'
|
||||
const ERRORS = '#f87171'
|
||||
const TIP_BG = '#0f172a'
|
||||
|
||||
/** The parts of the dashboard payload that older daemons may not send. */
|
||||
type DashboardView = Partial<DashboardData> & {
|
||||
@@ -64,20 +63,6 @@ function view(data: DashboardData | null): DashboardView {
|
||||
return (data ?? {}) as DashboardView
|
||||
}
|
||||
|
||||
/** `useToast()` shape is the UI kit's business; this just needs a call. */
|
||||
type ToastLike = {
|
||||
success?: (message: string) => void
|
||||
error?: (message: string) => void
|
||||
toast?: (message: string) => void
|
||||
push?: (message: string) => void
|
||||
}
|
||||
|
||||
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
|
||||
const t = toast as ToastLike
|
||||
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
|
||||
fn?.call(t, message)
|
||||
}
|
||||
|
||||
type TipProps = {
|
||||
active?: boolean
|
||||
label?: number | string
|
||||
@@ -222,7 +207,7 @@ export default function Dashboard() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<RefreshCw size={14} />}
|
||||
icon={RefreshCw}
|
||||
onClick={refetch}
|
||||
loading={loading}
|
||||
>
|
||||
@@ -251,26 +236,26 @@ export default function Dashboard() {
|
||||
label="Queries / s"
|
||||
value={formatCompact(d.queries_per_sec ?? 0)}
|
||||
hint={`${formatNumber(d.queries_total ?? 0)} total`}
|
||||
icon={<Activity size={16} />}
|
||||
icon={Activity}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cache hit rate"
|
||||
value={formatRate(hitRate)}
|
||||
hint="higher is better"
|
||||
icon={<Gauge size={16} />}
|
||||
icon={Gauge}
|
||||
tone={hitRate >= 0.7 ? 'ok' : hitRate >= 0.4 ? 'warn' : 'danger'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Blocked"
|
||||
value={formatPercent(d.blocked_pct ?? 0)}
|
||||
hint={`${formatNumber(d.blocked_total ?? 0)} queries`}
|
||||
icon={<Ban size={16} />}
|
||||
icon={Ban}
|
||||
/>
|
||||
<StatCard
|
||||
label={latencyLabel}
|
||||
value={latencyValue != null ? formatMs(latencyValue) : '—'}
|
||||
hint={p99 != null ? 'from the server' : 'slowest upstream p95'}
|
||||
icon={<Timer size={16} />}
|
||||
icon={Timer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Login — the only page that renders outside AppShell.
|
||||
*
|
||||
* The daemon may be running with `auth.mode: none`, `apikey` or `basic`, so the
|
||||
* form is built from what `/api/whoami` reports rather than hard-coded. A 401
|
||||
* from that probe is expected (that is why the visitor is here) and is not
|
||||
* shown as an error.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Globe, KeyRound, LogIn, ShieldCheck, Terminal } from 'lucide-react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { ApiError, login, whoami } from '../lib/api'
|
||||
import type { WhoamiResponse } from '../lib/api'
|
||||
import { Button, Card, CardBody, Input } from '../components/ui'
|
||||
import { Spinner } from '../components/ui'
|
||||
|
||||
/** "apikey" -> one field, "basic" -> two, anything else -> one field. */
|
||||
function wantsPassword(mode: string): boolean {
|
||||
return mode === 'basic'
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const [params] = useSearchParams()
|
||||
const next = params.get('next') ?? '/'
|
||||
|
||||
const [mode, setMode] = useState<string | null>(null)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
whoami()
|
||||
.then((who: WhoamiResponse) => {
|
||||
if (live) setMode(who.auth_mode ?? 'apikey')
|
||||
})
|
||||
.catch(() => {
|
||||
// Unauthenticated probes and older daemons both land here: show the
|
||||
// form rather than an error, the submit will say what is wrong.
|
||||
if (live) setMode('apikey')
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
if (pending) return
|
||||
setError(null)
|
||||
setPending(true)
|
||||
try {
|
||||
if (wantsPassword(mode ?? 'apikey')) {
|
||||
await login({ username, password })
|
||||
} else {
|
||||
await login({ api_key: apiKey })
|
||||
}
|
||||
// A full navigation: the shell reads whoami and the token on boot.
|
||||
window.location.assign(next.startsWith('/') ? next : '/')
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'login failed',
|
||||
)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const passwordMode = wantsPassword(mode ?? 'apikey')
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center bg-canvas px-4 py-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-5 flex items-center justify-center gap-2">
|
||||
<span className="grid size-8 place-items-center rounded-lg bg-accent-soft text-accent">
|
||||
<Globe size={17} strokeWidth={2.25} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-base font-semibold tracking-tight text-fg">fancy-dns</span>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="Sign in"
|
||||
subtitle={
|
||||
mode === null
|
||||
? 'Checking the authentication mode…'
|
||||
: passwordMode
|
||||
? 'This node uses username and password.'
|
||||
: 'This node uses an API key.'
|
||||
}
|
||||
>
|
||||
<CardBody>
|
||||
<form className="space-y-3" onSubmit={(event) => void onSubmit(event)}>
|
||||
{passwordMode ? (
|
||||
<>
|
||||
<Input
|
||||
label="Username"
|
||||
icon={ShieldCheck}
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
icon={KeyRound}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
label="API key"
|
||||
type="password"
|
||||
icon={KeyRound}
|
||||
placeholder="paste the node's API key"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-lg border border-danger/40 bg-danger-soft px-3 py-2 text-xs text-danger"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={pending ? undefined : LogIn}
|
||||
loading={pending}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{pending ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<p className="mt-4 flex items-start gap-2 text-xs text-fg-subtle">
|
||||
<Terminal size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
Scripts can skip this page: send the same credential as an{' '}
|
||||
<span className="font-mono text-fg-muted">X-API-Key</span> header, for example{' '}
|
||||
<span className="font-mono text-fg-muted">
|
||||
curl -H "X-API-Key: $KEY" http://node:8080/api/dashboard
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{pending && (
|
||||
<p className="mt-3 flex items-center justify-center gap-2 text-xs text-fg-muted">
|
||||
<Spinner size="sm" />
|
||||
waiting for the node
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Logs — the query log, and the live tail of it.
|
||||
*
|
||||
* One table, two sources: the paged `/api/logs` result and the SSE stream from
|
||||
* `/api/logs/stream`. While LIVE is on, streamed entries are prepended and run
|
||||
* through the same filters client-side, so a live row can never contradict what
|
||||
* the filter bar says. Rendering is capped: a busy node emits far faster than a
|
||||
* browser can lay out rows, and an uncapped list is the tab's worst enemy.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Eraser, Radio, Search, Signal, WifiOff } from 'lucide-react'
|
||||
|
||||
import { clearLogs, getLogs, streamLogs } from '../lib/api'
|
||||
import type { LogFilters, LogItem, LiveStats } from '../lib/types'
|
||||
import { useApi, useAsyncAction } from '../hooks/useApi'
|
||||
import { useAutoRefresh } from '../hooks/useAutoRefresh'
|
||||
import { useDebounced } from '../hooks/useDebounced'
|
||||
import {
|
||||
formatClock,
|
||||
formatCompact,
|
||||
formatMs,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
formatRate,
|
||||
formatRcode,
|
||||
} from '../lib/format'
|
||||
import { cn } from '../lib/cn'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
ConfirmDialog,
|
||||
Input,
|
||||
Pagination,
|
||||
Select,
|
||||
Switch,
|
||||
Table,
|
||||
useToast,
|
||||
} from '../components/ui'
|
||||
import { PageError, PageHeader } from '../components/layout'
|
||||
|
||||
type Row = Record<string, ReactNode>
|
||||
|
||||
const PER_PAGE = 50
|
||||
/** Hard cap on rendered rows, live tail included. */
|
||||
const MAX_ROWS = 300
|
||||
const REFRESH_LIVE_MS = 3000
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
{ value: '', label: 'All actions' },
|
||||
{ value: 'allow', label: 'Allow' },
|
||||
{ value: 'cached', label: 'Cached' },
|
||||
{ value: 'block', label: 'Block' },
|
||||
{ value: 'error', label: 'Error' },
|
||||
{ value: 'drop', label: 'Drop' },
|
||||
]
|
||||
|
||||
/** The filter set, reduced to what both the server and the live tail need. */
|
||||
interface Filter {
|
||||
q: string
|
||||
action: string
|
||||
client: string
|
||||
blocked: boolean
|
||||
}
|
||||
|
||||
function actionTone(action: string): 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted' {
|
||||
switch (action) {
|
||||
case 'allow':
|
||||
return 'default'
|
||||
case 'cached':
|
||||
return 'info'
|
||||
case 'block':
|
||||
return 'warn'
|
||||
case 'error':
|
||||
return 'danger'
|
||||
case 'drop':
|
||||
return 'muted'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
/** The server filters; the live tail re-implements the same rules. */
|
||||
function matches(entry: LogItem, filter: Filter): boolean {
|
||||
if (filter.action !== '' && entry.action !== filter.action) return false
|
||||
if (filter.blocked && entry.action !== 'block') return false
|
||||
|
||||
if (filter.client !== '') {
|
||||
const needle = filter.client.toLowerCase()
|
||||
const hay = `${entry.client} ${entry.client_group}`.toLowerCase()
|
||||
if (!hay.includes(needle)) return false
|
||||
}
|
||||
|
||||
if (filter.q !== '') {
|
||||
const needle = filter.q.toLowerCase()
|
||||
const hay = [
|
||||
entry.name,
|
||||
entry.list,
|
||||
entry.upstream,
|
||||
entry.client,
|
||||
entry.client_group,
|
||||
entry.action,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
if (!hay.includes(needle)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Older daemons omit stats fields; a missing counter reads as zero, not NaN. */
|
||||
function liveStats(stats: LiveStats): LiveStats {
|
||||
return {
|
||||
qps: Number(stats.qps) || 0,
|
||||
blocked_pct: Number(stats.blocked_pct) || 0,
|
||||
cache_hit_rate: Number(stats.cache_hit_rate) || 0,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const { toast } = useToast()
|
||||
const notify = (title: string, description?: string, tone: 'ok' | 'fail' = 'fail'): void =>
|
||||
toast({ title, description, tone: tone === 'ok' ? 'ok' : 'danger' })
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [action, setAction] = useState('')
|
||||
const [client, setClient] = useState('')
|
||||
const [blockedOnly, setBlockedOnly] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const q = useDebounced(search, 300)
|
||||
|
||||
const [live, setLive] = useState(false)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [streamError, setStreamError] = useState<string | null>(null)
|
||||
const [liveRows, setLiveRows] = useState<LogItem[]>([])
|
||||
const [stats, setStats] = useState<LiveStats | null>(null)
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
|
||||
const clear = useAsyncAction(async () => clearLogs())
|
||||
|
||||
// Every filter change invalidates both the current page and the live tail.
|
||||
const applyFilter = (): void => {
|
||||
setPage(1)
|
||||
setLiveRows([])
|
||||
}
|
||||
|
||||
const filters = useMemo<LogFilters>(
|
||||
() => ({
|
||||
q: q.trim() || undefined,
|
||||
action: action === '' ? undefined : action,
|
||||
client: client.trim() || undefined,
|
||||
blocked: blockedOnly ? true : undefined,
|
||||
limit: PER_PAGE,
|
||||
offset: (page - 1) * PER_PAGE,
|
||||
}),
|
||||
[q, action, client, blockedOnly, page],
|
||||
)
|
||||
|
||||
const { data, error, loading, refetch } = useApi(() => getLogs(filters), [filters])
|
||||
useAutoRefresh(refetch, REFRESH_LIVE_MS, live)
|
||||
|
||||
// The stream outlives every filter change, so it reads the current filter
|
||||
// through a ref instead of resubscribing on each keystroke.
|
||||
const filterRef = useRef<Filter>({ q: '', action: '', client: '', blocked: false })
|
||||
useEffect(() => {
|
||||
filterRef.current = { q: q.trim(), action, client: client.trim(), blocked: blockedOnly }
|
||||
}, [q, action, client, blockedOnly])
|
||||
|
||||
const toggleLive = (next: boolean): void => {
|
||||
setLiveRows([])
|
||||
setStats(null)
|
||||
setConnected(false)
|
||||
setStreamError(null)
|
||||
setLive(next)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!live) return
|
||||
const stop = streamLogs({
|
||||
onEntry: (entry) => {
|
||||
if (!matches(entry, filterRef.current)) return
|
||||
setLiveRows((prev) => [entry, ...prev].slice(0, MAX_ROWS))
|
||||
},
|
||||
onStats: (next) => setStats(liveStats(next)),
|
||||
onReady: () => setConnected(true),
|
||||
onOpen: () => {
|
||||
setConnected(true)
|
||||
setStreamError(null)
|
||||
},
|
||||
onError: (message) => {
|
||||
setConnected(false)
|
||||
setStreamError(message)
|
||||
},
|
||||
})
|
||||
return stop
|
||||
}, [live])
|
||||
|
||||
const items: LogItem[] = useMemo(() => data?.items ?? [], [data])
|
||||
const total = data?.total ?? items.length
|
||||
const rows = useMemo<LogItem[]>(
|
||||
() => (liveRows.length > 0 ? [...liveRows, ...items] : items).slice(0, MAX_ROWS),
|
||||
[liveRows, items],
|
||||
)
|
||||
|
||||
const onClear = async (): Promise<void> => {
|
||||
const res = await clear.run()
|
||||
if (res === null) {
|
||||
notify('Could not clear the log', clear.error ?? 'the server rejected the request')
|
||||
return
|
||||
}
|
||||
setConfirmClear(false)
|
||||
setLiveRows([])
|
||||
notify('Log cleared', 'the server dropped every buffered entry', 'ok')
|
||||
refetch()
|
||||
}
|
||||
|
||||
if (error) return <PageError error={error} onRetry={refetch} />
|
||||
|
||||
const columns = [
|
||||
{ key: 'ts', header: 'Time', width: '9%' },
|
||||
{ key: 'client', header: 'Client', width: '13%' },
|
||||
{ key: 'group', header: 'Group', width: '10%' },
|
||||
{ key: 'name', header: 'Name', width: '24%' },
|
||||
{ key: 'qtype', header: 'Type', width: '6%' },
|
||||
{ key: 'rcode', header: 'Rcode', width: '12%' },
|
||||
{ key: 'action', header: 'Action', width: '8%' },
|
||||
{ key: 'duration', header: 'Time', width: '8%', align: 'right' as const },
|
||||
{ key: 'upstream', header: 'Upstream', width: '10%' },
|
||||
{ key: 'list', header: 'List', width: '12%' },
|
||||
]
|
||||
|
||||
const tableRows: Row[] = rows.map((entry, index) => ({
|
||||
__key: `${entry.id}-${entry.ts}-${index}`,
|
||||
ts: <span className="tabular font-mono text-xs text-fg-muted">{formatClock(entry.ts)}</span>,
|
||||
client: (
|
||||
<span className="block max-w-[180px] truncate font-mono text-xs">
|
||||
{entry.client || '—'}
|
||||
</span>
|
||||
),
|
||||
group: entry.client_group ? (
|
||||
<Badge tone="muted">{entry.client_group}</Badge>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
name: (
|
||||
<span className="block max-w-[320px] truncate font-mono text-xs" title={entry.name}>
|
||||
{entry.name}
|
||||
</span>
|
||||
),
|
||||
qtype: <span className="font-mono text-xs">{entry.qtype || '—'}</span>,
|
||||
rcode: <span className="font-mono text-xs text-fg-muted">{formatRcode(entry.rcode)}</span>,
|
||||
action: <Badge tone={actionTone(entry.action)}>{entry.action || 'unknown'}</Badge>,
|
||||
duration: <span className="font-mono text-xs">{formatMs(entry.duration_ms)}</span>,
|
||||
upstream: (
|
||||
<span className="block max-w-[160px] truncate font-mono text-xs text-fg-muted">
|
||||
{entry.upstream || '—'}
|
||||
</span>
|
||||
),
|
||||
list: (
|
||||
<span className="block max-w-[200px] truncate text-xs text-fg-muted" title={entry.list}>
|
||||
{entry.list || '—'}
|
||||
</span>
|
||||
),
|
||||
}))
|
||||
|
||||
const qps = stats?.qps ?? 0
|
||||
const blockedPct = stats?.blocked_pct ?? 0
|
||||
const hitRate = stats?.cache_hit_rate ?? 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Logs"
|
||||
description="Every query the daemon answered, with the rule that decided it."
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
tone={live ? (connected ? 'ok' : 'danger') : 'muted'}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
live ? (connected ? 'animate-pulse-soft bg-ok' : 'animate-pulse-soft bg-danger') : 'bg-fg-subtle',
|
||||
)}
|
||||
/>
|
||||
{live ? (connected ? 'LIVE' : 'DISCONNECTED') : 'PAUSED'}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={live}
|
||||
onChange={toggleLive}
|
||||
label="Live tail"
|
||||
description="Stream new entries over SSE"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={Eraser}
|
||||
onClick={() => setConfirmClear(true)}
|
||||
loading={clear.pending}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{live && (
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 rounded-lg border border-line bg-surface px-4 py-2.5 text-xs shadow-panel">
|
||||
<span className="inline-flex items-center gap-1.5 text-fg-muted">
|
||||
<Signal size={13} aria-hidden="true" />
|
||||
<span className="tabular font-mono text-fg">{formatCompact(qps)}</span> qps
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
<span className="tabular font-mono text-fg">{formatPercent(blockedPct)}</span>{' '}
|
||||
blocked
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
<span className="tabular font-mono text-fg">{formatRate(hitRate)}</span> cache hits
|
||||
</span>
|
||||
<span className="text-fg-subtle">sampled by the daemon every few seconds</span>
|
||||
{streamError !== null && (
|
||||
<span className="text-danger" title={streamError}>
|
||||
<WifiOff size={13} className="mr-1 inline align-[-2px]" aria-hidden="true" />
|
||||
{streamError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card title="Filters" subtitle="Free text covers name, list, upstream and client.">
|
||||
<CardBody>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Input
|
||||
label="Search"
|
||||
icon={Search}
|
||||
placeholder="example.com, ads, 10.0.0.0/8"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value)
|
||||
applyFilter()
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Select
|
||||
label="Action"
|
||||
options={ACTION_OPTIONS}
|
||||
value={action}
|
||||
onChange={(event) => {
|
||||
setAction(event.target.value)
|
||||
applyFilter()
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label="Client"
|
||||
placeholder="address or group name"
|
||||
value={client}
|
||||
onChange={(event) => {
|
||||
setClient(event.target.value)
|
||||
applyFilter()
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex items-end pb-1">
|
||||
<Switch
|
||||
checked={blockedOnly}
|
||||
onChange={(next) => {
|
||||
setBlockedOnly(next)
|
||||
applyFilter()
|
||||
}}
|
||||
label="Blocked only"
|
||||
description="Hide allowed and cached answers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Query log"
|
||||
subtitle={`${rows.length} of ${formatNumber(total)} matching entries`}
|
||||
padded={false}
|
||||
actions={
|
||||
rows.length >= MAX_ROWS ? (
|
||||
<Badge tone="warn">capped at {MAX_ROWS} rows</Badge>
|
||||
) : (
|
||||
<Badge tone="muted">
|
||||
<Radio size={11} aria-hidden="true" />
|
||||
{live ? 'streaming' : 'paused'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={tableRows}
|
||||
rowKey={(row) => String(row.__key)}
|
||||
loading={loading && items.length === 0 && liveRows.length === 0}
|
||||
maxHeight={640}
|
||||
empty="No query matched these filters."
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
perPage={PER_PAGE}
|
||||
total={total}
|
||||
onPage={setPage}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmClear}
|
||||
onClose={() => setConfirmClear(false)}
|
||||
onConfirm={() => void onClear()}
|
||||
title="Clear the query log?"
|
||||
message="Every buffered entry is dropped on the server. This cannot be undone, and the running log keeps filling from the next query."
|
||||
confirmLabel="Clear log"
|
||||
destructive
|
||||
pending={clear.pending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* NotFound — the catch-all route.
|
||||
*/
|
||||
|
||||
import { Compass, LayoutDashboard } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { Button, Card, CardBody, EmptyState } from '../components/ui'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<EmptyState
|
||||
icon={Compass}
|
||||
title="404 — that page does not exist"
|
||||
description="The console has no route for this address. It may have been renamed, or the link may be stale."
|
||||
action={
|
||||
<Link to="/">
|
||||
<Button variant="primary" icon={LayoutDashboard}>
|
||||
Back to the dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Card title="Available pages" subtitle="The console ships these routes">
|
||||
<CardBody>
|
||||
<ul className="grid grid-cols-1 gap-1.5 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
['/', 'Dashboard'],
|
||||
['/blocklists', 'Blocklists'],
|
||||
['/domains', 'Domains'],
|
||||
['/clients', 'Clients'],
|
||||
['/upstreams', 'Upstreams'],
|
||||
['/logs', 'Logs'],
|
||||
['/cache', 'Cache'],
|
||||
['/settings', 'Settings'],
|
||||
].map(([to, label]) => (
|
||||
<li key={to}>
|
||||
<Link
|
||||
to={to}
|
||||
className="flex items-baseline gap-2 rounded-lg px-2 py-1.5 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<span className="font-mono text-xs text-fg-subtle">{to}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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