/** * 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, Card, ConfirmDialog, Dialog, EmptyState, IconButton, Input, Pagination, Select, Table, Textarea, useToast, } from '../components/ui' import type { ToastOptions } from '../components/ui' import { PageError, PageHeader } from '../components/layout' type Row = Record /** * One line for every mutation on this page: the message plus the tone it * earned. `fail` is the danger tone, `ok` the success one. */ function notify( toast: (options: ToastOptions) => void, kind: 'ok' | 'fail', message: string, ): void { toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' }) } const PER_PAGE = 50 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(EMPTY_FORM) const [formError, setFormError] = useState(null) const [pendingDelete, setPendingDelete] = useState(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() 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 => { 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 => { 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 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: {item.name}, action: {item.action}, source: ( {item.list || item.source || '—'} ), hits: {formatNumber(item.hits)}, updated: {formatRelative(item.updated_at)}, delete: ( setPendingDelete(item)} /> ), })) return (
setAddOpen(true)}> Add rule } />
setSearch(e.target.value)} autoComplete="off" spellCheck={false} /> setList(e.target.value)} options={[{ value: '', label: 'All lists' }, ...lists.map((l) => ({ value: l, label: l }))]} />
String(row.__key)} loading={loading} empty={ } />
setAddOpen(false)} title="Add a rule" description="Rules are checked before the blocklists, so an allow rule wins over a block." footer={
} >
{formError ? (

{formError}

) : null} setForm((f) => ({ ...f, name: e.target.value }))} disabled={form.advanced} autoComplete="off" spellCheck={false} />
setForm((f) => ({ ...f, list: e.target.value }))} autoComplete="off" />
{form.advanced ? (