From f24fd63e9a7e5f813f5af3a90b5de81ee7b78ada Mon Sep 17 00:00:00 2001 From: developer Date: Sun, 27 Sep 2026 11:32:39 +0000 Subject: [PATCH 1/5] web: give the toast helper a real type and keep the dist bundle out of git Blocklists, Clients, Domains and Upstreams each carried a ToastLike shim that duck-typed a react-hot-toast style API. The console uses its own Toast context, so notify() was calling toast('some message') with a string where ToastOptions is expected: every mutation toast rendered an empty title. They now take the toast function from useToast() and send a real { title, tone } payload, mapping 'fail' onto the danger tone. --- web/src/pages/Blocklists.tsx | 50 +++++++++++++++--------------- web/src/pages/Clients.tsx | 46 ++++++++++++++-------------- web/src/pages/Domains.tsx | 43 +++++++++++++------------- web/src/pages/Upstreams.tsx | 59 +++++++++++++++++++----------------- 4 files changed, 102 insertions(+), 96 deletions(-) diff --git a/web/src/pages/Blocklists.tsx b/web/src/pages/Blocklists.tsx index 77dc6c7..17c36e4 100644 --- a/web/src/pages/Blocklists.tsx +++ b/web/src/pages/Blocklists.tsx @@ -10,12 +10,12 @@ import { useMemo, useState } from 'react' import type { ReactNode } from 'react' import { AlertTriangle, Clock, Layers, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react' -import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../../lib/api' -import type { BlocklistItem, BlocklistStats, DomainCheck } from '../../lib/types' -import { useApi, useAsyncAction } from '../../hooks/useApi' -import { useAutoRefresh } from '../../hooks/useAutoRefresh' -import { useDebounced } from '../../hooks/useDebounced' -import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../../lib/format' +import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../lib/api' +import type { BlocklistItem, BlocklistStats, DomainCheck } from '../lib/types' +import { useApi, useAsyncAction } from '../hooks/useApi' +import { useAutoRefresh } from '../hooks/useAutoRefresh' +import { useDebounced } from '../hooks/useDebounced' +import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../lib/format' import { Badge, Button, @@ -29,21 +29,21 @@ import { Table, useToast, } from '../components/ui' +import type { ToastOptions } from '../components/ui' import { PageError, PageHeader } from '../components/layout' type Row = Record -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) +/** + * One line for every mutation on this page: the message plus the tone it + * earned. `fail` is the danger tone, `ok` the success one. + */ +function notify( + toast: (options: ToastOptions) => void, + kind: 'ok' | 'fail', + message: string, +): void { + toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' }) } const EMPTY_STATS: BlocklistStats = { @@ -79,7 +79,7 @@ function truncate(value: string, max = 42): string { } export default function Blocklists() { - const toast = useToast() + const { toast } = useToast() const { data, error, loading, refetch } = useApi(() => getBlocklists(), []) useAutoRefresh(refetch, 15000) @@ -200,7 +200,7 @@ export default function Blocklists() { ), refresh: ( } + icon={RefreshCw} label={`refresh ${item.name}`} variant="ghost" disabled={busy} @@ -221,7 +221,7 @@ export default function Blocklists() { description="Compiled rule sources, their freshness and their health." actions={ } @@ -322,11 +322,11 @@ export default function Clients() { {groups.length === 0 ? ( } + icon={Users} title="No client groups" description="Without a group every client gets the default policy." action={ - } @@ -362,10 +362,10 @@ export default function Clients() {
- } label={`edit ${group.name}`} variant="ghost" onClick={() => openEdit(i)} /> - } label={`duplicate ${group.name}`} variant="ghost" onClick={() => duplicate(i)} /> + openEdit(i)} /> + duplicate(i)} /> } + icon={Trash2} label={`delete ${group.name}`} variant="ghost" onClick={() => setPendingDelete(i)} diff --git a/web/src/pages/Domains.tsx b/web/src/pages/Domains.tsx index 82f501d..0129f93 100644 --- a/web/src/pages/Domains.tsx +++ b/web/src/pages/Domains.tsx @@ -10,15 +10,16 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import { Plus, Search, Trash2 } from 'lucide-react' -import { addDomain, deleteDomain, getDomains } from '../../lib/api' -import type { AddDomainInput } from '../../lib/api' -import type { DomainItem } from '../../lib/types' -import { useApi, useAsyncAction } from '../../hooks/useApi' -import { useDebounced } from '../../hooks/useDebounced' -import { formatNumber, formatRelative, toLines } from '../../lib/format' +import { addDomain, deleteDomain, getDomains } from '../lib/api' +import type { AddDomainInput } from '../lib/api' +import type { DomainItem } from '../lib/types' +import { useApi, useAsyncAction } from '../hooks/useApi' +import { useDebounced } from '../hooks/useDebounced' +import { formatNumber, formatRelative, toLines } from '../lib/format' import { Badge, Button, + Card, ConfirmDialog, Dialog, EmptyState, @@ -30,21 +31,21 @@ import { Textarea, useToast, } from '../components/ui' +import type { ToastOptions } from '../components/ui' import { PageError, PageHeader } from '../components/layout' type Row = Record -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) +/** + * One line for every mutation on this page: the message plus the tone it + * earned. `fail` is the danger tone, `ok` the success one. + */ +function notify( + toast: (options: ToastOptions) => void, + kind: 'ok' | 'fail', + message: string, +): void { + toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' }) } const PER_PAGE = 50 @@ -88,7 +89,7 @@ type AddForm = { const EMPTY_FORM: AddForm = { name: '', action: 'block', list: '', bulk: '', advanced: false } export default function Domains() { - const toast = useToast() + const { toast } = useToast() const [search, setSearch] = useState('') const [action, setAction] = useState('') const [list, setList] = useState('') @@ -220,7 +221,7 @@ export default function Domains() { updated: {formatRelative(item.updated_at)}, delete: ( } + icon={Trash2} label={`delete ${item.name}`} variant="ghost" onClick={() => setPendingDelete(item)} @@ -234,7 +235,7 @@ export default function Domains() { title="Domains" description="Inline allow and block rules, checked before the downloaded lists." actions={ - } @@ -245,7 +246,7 @@ export default function Domains() { } + icon={Search} value={search} onChange={(e) => setSearch(e.target.value)} autoComplete="off" diff --git a/web/src/pages/Upstreams.tsx b/web/src/pages/Upstreams.tsx index 39f4891..dcdab1e 100644 --- a/web/src/pages/Upstreams.tsx +++ b/web/src/pages/Upstreams.tsx @@ -20,10 +20,10 @@ import { Trash2, } from 'lucide-react' -import { getUpstreams, putUpstreams } from '../../lib/api' -import type { UpstreamBootstrap, UpstreamServer } from '../../lib/types' -import { useApi, useAsyncAction } from '../../hooks/useApi' -import { formatLatency, formatMs, formatNumber, formatRate } from '../../lib/format' +import { getUpstreams, putUpstreams } from '../lib/api' +import type { UpstreamBootstrap, UpstreamHealth, UpstreamServer } from '../lib/types' +import { useApi, useAsyncAction } from '../hooks/useApi' +import { formatLatency, formatMs, formatNumber, formatRate } from '../lib/format' import { Badge, Button, @@ -40,21 +40,21 @@ import { Textarea, useToast, } from '../components/ui' +import type { ToastOptions } from '../components/ui' import { PageError, PageHeader } from '../components/layout' type Row = Record -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) +/** + * One line for every mutation on this page: the message plus the tone it + * earned. `fail` is the danger tone, `ok` the success one. + */ +function notify( + toast: (options: ToastOptions) => void, + kind: 'ok' | 'fail', + message: string, +): void { + toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' }) } const POLICIES = [ @@ -167,7 +167,7 @@ function splitList(value: string): string[] { } export default function Upstreams() { - const toast = useToast() + const { toast } = useToast() const { data, error, loading, refetch } = useApi(() => getUpstreams(), []) const [draft, setDraft] = useState(null) @@ -210,10 +210,15 @@ export default function Upstreams() { insecure_skip_verify: s.insecure_skip_verify, weight: s.weight, comment: s.comment, - health: s.health, + health: s.health as UpstreamHealth, latency_ms: s.latency_ms, fail_rate: s.fail_rate, breaker_open: s.breaker_open, + // Runtime counters are not part of a write: the server owns them and + // ignores these fields on PUT /upstreams. + queries: 0, + avg_ms: 0, + p95_ms: 0, })), bootstraps: working.bootstraps.map((b) => ({ name: b.name.trim(), @@ -314,10 +319,10 @@ export default function Upstreams() { comment: {s.comment}, order: ( - } label={`move ${s.addr} up`} variant="ghost" disabled={i === 0} onClick={() => move(i, -1)} /> - } label={`move ${s.addr} down`} variant="ghost" disabled={i === working.servers.length - 1} onClick={() => move(i, 1)} /> - } label={`edit ${s.addr}`} variant="ghost" onClick={() => { setEditing(s); setEditorOpen(true) }} /> - } label={`remove ${s.addr}`} variant="ghost" onClick={() => setPendingRemove(i)} /> + move(i, -1)} /> + move(i, 1)} /> + { setEditing(s); setEditorOpen(true) }} /> + setPendingRemove(i)} /> ), })) @@ -331,13 +336,13 @@ export default function Upstreams() {
-
@@ -386,7 +391,7 @@ export default function Upstreams() { actions={ - - -
- -
- -
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ) } -export default App +export default function App() { + useThemeInit() + + return ( + + + + {/* Bare: no shell, no header, no nav. */} + } /> + + + + } + /> + + + + ) +} diff --git a/web/src/hooks/useApi.ts b/web/src/hooks/useApi.ts index 902c1ff..ed996b0 100644 --- a/web/src/hooks/useApi.ts +++ b/web/src/hooks/useApi.ts @@ -27,7 +27,9 @@ export function useApi(fn: () => Promise, deps: unknown[] = []): ApiState< fnRef.current = fn useEffect(() => { - const controller = new AbortController() + // The fetcher takes no AbortSignal, so cancellation is a guard: a response + // that arrives after an unmount or a newer request is dropped on the floor + // instead of overwriting fresher state. let live = true setLoading(true) @@ -50,7 +52,6 @@ export function useApi(fn: () => Promise, deps: unknown[] = []): ApiState< return () => { live = false - controller.abort() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [...deps, nonce]) diff --git a/web/src/index.css b/web/src/index.css index f658a10..f2d33de 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -198,12 +198,6 @@ color: var(--fg); } - /* Numeric columns must not jitter as digits change. */ - .tabular { - font-variant-numeric: tabular-nums; - font-feature-settings: "tnum" 1; - } - * { scrollbar-width: thin; scrollbar-color: var(--scroll-thumb) transparent; @@ -244,3 +238,9 @@ } } } + +/** Numeric columns must not jitter as their digits change. */ +@utility tabular { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 119fc4b..e227aa3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -417,6 +417,8 @@ export function streamLogs(handlers: LogStreamHandlers): () => void { let backoff = RECONNECT_MIN_MS let timer: ReturnType | undefined let attempt = 0 + // One error report per outage streak: a flaky link must not toast every 15s. + let reported = false const open = (): void => { if (stopped) return @@ -447,11 +449,15 @@ export function streamLogs(handlers: LogStreamHandlers): () => void { const message = res.ok ? 'the live log stream is not supported by this browser' : `the live log stream failed with HTTP ${res.status}` - handlers.onError(message) + if (!reported) { + reported = true + handlers.onError(message) + } schedule() return } + reported = false attempt = 0 backoff = RECONNECT_MIN_MS handlers.onOpen() @@ -463,7 +469,10 @@ export function streamLogs(handlers: LogStreamHandlers): () => void { if (stopped) return if (controller.signal.aborted) return const message = err instanceof Error ? err.message : 'the live log stream dropped' - handlers.onError(message) + if (!reported) { + reported = true + handlers.onError(message) + } schedule() } } diff --git a/web/src/main.tsx b/web/src/main.tsx index bef5202..aa2970d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,9 +1,19 @@ +/** + * Entry point. The stylesheet is imported first so Tailwind's layers are in + * place before the first React render commits anything. + */ + import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' -createRoot(document.getElementById('root')!).render( +const container = document.getElementById('root') +if (container === null) { + throw new Error('fancy-dns: #root is missing from index.html') +} + +createRoot(container).render( , diff --git a/web/src/pages/Cache.tsx b/web/src/pages/Cache.tsx new file mode 100644 index 0000000..19a55ca --- /dev/null +++ b/web/src/pages/Cache.tsx @@ -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 + +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 & { + 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 ( +
+
    + {payload.map((item) => ( +
  • +
  • + ))} +
+
+ ) +} + +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 => { + 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 + + 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: ( + + {entry.name} + + ), + qtype: {entry.qtype || '—'}, + count: {formatNumber(entry.count)}, + hits: {formatNumber(entry.hits)}, + last_seen: {formatRelative(entry.last_seen)}, + })) + + return ( +
+ setConfirmFlush(true)} + loading={flush.pending} + > + Flush cache + + } + /> + +
+ + + + + + +
+ +
+ + +
+
+ + + + {pie.map((slice) => ( + + ))} + + } /> + + +
+
+

