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.
380 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|