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