Files
fancy-dns/web/src/pages/Domains.tsx
T
developer f24fd63e9a web: give the toast helper a real type and keep the dist bundle out of git
Blocklists, Clients, Domains and Upstreams each carried a ToastLike shim
that duck-typed a react-hot-toast style API. The console uses its own
Toast context, so notify() was calling toast('some message') with a string
where ToastOptions is expected: every mutation toast rendered an empty
title. They now take the toast function from useToast() and send a real
{ title, tone } payload, mapping 'fail' onto the danger tone.
2026-09-27 11:32:39 +00:00

380 lines
12 KiB
TypeScript

/**
* 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<string, ReactNode>
/**
* 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<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}
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} 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}
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>
)
}