{formatRate(hitRate, 0)}

+

hit rate

+
+
+
+
    +
  • +
  • +
  • +
  • +
  • + Negative TTL + + {formatMs(data?.negative_ttl_ms ?? 0)} + +
  • + {lookups === 0 && ( +
  • + Nothing has been looked up since the daemon started. +
  • + )} +
+
+
+
+ + + {persisted.enabled ? 'enabled' : 'disabled'} + + } + > + +
+
+
Path
+
+ {persisted.path || '—'} +
+
+
+
Size
+
+ {formatBytes(persisted.bytes ?? 0)} +
+
+
+
Entries
+
+ {formatNumber(persisted.entries ?? 0)} +
+
+
+
Writes
+
+ {formatNumber(persisted.writes ?? 0)} +
+
+
+
Errors
+
0 + ? 'tabular mt-0.5 font-mono text-sm text-danger' + : 'tabular mt-0.5 font-mono text-sm' + } + > + {formatNumber(persistErrors)} +
+
+
+
Last snapshot
+
{formatRelative(persisted.last_snapshot)}
+
+
+ {persistMessage !== '' && ( +

+ {persistMessage} +

+ )} +
+
+
+ + auto-refresh every 15s} + > + {topError !== null ? ( + +

{topError}

+
+ ) : ( +
+ String(row.__key)} + loading={topLoading && entries.length === 0} + empty={ + + } + /> + + )} + + + 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} + /> + + ) +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 864c4a8..df9b60c 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -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 & { @@ -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() { + + + + +

+

+ + {pending && ( +

+ + waiting for the node +

+ )} + + + ) +} diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx new file mode 100644 index 0000000..e24527a --- /dev/null +++ b/web/src/pages/Logs.tsx @@ -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 + +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(null) + const [liveRows, setLiveRows] = useState([]) + const [stats, setStats] = useState(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( + () => ({ + 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({ 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( + () => (liveRows.length > 0 ? [...liveRows, ...items] : items).slice(0, MAX_ROWS), + [liveRows, items], + ) + + const onClear = async (): Promise => { + 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 + + 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: {formatClock(entry.ts)}, + client: ( + + {entry.client || '—'} + + ), + group: entry.client_group ? ( + {entry.client_group} + ) : ( + '—' + ), + name: ( + + {entry.name} + + ), + qtype: {entry.qtype || '—'}, + rcode: {formatRcode(entry.rcode)}, + action: {entry.action || 'unknown'}, + duration: {formatMs(entry.duration_ms)}, + upstream: ( + + {entry.upstream || '—'} + + ), + list: ( + + {entry.list || '—'} + + ), + })) + + const qps = stats?.qps ?? 0 + const blockedPct = stats?.blocked_pct ?? 0 + const hitRate = stats?.cache_hit_rate ?? 0 + + return ( +
+ + + + + +
+ } + /> + + {live && ( +
+ + + + {formatPercent(blockedPct)}{' '} + blocked + + + {formatRate(hitRate)} cache hits + + sampled by the daemon every few seconds + {streamError !== null && ( + + + )} +
+ )} + + + +
+ { + setSearch(event.target.value) + applyFilter() + }} + autoComplete="off" + spellCheck={false} + /> + { + setClient(event.target.value) + applyFilter() + }} + autoComplete="off" + spellCheck={false} + /> +
+ { + setBlockedOnly(next) + applyFilter() + }} + label="Blocked only" + description="Hide allowed and cached answers" + /> +
+
+
+
+ + = MAX_ROWS ? ( + capped at {MAX_ROWS} rows + ) : ( + + + ) + } + > +
+
String(row.__key)} + loading={loading && items.length === 0 && liveRows.length === 0} + maxHeight={640} + empty="No query matched these filters." + /> + + + + + + 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} + /> + + ) +} diff --git a/web/src/pages/NotFound.tsx b/web/src/pages/NotFound.tsx new file mode 100644 index 0000000..4eeb20b --- /dev/null +++ b/web/src/pages/NotFound.tsx @@ -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 ( +
+ + + + } + /> + + +
    + {[ + ['/', 'Dashboard'], + ['/blocklists', 'Blocklists'], + ['/domains', 'Domains'], + ['/clients', 'Clients'], + ['/upstreams', 'Upstreams'], + ['/logs', 'Logs'], + ['/cache', 'Cache'], + ['/settings', 'Settings'], + ].map(([to, label]) => ( +
  • + + {to} + {label} + +
  • + ))} +
+
+
+
+ ) +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx new file mode 100644 index 0000000..7f239bf --- /dev/null +++ b/web/src/pages/Settings.tsx @@ -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 ( +
    + {list.map((issue, index) => ( +
  • + {tone === 'danger' ? ( +
  • + ))} +
+ ) +} + +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'}} + > +
+ +