initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user