initial commit

This commit is contained in:
developer committed 2026-09-27 11:20:34 +00:00
1 parent 5b935301e5
commit 85453852d5
105 files changed
+24846

No files matched your search

+342
View File
@@ -0,0 +1,342 @@
/**
* Blocklists — the compiled rule sources.
*
* Two read paths (`/api/blocklists`) and three mutations (toggle, refresh one,
* refresh all). Toggles are optimistic because the endpoint is a single write
* to an in-memory flag: the switch has to feel instant or it feels broken.
*/
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 {
Badge,
Button,
Card,
CardBody,
EmptyState,
IconButton,
Input,
StatCard,
Switch,
Table,
useToast,
} from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode>
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)
}
const EMPTY_STATS: BlocklistStats = {
total: 0,
exact: 0,
subdomain_rules: 0,
root_rules: 0,
allow: 0,
block: 0,
duplicates: 0,
sources: 0,
built_at: '',
}
type CheckView = Partial<DomainCheck> & {
list?: string
pattern?: string
error?: string
/** Older daemons called the matched rule "rule". */
rule?: string
matched_pattern?: string
}
/** The lookup endpoint answers 200 with an `error` string, not a 4xx. */
function checkView(value: unknown): CheckView | null {
if (!value || typeof value !== 'object') return null
return value as CheckView
}
function truncate(value: string, max = 42): string {
if (value.length <= max) return value
return `${value.slice(0, max - 1)}…`
}
export default function Blocklists() {
const toast = useToast()
const { data, error, loading, refetch } = useApi(() => getBlocklists(), [])
useAutoRefresh(refetch, 15000)
const [overrides, setOverrides] = useState<Record<string, boolean>>({})
const [pendingRows, setPendingRows] = useState<Record<string, boolean>>({})
const [probe, setProbe] = useState('')
const debouncedProbe = useDebounced(probe, 300)
const name = debouncedProbe.trim()
const { data: checked, error: checkError, loading: checking } = useApi(
() => (name.length > 0 ? checkDomain(name) : Promise.resolve(null)),
[name],
)
const result = checkView(checked)
const stats = (data?.stats as BlocklistStats | undefined) ?? EMPTY_STATS
const items: BlocklistItem[] = useMemo(() => data?.items ?? [], [data])
const compiledAgo = useMemo(() => {
const built = toDate(stats.built_at)
if (built) return formatRelative(built.toISOString())
const newest = items
.map((i) => toDate(i.updated_at))
.filter((d): d is Date => d !== null)
.sort((a, b) => b.getTime() - a.getTime())[0]
return newest ? formatRelative(newest.toISOString()) : 'never'
}, [stats.built_at, items])
const toggle = useAsyncAction(async (list: string, enabled: boolean) =>
toggleBlocklist(list, enabled),
)
const refresh = useAsyncAction(async (names?: string[]) => refreshBlocklists(names))
const onToggle = async (item: BlocklistItem, next: boolean): Promise<void> => {
setOverrides((prev) => ({ ...prev, [item.name]: next }))
const applied = await toggle.run(item.name, next)
if (applied === null) {
setOverrides((prev) => {
const copy = { ...prev }
delete copy[item.name]
return copy
})
notify(toast, 'fail', toggle.error ?? `could not toggle ${item.name}`)
return
}
refetch()
}
const onRefresh = async (names?: string[]): Promise<void> => {
const key = names?.join(',') ?? ''
setPendingRows((prev) => ({ ...prev, [key]: true }))
const res = await refresh.run(names)
setPendingRows((prev) => ({ ...prev, [key]: false }))
if (res === null) {
notify(toast, 'fail', refresh.error ?? 'blocklist refresh failed')
return
}
notify(
toast,
'ok',
names && names.length > 0
? `refresh started for ${names.join(', ')}`
: 'refresh started for every source',
)
refetch()
}
if (error) return <PageError error={error} onRetry={refetch} />
const columns = [
{ key: 'name', header: 'List', width: '22%' },
{ key: 'format', header: 'Format', width: '10%' },
{ key: 'enabled', header: 'Enabled', width: '10%' },
{ key: 'rules', header: 'Rules', width: '9%', align: 'right' as const },
{ key: 'size', header: 'Size', width: '9%', align: 'right' as const },
{ key: 'updated', header: 'Updated', width: '12%' },
{ key: 'duration', header: 'Duration', width: '10%', align: 'right' as const },
{ key: 'source', header: 'Source', width: '24%' },
{ key: 'refresh', header: '', width: '5%', align: 'right' as const },
]
const rows: Row[] = items.map((item) => {
const enabled = overrides[item.name] ?? item.enabled
const busy = pendingRows[item.name] === true
return {
__key: item.name,
name: (
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{item.name}</span>
{item.err ? (
<span className="truncate text-xs text-red-600 dark:text-red-400">{item.err}</span>
) : null}
</span>
),
format: <Badge tone="muted">{item.format}</Badge>,
enabled: (
<Switch
checked={enabled}
onChange={(next) => void onToggle(item, next)}
label={enabled ? 'on' : 'off'}
/>
),
rules: <span className="font-mono text-xs">{formatNumber(item.rules)}</span>,
size: <span className="font-mono text-xs">{formatBytes(item.bytes)}</span>,
updated: (
<span className="flex items-center gap-1.5 whitespace-nowrap text-xs">
{formatRelative(item.updated_at)}
{item.stale ? <Badge tone="warn">stale</Badge> : null}
</span>
),
duration: <span className="font-mono text-xs">{formatMs(item.duration_ms)}</span>,
source: (
<span
className="block max-w-[240px] truncate font-mono text-xs text-slate-500 dark:text-slate-400"
title={item.url || item.path}
>
{truncate(item.url || item.path)}
</span>
),
refresh: (
<IconButton
icon={<RefreshCw size={14} />}
label={`refresh ${item.name}`}
variant="ghost"
disabled={busy}
onClick={() => void onRefresh([item.name])}
/>
),
}
})
const action = result?.action ?? null
const matchedList = result?.source || result?.list || ''
const matchedPattern = result?.matched_pattern || result?.rule || result?.pattern || ''
return (
<div className="space-y-4">
<PageHeader
title="Blocklists"
description="Compiled rule sources, their freshness and their health."
actions={
<Button
icon={<RefreshCw size={14} />}
onClick={() => void onRefresh()}
loading={refresh.pending}
>
Refresh all
</Button>
}
/>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5">
<StatCard label="Total rules" value={formatNumber(stats.total)} icon={<Layers size={16} />} />
<StatCard label="Sources" value={formatNumber(stats.sources)} hint={`${items.filter((i) => i.enabled).length} enabled`} />
<StatCard
label="Allow rules"
value={formatNumber(stats.allow)}
icon={<ShieldCheck size={16} />}
tone={stats.allow > 0 ? 'default' : 'warn'}
/>
<StatCard
label="Duplicates"
value={formatNumber(stats.duplicates)}
icon={<Split size={16} />}
tone={stats.duplicates > 0 ? 'warn' : 'ok'}
/>
<StatCard
label="Last compile"
value={compiledAgo}
icon={<Clock size={16} />}
hint={stats.built_at ? undefined : 'no build recorded'}
/>
</div>
<Card
title="Test a domain"
subtitle="How would the current blocklists treat this name? Nothing is changed."
>
<CardBody>
<div className="flex flex-col gap-3 md:flex-row md:items-start">
<div className="w-full md:max-w-sm">
<Input
label="Domain"
placeholder="ads.example.com"
value={probe}
onChange={(e) => setProbe(e.target.value)}
icon={<ShieldCheck size={14} />}
autoComplete="off"
spellCheck={false}
/>
</div>
<div className="min-w-0 flex-1">
{!name ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
Type a name to see the matching rule, the list it came from and how often it
fired.
</p>
) : checking ? (
<p className="text-sm text-slate-500">Checking {name}…</p>
) : checkError ? (
<p className="text-sm text-red-600 dark:text-red-400">{checkError}</p>
) : result?.error ? (
<p className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
<AlertTriangle size={14} aria-hidden="true" />
{result.error}
</p>
) : result ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<Badge tone={action === 'allow' ? 'default' : 'warn'}>{action ?? 'unknown'}</Badge>
<span className="font-mono text-xs">{name}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
list: <span className="font-mono">{matchedList || '—'}</span>
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
rule: <span className="font-mono">{matchedPattern || '—'}</span>
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
hits: <span className="font-mono">{formatNumber(result.hits ?? 0)}</span>
</span>
{Array.isArray(result.categories) && result.categories.length > 0 ? (
<span className="flex flex-wrap gap-1">
{result.categories.map((c) => (
<Badge key={c} tone="info">
{c}
</Badge>
))}
</span>
) : null}
</div>
) : (
<p className="text-sm text-slate-500">No answer for {name}.</p>
)}
</div>
</div>
</CardBody>
</Card>
<Card title="Sources" subtitle={`${items.length} configured`} padded={false}>
<div className="overflow-x-auto">
<Table
columns={columns}
rows={rows}
rowKey={(row) => String(row.__key)}
loading={loading && items.length === 0}
empty={
<EmptyState
title="No blocklists configured"
description="Add list sources in the configuration to compile rules."
/>
}
/>
</div>
</Card>
<p className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<Trash2 size={12} aria-hidden="true" />
Removing a list is a configuration change: delete its source in Settings, then refresh.
</p>
</div>
)
}
+558
View File
@@ -0,0 +1,558 @@
/**
* Clients — per-client ACL groups.
*
* The whole file is rewritten on every save (`PUT /api/clients`), so the editor
* works on a local draft and only commits on Save. A 400 comes back with the
* offending field paths, which are mapped back onto the draft fields so the
* user sees what is wrong next to the input rather than in a toast alone.
*/
import { useMemo, useState } from 'react'
import { Copy, Pencil, Plus, Trash2, Users } from 'lucide-react'
import { ApiError, getClients, putClients } from '../../lib/api'
import type { ClientGroup, ClientMatch } from '../../lib/types'
import { useApi, useAsyncAction } from '../../hooks/useApi'
import { fromLines, toLines } from '../../lib/format'
import {
Badge,
Button,
Card,
ConfirmDialog,
Dialog,
EmptyState,
IconButton,
Input,
Select,
Switch,
Textarea,
useToast,
} from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
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)
}
const POLICIES = [
{ value: 'allow', label: 'Allow — resolve everything' },
{ value: 'block_only', label: 'Block only — no recursion, blocklists apply' },
{ value: 'deny', label: 'Deny — refuse everything' },
]
const DEFAULT_POLICIES = [
{ value: 'allow', label: 'Allow' },
{ value: 'block_only', label: 'Block only' },
{ value: 'deny', label: 'Deny' },
]
/** Textarea-friendly draft: numbers stay strings until they are parsed. */
type GroupDraft = {
name: string
comment: string
policy: string
cidrs: string
ptr_suffixes: string
tags: string
any: boolean
allow_domains: string
deny_domains: string
skip_blocklist: boolean
skip_cache: boolean
log_queries: boolean
rate_limit: string
max_concurrent: string
upstreams: string
}
const EMPTY_DRAFT: GroupDraft = {
name: '',
comment: '',
policy: 'allow',
cidrs: '',
ptr_suffixes: '',
tags: '',
any: false,
allow_domains: '',
deny_domains: '',
skip_blocklist: false,
skip_cache: false,
log_queries: false,
rate_limit: '0',
max_concurrent: '0',
upstreams: '',
}
function toNumber(value: string): number {
const n = Number.parseInt(value.trim(), 10)
return Number.isFinite(n) && n > 0 ? n : 0
}
function matchOf(g: ClientGroup): ClientMatch {
const m = g.match
return {
cidrs: m?.cidrs ?? [],
ptr_suffixes: m?.ptr_suffixes ?? [],
tags: m?.tags ?? [],
any: m?.any ?? false,
}
}
/** One line describing what the group matches, e.g. "10.0.0.0/8, .lan · *". */
function matchSummary(g: ClientGroup): string {
const m = matchOf(g)
const bits: string[] = []
if (m.any) bits.push('any client')
if (m.cidrs.length > 0) {
const shown = m.cidrs.slice(0, 2).join(', ')
bits.push(m.cidrs.length > 2 ? `${shown} +${m.cidrs.length - 2}` : shown)
}
if (m.ptr_suffixes.length > 0) bits.push(m.ptr_suffixes.join(', '))
if (m.tags.length > 0) bits.push(m.tags.map((t) => `#${t}`).join(' '))
if (bits.length === 0) return 'matches nothing — this group is inactive'
return bits.join(' · ')
}
function fromGroup(g: ClientGroup): GroupDraft {
const m = matchOf(g)
return {
name: g.name ?? '',
comment: g.comment ?? '',
policy: String(g.policy ?? 'allow'),
cidrs: fromLines(m.cidrs),
ptr_suffixes: fromLines(m.ptr_suffixes),
tags: fromLines(m.tags),
any: m.any,
allow_domains: fromLines(g.allow_domains),
deny_domains: fromLines(g.deny_domains),
skip_blocklist: g.skip_blocklist === true,
skip_cache: g.skip_cache === true,
log_queries: g.log_queries === true,
rate_limit: String(g.rate_limit ?? 0),
max_concurrent: String(g.max_concurrent ?? 0),
upstreams: fromLines(g.upstreams),
}
}
function toGroup(d: GroupDraft): ClientGroup {
return {
name: d.name.trim(),
comment: d.comment,
policy: d.policy,
match: {
cidrs: toLines(d.cidrs),
ptr_suffixes: toLines(d.ptr_suffixes),
tags: toLines(d.tags),
any: d.any,
},
allow_domains: toLines(d.allow_domains),
deny_domains: toLines(d.deny_domains),
skip_blocklist: d.skip_blocklist,
skip_cache: d.skip_cache,
rate_limit: toNumber(d.rate_limit),
max_concurrent: toNumber(d.max_concurrent),
log_queries: d.log_queries,
upstreams: toLines(d.upstreams),
}
}
/** `groups[2].match.cidrs` -> `cidrs`. */
function fieldLeaf(path: string): string {
const cleaned = path.replace(/^groups\[\d+\]\.?/, '').replace(/^groups\.\d+\.?/, '')
const parts = cleaned.split('.')
return parts[parts.length - 1] ?? cleaned
}
export default function Clients() {
const toast = useToast()
const { data, error, loading, refetch } = useApi(() => getClients(), [])
const groups = useMemo<ClientGroup[]>(() => data?.groups ?? [], [data])
const [defaultPolicy, setDefaultPolicy] = useState<string>('')
const [defaultDraft, setDefaultDraft] = useState<string | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const [draft, setDraft] = useState<GroupDraft>(EMPTY_DRAFT)
const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [fieldErrors, setFieldErrors] = useState<string[]>([])
const [pendingDelete, setPendingDelete] = useState<number | null>(null)
const effectiveDefault = defaultDraft ?? defaultPolicy ?? String(data?.default_policy ?? 'allow')
const save = useAsyncAction(async (payload: { groups: ClientGroup[]; default_policy: string }) => {
try {
return await putClients(payload)
} catch (err) {
if (err instanceof ApiError && err.status === 400) {
try {
const parsed: unknown = JSON.parse(err.body)
if (parsed && typeof parsed === 'object') {
const fields = (parsed as { fields?: unknown }).fields
if (Array.isArray(fields)) {
setFieldErrors(fields.filter((f): f is string => typeof f === 'string'))
}
}
} catch {
/* a 400 without JSON is still reported through the toast */
}
}
throw err
}
})
const fieldError = (key: string): string | undefined => {
const leaf = fieldLeaf(key)
return fieldErrors.find((f) => fieldLeaf(f) === leaf)
}
const commit = async (
nextGroups: ClientGroup[],
nextDefault: string,
message: string,
): Promise<boolean> => {
const res = await save.run({ groups: nextGroups, default_policy: nextDefault })
if (res === null) {
notify(toast, 'fail', save.error ?? 'the server rejected the client groups')
return false
}
notify(toast, 'ok', message)
refetch()
return true
}
const openNew = (): void => {
setEditingIndex(null)
setDraft(EMPTY_DRAFT)
setFieldErrors([])
setEditorOpen(true)
}
const openEdit = (index: number): void => {
const group = groups[index]
if (!group) return
setEditingIndex(index)
setDraft(fromGroup(group))
setFieldErrors([])
setEditorOpen(true)
}
const duplicate = (index: number): void => {
const group = groups[index]
if (!group) return
const copy = fromGroup(group)
setEditingIndex(null)
setDraft({ ...copy, name: `${copy.name}-copy` })
setFieldErrors([])
setEditorOpen(true)
}
const saveGroup = async (): Promise<void> => {
if (draft.name.trim() === '') {
setFieldErrors(['name'])
notify(toast, 'fail', 'a group needs a name')
return
}
const group = toGroup(draft)
const next = [...groups]
if (editingIndex !== null && next[editingIndex]) {
next[editingIndex] = group
} else {
next.push(group)
}
if (await commit(next, effectiveDefault, `saved ${group.name}`)) {
setEditorOpen(false)
setFieldErrors([])
}
}
const deleteGroup = async (): Promise<void> => {
const index = pendingDelete
if (index === null) return
const group = groups[index]
if (!group) return
const next = groups.filter((_, i) => i !== index)
if (await commit(next, effectiveDefault, `deleted ${group.name}`)) {
setPendingDelete(null)
}
}
const saveDefault = async (next: string): Promise<void> => {
setDefaultDraft(next)
const ok = await commit(groups, next, `default policy set to ${next}`)
if (!ok) {
setDefaultDraft(null)
return
}
setDefaultDraft(null)
}
if (error) return <PageError error={error} onRetry={refetch} />
return (
<div className="space-y-4">
<PageHeader
title="Clients"
description="Group clients by network, reverse-DNS name or tag and give each group its own policy."
actions={
<Button icon={<Plus size={14} />} onClick={openNew}>
Add group
</Button>
}
/>
<Card title="Default policy" subtitle="Applied to clients that match no group.">
<div className="w-full sm:max-w-xs">
<Select
label="Policy"
value={effectiveDefault}
disabled={loading || save.pending}
onChange={(e) => void saveDefault(e.target.value)}
options={DEFAULT_POLICIES}
/>
</div>
</Card>
{groups.length === 0 ? (
<EmptyState
icon={<Users size={20} />}
title="No client groups"
description="Without a group every client gets the default policy."
action={
<Button icon={<Plus size={14} />} onClick={openNew}>
Add the first group
</Button>
}
/>
) : (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
{groups.map((group, i) => (
<Card key={group.name}>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-medium">{group.name}</h3>
<Badge tone={group.policy === 'deny' ? 'danger' : group.policy === 'block_only' ? 'warn' : 'ok'}>
{group.policy}
</Badge>
</div>
{group.comment ? (
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{group.comment}</p>
) : null}
<p className="mt-1.5 truncate font-mono text-xs text-slate-500 dark:text-slate-400">
{matchSummary(group)}
</p>
<div className="mt-2 flex flex-wrap gap-1">
{group.skip_blocklist ? <Badge tone="info">skip blocklist</Badge> : null}
{group.skip_cache ? <Badge tone="info">skip cache</Badge> : null}
{group.log_queries ? <Badge tone="muted">log</Badge> : null}
{group.rate_limit > 0 ? (
<Badge tone="muted">{group.rate_limit} qps</Badge>
) : null}
{group.max_concurrent > 0 ? (
<Badge tone="muted">max {group.max_concurrent}</Badge>
) : null}
</div>
</div>
<div className="flex shrink-0 gap-1">
<IconButton icon={<Pencil size={14} />} label={`edit ${group.name}`} variant="ghost" onClick={() => openEdit(i)} />
<IconButton icon={<Copy size={14} />} label={`duplicate ${group.name}`} variant="ghost" onClick={() => duplicate(i)} />
<IconButton
icon={<Trash2 size={14} />}
label={`delete ${group.name}`}
variant="ghost"
onClick={() => setPendingDelete(i)}
/>
</div>
</div>
</Card>
))}
</div>
)}
<Dialog
open={editorOpen}
onClose={() => setEditorOpen(false)}
title={editingIndex === null ? 'New client group' : `Edit ${draft.name}`}
description="Groups are matched top to bottom; the first match wins."
size="lg"
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setEditorOpen(false)} disabled={save.pending}>
Cancel
</Button>
<Button onClick={() => void saveGroup()} loading={save.pending}>
{editingIndex === null ? 'Create group' : 'Save changes'}
</Button>
</div>
}
>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
label="Name"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
error={fieldError('name')}
autoComplete="off"
spellCheck={false}
/>
<Select
label="Policy"
value={draft.policy}
onChange={(e) => setDraft((d) => ({ ...d, policy: e.target.value }))}
options={POLICIES}
/>
</div>
<Input
label="Comment"
value={draft.comment}
onChange={(e) => setDraft((d) => ({ ...d, comment: e.target.value }))}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Textarea
label="Client CIDRs"
hint="One per line, e.g. 192.168.1.0/24"
monospace
rows={4}
value={draft.cidrs}
onChange={(e) => setDraft((d) => ({ ...d, cidrs: e.target.value }))}
error={fieldError('cidrs')}
/>
<Textarea
label="Reverse-DNS suffixes"
hint="One per line, e.g. .lan"
monospace
rows={4}
value={draft.ptr_suffixes}
onChange={(e) => setDraft((d) => ({ ...d, ptr_suffixes: e.target.value }))}
error={fieldError('ptr_suffixes')}
/>
</div>
<Textarea
label="Tags"
hint="One per line; matches ECS/DNS tag metadata when the transport provides it."
monospace
rows={2}
value={draft.tags}
onChange={(e) => setDraft((d) => ({ ...d, tags: e.target.value }))}
error={fieldError('tags')}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Textarea
label="Allow domains"
hint="Glob patterns that skip the blocklists for this group."
monospace
rows={3}
value={draft.allow_domains}
onChange={(e) => setDraft((d) => ({ ...d, allow_domains: e.target.value }))}
error={fieldError('allow_domains')}
/>
<Textarea
label="Deny domains"
hint="Glob patterns always refused for this group."
monospace
rows={3}
value={draft.deny_domains}
onChange={(e) => setDraft((d) => ({ ...d, deny_domains: e.target.value }))}
error={fieldError('deny_domains')}
/>
</div>
<Textarea
label="Upstreams"
hint="Optional per-group resolver addresses, e.g. 10.0.0.53:5353"
monospace
rows={2}
value={draft.upstreams}
onChange={(e) => setDraft((d) => ({ ...d, upstreams: e.target.value }))}
error={fieldError('upstreams')}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
label="Rate limit (qps)"
type="number"
min={0}
value={draft.rate_limit}
onChange={(e) => setDraft((d) => ({ ...d, rate_limit: e.target.value }))}
hint="0 disables the limit."
error={fieldError('rate_limit')}
/>
<Input
label="Max concurrent queries"
type="number"
min={0}
value={draft.max_concurrent}
onChange={(e) => setDraft((d) => ({ ...d, max_concurrent: e.target.value }))}
hint="0 means unlimited."
error={fieldError('max_concurrent')}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Switch
checked={draft.any}
onChange={(next) => setDraft((d) => ({ ...d, any: next }))}
label="Match any client"
description="Last-resort group that matches everything not claimed above."
/>
<Switch
checked={draft.skip_blocklist}
onChange={(next) => setDraft((d) => ({ ...d, skip_blocklist: next }))}
label="Skip blocklists"
description="Resolve everything, even listed names."
/>
<Switch
checked={draft.skip_cache}
onChange={(next) => setDraft((d) => ({ ...d, skip_cache: next }))}
label="Skip cache"
description="Always go upstream, never serve a cached answer."
/>
<Switch
checked={draft.log_queries}
onChange={(next) => setDraft((d) => ({ ...d, log_queries: next }))}
label="Log queries"
description="Include this group's queries in the query log."
/>
</div>
{fieldErrors.length > 0 ? (
<div className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
<p className="font-medium">The server rejected these fields:</p>
<ul className="mt-1 list-inside list-disc font-mono text-xs">
{fieldErrors.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
</div>
) : null}
</div>
</Dialog>
<ConfirmDialog
open={pendingDelete !== null}
onClose={() => setPendingDelete(null)}
onConfirm={() => void deleteGroup()}
title="Delete this group?"
message={
pendingDelete !== null && groups[pendingDelete]
? `Clients matching "${groups[pendingDelete].name}" fall back to the default policy.`
: ''
}
confirmLabel="Delete"
destructive
pending={save.pending}
/>
</div>
)
}
+490
View File
@@ -0,0 +1,490 @@
/**
* Dashboard — the "is it working" page.
*
* Everything on it is a single `/api/dashboard` fetch for the selected range;
* the charts are pure functions of `series`, so they never need their own
* polling. The server builds that payload, so every field is read defensively:
* a node running an older build simply shows less, it never throws.
*/
import { useMemo } from 'react'
import type { ReactNode } from 'react'
import {
Area,
Bar,
BarChart,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip as ChartTooltip,
XAxis,
YAxis,
} 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 {
formatClock,
formatCompact,
formatMs,
formatNumber,
formatPercent,
formatRate,
formatUptime,
} from '../../lib/format'
import { Badge, Button, Card, CardBody, EmptyState, Progress, StatCard, Table } from '../components/ui'
import { HealthPill, PageError, PageHeader } from '../components/layout'
/** Chart palette: fixed hex so it reads on both the light and dark theme. */
const GRID = 'rgba(148, 163, 184, 0.22)'
const AXIS_TEXT = '#94a3b8'
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> & {
degraded_reason?: string
reason?: string
message?: string
latency?: { p50_ms?: number; p95_ms?: number; p99_ms?: number }
recent?: LogItem[]
}
type Row = Record<string, ReactNode>
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
payload?: { name?: string; value?: number | string; color?: string }[]
}
/** Dark tooltip content, so the chart stays legible in the light theme too. */
function ChartTip({ active, payload, label }: TipProps) {
if (!active || !payload || 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">
<div className="mb-1 font-mono text-slate-400">{formatClock(Number(label) * 1000)}</div>
<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.color }}
/>
<span className="text-slate-400">{item.name}</span>
<span className="ml-auto font-mono">{formatNumber(Number(item.value ?? 0))}</span>
</li>
))}
</ul>
</div>
)
}
function healthTone(health: string | undefined): 'default' | 'ok' | 'warn' | 'danger' {
if (health === 'ok') return 'ok'
if (health === 'degraded') return 'warn'
return 'danger'
}
function seriesOf(d: DashboardView) {
return d.series ?? []
}
export default function Dashboard() {
const { range, setRange } = useTimeRange('1h')
const { data, error, loading, refetch } = useApi(() => getDashboard(range), [range])
useAutoRefresh(refetch, 5000)
const d = view(data)
const series = useMemo(() => seriesOf(d), [d])
const upstreams = useMemo(() => d.upstreams ?? [], [d])
const slowest = useMemo(
() => upstreams.reduce((max, u) => Math.max(max, u.latency_ms || 0), 0),
[upstreams],
)
const topBlocked = useMemo(() => d.top_blocked ?? [], [d])
const topQueried = useMemo(() => d.top_queried ?? [], [d])
const hitRate = d.cache_hit_rate ?? 0
const p99 = d.latency?.p99_ms
const p95Fallback = useMemo(() => {
const values = upstreams.map((u) => u.p95_ms || 0).filter((v) => v > 0)
return values.length > 0 ? Math.max(...values) : undefined
}, [upstreams])
const latencyValue = p99 ?? p95Fallback
const latencyLabel = p99 != null ? 'p99 latency' : 'p95 latency'
const degraded = d.health != null && d.health !== 'ok'
const degradedReason =
d.degraded_reason ?? d.reason ?? d.message ?? 'no reason reported by the server'
if (error) return <PageError error={error} onRetry={refetch} />
const upstreamColumns = [
{ key: 'addr', header: 'Server', width: '28%' },
{ key: 'proto', header: 'Proto', width: '8%' },
{ key: 'health', header: 'Health', width: '12%' },
{ key: 'latency', header: 'Latency', width: '22%' },
{ key: 'fail', header: 'Fail rate', width: '12%', align: 'right' as const },
{ key: 'queries', header: 'Queries', width: '14%', align: 'right' as const },
]
const upstreamRows: Row[] = upstreams.map((u: UpstreamStat, i) => ({
__key: `${u.addr}-${i}`,
addr: <span className="font-mono text-xs">{u.addr}</span>,
proto: <Badge tone="muted">{u.proto}</Badge>,
health: <Badge tone={healthTone(u.health)}>{u.health}</Badge>,
latency: (
<span className="flex items-center gap-2">
<span className="w-12 shrink-0 font-mono text-xs text-slate-500 dark:text-slate-400">
{formatMs(u.latency_ms)}
</span>
<span className="w-full min-w-[60px]">
<Progress
value={u.latency_ms}
max={slowest || 1}
tone={u.fail_rate > 0.1 ? 'danger' : u.fail_rate > 0.02 ? 'warn' : 'ok'}
/>
</span>
</span>
),
fail: (
<span className="font-mono text-xs">{u.fail_rate > 0 ? formatRate(u.fail_rate) : '—'}</span>
),
queries: <span className="font-mono text-xs">{formatNumber(u.queries)}</span>,
}))
const recent: LogItem[] = d.recent ?? []
const recentSource: { name: string; list: string; action: string; ts?: string }[] =
recent.length > 0
? recent.slice(0, 10).map((e) => ({
name: e.name,
list: e.list,
action: e.action,
ts: e.ts,
}))
: topBlocked.slice(0, 10).map((e) => ({ name: e.domain, list: e.list, action: 'block' }))
return (
<div className="space-y-4">
<PageHeader
title="Dashboard"
description="Live traffic, cache efficiency and upstream health."
actions={
<div className="flex items-center gap-2">
<div
className="flex overflow-hidden rounded-md border border-slate-300 dark:border-slate-700"
role="group"
aria-label="Time range"
>
{TIME_RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => setRange(r)}
aria-pressed={range === r}
className={
range === r
? 'bg-slate-900 px-2.5 py-1 text-xs font-medium text-white dark:bg-slate-100 dark:text-slate-900'
: 'px-2.5 py-1 text-xs text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}
>
{r}
</button>
))}
</div>
<Button
variant="outline"
size="sm"
icon={<RefreshCw size={14} />}
onClick={refetch}
loading={loading}
>
Refresh
</Button>
</div>
}
/>
{degraded && (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-200"
>
<Activity size={18} className="mt-0.5 shrink-0" aria-hidden="true" />
<div className="min-w-0">
<p className="font-semibold">The server reports degraded service</p>
<p className="mt-0.5 break-words opacity-90">{degradedReason}</p>
</div>
<Badge tone={healthTone(d.health)}>{d.health}</Badge>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Queries / s"
value={formatCompact(d.queries_per_sec ?? 0)}
hint={`${formatNumber(d.queries_total ?? 0)} total`}
icon={<Activity size={16} />}
/>
<StatCard
label="Cache hit rate"
value={formatRate(hitRate)}
hint="higher is better"
icon={<Gauge size={16} />}
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} />}
/>
<StatCard
label={latencyLabel}
value={latencyValue != null ? formatMs(latencyValue) : '—'}
hint={p99 != null ? 'from the server' : 'slowest upstream p95'}
icon={<Timer size={16} />}
/>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
version {d.version || 'unknown'} · up {formatUptime(d.uptime_seconds ?? 0)}
{d.node_id ? ` · node ${d.node_id}` : ''} · auto-refreshing every 5s
</p>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<Card title="Queries" subtitle={`Last ${range} · total, cache hits and errors`}>
<CardBody>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={series} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="t"
tickFormatter={formatClock}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
minTickGap={24}
/>
<YAxis
tickFormatter={formatCompact}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
width={48}
/>
<ChartTooltip content={<ChartTip />} cursor={{ stroke: GRID }} />
<Area
type="monotone"
dataKey="queries"
name="queries"
stroke={QUERIES}
fill={QUERIES}
fillOpacity={0.18}
strokeWidth={1.5}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="cache_hits"
name="cached"
stroke={CACHED}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="errors"
name="errors"
stroke={ERRORS}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</CardBody>
</Card>
<Card title="Blocked rate" subtitle={`Blocked queries per bucket, last ${range}`}>
<CardBody>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={series} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="t"
tickFormatter={formatClock}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
minTickGap={24}
/>
<YAxis
tickFormatter={formatCompact}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
width={48}
/>
<ChartTooltip content={<ChartTip />} cursor={{ stroke: GRID }} />
<Bar
dataKey="blocked"
name="blocked"
fill={BLOCKED}
radius={[3, 3, 0, 0]}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="queries"
name="queries"
stroke={QUERIES}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</CardBody>
</Card>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<Card title="Top blocked domains" subtitle="Ranked by hits in the selected range">
<CardBody>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={topBlocked}
layout="vertical"
margin={{ top: 0, right: 16, bottom: 0, left: 0 }}
>
<CartesianGrid stroke={GRID} strokeDasharray="3 3" horizontal={false} />
<XAxis
type="number"
tickFormatter={formatCompact}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
/>
<YAxis
type="category"
dataKey="domain"
width={180}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
tickFormatter={(value: string) =>
value.length > 26 ? `${value.slice(0, 25)}…` : value
}
/>
<ChartTooltip content={<ChartTip />} cursor={{ fill: 'rgba(148,163,184,0.12)' }} />
<Bar dataKey="count" name="blocked" fill={BLOCKED} radius={[0, 3, 3, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
</CardBody>
</Card>
<Card
title="Recent blocks"
subtitle={recent.length > 0 ? 'Newest entries from the server' : 'From the top-blocked ranking'}
>
<CardBody>
{recentSource.length === 0 ? (
<EmptyState
title="Nothing blocked yet"
description="No blocked queries in the selected range."
/>
) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
{recentSource.map((e, i) => (
<li key={`${e.name}-${i}`} className="flex items-center gap-3 px-4 py-2">
<Badge tone="warn">{e.action || 'block'}</Badge>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{e.name}</span>
{e.list ? (
<span className="hidden truncate text-xs text-slate-500 sm:inline dark:text-slate-400">
{e.list}
</span>
) : null}
{e.ts ? (
<span className="font-mono text-xs text-slate-400">{formatClock(e.ts)}</span>
) : null}
</li>
))}
</ul>
)}
</CardBody>
</Card>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-3">
<div className="xl:col-span-2">
<Card
title="Upstreams"
subtitle="Health, latency relative to the slowest server, and fail rate"
actions={<HealthPill health={d.health ?? 'down'} />}
>
<CardBody>
<div className="overflow-x-auto">
<Table
columns={upstreamColumns}
rows={upstreamRows}
rowKey={(row) => String(row.__key)}
empty="No upstreams are configured."
loading={loading && upstreams.length === 0}
/>
</div>
</CardBody>
</Card>
</div>
<Card title="Top queried" subtitle="Most requested names in range">
<CardBody>
{topQueried.length === 0 ? (
<EmptyState title="No query data" description="Nothing was resolved in this range." />
) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
{topQueried.slice(0, 15).map((q) => (
<li key={`${q.domain}-${q.qtype}`} className="flex items-center gap-2 px-4 py-2 text-sm">
<Badge tone={q.blocked ? 'warn' : 'default'}>
{q.qtype || '?'}
</Badge>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{q.domain}</span>
<span className="font-mono text-xs text-slate-500">{formatNumber(q.count)}</span>
</li>
))}
</ul>
)}
</CardBody>
</Card>
</div>
</div>
)
}
+378
View File
@@ -0,0 +1,378 @@
/**
* Domains — the inline rules that override the downloaded lists.
*
* The table is server-paged, so every filter change rewinds to page 1 and the
* query object is memoised so `useApi` only refetches when something the
* server cares about actually changed.
*/
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 {
Badge,
Button,
ConfirmDialog,
Dialog,
EmptyState,
IconButton,
Input,
Pagination,
Select,
Table,
Textarea,
useToast,
} from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode>
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)
}
const PER_PAGE = 50
const ACTIONS = [
{ value: 'block', label: 'Block (NXDOMAIN)' },
{ value: 'allow', label: 'Allow (pass through)' },
{ value: 'drop', label: 'Drop (no response)' },
{ value: 'nxdomain', label: 'NXDOMAIN' },
{ value: 'redirect', label: 'Redirect' },
]
const NAME_HINT =
'*.example.com matches subdomains, **.example.com matches the apex too, a bare name matches exactly.'
function actionTone(action: string): 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted' {
switch (action) {
case 'allow':
return 'ok'
case 'block':
return 'warn'
case 'drop':
return 'danger'
case 'nxdomain':
return 'warn'
case 'redirect':
return 'info'
default:
return 'default'
}
}
type AddForm = {
name: string
action: string
list: string
bulk: string
advanced: boolean
}
const EMPTY_FORM: AddForm = { name: '', action: 'block', list: '', bulk: '', advanced: false }
export default function Domains() {
const toast = useToast()
const [search, setSearch] = useState('')
const [action, setAction] = useState('')
const [list, setList] = useState('')
const [page, setPage] = useState(1)
const q = useDebounced(search, 300)
const [addOpen, setAddOpen] = useState(false)
const [form, setForm] = useState<AddForm>(EMPTY_FORM)
const [formError, setFormError] = useState<string | null>(null)
const [pendingDelete, setPendingDelete] = useState<DomainItem | null>(null)
// A new filter makes the old page number meaningless.
useEffect(() => {
setPage(1)
}, [q, action, list])
const filters = useMemo(
() => ({
q: q.trim(),
action: action === '' ? undefined : action,
list: list === '' ? undefined : list,
page,
per_page: PER_PAGE,
}),
[q, action, list, page],
)
const { data, error, loading, refetch } = useApi(() => getDomains(filters), [filters])
const items: DomainItem[] = useMemo(() => data?.items ?? [], [data])
const total = data?.total ?? items.length
const add = useAsyncAction(async (input: AddDomainInput) => addDomain(input))
const remove = useAsyncAction(async (name: string, act: string, src: string) =>
deleteDomain(name, act, src),
)
const lists = useMemo(() => {
const seen = new Set<string>()
for (const item of items) {
if (item.list) seen.add(item.list)
if (item.source) seen.add(item.source)
}
return Array.from(seen).sort()
}, [items])
const bulkLines = useMemo(() => toLines(form.bulk), [form.bulk])
const canSubmit =
form.action !== '' && (form.bulk.trim() !== '' || form.name.trim() !== '')
const submit = async (): Promise<void> => {
setFormError(null)
if (!canSubmit) {
setFormError('enter a domain, or paste rules in the bulk box')
return
}
if (form.bulk.trim() !== '') {
for (const line of bulkLines) {
const allow = line.startsWith('!')
const ruleName = allow ? line.slice(1).trim() : line
if (ruleName === '') continue
const res = await add.run({
name: ruleName,
action: allow ? 'allow' : form.action,
list: form.list.trim() || undefined,
source: 'console',
content: '',
})
if (res === null) {
notify(toast, 'fail', add.error ?? `could not add ${ruleName}`)
return
}
}
notify(toast, 'ok', `added ${bulkLines.length} rule${bulkLines.length === 1 ? '' : 's'}`)
} else {
const res = await add.run({
name: form.name.trim(),
action: form.action,
list: form.list.trim() || undefined,
source: 'console',
content: '',
})
if (res === null) {
setFormError(add.error ?? 'the server rejected that rule')
notify(toast, 'fail', add.error ?? 'could not add the rule')
return
}
notify(toast, 'ok', `added ${form.name.trim()}`)
}
setForm(EMPTY_FORM)
setAddOpen(false)
refetch()
}
const confirmDelete = async (): Promise<void> => {
const target = pendingDelete
if (!target) return
const res = await remove.run(target.name, String(target.action), target.list || target.source)
if (res === null) {
notify(toast, 'fail', remove.error ?? `could not delete ${target.name}`)
return
}
setPendingDelete(null)
notify(toast, 'ok', `deleted ${target.name}`)
refetch()
}
if (error) return <PageError error={error} onRetry={refetch} />
const columns = [
{ key: 'name', header: 'Domain', width: '40%' },
{ key: 'action', header: 'Action', width: '16%' },
{ key: 'source', header: 'List', width: '20%' },
{ key: 'hits', header: 'Hits', width: '10%', align: 'right' as const },
{ key: 'updated', header: 'Updated', width: '10%' },
{ key: 'delete', header: '', width: '4%', align: 'right' as const },
]
const rows: Row[] = items.map((item, i) => ({
__key: `${item.name}-${item.action}-${item.list}-${i}`,
name: <span className="block max-w-[320px] truncate font-mono text-xs">{item.name}</span>,
action: <Badge tone={actionTone(String(item.action))}>{item.action}</Badge>,
source: (
<span className="block max-w-[200px] truncate text-xs text-slate-500 dark:text-slate-400">
{item.list || item.source || '—'}
</span>
),
hits: <span className="font-mono text-xs">{formatNumber(item.hits)}</span>,
updated: <span className="whitespace-nowrap text-xs">{formatRelative(item.updated_at)}</span>,
delete: (
<IconButton
icon={<Trash2 size={14} />}
label={`delete ${item.name}`}
variant="ghost"
onClick={() => setPendingDelete(item)}
/>
),
}))
return (
<div className="space-y-4">
<PageHeader
title="Domains"
description="Inline allow and block rules, checked before the downloaded lists."
actions={
<Button icon={<Plus size={14} />} onClick={() => setAddOpen(true)}>
Add rule
</Button>
}
/>
<Card>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Input
label="Search"
placeholder="ads.example.com"
icon={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<Select
label="Action"
value={action}
onChange={(e) => setAction(e.target.value)}
options={[{ value: '', label: 'All actions' }, ...ACTIONS.map((a) => ({ value: a.value, label: a.label }))]}
/>
<Select
label="List"
value={list}
onChange={(e) => setList(e.target.value)}
options={[{ value: '', label: 'All lists' }, ...lists.map((l) => ({ value: l, label: l }))]}
/>
</div>
</Card>
<Card
title="Rules"
subtitle={`${formatNumber(total)} matching`}
padded={false}
>
<div className="overflow-x-auto">
<Table
columns={columns}
rows={rows}
rowKey={(row) => String(row.__key)}
loading={loading}
empty={
<EmptyState
title="No rules match"
description="Change the filters, or add a rule with the button above."
/>
}
/>
</div>
<div className="border-t border-slate-200 p-3 dark:border-slate-800">
<Pagination page={page} perPage={PER_PAGE} total={total} onPage={setPage} />
</div>
</Card>
<Dialog
open={addOpen}
onClose={() => setAddOpen(false)}
title="Add a rule"
description="Rules are checked before the blocklists, so an allow rule wins over a block."
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setAddOpen(false)} disabled={add.pending}>
Cancel
</Button>
<Button onClick={() => void submit()} loading={add.pending} disabled={!canSubmit}>
{bulkLines.length > 0 ? `Add ${bulkLines.length} rules` : 'Add rule'}
</Button>
</div>
}
>
<div className="space-y-3">
{formError ? (
<p className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
{formError}
</p>
) : null}
<Input
label="Domain"
placeholder="ads.example.com"
hint={NAME_HINT}
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
disabled={form.advanced}
autoComplete="off"
spellCheck={false}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Select
label="Action"
value={form.action}
onChange={(e) => setForm((f) => ({ ...f, action: e.target.value }))}
options={ACTIONS.map((a) => ({ value: a.value, label: a.label }))}
/>
<Input
label="List"
placeholder="inline"
hint="Optional group name, defaults to the inline list."
value={form.list}
onChange={(e) => setForm((f) => ({ ...f, list: e.target.value }))}
autoComplete="off"
/>
</div>
<button
type="button"
className="text-xs font-medium text-sky-600 underline-offset-2 hover:underline dark:text-sky-400"
onClick={() => setForm((f) => ({ ...f, advanced: !f.advanced }))}
>
{form.advanced ? 'Hide bulk paste' : 'Bulk paste'}
</button>
{form.advanced ? (
<Textarea
label="Rules"
hint={`One per line; a leading ! makes it an allow rule. ${bulkLines.length} line${bulkLines.length === 1 ? '' : 's'} parsed.`}
monospace
rows={8}
value={form.bulk}
onChange={(e) => setForm((f) => ({ ...f, bulk: e.target.value }))}
spellCheck={false}
/>
) : null}
</div>
</Dialog>
<ConfirmDialog
open={pendingDelete !== null}
onClose={() => setPendingDelete(null)}
onConfirm={() => void confirmDelete()}
title="Delete this rule?"
message={
pendingDelete
? `${pendingDelete.name} (${pendingDelete.action}) will be removed and the change persisted to the configuration file.`
: ''
}
confirmLabel="Delete"
destructive
pending={remove.pending}
/>
</div>
)
}
+584
View File
@@ -0,0 +1,584 @@
/**
* Upstreams — the resolver pool.
*
* Server order is meaningful under the `failover` policy, so the move up/down
* buttons edit a local draft that is only written on Save. Nothing here talks
* to the resolver directly: the PUT is validated by the daemon and swapped in
* atomically, or rejected with a message.
*/
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import {
ArrowDown,
ArrowUp,
Gauge,
Pencil,
Plus,
RotateCw,
Save,
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 {
Badge,
Button,
Card,
ConfirmDialog,
Dialog,
EmptyState,
IconButton,
Input,
Progress,
Select,
Switch,
Table,
Textarea,
useToast,
} from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode>
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)
}
const POLICIES = [
{ value: 'parallel', label: 'Parallel — query all, take the first answer' },
{ value: 'random', label: 'Random — weighted pick' },
{ value: 'failover', label: 'Failover — in order, skip the unhealthy' },
]
const PROTOS = [
{ value: 'udp', label: 'UDP' },
{ value: 'tcp', label: 'TCP' },
{ value: 'tls', label: 'TLS (DoT)' },
]
/** "30s", "1m30s", "1h" or a bare number -> seconds. */
function parseDuration(value: string): number {
const text = value.trim().toLowerCase()
if (text === '') return 30
if (/^\d+(\.\d+)?$/.test(text)) return Number.parseFloat(text)
let total = 0
let matched = false
const re = /(\d+(?:\.\d+)?)\s*(ms|s|m|h)/g
let m = re.exec(text)
while (m !== null) {
matched = true
const n = Number.parseFloat(m[1])
if (m[2] === 'ms') total += n / 1000
else if (m[2] === 's') total += n
else if (m[2] === 'm') total += n * 60
else total += n * 3600
m = re.exec(text)
}
return matched && total > 0 ? Math.round(total) : 30
}
/** What the user edits: runtime fields ride along untouched. */
type ServerDraft = {
addr: string
proto: string
server_name: string
insecure_skip_verify: boolean
weight: number
comment: string
health: string
latency_ms: number
fail_rate: number
breaker_open: boolean
}
type BootstrapDraft = { name: string; addresses: string; refresh: string }
type Draft = {
policy: string
max_attempts: string
breaker_threshold: string
healthcheck_s: string
servers: ServerDraft[]
bootstraps: BootstrapDraft[]
}
function toServerDraft(s: UpstreamServer): ServerDraft {
return {
addr: s.addr ?? '',
proto: String(s.proto ?? 'udp'),
server_name: s.server_name ?? '',
insecure_skip_verify: false,
weight: typeof s.weight === 'number' ? s.weight : 1,
comment: s.comment ?? '',
health: String(s.health ?? 'down'),
latency_ms: s.latency_ms ?? 0,
fail_rate: s.fail_rate ?? 0,
breaker_open: s.breaker_open === true,
}
}
function healthTone(health: string): 'default' | 'ok' | 'warn' | 'danger' {
if (health === 'up') return 'ok'
if (health === 'degraded') return 'warn'
return 'danger'
}
const EMPTY_SERVER: ServerDraft = {
addr: '',
proto: 'udp',
server_name: '',
insecure_skip_verify: false,
weight: 1,
comment: '',
health: 'down',
latency_ms: 0,
fail_rate: 0,
breaker_open: false,
}
function toInt(value: string, fallback: number): number {
const n = Number.parseInt(value.trim(), 10)
return Number.isFinite(n) && n >= 0 ? n : fallback
}
function toFloat(value: string, fallback: number): number {
const n = Number.parseFloat(value.trim())
return Number.isFinite(n) && n >= 0 ? n : fallback
}
function splitList(value: string): string[] {
return value
.split(/[,\s]+/)
.map((v) => v.trim())
.filter((v) => v !== '')
}
export default function Upstreams() {
const toast = useToast()
const { data, error, loading, refetch } = useApi(() => getUpstreams(), [])
const [draft, setDraft] = useState<Draft | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<ServerDraft | null>(null)
const [pendingRemove, setPendingRemove] = useState<number | null>(null)
const servers: UpstreamServer[] = useMemo(() => data?.servers ?? [], [data])
const bootstraps: UpstreamBootstrap[] = useMemo(() => data?.bootstraps ?? [], [data])
const working: Draft = useMemo(() => {
if (draft) return draft
return {
policy: String(data?.policy ?? 'parallel'),
max_attempts: String(data?.max_attempts ?? 2),
breaker_threshold: String(data?.breaker_threshold ?? 5),
healthcheck_s: String(parseDuration(String(data?.healthcheck_interval ?? '30s'))),
servers: servers.map(toServerDraft),
bootstraps: bootstraps.map((b) => ({
name: b.name ?? '',
addresses: (b.addresses ?? []).join(', '),
refresh: b.refresh ?? '',
})),
}
}, [draft, data, servers, bootstraps])
const dirty = draft !== null
const slowest = working.servers.reduce((max, s) => Math.max(max, s.latency_ms || 0), 0)
const save = useAsyncAction(async () => {
const payload = {
policy: working.policy,
max_attempts: toInt(working.max_attempts, 2),
breaker_threshold: toInt(working.breaker_threshold, 5),
healthcheck_interval_s: toInt(working.healthcheck_s, 30),
servers: working.servers.map((s) => ({
addr: s.addr.trim(),
proto: s.proto,
server_name: s.server_name.trim(),
insecure_skip_verify: s.insecure_skip_verify,
weight: s.weight,
comment: s.comment,
health: s.health,
latency_ms: s.latency_ms,
fail_rate: s.fail_rate,
breaker_open: s.breaker_open,
})),
bootstraps: working.bootstraps.map((b) => ({
name: b.name.trim(),
addresses: splitList(b.addresses),
refresh: b.refresh.trim(),
})),
}
return putUpstreams(payload)
})
const commit = async (): Promise<void> => {
if (working.servers.some((s) => s.addr.trim() === '')) {
notify(toast, 'fail', 'every upstream needs an address')
return
}
const res = await save.run()
if (res === null) {
notify(toast, 'fail', save.error ?? 'the server rejected the upstream configuration')
return
}
setDraft(null)
notify(toast, 'ok', 'upstream configuration saved')
refetch()
}
const move = (index: number, delta: number): void => {
const next = [...working.servers]
const target = index + delta
if (target < 0 || target >= next.length) return
const [item] = next.splice(index, 1)
next.splice(target, 0, item)
setDraft({ ...working, servers: next })
}
const setServer = (index: number, patch: Partial<ServerDraft>): void => {
const next = [...working.servers]
const current = next[index]
if (!current) return
next[index] = { ...current, ...patch }
setDraft({ ...working, servers: next })
}
const setBootstrap = (index: number, patch: Partial<BootstrapDraft>): void => {
const next = [...working.bootstraps]
const current = next[index]
if (!current) return
next[index] = { ...current, ...patch }
setDraft({ ...working, bootstraps: next })
}
const removeServer = async (): Promise<void> => {
const index = pendingRemove
if (index === null) return
const next = working.servers.filter((_, i) => i !== index)
setDraft({ ...working, servers: next })
setPendingRemove(null)
}
if (error) return <PageError error={error} onRetry={refetch} />
const columns = [
{ key: 'addr', header: 'Address', width: '22%' },
{ key: 'proto', header: 'Proto', width: '7%' },
{ key: 'server_name', header: 'SNI', width: '14%' },
{ key: 'health', header: 'Health', width: '12%' },
{ key: 'latency', header: 'Latency', width: '17%' },
{ key: 'fail', header: 'Fail', width: '8%', align: 'right' as const },
{ key: 'weight', header: 'Weight', width: '7%', align: 'right' as const },
{ key: 'comment', header: 'Comment', width: '9%' },
{ key: 'order', header: 'Order', width: '4%' },
]
const rows: Row[] = working.servers.map((s, i) => ({
__key: `${s.addr}-${i}`,
addr: <span className="font-mono text-xs">{s.addr || '(empty)'}</span>,
proto: <Badge tone="muted">{s.proto}</Badge>,
server_name: <span className="font-mono text-xs">{s.server_name || '—'}</span>,
health: (
<span className="flex flex-wrap items-center gap-1">
<Badge tone={healthTone(s.health)}>{s.health}</Badge>
{s.breaker_open ? <Badge tone="danger">breaker</Badge> : null}
</span>
),
latency: (
<span className="flex items-center gap-2">
<span className="w-10 shrink-0 font-mono text-xs text-slate-500">{formatLatency(s.latency_ms)}</span>
<span className="w-full min-w-[48px]">
<Progress
value={s.latency_ms}
max={slowest || 1}
tone={s.health === 'up' ? 'ok' : s.health === 'degraded' ? 'warn' : 'danger'}
/>
</span>
</span>
),
fail: <span className="font-mono text-xs">{s.fail_rate > 0 ? formatRate(s.fail_rate) : '—'}</span>,
weight: <span className="font-mono text-xs">{s.weight}</span>,
comment: <span className="block max-w-[160px] truncate text-xs text-slate-500">{s.comment}</span>,
order: (
<span className="flex justify-end gap-0.5">
<IconButton icon={<ArrowUp size={13} />} label={`move ${s.addr} up`} variant="ghost" disabled={i === 0} onClick={() => move(i, -1)} />
<IconButton icon={<ArrowDown size={13} />} label={`move ${s.addr} down`} variant="ghost" disabled={i === working.servers.length - 1} onClick={() => move(i, 1)} />
<IconButton icon={<Pencil size={13} />} label={`edit ${s.addr}`} variant="ghost" onClick={() => { setEditing(s); setEditorOpen(true) }} />
<IconButton icon={<Trash2 size={13} />} label={`remove ${s.addr}`} variant="ghost" onClick={() => setPendingRemove(i)} />
</span>
),
}))
return (
<div className="space-y-4">
<PageHeader
title="Upstreams"
description="Where queries go, and what happens when a server stops answering."
actions={
<div className="flex items-center gap-2">
<Button
variant="outline"
icon={<RotateCw size={14} />}
onClick={() => setDraft(null)}
disabled={!dirty}
>
Discard
</Button>
<Button icon={<Save size={14} />} onClick={() => void commit()} loading={save.pending} disabled={!dirty}>
Save
</Button>
</div>
}
/>
<Card title="Pool behaviour">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Select
label="Policy"
value={working.policy}
onChange={(e) => setDraft({ ...working, policy: e.target.value })}
options={POLICIES}
hint="Failover uses the order below."
/>
<Input
label="Max attempts"
type="number"
min={1}
value={working.max_attempts}
onChange={(e) => setDraft({ ...working, max_attempts: e.target.value })}
hint="How many servers to try per query."
/>
<Input
label="Breaker threshold"
type="number"
min={1}
value={working.breaker_threshold}
onChange={(e) => setDraft({ ...working, breaker_threshold: e.target.value })}
hint="Consecutive failures before the breaker opens."
/>
<Input
label="Health check interval (s)"
type="number"
min={1}
value={working.healthcheck_s}
onChange={(e) => setDraft({ ...working, healthcheck_s: e.target.value })}
hint="Probes run this often."
/>
</div>
</Card>
<Card
title="Servers"
subtitle={`${working.servers.length} configured`}
actions={
<Button
size="sm"
icon={<Plus size={14} />}
onClick={() => {
setEditing(EMPTY_SERVER)
setEditorOpen(true)
}}
>
Add server
</Button>
}
padded={false}
>
<div className="overflow-x-auto">
<Table
columns={columns}
rows={rows}
rowKey={(row) => String(row.__key)}
loading={loading && working.servers.length === 0}
empty={<EmptyState title="No upstreams" description="Add at least one resolver or queries will fail." />}
/>
</div>
</Card>
<Card
title="Bootstraps"
subtitle="Name servers used to resolve the upstream hostnames themselves"
actions={
<Button
size="sm"
icon={<Plus size={14} />}
onClick={() =>
setDraft({
...working,
bootstraps: [...working.bootstraps, { name: '', addresses: '', refresh: '' }],
})
}
>
Add bootstrap
</Button>
}
>
{working.bootstraps.length === 0 ? (
<EmptyState
icon={<Gauge size={20} />}
title="No bootstrap resolvers"
description="Without them, an upstream address must already be an IP."
/>
) : (
<div className="space-y-3">
{working.bootstraps.map((b, i) => (
<div key={`${b.name}-${i}`} className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Input
label="Name"
value={b.name}
onChange={(e) => setBootstrap(i, { name: e.target.value })}
placeholder="root servers"
/>
<Input
label="Addresses"
value={b.addresses}
onChange={(e) => setBootstrap(i, { addresses: e.target.value })}
placeholder="198.41.0.4, 170.247.170.2"
hint="Comma separated."
/>
<div className="flex items-end gap-2">
<Input
label="Refresh"
value={b.refresh}
onChange={(e) => setBootstrap(i, { refresh: e.target.value })}
placeholder="24h"
/>
<IconButton
icon={<Trash2 size={14} />}
label={`remove bootstrap ${b.name}`}
variant="ghost"
onClick={() =>
setDraft({
...working,
bootstraps: working.bootstraps.filter((_, j) => j !== i),
})
}
/>
</div>
</div>
))}
</div>
)}
</Card>
<p className="text-xs text-slate-500 dark:text-slate-400">
{working.servers.length} server{working.servers.length === 1 ? '' : 's'} ·{' '}
{formatNumber(working.servers.reduce((sum, s) => sum + s.weight, 0))} total weight ·
slowest {formatMs(slowest)}
{dirty ? ' · unsaved changes' : ''}
</p>
<Dialog
open={editorOpen}
onClose={() => setEditorOpen(false)}
title={editing && working.servers.some((s) => s.addr === editing.addr) ? 'Edit server' : 'Add server'}
description="An address is host:port; the SNI name is what TLS upstreams are verified against."
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setEditorOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
if (!editing) return
const index = working.servers.findIndex((s) => s.addr === editing.addr && s !== editing)
if (index === -1) {
setDraft({ ...working, servers: [...working.servers, editing] })
} else {
setServer(index, editing)
}
setEditorOpen(false)
notify(toast, 'ok', 'server updated — remember to save')
}}
disabled={!editing || editing.addr.trim() === ''}
>
Apply
</Button>
</div>
}
>
{editing ? (
<div className="space-y-3">
<Input
label="Address"
value={editing.addr}
onChange={(e) => setEditing({ ...editing, addr: e.target.value })}
placeholder="9.9.9.9:53"
autoComplete="off"
spellCheck={false}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Select
label="Protocol"
value={editing.proto}
onChange={(e) => setEditing({ ...editing, proto: e.target.value })}
options={PROTOS}
/>
<Input
label="Weight"
type="number"
min={0}
step="0.1"
value={String(editing.weight)}
onChange={(e) => setEditing({ ...editing, weight: toFloat(e.target.value, 1) })}
hint="Relative share for the random policy."
/>
</div>
<Input
label="Server name (SNI)"
value={editing.server_name}
onChange={(e) => setEditing({ ...editing, server_name: e.target.value })}
placeholder="dns.quad9.net"
autoComplete="off"
spellCheck={false}
/>
<Input
label="Comment"
value={editing.comment}
onChange={(e) => setEditing({ ...editing, comment: e.target.value })}
/>
<Textarea
label="Notes"
hidden
onChange={() => undefined}
value=""
/>
<Switch
checked={editing.insecure_skip_verify}
onChange={(next) => setEditing({ ...editing, insecure_skip_verify: next })}
label="Skip TLS verification"
description="Accept any certificate. Only for a self-signed resolver you control."
/>
</div>
) : null}
</Dialog>
<ConfirmDialog
open={pendingRemove !== null}
onClose={() => setPendingRemove(null)}
onConfirm={() => void removeServer()}
title="Remove this upstream?"
message={
pendingRemove !== null && working.servers[pendingRemove]
? `${working.servers[pendingRemove].addr} will be dropped from the pool. Save to make it permanent.`
: ''
}
confirmLabel="Remove"
destructive
/>
</div>
)
}