Move the console onto ReUI PRO

Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move
the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel,
Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame
(DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale.

Design only: behaviour, texts and roles are unchanged. Tests that asserted the old
DOM were updated; the tests now format numbers in English wherever they run.
docs/reui-migration-plan.md records the plan and its decisions.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 00:06:06 +07:00
commit f3531c64f9
680 files changed
+139836

No files matched your search

@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import quickBuiltin from '../../../public/json/quick-block-lists-builtin.json'
import * as zonelists from '../../api/zonelists'
import REAL from './check-domain.real.json'
import { AddDomainBar } from './AddDomainBar'
import { wholeText } from '../../test/whole-text'
/*
"Check" in the panel, fed at the `fetch` level with what a live v15.6.1 answered
(`check-domain.real.json`), so the client, the parsers and the verdict run as they do
in production.
*/
const ANSWERS: Record<string, unknown> = {
'app-measurement.com': REAL.blockLists,
'telemetry.example-iot.net': REAL.ownRule,
'sub.telemetry.example-iot.net': REAL.ownRuleParent,
's.youtube.com': REAL.allowedRule,
'example.com': REAL.notBlocked,
'this-does-not-exist-xyz123.invalid': REAL.nonExistent,
'not a domain': REAL.invalidName,
}
const json = (body: unknown) => new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } })
const calls: string[] = []
beforeEach(() => {
calls.length = 0
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = new URL(String(input), window.location.href)
const path = url.pathname.replace(/^.*?\/api\//, '')
calls.push(path)
if (url.pathname.endsWith('/json/quick-block-lists-builtin.json')) return json(quickBuiltin)
if (url.pathname.includes('/json/')) return new Response('', { status: 404, statusText: 'Not Found' })
if (path === 'dnsClient/resolve') return json(ANSWERS[url.searchParams.get('domain') ?? ''])
if (path === 'settings/get') return json(REAL.settings)
if (path === 'allowed/export') return new Response(REAL.allowedExport, { headers: { 'Content-Type': 'text/plain' } })
throw new Error(`unexpected ${path}`)
})
})
afterEach(() => vi.restoreAllMocks())
const P = (v: boolean) => ({ canView: v, canModify: true, canDelete: true })
function setup(permissions?: Parameters<typeof AddDomainBar>[0]['permissions']) {
const onNotice = vi.fn()
render(<AddDomainBar token="T" permissions={permissions} onNotice={onNotice} onChanged={vi.fn()} />)
return { onNotice, field: screen.getByLabelText('Domain') }
}
async function check(domain: string) {
const { field, onNotice } = setup()
await userEvent.type(field, domain)
await userEvent.click(screen.getByRole('button', { name: 'Check' }))
return { field, onNotice }
}
describe('Check a domain', () => {
it('names the panel for its three verbs', () => {
setup()
expect(screen.getByText('Check, block or allow a domain')).toBeInTheDocument()
})
it('blocked by two lists: their names as the Lists tab gives them, their URLs and the way to Lists', async () => {
await check('app-measurement.com')
expect(await screen.findByText('Blocked by 2 block lists')).toBeInTheDocument()
expect(screen.getByText('Steven Black [adware + malware]')).toBeInTheDocument()
expect(screen.getByText('https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts')).toBeInTheDocument()
expect(screen.getByText('https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/pro-onlydomains.txt')).toBeInTheDocument()
expect(screen.queryByText('Listed as')).toBeNull()
expect(screen.getByRole('link', { name: 'Open Lists' })).toHaveAttribute('href', expect.stringMatching(/blocking\/lists\/$/))
})
it('blocked by a rule on a parent: the rule, and the way to Rules', async () => {
await check('sub.telemetry.example-iot.net')
expect(await screen.findByText('Blocked by your rule')).toBeInTheDocument()
expect(screen.getByText(wholeText('sub.telemetry.example-iot.net'), { selector: 'div' })).toBeInTheDocument()
expect(screen.getByText(wholeText('telemetry.example-iot.net'))).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Open Rules' })).toHaveAttribute('href', expect.stringMatching(/blocking\/rules\/$/))
})
it('allowed by an allow rule', async () => {
await check('s.youtube.com')
expect(await screen.findByText('Allowed by your rule')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Open Rules' })).toBeInTheDocument()
})
it('not blocked, and a name that does not exist is not blocked either', async () => {
await check('example.com')
expect(await screen.findByText('Not blocked')).toBeInTheDocument()
expect(screen.queryByText(/not checked/)).toBeNull()
})
it('asks DNS Client, the settings and the allow rules, and changes nothing', async () => {
await check('example.com')
await screen.findByText('Not blocked')
expect(calls.filter((c) => !c.includes('json/')).sort()).toEqual(['allowed/export', 'dnsClient/resolve', 'settings/get'])
const url = new URL(String(vi.mocked(fetch).mock.calls.find((c) => String(c[0]).includes('dnsClient/resolve'))![0]), 'http://x')
expect(Object.fromEntries(url.searchParams)).toEqual({
server: 'this-server', domain: 'example.com', type: 'A', protocol: 'UDP', dnssec: 'false', eDnsClientSubnet: '',
})
})
it('the server refusing the name says why under the field, not on the page', async () => {
const { field, onNotice } = await check('not a domain')
const why = await screen.findByText('Invalid domain name [not a domain]: invalid character [32] was found.')
expect(field).toHaveAttribute('aria-invalid', 'true')
expect(field).toHaveAttribute('aria-describedby', why.id)
expect(onNotice).not.toHaveBeenCalled()
await userEvent.type(field, 'x')
expect(field).not.toHaveAttribute('aria-invalid')
expect(screen.queryByText(/Invalid domain name/)).toBeNull()
})
it('an empty field is said under it, before any call', async () => {
const { field, onNotice } = setup()
await userEvent.type(field, ' ')
await userEvent.click(screen.getByRole('button', { name: 'Check' }))
expect(screen.getByText('Please enter a domain name to check.')).toBeInTheDocument()
expect(field).toHaveFocus()
expect(onNotice).not.toHaveBeenCalled()
expect(calls.filter((c) => c.includes('dnsClient'))).toEqual([])
})
it('editing the name takes the verdict away', async () => {
const { field } = await check('example.com')
await screen.findByText('Not blocked')
await userEvent.type(field, 'x')
expect(screen.queryByText('Not blocked')).toBeNull()
})
it('Enter still blocks and never checks', async () => {
const add = vi.spyOn(zonelists, 'addDomain').mockResolvedValue({ kind: 'ok', data: {} })
const { field } = setup()
await userEvent.type(field, 'example.com{Enter}')
expect(add).toHaveBeenCalledWith('blocked', 'T', 'example.com')
expect(calls.filter((c) => c.includes('dnsClient'))).toEqual([])
})
it('a Block after a check takes the verdict away: it answered the old state', async () => {
vi.spyOn(zonelists, 'addDomain').mockResolvedValue({ kind: 'ok', data: {} })
await check('example.com')
await screen.findByText('Not blocked')
await userEvent.click(screen.getByRole('button', { name: 'Block' }))
expect(screen.queryByText('Not blocked')).toBeNull()
})
it('without DnsClient.canView, Check keeps its place with its padlock', () => {
setup({ DnsClient: P(false), Blocked: P(true), Allowed: P(true) })
expect(screen.getByRole('button', { name: /Check/ })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Block' })).toBeEnabled()
})
it('without Allowed.canView and Settings.canView, a not blocked says what it could not read', async () => {
const { field } = setup({ DnsClient: P(true), Allowed: P(false), Settings: P(false) })
await userEvent.type(field, 's.youtube.com')
await userEvent.click(screen.getByRole('button', { name: 'Check' }))
const verdict = (await screen.findByText('Not blocked')).closest('[data-verdict]') as HTMLElement
expect(within(verdict).getByText(/Your allow rules were not checked\./)).toHaveTextContent(
'Your allow rules were not checked. Requires Allowed: View',
)
expect(within(verdict).getByText(/The blocking settings were not checked\./)).toHaveTextContent(
'The blocking settings were not checked. Requires Settings: View',
)
expect(calls).not.toContain('allowed/export')
expect(calls).not.toContain('settings/get')
})
})
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as api from '../../api/zonelists'
import { AddDomainBar } from './AddDomainBar'
afterEach(() => vi.restoreAllMocks())
const OK = { kind: 'ok' as const, data: {} }
const P = (m: boolean) => ({ canView: true, canModify: m, canDelete: true })
function setup(permissions?: Parameters<typeof AddDomainBar>[0]['permissions']) {
const onNotice = vi.fn()
const onChanged = vi.fn()
render(<AddDomainBar token="T" permissions={permissions} onNotice={onNotice} onChanged={onChanged} />)
return { onNotice, onChanged, field: screen.getByLabelText('Domain') }
}
describe('AddDomainBar', () => {
it('Enter blocks, as upstream form does, and clears the field', async () => {
const add = vi.spyOn(api, 'addDomain').mockResolvedValue(OK)
const { onNotice, onChanged, field } = setup()
await userEvent.type(field, 'ads.example.com{Enter}')
expect(add).toHaveBeenCalledWith('blocked', 'T', 'ads.example.com')
expect(onNotice).toHaveBeenCalledWith({
type: 'success', title: 'Blocked!', text: "Domain 'ads.example.com' was added to Blocked Zone successfully.",
})
expect(onChanged).toHaveBeenCalledWith('blocked', 'ads.example.com')
expect(field).toHaveValue('')
})
it('Allow adds to Allowed with upstream sentence', async () => {
const add = vi.spyOn(api, 'addDomain').mockResolvedValue(OK)
const { onNotice, field } = setup()
await userEvent.type(field, 's.youtube.com')
await userEvent.click(screen.getByRole('button', { name: 'Allow' }))
expect(add).toHaveBeenCalledWith('allowed', 'T', 's.youtube.com')
expect(onNotice).toHaveBeenCalledWith({
type: 'success', title: 'Allowed!', text: "Domain 's.youtube.com' was added to Allowed Zone successfully.",
})
})
it('an empty field warns before any call and keeps the focus', async () => {
const add = vi.spyOn(api, 'addDomain')
const { onNotice, field } = setup()
await userEvent.click(screen.getByRole('button', { name: 'Block' }))
expect(add).not.toHaveBeenCalled()
expect(onNotice).toHaveBeenCalledWith({ type: 'warning', title: 'Missing!', text: 'Please enter a domain name to block.' })
expect(field).toHaveFocus()
})
it('an empty field warns with the Allow sentence when Allow is pressed', async () => {
const add = vi.spyOn(api, 'addDomain')
const { onNotice, field } = setup()
await userEvent.click(screen.getByRole('button', { name: 'Allow' }))
expect(add).not.toHaveBeenCalled()
expect(onNotice).toHaveBeenCalledWith({ type: 'warning', title: 'Missing!', text: 'Please enter a domain name to allow.' })
expect(field).toHaveFocus()
})
it('a server error is reported and the field is kept', async () => {
vi.spyOn(api, 'addDomain').mockResolvedValue({ kind: 'error', message: 'Access was denied.' })
const { onNotice, field } = setup()
await userEvent.type(field, 'x.test{Enter}')
expect(onNotice).toHaveBeenCalledWith(expect.objectContaining({ type: 'danger', text: 'Access was denied.' }))
expect(field).toHaveValue('x.test')
})
it('without Blocked.canModify, Block is disabled with its padlock and Allow still works', () => {
setup({ Blocked: P(false), Allowed: P(true) })
expect(screen.getByRole('button', { name: /Block/ })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Allow' })).toBeEnabled()
})
})
+207
View File
@@ -0,0 +1,207 @@
import { useId, useRef, useState } from 'react'
import { addDomain, type DomainList } from '../../api/zonelists'
import { Panel, Body } from '../../ui/Panel'
import { Field, Input } from '../../ui/Field'
import { Loading } from '../../ui/Empty'
import { PermissionButton } from '../../ui/PermissionButton'
import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
import { noticeFromFailure, type Notice } from '../../lib/notice'
import { checkDomain, verdictOf, type Verdict } from './check-domain'
import { DomainCheck } from './DomainCheck'
import { moreLists } from './extra-lists'
import { missing, type Permissions } from './permissions'
import styles from './Blocking.module.css'
/*
"Block or allow a domain": upstream's single field with its verbs beside it
(index.html:768-777), which this console had split: `Block` went up to the page
header and the field stayed in the tree, labelled next to `Browse`, so it read as a
search box. The panel title and the field label `Domain` are OURS (upstream's field
has only its `example.com` placeholder); the `Block` / `Allow` buttons, the calls,
the empty-field warning and the success sentences are upstream's
(other-zones.js:171-200, 348-375).
ENTER BLOCKS. Upstream's form is submitted by `btnBlockZone`, so pressing Enter in
the field blocks; this console's tree field navigated instead.
CHECK is OURS, and so are its words: the button `Check`, the panel title's `Check, `
(it read "Block or allow a domain"), the empty-field sentence `Please enter a domain
name to check.` (worded as upstream's two) and the verdict (`DomainCheck.tsx`).
Unlike Block and Allow, whose alerts are upstream's and go to the page, a check that
cannot answer says why UNDER THE FIELD, where its answer would have been: an empty
field, or the server's own message (`Invalid domain name [x y]: …` is the server
validating the name). It
lives here and not in a tab of its own because it asks about the same name the two
verbs act on: the answer lands under the field, and the next step (`Block`,
`Allow`) is already beside it. It needs `DnsClient: View`, the permission of the
call it makes (WebServiceApi.cs:147), and draws its padlock like the other two.
Enter still blocks: Check is never the form's submit.
The verdict answers the name it was asked for, so it goes as soon as the field
changes, and after a Block or an Allow, which changes the answer.
*/
export function AddDomainBar({
token,
permissions,
onNotice,
onChanged,
}: {
token: string | null
permissions: Permissions
onNotice: (n: Notice) => void
onChanged: (list: DomainList, domain: string) => void
}) {
const [field, setField] = useState('')
const [busy, setBusy] = useState(false)
const entry = useRef<HTMLInputElement>(null)
const [checking, setChecking] = useState(false)
const [verdict, setVerdict] = useState<Verdict | null>(null)
/** Why the last check has no verdict: an empty field, or the server's message. */
const [problem, setProblem] = useState<string | null>(null)
const problemId = useId()
const [catalog, setCatalog] = useState<QuickEntry[]>([])
/** Numbers the checks, so a slow answer cannot land over a newer question. */
const asked = useRef(0)
const checkNeed = missing(permissions, 'DnsClient.canView')
async function check() {
const domain = field.trim()
if (domain === '') {
forget()
setProblem('Please enter a domain name to check.')
entry.current?.focus()
return
}
const mine = ++asked.current
setVerdict(null)
setProblem(null)
setChecking(true)
const [outcome, named] = await Promise.all([
checkDomain(token, domain, {
settings: missing(permissions, 'Settings.canView'),
allowed: missing(permissions, 'Allowed.canView'),
}),
/* The names the Lists tab gives its rows: Technitium's catalogue, then ours. */
loadQuickList('quick-block-lists').then((c) => [...c, ...moreLists(c)]),
])
if (mine !== asked.current) return
setChecking(false)
if (outcome.kind !== 'ok') {
setProblem(noticeFromFailure(outcome).text)
return
}
setCatalog(named)
setVerdict(verdictOf(outcome.data))
}
/** Whatever was on screen answered another question. */
function forget() {
asked.current++
setChecking(false)
setVerdict(null)
setProblem(null)
}
async function run(list: DomainList) {
const domain = field
// The alert goes BEFORE any call, and leaves the focus in the field:
// other-zones.js:171-176 and 348-353.
if (domain === '') {
onNotice({
type: 'warning',
title: 'Missing!',
text: list === 'allowed' ? 'Please enter a domain name to allow.' : 'Please enter a domain name to block.',
})
entry.current?.focus()
return
}
setBusy(true)
const outcome = await addDomain(list, token, domain)
setBusy(false)
if (outcome.kind !== 'ok') {
onNotice(noticeFromFailure(outcome))
return
}
setField('')
forget()
onNotice(
list === 'allowed'
? { type: 'success', title: 'Allowed!', text: `Domain '${domain}' was added to Allowed Zone successfully.` }
: { type: 'success', title: 'Blocked!', text: `Domain '${domain}' was added to Blocked Zone successfully.` },
)
onChanged(list, domain)
}
return (
<Panel title="Check, block or allow a domain">
<Body>
<form
className={styles.add}
onSubmit={(e) => {
e.preventDefault()
void run('blocked')
}}
>
<Field label="Domain">
{(id) => (
<Input
id={id}
ref={entry}
mono
className={styles.bad}
placeholder="example.com"
value={field}
aria-invalid={problem != null || undefined}
aria-describedby={problem != null ? problemId : undefined}
onChange={(e) => {
setField(e.target.value)
forget()
}}
/>
)}
</Field>
<PermissionButton
type="submit"
variant="primary"
disabled={busy}
permission={missing(permissions, 'Blocked.canModify')}
>
Block
</PermissionButton>
<PermissionButton
type="button"
disabled={busy}
permission={missing(permissions, 'Allowed.canModify')}
onClick={() => void run('allowed')}
>
Allow
</PermissionButton>
<PermissionButton
type="button"
disabled={checking}
permission={checkNeed}
onClick={() => void check()}
>
Check
</PermissionButton>
</form>
{/* One live region for the whole answer: the wait and the verdict that
replaces it are announced once each. */}
<div aria-live="polite">
{problem != null && (
<div id={problemId} className={styles.invalid}>
{problem}
</div>
)}
{checking && <Loading compact announce={false} />}
{verdict != null && <DomainCheck verdict={verdict} catalog={catalog} />}
</div>
</Body>
</Panel>
)
}
@@ -0,0 +1,168 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as settings from '../../api/settings'
import * as dashboard from '../../api/dashboard'
import * as quick from '../../lib/quick-lists'
import { chooseIn } from '../../test/dropdown'
import { BlockLists } from './BlockLists'
/*
Groups in the Lists tab: a comment line is a heading, and every change to them is a
change to `lines`, kept until Save like the rest (`list-lines.ts`, GROUPS).
*/
afterEach(() => vi.restoreAllMocks())
const A = 'https://a.test/a'
const B = 'https://b.test/b'
const C = 'https://c.test/c'
const ok = (data: unknown) => ({ kind: 'ok' as const, data: data as never })
function serve(urls: string[]) {
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'error', message: 'n/a' })
vi.spyOn(quick, 'loadQuickList').mockResolvedValue([{ name: 'Cee list', urls: [C] }])
vi.spyOn(settings, 'readSettings').mockResolvedValue(ok({
blockListUrls: urls, blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: null,
}))
return vi.spyOn(settings, 'setSettings').mockImplementation(async (_t, body) => ({
kind: 'ok',
data: { server: 'x', response: { blockListUrls: body.blockListUrls.split(','), blockListUpdateIntervalHours: 24 } as never },
}))
}
async function draw(urls: string[], permissions?: Parameters<typeof BlockLists>[0]['permissions']) {
const save = serve(urls)
render(<BlockLists token="T" permissions={permissions} />)
const table = await screen.findByRole('table')
return { save, table }
}
/** The List cell texts and the headings, in table order. */
function order(table: HTMLElement): string[] {
return within(table).getAllByRole('row').slice(1).map((r) => {
const head = within(r).queryByRole('columnheader')
return head != null ? `[${head.querySelector('span')?.textContent}]` : (r.querySelectorAll('td')[1]?.querySelector('div:last-of-type')?.textContent ?? '')
})
}
async function saved(save: ReturnType<typeof serve>) {
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
return save.mock.calls.at(-1)![1].blockListUrls
}
describe('Lists groups', () => {
it('Add group writes a heading last, as "# Name", and only on Save', async () => {
const { save, table } = await draw([A])
await userEvent.click(screen.getByRole('button', { name: 'Add group' }))
await userEvent.type(within(table).getByLabelText('Group name'), 'Ads{Enter}')
expect(order(table)).toEqual(['[No group]', A, '[Ads]'])
expect(screen.getByText('1 list · 0 disabled · 1 group')).toBeInTheDocument()
expect(screen.getByText('Unsaved changes to the block list URLs: 1 group added')).toBeInTheDocument()
expect(save).not.toHaveBeenCalled()
expect(await saved(save)).toBe(`${A},# Ads`)
})
it('refuses a comma, a URL and an empty name under the field, and saves nothing', async () => {
const { table } = await draw([A])
await userEvent.click(screen.getByRole('button', { name: 'Add group' }))
const field = within(table).getByLabelText('Group name')
await userEvent.click(within(table).getByRole('button', { name: 'Add group' }))
expect(screen.getByText('Enter a group name.')).toBeInTheDocument()
await userEvent.type(field, 'Ads, trackers{Enter}')
expect(field).toHaveAccessibleDescription('A group name cannot contain a comma: the server splits the list at commas.')
await userEvent.clear(field)
await userEvent.type(field, `${B}{Enter}`)
expect(field).toHaveAccessibleDescription('A group name cannot be a list URL.')
await userEvent.keyboard('{Escape}')
expect(within(table).queryByLabelText('Group name')).toBeNull()
expect(screen.queryByText(/Unsaved changes/)).toBeNull()
})
it('renames a heading as one change', async () => {
const { save, table } = await draw(['# Ads', A])
await userEvent.click(screen.getByRole('button', { name: 'Rename group Ads' }))
const field = within(table).getByLabelText('Group name')
expect(field).toHaveValue('Ads')
await userEvent.clear(field)
await userEvent.type(field, 'Adverts{Enter}')
expect(order(table)).toEqual(['[Adverts]', A])
expect(screen.getByText('Unsaved changes to the block list URLs: 1 group renamed')).toBeInTheDocument()
expect(await saved(save)).toBe(`# Adverts,${A}`)
})
it('closing the editor gives the focus back to the button that opened it', async () => {
const { table } = await draw(['# Ads', A])
await userEvent.click(screen.getByRole('button', { name: 'Rename group Ads' }))
await userEvent.type(within(table).getByLabelText('Group name'), 's{Enter}')
expect(screen.getByRole('button', { name: 'Rename group Adss' })).toHaveFocus()
await userEvent.click(screen.getByRole('button', { name: 'Add group' }))
await userEvent.keyboard('{Escape}')
expect(screen.getByRole('button', { name: 'Add group' })).toHaveFocus()
})
it('Ungroup removes only the heading: its lists join the group above', async () => {
const { save, table } = await draw(['# One', A, '# Two', B])
await userEvent.click(screen.getByRole('button', { name: 'Ungroup Two' }))
expect(order(table)).toEqual(['[One]', A, B])
expect(within(table).getByRole('columnheader', { name: /^One/ })).toHaveTextContent('2 lists')
expect(await saved(save)).toBe(`# One,${A},${B}`)
})
it('adds a URL into the chosen group', async () => {
const { save, table } = await draw([A, '# One', B, '# Two'])
await chooseIn(userEvent.setup(), 'Add to group', 'One')
await userEvent.type(screen.getByLabelText('List URL'), 'https://e.test/e')
await userEvent.click(screen.getByRole('button', { name: 'Add allow list' }))
expect(order(table)).toEqual(['[No group]', A, '[One]', B, 'https://e.test/e', '[Two]'])
expect(await saved(save)).toBe(`${A},# One,${B},!https://e.test/e,# Two`)
})
it('Quick Add puts its lists into the chosen group', async () => {
const { table } = await draw([A, '# One', '# Two', B])
await chooseIn(userEvent.setup(), 'Add to group', 'One')
await chooseIn(userEvent.setup(), 'Quick Add', 'Cee list')
expect(order(table)).toEqual(['[No group]', A, '[One]', C, '[Two]', B])
})
it('a new group becomes the group lists go into', async () => {
const { table } = await draw([A])
await userEvent.click(screen.getByRole('button', { name: 'Add group' }))
await userEvent.type(within(table).getByLabelText('Group name'), 'Ads{Enter}')
expect(screen.getByLabelText('Add to group')).toHaveTextContent('Ads')
await userEvent.type(screen.getByLabelText('List URL'), B)
await userEvent.click(screen.getByRole('button', { name: 'Add block list' }))
expect(order(table)).toEqual(['[No group]', A, '[Ads]', B])
})
it('moves a list up and down, across headings, keeping the focus on the button', async () => {
const { save, table } = await draw([A, '# One', B, '# Two', C])
const up = screen.getByRole('button', { name: `Move up block list ${B}` })
await userEvent.click(up)
expect(order(table)).toEqual(['[No group]', A, B, '[One]', '[Two]', C])
expect(up).toHaveFocus()
await userEvent.click(screen.getByRole('button', { name: `Move down block list ${A}` }))
expect(order(table)).toEqual(['[No group]', B, A, '[One]', '[Two]', C])
expect(screen.getByRole('button', { name: `Move up block list ${B}` })).toBeDisabled()
expect(screen.getByRole('button', { name: `Move down block list ${C}` })).toBeDisabled()
expect(screen.getByText('Unsaved changes to the block list URLs: 1 moved')).toBeInTheDocument()
expect(await saved(save)).toBe(`${B},${A},# One,# Two,${C}`)
})
it('Discard brings the headings back as they were', async () => {
const { table } = await draw(['# One', A])
await userEvent.click(screen.getByRole('button', { name: 'Ungroup One' }))
await userEvent.click(screen.getByRole('button', { name: 'Discard' }))
expect(order(table)).toEqual(['[One]', A])
})
it('without Settings.canModify every group control is off', async () => {
const perms = { Settings: { canView: true, canModify: false, canDelete: false } }
await draw(['# One', A, B], perms)
expect(screen.getByRole('button', { name: /Add group/ })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Rename group One' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Ungroup One' })).toBeDisabled()
expect(screen.getByRole('button', { name: `Move down block list ${A}` })).toBeDisabled()
expect(screen.getByLabelText('Add to group')).toBeDisabled()
})
})
+135
View File
@@ -0,0 +1,135 @@
/*
The Lists tab. Every value is a token of theme/tokens.css.
*/
/* The save bar of Settings, sticky at the bottom: same piece, same place. */
.bar { composes: bar from '../../ui/PanelForm.module.css'; }
.spacer { flex: 1; }
/* The figures are the shared ones (`.kpi*`, Blocking.module.css). A count the server is
still reloading: dimmed, and its sub-line says why, at the sub-line's size so the
card does not change height. */
.stale { opacity: var(--dim); }
.pending { color: var(--mute); font-size: var(--t-2); }
/*
"Add a list" on a grid. The first column is the URL's: its field, its error, its
hint and, on a line of its own, Quick Add, as wide as the URL. The two Add buttons
sit beside the URL field, bottom-aligned with it, and their column stays empty
below. Rows are separated by each item's own margin, not by `row-gap`, so the
error and the hint keep hugging their field.
*/
.add {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: end;
column-gap: var(--s-5);
}
.add > .invalid,
.add > .help,
.add > .quick { grid-column: 1; }
.quick { margin-top: var(--s-10); }
/*
`--bp-stack`: one column. The URL keeps its error and hint right under it, the two
buttons share the next line in halves, and Quick Add closes. Rows are explicit
because the DOM order puts the buttons before the hint; the error's row collapses
to nothing while there is no error.
*/
@media (max-width: 560px) {
.add { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.add > :first-child { grid-column: 1 / -1; grid-row: 1; }
.add > .invalid { grid-column: 1 / -1; grid-row: 2; }
.add > .help { grid-column: 1 / -1; grid-row: 3; }
.add > :nth-child(2),
.add > :nth-child(3) { grid-row: 4; margin-top: var(--s-8); }
/* A locked button comes inside the tooltip's wrapper: the wrapper takes the half. */
.add > :nth-child(2) > button,
.add > :nth-child(3) > button { width: 100%; }
.add > .quick { grid-column: 1 / -1; grid-row: 5; }
}
/* The validation sentence under its field, with the field's border in `--dan`
(the design rule: the error goes next to its field, once). */
.bad[aria-invalid],
.bad[aria-invalid]:focus { border-color: var(--dan); }
.invalid { color: var(--dan); font-size: var(--t-2); margin-top: var(--s-4); }
.help { color: var(--faint); font-size: var(--t-2); margin-top: var(--s-5); }
/*
The row switch: `ui/Check`'s switch drawn on a bare checkbox. `Check` always paints
its label, and here the label would repeat the URL the next cell already shows; the
row's name goes in `aria-label` instead, as the table checkboxes of Zones do.
It takes BOTH classes `Check` gives its own DOM: `check` on the label (the 26 px
target and the input's alignment) and `toggle` for the switch drawing.
*/
.switch { composes: check toggle from '../../ui/Check.module.css'; }
.name { font-weight: 600; }
/* The URL wraps after its separators (`ui/UrlText`), not at whatever letter meets the
edge: `anywhere` drew `raw.githubuser` / `content.com` at 390 px. */
.url { font-family: var(--font-mono); color: var(--mute); overflow-wrap: break-word; }
/* A switched-off list: its name and kind fade, its controls do not. */
.off { opacity: var(--dim); }
.kind { display: inline-flex; align-items: center; gap: var(--s-2); }
.block { color: var(--ch-block); }
/*
`--ok`, the text green, and not `--ch-ok`, the line green: the same value in dark;
in light the series is tuned against the other ten lines and gives 4.45:1 under
the row hover, the text tone 5.0:1.
*/
.allow { color: var(--ok); }
/* Move up, Move down and Remove, hard against the row's end as Delete is in Rules. */
.actions { text-align: right; white-space: nowrap; }
.rowActions { display: inline-flex; align-items: center; gap: var(--s-2); }
/*
The kind has two places: its own column, and under the URL. Only one is drawn at a
time (`display: none` also takes the other out of what a screen reader reads).
*/
.kindInline { display: none; }
/*
`--bp-stack`. Four columns and three row buttons left the URL six characters a line
at 390: the Type column folds into the List cell, under the URL, and the row's
buttons stack in their column, so the URL keeps most of the width.
*/
@media (max-width: 560px) {
.kindCell { display: none; }
.kindInline { display: inline-flex; margin-top: var(--s-2); }
.rowActions { flex-direction: column; align-items: flex-end; }
}
/*
"Add to group", over the URL and Quick Add because it serves both. As wide as a
help column: a group name is short, and a field across the whole panel would read
as a second URL.
*/
.target { max-width: var(--help-col); margin-bottom: var(--s-10); }
/*
A group heading: a row across the table on the raised step, as the table's own
header is, so the lists under it read as its rows. Its name weighs as a row's
entity; its count is metadata. The cell is a `th` in the body, so it is reset from
the header's small capitals.
*/
.groupRow > th {
background: var(--pan2);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line2);
padding: var(--s-5) var(--s-7);
text-align: left;
font-weight: 400;
}
.groupHead { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-5); }
.groupName { font-size: var(--t-5); font-weight: 600; color: var(--ink); overflow-wrap: break-word; }
.groupCount { color: var(--mute); font-size: var(--t-2); }
/* The heading's name being written, in the heading's own row. */
.editor { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-5); }
.editorField { flex: 1 1 var(--s-12); min-width: 0; max-width: var(--help-col); }
.editor > .invalid { flex-basis: 100%; margin-top: 0; }
.foot { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-5); padding: var(--s-7) var(--s-9); color: var(--mute); }
@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as settings from '../../api/settings'
import * as dashboard from '../../api/dashboard'
import * as quick from '../../lib/quick-lists'
import { BlockLists } from './BlockLists'
/*
Quick Add on the Lists tab with the console's own catalogue (`extra-lists.ts`) next
to Technitium's. The bundled file is replaced by a fixture, so these tests do not
move when its content does.
*/
const SB = 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'
const ADS = 'https://lists.example.org/ads.txt'
vi.mock('./extra-lists.json', () => ({
default: [
{ name: 'Example Ads', urls: ['https://lists.example.org/ads.txt'] },
{ name: 'A copy of Steven Black', urls: ['https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'] },
{ name: 'Example Trackers', urls: ['https://trackers.example.net/list.txt'] },
],
}))
afterEach(() => vi.restoreAllMocks())
function serve(urls: string[]) {
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'error', message: 'n/a' })
vi.spyOn(quick, 'loadQuickList').mockResolvedValue([{ name: 'Steven Black [adware + malware]', urls: [SB] }])
vi.spyOn(settings, 'readSettings').mockResolvedValue({
kind: 'ok',
data: { blockListUrls: urls, blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: null } as never,
})
render(<BlockLists token="T" permissions={undefined} />)
}
async function openQuickAdd() {
await screen.findByRole('table')
await userEvent.click(screen.getByLabelText('Quick Add'))
return screen.findByRole('listbox')
}
describe('Quick Add with the console catalogue', () => {
it('offers Technitium entries first, then ours under More lists, without repeats', async () => {
serve([])
const list = await openQuickAdd()
const all = within(list).getAllByRole('option').map((o) => o.textContent)
expect(all).toEqual(['—', 'None', 'Steven Black [adware + malware]', 'Example Ads', 'Example Trackers'])
const group = within(list).getByRole('group', { name: 'More lists' })
expect(within(group).getAllByRole('option').map((o) => o.textContent)).toEqual(['Example Ads', 'Example Trackers'])
})
it('choosing one of ours adds its row, named, as one of theirs does', async () => {
serve([SB])
await openQuickAdd()
await userEvent.click(screen.getByRole('option', { name: 'Example Ads' }))
const table = screen.getByRole('table')
const row = within(table).getByText(ADS).closest('tr')!
expect(row).toHaveTextContent('Example Ads')
expect(row).toHaveTextContent('Block')
expect(screen.getByText('Unsaved changes to the block list URLs: 1 added')).toBeInTheDocument()
})
it('a saved URL of ours is named in the table', async () => {
serve([ADS])
const table = await screen.findByRole('table')
expect(within(table).getByText(ADS).closest('tr')).toHaveTextContent('Example Ads')
})
it('the search filters both catalogues, by name or host, and Enter picks', async () => {
serve([])
await openQuickAdd()
const field = screen.getByRole('combobox', { name: 'Search lists' })
expect(field).toHaveFocus()
await userEvent.keyboard('trackers.example')
expect(within(screen.getByRole('listbox')).getAllByRole('option').map((o) => o.textContent))
.toEqual(['Example Trackers'])
await userEvent.keyboard('{Enter}')
expect(within(screen.getByRole('table')).getByText('https://trackers.example.net/list.txt')).toBeInTheDocument()
})
it('a search with no match says No lists match', async () => {
serve([])
await openQuickAdd()
await userEvent.keyboard('nothing like this')
expect(screen.getByRole('status')).toHaveTextContent('No lists match')
})
})
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import { REAL, realServer } from '../../test/real-blocking'
import { nextUpdateText } from '../settings/panes/Blocking'
import { BlockLists } from './BlockLists'
/*
The Lists tab fed with the harness's real `settings/get` and `dashboard/stats/get`
(`test/real-blocking.ts`) and the real built-in Quick Add catalog. The harness's
`blockListUrls` carries every row kind of the grammar at once (a comment, drawn as
the heading of the group the four lists form, a URL in
the catalog, an enabled `file://` list, a `#`-disabled one and a `!` allow list), so
each is checked as the server sent it.
*/
beforeEach(() => {
vi.spyOn(globalThis, 'fetch').mockImplementation(realServer())
})
afterEach(() => vi.restoreAllMocks())
const URLS = REAL.settings.response.blockListUrls
const STATS = REAL.statsLastHour.response.stats
/** The table row whose List cell shows `url`. */
function row(table: HTMLElement, url: string) {
return within(table).getByText(url).closest('tr')!
}
describe('Lists against the harness', () => {
it('draws one row per line the server keeps, each with its kind', async () => {
render(<BlockLists token="T" permissions={undefined} />)
const table = await screen.findByRole('table')
expect(within(table).getAllByRole('row').slice(1)).toHaveLength(URLS.length)
const hosts = 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'
expect(URLS).toContain(hosts)
const named = row(table, hosts)
expect(named).toHaveTextContent('Steven Black [adware + malware]')
expect(within(named).getByRole('checkbox')).toBeChecked()
expect(URLS).toContain('#file:///etc/dns/t15-lists/list-b.txt')
const off = row(table, 'file:///etc/dns/t15-lists/list-b.txt')
expect(within(off).getByRole('checkbox')).not.toBeChecked()
expect(off).toHaveTextContent('Block')
expect(URLS).toContain('!file:///etc/dns/t15-lists/allow.txt')
const allow = row(table, 'file:///etc/dns/t15-lists/allow.txt')
expect(allow).toHaveTextContent('Allow')
expect(within(allow).getByRole('checkbox')).toBeChecked()
// The comment is the heading of the four lists after it.
const heading = row(table, 'T15 harness lists')
expect(heading).toHaveTextContent('4 lists')
expect(within(heading).queryByRole('checkbox')).toBeNull()
expect(screen.getByText('4 lists · 1 disabled · 1 group')).toBeInTheDocument()
})
it('the figures and the next update are the server ones', async () => {
render(<BlockLists token="T" permissions={undefined} />)
await screen.findByRole('table')
expect(await screen.findByText(STATS.blockListZones.toLocaleString())).toBeInTheDocument()
expect(screen.getByText(STATS.allowListZones.toLocaleString())).toBeInTheDocument()
expect(screen.getByText(nextUpdateText(REAL.settings.response.blockListNextUpdatedOn))).toBeInTheDocument()
expect(screen.getByText(`Next update · every ${REAL.settings.response.blockListUpdateIntervalHours} h`))
.toBeInTheDocument()
})
it('nothing is pending after reading: the rows are the saved lines', async () => {
render(<BlockLists token="T" permissions={undefined} />)
await screen.findByRole('table')
expect(screen.queryByText(/Unsaved changes/)).toBeNull()
})
})
+430
View File
@@ -0,0 +1,430 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as settings from '../../api/settings'
import * as dashboard from '../../api/dashboard'
import * as quick from '../../lib/quick-lists'
import { chooseIn } from '../../test/dropdown'
import { BlockLists, LISTS_SUBTITLE } from './BlockLists'
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
const HOSTS = 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'
/* `settings/get` as the outcome Lists reads it: the server's message has to reach
the notice when the read fails (M2). */
const ok = (data: unknown) => ({ kind: 'ok' as const, data: data as never })
function serve(urls: string[] | null) {
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'error', message: 'n/a' })
vi.spyOn(quick, 'loadQuickList').mockResolvedValue([{ name: 'Steven Black [adware + malware]', urls: [HOSTS] }])
return vi.spyOn(settings, 'readSettings').mockResolvedValue(ok({
blockListUrls: urls, blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: null,
}))
}
function draw(clusterInitialised = false) {
render(<BlockLists token="T" permissions={undefined} clusterInitialised={clusterInitialised} />)
}
describe('BlockLists', () => {
it('draws one row per line, with its readable name and kind', async () => {
serve([HOSTS, '#!https://a.test/x', '# my lists'])
draw()
const table = await screen.findByRole('table')
expect(within(table).getByText('Steven Black [adware + malware]')).toBeInTheDocument()
expect(within(table).getAllByText('Allow')).not.toHaveLength(0)
// The comment is a group heading, of nothing yet; the two lists above it have no group.
expect(within(table).getByRole('columnheader', { name: /^my lists/ })).toHaveTextContent('0 lists')
expect(within(table).getByRole('columnheader', { name: /^No group/ })).toHaveTextContent('2 lists')
})
it('a comment and an enabled line that is not a list URL get no switch', async () => {
serve([HOSTS, 'not-a-url', '# my lists'])
draw()
await screen.findByRole('table')
expect(screen.getAllByRole('checkbox')).toHaveLength(1)
expect(screen.getByRole('checkbox', { name: `Enabled block list ${HOSTS}` })).toBeChecked()
})
it('nothing is saved until Save, and Save sends only blockListUrls on the cluster', async () => {
serve([HOSTS])
const save = vi.spyOn(settings, 'setSettings').mockResolvedValue({
kind: 'ok', data: { server: 'x', response: { blockListUrls: [`#${HOSTS}`] } as never },
})
draw(true)
await userEvent.click(await screen.findByRole('checkbox', { name: /Enabled/ }))
expect(save).not.toHaveBeenCalled()
expect(screen.getByText('Unsaved changes to the block list URLs: 1 switched off')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(save).toHaveBeenCalledWith('T', { node: 'cluster', blockListUrls: `#${HOSTS}` })
expect(await screen.findByText('DNS Server settings were saved successfully.')).toBeInTheDocument()
expect(screen.queryByText(/Unsaved changes/)).not.toBeInTheDocument()
})
it('says what changed, not just whether something did', async () => {
serve([HOSTS])
draw()
await userEvent.click(await screen.findByRole('checkbox', { name: /Enabled/ }))
await userEvent.type(screen.getByLabelText('List URL'), 'https://a.test/block.txt')
await userEvent.click(screen.getByRole('button', { name: 'Add block list' }))
expect(screen.getByText('Unsaved changes to the block list URLs: 1 added, 1 switched off')).toBeInTheDocument()
// Switching the first one back leaves only the addition.
await userEvent.click(screen.getByRole('checkbox', { name: `Enabled block list ${HOSTS}` }))
expect(screen.getByText('Unsaved changes to the block list URLs: 1 added')).toBeInTheDocument()
})
it('tells its tab whether there are unsaved changes, and nothing is left marked after Discard or on leaving', async () => {
serve([HOSTS])
const dirty = vi.fn()
const { unmount } = render(<BlockLists token="T" permissions={undefined} onDirty={dirty} />)
await userEvent.click(await screen.findByRole('checkbox', { name: /Enabled/ }))
expect(dirty).toHaveBeenLastCalledWith(true)
await userEvent.click(screen.getByRole('button', { name: 'Discard' }))
expect(dirty).toHaveBeenLastCalledWith(false)
await userEvent.click(screen.getByRole('checkbox', { name: /Enabled/ }))
expect(dirty).toHaveBeenLastCalledWith(true)
unmount()
expect(dirty).toHaveBeenLastCalledWith(false)
})
it('says what the tab is for under its title', async () => {
serve([HOSTS])
draw()
expect(await screen.findByText(LISTS_SUBTITLE)).toBeInTheDocument()
})
it('the same URL as a block and an allow list gets two distinct switch names', async () => {
serve([HOSTS, `#!${HOSTS}`])
draw()
await screen.findByRole('table')
expect(screen.getByRole('checkbox', { name: `Enabled block list ${HOSTS}` })).toBeChecked()
expect(screen.getByRole('checkbox', { name: `Enabled allow list ${HOSTS}` })).not.toBeChecked()
})
it('on a standalone server Save sends node empty', async () => {
serve([HOSTS])
const save = vi.spyOn(settings, 'setSettings').mockResolvedValue({
kind: 'ok', data: { server: 'x', response: { blockListUrls: null, blockListUpdateIntervalHours: 24 } as never },
})
draw(false)
await screen.findByRole('table')
await chooseIn(userEvent.setup(), 'Quick Add', 'None')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(save).toHaveBeenCalledWith('T', { node: '', blockListUrls: 'false' })
})
it('after a save the next update is the one the server answers', async () => {
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'error', message: 'n/a' })
vi.spyOn(quick, 'loadQuickList').mockResolvedValue([])
vi.spyOn(settings, 'readSettings').mockResolvedValue(ok({
blockListUrls: [HOSTS], blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: '2999-01-01T00:00:00Z',
}))
// Emptying the lists stops the server's timer: the key comes back omitted.
vi.spyOn(settings, 'setSettings').mockResolvedValue({
kind: 'ok', data: { server: 'x', response: { blockListUrls: null, blockListUpdateIntervalHours: 12 } as never },
})
draw()
await screen.findByRole('table')
expect(screen.queryByText('Not Scheduled')).not.toBeInTheDocument()
await chooseIn(userEvent.setup(), 'Quick Add', 'None')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
await screen.findByText('DNS Server settings were saved successfully.')
expect(screen.getByText('Not Scheduled')).toBeInTheDocument()
expect(screen.getByText('Next update · every 12 h')).toBeInTheDocument()
})
it('a failed count read says so on both figures', async () => {
serve([HOSTS])
draw()
await screen.findByRole('table')
expect(await screen.findAllByText('Could not read the counts.')).toHaveLength(2)
})
it('the counts are drawn when the stats arrive, zero included', async () => {
serve([HOSTS])
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({
kind: 'ok', data: { stats: { blockListZones: 1234, allowListZones: 0 } } as never,
})
draw()
expect(await screen.findByText((1234).toLocaleString())).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.queryByText('Could not read the counts.')).not.toBeInTheDocument()
})
it('Quick Add None empties the table and Discard brings it back', async () => {
serve([HOSTS])
draw()
await screen.findByRole('table')
await chooseIn(userEvent.setup(), 'Quick Add', 'None')
expect(screen.getByText('No lists')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Discard' }))
expect(screen.getByText('Steven Black [adware + malware]')).toBeInTheDocument()
})
it('adds an allow list with !', async () => {
serve([])
draw()
await userEvent.type(await screen.findByLabelText('List URL'), 'https://a.test/allow.txt')
await userEvent.click(screen.getByRole('button', { name: 'Add allow list' }))
expect(within(screen.getByRole('table')).getAllByText('Allow')).not.toHaveLength(0)
expect(screen.getByLabelText('List URL')).toHaveValue('')
})
it('an empty field adds nothing and says nothing', async () => {
serve([])
draw()
await screen.findByRole('table')
await userEvent.click(screen.getByRole('button', { name: 'Add block list' }))
expect(screen.getByText('No lists')).toBeInTheDocument()
expect(screen.queryByText(/Unsaved changes/)).not.toBeInTheDocument()
})
it('rejects a URL typed with # or !, under the field', async () => {
serve([])
draw()
const field = await screen.findByLabelText('List URL')
await userEvent.type(field, '!https://a.test/allow.txt')
await userEvent.click(screen.getByRole('button', { name: 'Add allow list' }))
const msg = 'Enter the URL without # or !; use the buttons to choose block or allow.'
expect(screen.getByText(msg)).toBeInTheDocument()
expect(field).toHaveAttribute('aria-invalid', 'true')
expect(field).toHaveAccessibleDescription(msg)
expect(field).toHaveValue('!https://a.test/allow.txt')
expect(screen.getByText('No lists')).toBeInTheDocument()
// Typing again clears the message.
await userEvent.clear(field)
expect(screen.queryByText(msg)).not.toBeInTheDocument()
await userEvent.type(field, '#https://a.test/x')
await userEvent.click(screen.getByRole('button', { name: 'Add block list' }))
expect(screen.getByText(msg)).toBeInTheDocument()
})
it('does not add a list already in the table, in any form', async () => {
serve([`#!${HOSTS}`])
draw()
const field = await screen.findByLabelText('List URL')
await userEvent.type(field, ` ${HOSTS} `)
await userEvent.click(screen.getByRole('button', { name: 'Add block list' }))
expect(screen.getByText('This list is already in the table.')).toBeInTheDocument()
expect(screen.getAllByRole('row')).toHaveLength(2)
expect(screen.queryByText(/Unsaved changes/)).not.toBeInTheDocument()
})
it('Update Now is off when no list was ever saved', async () => {
serve(null)
draw()
expect(await screen.findByRole('button', { name: 'Update Now' })).toBeDisabled()
})
it('a next update the server omits reads Not Scheduled', async () => {
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'error', message: 'n/a' })
vi.spyOn(quick, 'loadQuickList').mockResolvedValue([])
// `settings/get` drops null keys: a fresh server sends no blockListNextUpdatedOn at all.
vi.spyOn(settings, 'readSettings').mockResolvedValue(ok({
blockListUrls: [HOSTS], blockListUpdateIntervalHours: 24,
}))
draw()
await screen.findByRole('table')
expect(screen.getByText('Not Scheduled')).toBeInTheDocument()
expect(screen.getByText('Next update · every 24 h')).toBeInTheDocument()
})
it('a failed read says so instead of loading for ever', async () => {
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'error', message: 'n/a' })
vi.spyOn(quick, 'loadQuickList').mockResolvedValue([])
vi.spyOn(settings, 'readSettings').mockResolvedValue({ kind: 'error', message: 'Access was denied.' })
draw()
expect(await screen.findByText('Could not read the block list settings.')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
/* As every other tab, a read that fails with nothing on screen says the
server's own message, in the notice. */
it('a failed read says the server message in the notice', async () => {
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'error', message: 'n/a' })
vi.spyOn(quick, 'loadQuickList').mockResolvedValue([])
const read = vi.spyOn(settings, 'readSettings').mockResolvedValue({ kind: 'error', message: 'Access was denied.' })
draw(true)
expect(await screen.findByText('Access was denied.')).toBeInTheDocument()
expect(screen.getByText('Error!')).toBeInTheDocument()
expect(read).toHaveBeenCalledWith('T', 'cluster')
})
it('Update Now asks first, then forces the update', async () => {
serve([HOSTS])
const force = vi.spyOn(settings, 'forceUpdateBlockLists').mockResolvedValue(true)
draw()
await screen.findByRole('table')
await userEvent.click(screen.getByRole('button', { name: 'Update Now' }))
expect(force).not.toHaveBeenCalled()
expect(screen.getByText('Are you sure to force download and update the block lists?')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Update' }))
expect(force).toHaveBeenCalledWith('T')
expect(await screen.findByText('Block list update was triggered successfully.')).toBeInTheDocument()
expect(screen.getByText('Updating Now')).toBeInTheDocument()
})
/*
Block List Domains changes
when the server finishes reloading, not at once, and the figure says so. What the
real server (v15.5.1) gives to tell: `blockListNextUpdatedOn` is the last SUCCESSFUL
update plus the interval
(WebServiceSettingsApi.cs:384), and the last update only moves when a download ends
well (BlockListZoneManager.cs:674-692). So while it reloads it keeps answering the
OLD date, and the reload has finished when the date differs from the one captured
when the action was taken. The mocks below answer exactly that way.
*/
describe('the figures after a change', () => {
const OLD = '2999-01-01T00:00:00Z'
const NEW = '2999-01-02T00:00:00Z'
const stats = (block: number) => ({ kind: 'ok' as const, data: { stats: { blockListZones: block, allowListZones: 0 } } as never })
const answer = (next: string | undefined, urls: string[] | null = [HOSTS]) =>
({ blockListUrls: urls, blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: next }) as never
function setup(urls: string[] | null = [HOSTS]) {
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.spyOn(quick, 'loadQuickList').mockResolvedValue([])
const read = vi.spyOn(settings, 'readSettings').mockResolvedValue(ok(answer(OLD, urls)))
const counts = vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue(stats(74771))
const force = vi.spyOn(settings, 'forceUpdateBlockLists').mockResolvedValue(true)
return { read, counts, force, user: userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) }
}
async function updateNow(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Update Now' }))
await user.click(screen.getByRole('button', { name: 'Update' }))
}
it('after Update Now the figures say they are updating while the server still answers the old date', async () => {
const { read, counts, user } = setup()
draw()
expect(await screen.findByText((74771).toLocaleString())).toBeInTheDocument()
await updateNow(user)
expect(await screen.findAllByText('Updating…')).toHaveLength(2)
expect(screen.getByText('Updating Now')).toBeInTheDocument()
// Reloading: the server still says OLD, a date in the future. Not finished.
await act(() => vi.advanceTimersByTimeAsync(3000))
await act(() => vi.advanceTimersByTimeAsync(3000))
expect(screen.getAllByText('Updating…')).toHaveLength(2)
expect(screen.getByText('Updating Now')).toBeInTheDocument()
counts.mockResolvedValue(stats(74763))
read.mockResolvedValue(ok(answer(NEW)))
await act(() => vi.advanceTimersByTimeAsync(3000))
expect(await screen.findByText((74763).toLocaleString())).toBeInTheDocument()
expect(screen.queryByText('Updating…')).not.toBeInTheDocument()
expect(screen.queryByText('Updating Now')).not.toBeInTheDocument()
})
it('the wait is bounded: after two minutes the figures are read once more and the mark goes', async () => {
const { read, counts, user } = setup()
draw()
await screen.findByText((74771).toLocaleString())
await updateNow(user)
await screen.findAllByText('Updating…')
const statsBefore = counts.mock.calls.length
await act(() => vi.advanceTimersByTimeAsync(121_000))
expect(screen.queryByText('Updating…')).not.toBeInTheDocument()
expect(counts.mock.calls.length).toBe(statsBefore + 1)
const settled = read.mock.calls.length
await act(() => vi.advanceTimersByTimeAsync(30_000))
expect(read.mock.calls.length).toBe(settled)
})
it('a second Update Now while waiting captures again and starts the two minutes again', async () => {
const { user } = setup()
draw()
await screen.findByText((74771).toLocaleString())
await updateNow(user)
await screen.findAllByText('Updating…')
await act(() => vi.advanceTimersByTimeAsync(100_000))
await updateNow(user)
await act(() => vi.advanceTimersByTimeAsync(60_000))
// 160 s after the first, 60 s after the second: still waiting.
expect(screen.getAllByText('Updating…')).toHaveLength(2)
await act(() => vi.advanceTimersByTimeAsync(65_000))
expect(screen.queryByText('Updating…')).not.toBeInTheDocument()
})
it('after a Save that changes the URLs the figures wait for the date to move, from the one the save answered', async () => {
const { read, counts, user } = setup()
vi.spyOn(settings, 'setSettings').mockResolvedValue({
kind: 'ok', data: { server: 'x', response: answer(OLD, [`#${HOSTS}`]) },
})
draw()
await screen.findByText((74771).toLocaleString())
await user.click(screen.getByRole('checkbox', { name: /Enabled/ }))
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findAllByText('Updating…')).toHaveLength(2)
await act(() => vi.advanceTimersByTimeAsync(3000))
expect(screen.getAllByText('Updating…')).toHaveLength(2)
counts.mockResolvedValue(stats(11))
read.mockResolvedValue(ok(answer(NEW, [`#${HOSTS}`])))
await act(() => vi.advanceTimersByTimeAsync(3000))
expect(await screen.findByText('11')).toBeInTheDocument()
expect(screen.queryByText('Updating…')).not.toBeInTheDocument()
})
it('a Save that leaves the same set of URLs arms nothing: the server does not reload', async () => {
const B = 'https://b.test/hosts'
const { read, user } = setup([HOSTS, B])
// Removed and added back: last now, a new order, the same items (HasSameItems).
vi.spyOn(settings, 'setSettings').mockResolvedValue({
kind: 'ok', data: { server: 'x', response: answer(OLD, [B, HOSTS]) },
})
draw()
await screen.findByText((74771).toLocaleString())
await user.click(screen.getByRole('button', { name: `Remove ${HOSTS}` }))
await user.type(screen.getByRole('textbox', { name: 'List URL' }), HOSTS)
await user.click(screen.getByRole('button', { name: 'Add block list' }))
await user.click(screen.getByRole('button', { name: 'Save' }))
await screen.findByText('DNS Server settings were saved successfully.')
expect(screen.queryByText('Updating…')).not.toBeInTheDocument()
const reads = read.mock.calls.length
await act(() => vi.advanceTimersByTimeAsync(10_000))
expect(read.mock.calls.length).toBe(reads)
})
it('a Save that empties the lists reads the figures at once: the server flushes them there and then', async () => {
const { counts, user } = setup()
vi.spyOn(settings, 'setSettings').mockResolvedValue({
kind: 'ok', data: { server: 'x', response: answer(undefined, null) },
})
draw()
await screen.findByText((74771).toLocaleString())
counts.mockResolvedValue(stats(0))
await chooseIn(user, 'Quick Add', 'None')
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findAllByText('0')).toHaveLength(2)
expect(screen.queryByText('Updating…')).not.toBeInTheDocument()
})
})
it('without Settings.canModify, Save and the switches are disabled', async () => {
serve([HOSTS])
render(
<BlockLists token="T" clusterInitialised={false}
permissions={{ Settings: { canView: true, canModify: false, canDelete: false } }} />,
)
expect(await screen.findByRole('checkbox', { name: /Enabled/ })).toBeDisabled()
expect(screen.getByRole('button', { name: /Add block list/ })).toBeDisabled()
})
it('without Settings.canView the lists are locked and nothing is read', async () => {
const get = serve([HOSTS])
render(
<BlockLists token="T" clusterInitialised={false}
permissions={{ Settings: { canView: false, canModify: false, canDelete: false } }} />,
)
expect(await screen.findByText('Requires Settings: View')).toBeInTheDocument()
expect(get).not.toHaveBeenCalled()
})
})
+829
View File
@@ -0,0 +1,829 @@
import { useCallback, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { readSettings, setSettings, forceUpdateBlockLists } from '../../api/settings'
import { getDashboardStats } from '../../api/dashboard'
import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
import { nextUpdateText } from '../settings/panes/Blocking'
import { SectionHeader } from '../../ui/SectionHeader'
import { Panel, Body } from '../../ui/Panel'
import { RowAction, Table } from '../../ui/Table'
import { Field, Input, Select } from '../../ui/Field'
import { Button } from '../../ui/Button'
import { PermissionButton } from '../../ui/PermissionButton'
import { Confirm } from '../../ui/Confirm'
import { Notifier } from '../../ui/Notifier'
import { Failure, Loading } from '../../ui/Empty'
import { RouteLink } from '../../ui/RouteLink'
import { Icon } from '../../ui/Icon'
import { noticeFromFailure, type Notice } from '../../lib/notice'
import {
addHeading, addListTo, applyQuickTo, canMove, canToggle, changesOf, changesText, fromUrls, headingIndexes, headingName,
headingProblem, listName, moveLine, removeHeading, renameHeading, saveBody, toggleLine,
type ListLine,
} from './list-lines'
import { Locked } from './Locked'
import { hostsOf, moreLists } from './extra-lists'
import { missing, type Permissions } from './permissions'
import shared from './Blocking.module.css'
import styles from './BlockLists.module.css'
import { UrlText } from '../../ui/UrlText'
/*
The Lists tab: the "Allow / Block List URLs" field of Settings > Blocking drawn as a
table. OURS as a table; every line is the server's own grammar (`list-lines.ts`).
Upstream's words here: `Quick Add` and its `None`, `Update Now`, the next-update
value (`nextUpdateText`, shared with Settings), the update confirmation sentence and
every alert of the update and the save. Everything else is OURS:
- the figures `Block List Domains` and `Allow List Domains`, and `Next update ·
every N h` (upstream's label is "Block List Next Update On");
- the panel titles `Lists`, `Block Lists`, `Add a list`, and `Update Block Lists`
with its `Update` button (upstream asks with a bare `confirm()`);
- the field label `List URL` (upstream's is "Allow / Block List URLs"), its
placeholder, the `file://` help line, the invalid-URL hint and `This list is
already in the table.`; `Add block list` / `Add allow list`;
- the table: the columns `Enabled`, `List` and `Type`, the types `Block` and `Allow`,
`Remove`, `Move up` / `Move down`, `No lists`, and the row controls' aria-labels;
- the groups (2026-10-15): a comment line drawn as a group heading (`list-lines.ts`,
GROUPS) with its `N list(s)`, `No group` for the lists before the first heading,
`Unnamed group` for a bare `#`, `Rename`, `Ungroup`, `Add group`, `Group name`,
`Cancel`, the field `Add to group`, and `headingProblem`'s sentences;
- the table's foot: `N list(s) · N disabled · N group(s)`;
- the subtitle under the title (`LISTS_SUBTITLE`);
- the unsaved-changes bar (2026-10-15): `Unsaved changes to the block list URLs:`
and what changed, in `changesText`'s words (`N added`, `N removed`, `N switched on`,
`N switched off`, `N changed`, `N moved`, `N group(s) added`, `renamed`,
`removed`; `list-lines.ts`, WHAT CHANGED), `Discard`, and `Save`, shortened from
upstream's "Save Settings" (index.html:2460); and the Lists tab's mark while there
are unsaved changes, read as `unsaved changes` (`onDirty`, `Blocking.tsx`);
- the two `Could not read …` sentences and the link to Settings › Blocking;
- Quick Add's search (`Search lists`, `No lists match`) and its second catalogue, the
console's own (`extra-lists.ts`), under `More lists` after Technitium's.
Quick Add here lists Technitium's catalogue first, as Settings does, then ours. An
entry of ours is chosen exactly as one of theirs (`applyQuick`), and its single-URL
entries name their rows too. Its search matches the name and the URL's host.
No node selector: `blockListUrls` is a CLUSTER-WIDE parameter (`nodeScope`,
settings/model.ts:514), so it is read and saved on the cluster when there is one and
on this server when there is not, which is what Settings does with the aggregate.
Groups and moves are kept with everything else: a heading is a line like any other,
so adding, renaming or removing one, and moving a list, only changes `lines`.
Changes are KEPT until Save, as upstream's Save Settings keeps the textarea:
`Quick Add > None` empties every list, and saving on each click would make that one
click unrecoverable. One save also means one request, so quick changes cannot land
out of order.
*/
/* The value of an entry of ours in Quick Add: by position, so a name shared with an
entry of Technitium's still picks the right one. */
const MORE = 'more:'
const KIND_LABEL = { block: 'Block', allow: 'Allow' } as const
const KIND_CLASS = { block: styles.block, allow: styles.allow } as const
/* An icon and a word, not only a colour, as the Rule column of Rules: the same two
icons, so a block list and a blocked rule read as the same kind. */
const KIND_ICON = { block: 'blocked', allow: 'allowed' } as const
/*
The add field takes the bare URL; the buttons choose block or allow. Both sentences
are OURS. `addList` only refuses an identical line, so the table checks the rest
here: a `#` or `!` typed by hand would make the field say one thing and the button
another, and the same URL switched off or on the other list would be a second row
the server loads twice.
*/
const MSG_PREFIX = 'Enter the URL without # or !; use the buttons to choose block or allow.'
const MSG_DUPLICATE = 'This list is already in the table.'
/* OURS: `dashboard/stats/get` failed, which is not the same as zero domains. */
const COUNTS_FAILED = 'Could not read the counts.'
/*
After a Save or an Update Now the server reloads the lists in the background, and
the counts change when it has finished, not at once. Until then both figures say "Updating…" (OURS).
How the end is told, from the server (v15.5.1): `blockListNextUpdatedOn` is the last
SUCCESSFUL update plus the interval (WebServiceSettingsApi.cs:384), and the last update
only moves when a download ends well (BlockListZoneManager.cs:674-692). While it
reloads, then, the server keeps answering the OLD date (a future one, so "Updating
Now" is no signal). The value is captured when the action is taken (Save: from its
own answer; Update Now: read just before the call) and the settings are read every
POLL_MS until it differs; then the counts are read once more. POLL_LIMIT_MS bounds the
wait (a download that fails never moves the date), and at the limit the counts are read
anyway. A second Save or Update Now while waiting captures again and starts the limit
again.
*/
const UPDATING = 'Updating…'
const POLL_MS = 3000
const POLL_LIMIT_MS = 120_000
/** A reload being waited for: the date captured when it was asked for. Each one is a
new object, so a second action restarts the wait even with the same date. */
type Reload = { from: string | null }
/*
Whether a Save makes the server reload. Only when the set of lines changed, by its
`HasSameItems` (TechnitiumLibrary CollectionExtensions.cs: same count, every item of
the one in the other, order aside; BlockListZoneManager.cs:512-523), and there is
something to reload with a timer to do it: with no lines the server flushes the zones
there and then (`Flush()`, :527), and with the interval at 0 it reloads nothing.
*/
function sameItems(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((x) => b.includes(x))
}
/** What the tab is for, under its title. OURS (CONVENTIONS.md, deviation 2). */
export const LISTS_SUBTITLE = 'The block and allow lists the server downloads. Changes to them wait until you save.'
/** A heading being written: an existing group by its ordinal, or a new one. */
type Editing = { group: number | 'new'; value: string; problem: string | null }
const UNNAMED = 'Unnamed group'
const nameOf = (line: ListLine) => headingName(line) || UNNAMED
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
/*
The heading editor: one field, its verb and `Cancel`. Enter takes it and Escape
leaves it, as a field in a row should; the sentence that refuses a name goes under
the field, once (the design rule).
*/
function HeadingEditor({
editing,
verb,
onChange,
onSubmit,
onCancel,
}: {
editing: Editing
verb: string
onChange: (value: string) => void
onSubmit: () => void
onCancel: () => void
}) {
const problemId = useId()
return (
<form
className={styles.editor}
onSubmit={(e: FormEvent) => {
e.preventDefault()
onSubmit()
}}
>
<Input
autoFocus
aria-label="Group name"
className={`${styles.bad} ${styles.editorField}`}
value={editing.value}
aria-invalid={editing.problem != null || undefined}
aria-describedby={editing.problem != null ? problemId : undefined}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
}}
/>
<Button size="sm" variant="primary" type="submit">{verb}</Button>
<Button size="sm" type="button" onClick={onCancel}>Cancel</Button>
{editing.problem != null && (
<p id={problemId} className={styles.invalid}>{editing.problem}</p>
)}
</form>
)
}
export function BlockLists({
tabs,
token,
permissions,
clusterInitialised = false,
onDirty,
}: {
tabs?: ReactNode
token: string | null
permissions: Permissions
clusterInitialised?: boolean
/** Told whether there are unsaved changes, for the tab's mark; false on leaving. */
onDirty?: (dirty: boolean) => void
}) {
const node = clusterInitialised ? 'cluster' : ''
const viewNeed = missing(permissions, 'Settings.canView')
const modifyNeed = missing(permissions, 'Settings.canModify')
const statsNeed = missing(permissions, 'Dashboard.canView')
const invalidId = useId()
const [saved, setSaved] = useState<ListLine[] | null>(null)
const [readFailed, setReadFailed] = useState(false)
const [lines, setLines] = useState<ListLine[]>([])
const [next, setNext] = useState<string | null>(null)
const [interval, setIntervalHours] = useState<number | null>(null)
const [hasSaved, setHasSaved] = useState(false)
const [counts, setCounts] = useState<{ block: number; allow: number } | null>(null)
const [countsFailed, setCountsFailed] = useState(false)
const [catalog, setCatalog] = useState<QuickEntry[]>([])
/** The console's own entries, once Technitium's are known to leave out repeats. */
const [more, setMore] = useState<QuickEntry[]>([])
const [field, setField] = useState('')
const [invalid, setInvalid] = useState<string | null>(null)
const [notice, setNotice] = useState<Notice | null>(null)
const [busy, setBusy] = useState(false)
const [askUpdate, setAskUpdate] = useState(false)
/** The group "Add a list" and Quick Add put lists into: a heading's ordinal, -1 for none. */
const [group, setGroup] = useState(-1)
const [editing, setEditing] = useState<Editing | null>(null)
/* Closing the editor removes the field that held the focus: it goes back to the
button that opened it, found by its name once the table is drawn again. */
const table = useRef<HTMLDivElement>(null)
const focusAfter = useRef<string | null>(null)
useEffect(() => {
const name = focusAfter.current
if (name == null || editing != null) return
focusAfter.current = null
const target = [...(table.current?.querySelectorAll('button') ?? [])].find(
(b) => (b.getAttribute('aria-label') ?? b.textContent) === name,
)
target?.focus()
}, [editing, lines])
/* What would be saved, against what the server answered. */
const changes = saved == null ? '' : changesText(changesOf(saved, lines))
const dirty = changes !== ''
useEffect(() => {
onDirty?.(dirty)
}, [dirty, onDirty])
useEffect(() => () => onDirty?.(false), [onDirty])
/** The server is reloading the lists: the counts on screen are not current yet. */
const [reloading, setReloading] = useState<Reload | null>(null)
/** The lines as the server last answered them, to tell whether a Save reloads. */
const serverUrls = useRef<string[]>([])
/** `blockListNextUpdatedOn` as the server last answered it. */
const serverNext = useRef<string | null>(null)
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, [])
useEffect(() => {
if (viewNeed != null) return
let live = true
void readSettings(token, node).then((r) => {
if (!live) return
/* As every other tab: with nothing on screen, the notice carries the server's
message, and the table slot says the read failed. */
if (r.kind !== 'ok') {
setReadFailed(true)
setNotice(noticeFromFailure(r))
return
}
const s = r.data
const read = fromUrls(s.blockListUrls)
setSaved(read)
setLines(read)
// `settings/get` OMITS this key when it is null (CONVENTIONS.md): absent is "Not Scheduled".
setNext(s.blockListNextUpdatedOn ?? null)
serverNext.current = s.blockListNextUpdatedOn ?? null
serverUrls.current = s.blockListUrls ?? []
setIntervalHours(s.blockListUpdateIntervalHours)
setHasSaved(s.blockListUrls != null)
})
return () => {
live = false
}
}, [token, node, viewNeed])
useEffect(() => {
let live = true
void loadQuickList('quick-block-lists').then((e) => {
if (!live) return
setCatalog(e)
setMore(moreLists(e))
})
return () => {
live = false
}
}, [])
const readCounts = useCallback(async () => {
if (statsNeed != null) return
const r = await getDashboardStats(token, 'LastHour', undefined, node)
if (!mounted.current) return
if (r.kind !== 'ok') {
setCountsFailed(true)
return
}
setCountsFailed(false)
setCounts({ block: r.data.stats.blockListZones, allow: r.data.stats.allowListZones })
}, [token, node, statsNeed])
useEffect(() => {
void readCounts()
}, [readCounts])
/* The wait described at POLL_MS: armed by a Save or an Update Now. Until it ends the
next-update line keeps what the action left there ("Updating Now" after Update
Now, main.js:2352-2356). */
useEffect(() => {
if (reloading == null) return
let live = true
let timer: ReturnType<typeof setTimeout> | undefined
const started = Date.now()
const tick = async () => {
const r = await readSettings(token, node)
if (!live) return
const now = r.kind !== 'ok' ? undefined : (r.data.blockListNextUpdatedOn ?? null)
const finished = now !== undefined && now !== reloading.from
if (!finished && Date.now() - started < POLL_LIMIT_MS) {
timer = setTimeout(() => void tick(), POLL_MS)
return
}
if (now !== undefined) {
setNext(now)
serverNext.current = now
}
await readCounts()
if (live) setReloading(null)
}
timer = setTimeout(() => void tick(), POLL_MS)
return () => {
live = false
clearTimeout(timer)
}
}, [reloading, token, node, readCounts])
if (viewNeed != null) {
return (
<>
<SectionHeader section="Blocking" title="Lists" subtitle={LISTS_SUBTITLE} tabs={tabs} />
<Locked title="Block Lists" need={viewNeed} />
</>
)
}
const off = modifyNeed != null
const loading = saved == null
const heads = headingIndexes(lines)
/* What the chosen group still means: a Discard or a Quick Add that emptied the
headings leaves an ordinal that no longer exists, and that is no group. */
const target = group < heads.length ? group : -1
const edit = editing != null && (editing.group === 'new' || editing.group < heads.length) ? editing : null
function submitHeading() {
if (edit == null) return
const problem = headingProblem(edit.value)
if (problem != null) {
setEditing({ ...edit, problem })
return
}
if (edit.group === 'new') {
setLines(addHeading(lines, edit.value))
// The new group is the one the next list goes into.
setGroup(heads.length)
focusAfter.current = 'Add group'
} else {
setLines(renameHeading(lines, heads[edit.group], edit.value))
focusAfter.current = `Rename group ${edit.value.trim()}`
}
setEditing(null)
}
function cancelEditing() {
if (edit == null) return
focusAfter.current = edit.group === 'new' ? 'Add group' : `Rename group ${nameOf(lines[heads[edit.group]])}`
setEditing(null)
}
function ungroup(g: number) {
setLines(removeHeading(lines, heads[g]))
// Its lists joined the group above, and so does the choice of group.
if (target >= g) setGroup(target - 1)
if (edit != null && edit.group !== 'new' && edit.group >= g) setEditing(null)
}
function add(kind: 'block' | 'allow') {
const url = field.trim()
if (url === '') return
if (url.startsWith('#') || url.startsWith('!')) {
setInvalid(MSG_PREFIX)
return
}
if (lines.some((l) => l.url === url)) {
setInvalid(MSG_DUPLICATE)
return
}
setLines(addListTo(lines, url, kind, target))
setField('')
setInvalid(null)
}
async function save() {
setBusy(true)
const r = await setSettings(token, saveBody(lines, node))
setBusy(false)
if (r.kind !== 'ok') {
setNotice(noticeFromFailure(r))
return
}
const read = fromUrls(r.data.response.blockListUrls)
setSaved(read)
setLines(read)
setHasSaved(r.data.response.blockListUrls != null)
// The response carries the server's schedule, as Settings redraws from it: emptying
// the lists stops the timer, and the key then comes back omitted ("Not Scheduled").
const answered = r.data.response.blockListNextUpdatedOn ?? null
setNext(answered)
setIntervalHours(r.data.response.blockListUpdateIntervalHours)
// Settings.tsx:253-257, title and sentence.
setNotice({ type: 'success', title: 'Settings Saved!', text: 'DNS Server settings were saved successfully.' })
// See `sameItems`: whether, and how, the counts change.
const urls = r.data.response.blockListUrls ?? []
const changed = !sameItems(urls, serverUrls.current)
serverUrls.current = urls
serverNext.current = answered
const interval = r.data.response.blockListUpdateIntervalHours ?? 0
if (!changed) return
if (urls.length > 0 && interval > 0) setReloading({ from: answered })
else {
setReloading(null)
void readCounts()
}
}
async function updateNow() {
setAskUpdate(false)
setBusy(true)
// The date to wait on, read just before the call (see POLL_MS); the last answer
// known if the read fails.
const before = await readSettings(token, node)
const from = before.kind !== 'ok' ? serverNext.current : (before.data.blockListNextUpdatedOn ?? null)
const ok = await forceUpdateBlockLists(token)
setBusy(false)
if (!ok) return
// main.js:2356. The label becomes "Updating Now" without reloading the settings.
setNext(new Date(0).toISOString())
setNotice({ type: 'success', title: 'Updating Block List!', text: 'Block list update was triggered successfully.' })
setReloading({ from })
}
/* What names a row: Technitium's entries first, so theirs wins a shared URL. */
const named = [...catalog, ...more]
const listsCount = lines.filter((l) => l.kind !== 'comment').length
const disabledCount = lines.filter((l) => l.kind !== 'comment' && !l.enabled).length
const groupCount = heads.length
/* The lists of each group, to say how many it holds. */
const groupSize = (g: number) => (heads[g + 1] ?? lines.length) - (g < 0 ? 0 : heads[g] + 1)
function headingRow(key: string, name: string, count: number, controls: ReactNode) {
return (
<tr key={key} className={styles.groupRow}>
<th colSpan={4} scope="colgroup">
<div className={styles.groupHead}>
<span className={styles.groupName}>{name}</span>
<span className={styles.groupCount}>{plural(count, 'list', 'lists')}</span>
{controls != null && <span className={styles.spacer} />}
{controls}
</div>
</th>
</tr>
)
}
/* One row per line. A key that follows the line and not its place, so a list that
moves or switches keeps its row, and the focus stays on the button pressed. */
const seen = new Map<string, number>()
const rows: ReactNode[] = []
if (heads.length > 0 && heads[0] > 0) rows.push(headingRow('none', 'No group', groupSize(-1), null))
lines.forEach((l, i) => {
if (l.kind === 'comment') {
const g = heads.indexOf(i)
const name = nameOf(l)
rows.push(
edit != null && edit.group === g ? (
<tr key={`H:${g}`} className={styles.groupRow}>
<th colSpan={4} scope="colgroup">
<HeadingEditor
editing={edit}
verb="Rename"
onChange={(value) => setEditing({ ...edit, value, problem: null })}
onSubmit={submitHeading}
onCancel={cancelEditing}
/>
</th>
</tr>
) : (
headingRow(`H:${g}`, name, groupSize(g), (
<>
<PermissionButton size="sm" permission={modifyNeed} aria-label={`Rename group ${name}`}
onClick={() => setEditing({ group: g, value: headingName(l), problem: null })}>
Rename
</PermissionButton>
<PermissionButton size="sm" permission={modifyNeed} aria-label={`Ungroup ${name}`}
onClick={() => ungroup(g)}>
Ungroup
</PermissionButton>
</>
))
),
)
return
}
const id = `${l.kind}:${l.url}`
const n = seen.get(id) ?? 0
seen.set(id, n + 1)
const name = l.url == null ? null : listName(l.url, named)
const faded = !l.enabled ? styles.off : undefined
const what = `${l.kind} list ${l.url ?? ''}`
rows.push(
<tr key={`L:${id}:${n}`}>
<td>
{canToggle(l) && (
<label className={styles.switch}>
<input
type="checkbox"
aria-label={`Enabled ${what}`}
checked={l.enabled}
disabled={off}
onChange={() => setLines((all) => all.map((x, j) => (j === i ? toggleLine(x) : x)))}
/>
</label>
)}
</td>
<td className={faded}>
{name != null && <div className={styles.name}>{name}</div>}
<div className={styles.url}><UrlText url={l.url ?? l.raw} /></div>
{/* The kind under the URL where the Type column is folded away. */}
<span className={`${styles.kind} ${styles.kindInline} ${KIND_CLASS[l.kind]}`}>
<Icon name={KIND_ICON[l.kind]} size={14} />
{KIND_LABEL[l.kind]}
</span>
</td>
<td className={`${faded ?? ''} ${styles.kindCell}`}>
<span className={`${styles.kind} ${KIND_CLASS[l.kind]}`}>
<Icon name={KIND_ICON[l.kind]} size={14} />
{KIND_LABEL[l.kind]}
</span>
</td>
<td className={styles.actions}>
<div className={styles.rowActions}>
<RowAction icon="moveUp" name="Move up" aria-label={`Move up ${what}`}
disabled={off || !canMove(lines, i, -1)} onClick={() => setLines((all) => moveLine(all, i, -1))} />
<RowAction icon="moveDown" name="Move down" aria-label={`Move down ${what}`}
disabled={off || !canMove(lines, i, 1)} onClick={() => setLines((all) => moveLine(all, i, 1))} />
<PermissionButton size="sm" variant="danger" permission={modifyNeed}
aria-label={`Remove ${l.url ?? l.raw}`}
onClick={() => setLines((all) => all.filter((_, j) => j !== i))}>
Remove
</PermissionButton>
</div>
</td>
</tr>,
)
})
if (edit != null && edit.group === 'new') {
rows.push(
<tr key="H:new" className={styles.groupRow}>
<th colSpan={4} scope="colgroup">
<HeadingEditor
editing={edit}
verb="Add group"
onChange={(value) => setEditing({ ...edit, value, problem: null })}
onSubmit={submitHeading}
onCancel={cancelEditing}
/>
</th>
</tr>,
)
}
return (
<>
<SectionHeader section="Blocking" title="Lists" subtitle={LISTS_SUBTITLE} tabs={tabs} />
<Notifier notice={notice} onClose={() => setNotice(null)} />
<div className={shared.stack}>
<div className={shared.kpis3}>
{statsNeed != null ? (
<>
<Locked title="Block List Domains" need={statsNeed} />
<Locked title="Allow List Domains" need={statsNeed} />
</>
) : (
<>
<Panel className={shared.centred}><Body>
<div className={shared.kpiText}>
{countsFailed ? (
<Failure>{COUNTS_FAILED}</Failure>
) : (
<span className={`${shared.kpiValue}${reloading != null ? ` ${styles.stale}` : ''}`}>
{counts ? counts.block.toLocaleString() : '—'}
</span>
)}
{reloading != null ? (
<span className={styles.pending}>{UPDATING}</span>
) : (
<span className={shared.kpiSub}>{'\u00a0'}</span>
)}
<span className={shared.kpiLabel}>Block List Domains</span>
</div>
</Body></Panel>
<Panel className={shared.centred}><Body>
<div className={shared.kpiText}>
{countsFailed ? (
<Failure>{COUNTS_FAILED}</Failure>
) : (
<span className={`${shared.kpiValue}${reloading != null ? ` ${styles.stale}` : ''}`}>
{counts ? counts.allow.toLocaleString() : '—'}
</span>
)}
{reloading != null ? (
<span className={styles.pending}>{UPDATING}</span>
) : (
<span className={shared.kpiSub}>{'\u00a0'}</span>
)}
<span className={shared.kpiLabel}>Allow List Domains</span>
</div>
</Body></Panel>
</>
)}
<Panel className={shared.centred}><Body>
<div className={shared.kpi}>
<div className={shared.kpiText}>
<span className={shared.kpiValue}>{loading ? '—' : nextUpdateText(next)}</span>
<span className={shared.kpiSub}>{'\u00a0'}</span>
<span className={shared.kpiLabel}>
Next update{interval != null && interval > 0 ? ` · every ${interval} h` : ''}
</span>
</div>
<PermissionButton
permission={modifyNeed}
disabled={busy || !hasSaved}
onClick={() => setAskUpdate(true)}
>
Update Now
</PermissionButton>
</div>
</Body></Panel>
</div>
<Panel title="Add a list">
<Body>
{/* Above both ways in, the URL and Quick Add, because it serves both. */}
<div className={styles.target}>
<Field label="Add to group">
{(id) => (
<Select
id={id}
disabled={off || loading || heads.length === 0}
value={String(target)}
onChange={(ev) => setGroup(Number(ev.target.value))}
>
<option value="-1">No group</option>
{heads.map((h, g) => (
<option key={g} value={String(g)}>{nameOf(lines[h])}</option>
))}
</Select>
)}
</Field>
</div>
<div className={styles.add}>
<Field label="List URL">
{(id) => (
<Input
id={id}
mono
className={styles.bad}
placeholder="https://…/hosts"
value={field}
disabled={off || loading}
aria-invalid={invalid != null || undefined}
aria-describedby={invalid != null ? invalidId : undefined}
onChange={(e) => {
setField(e.target.value)
setInvalid(null)
}}
/>
)}
</Field>
<PermissionButton variant="primary" permission={modifyNeed} disabled={loading} onClick={() => add('block')}>
Add block list
</PermissionButton>
<PermissionButton permission={modifyNeed} disabled={loading} onClick={() => add('allow')}>
Add allow list
</PermissionButton>
{/* The URL's own sentences stay under it, in the URL's column. */}
{invalid != null && (
<p id={invalidId} className={styles.invalid}>
{invalid}
</p>
)}
<p className={styles.help}>
Use <code>file://</code> for a list stored on this server. Hosts, plain domain, wildcard or
Adblock Plus files.
</p>
{/* Its own line under the URL, as wide as the URL, as upstream draws it
(`index.html:2156-2157`, `width: 100%`): the catalogue's names run to
86 characters and a trigger beside the buttons cut them to one letter. */}
<div className={styles.quick}>
<Field label="Quick Add">
{(id) => (
<Select
id={id}
disabled={off || loading}
value=""
searchable
searchLabel="Search lists"
noMatchText="No lists match"
onChange={(ev) => {
const chosen = ev.target.value
if (chosen === '') return
const entry = chosen === 'none'
? 'none'
: chosen.startsWith(MORE)
? more[Number(chosen.slice(MORE.length))]
: catalog.find((q) => q.name === chosen)
if (entry != null) setLines((l) => applyQuickTo(l, entry, target))
}}
>
<option value="" />
<option value="none">None</option>
{catalog.map((q) => (
<option key={q.name} value={q.name} data-search={hostsOf(q)}>{q.name}</option>
))}
{more.length > 0 && (
<optgroup label="More lists">
{more.map((q, i) => (
<option key={`${MORE}${i}`} value={`${MORE}${i}`} data-search={hostsOf(q)}>
{q.name}
</option>
))}
</optgroup>
)}
</Select>
)}
</Field>
</div>
</div>
</Body>
</Panel>
<Panel className={shared.flush}>
<div ref={table}>
{readFailed ? (
<Body><Failure>Could not read the block list settings.</Failure></Body>
) : saved == null ? (
<Body><Loading /></Body>
) : (
<Table
header={<><th>Enabled</th><th>List</th><th className={styles.kindCell}>Type</th><th /></>}
isEmpty={lines.length === 0 && edit?.group !== 'new'}
emptyText="No lists"
columns={4}
className={shared.inPanel}
>
{rows}
</Table>
)}
<div className={styles.foot}>
<PermissionButton size="sm" permission={modifyNeed} disabled={loading || edit?.group === 'new'}
onClick={() => setEditing({ group: 'new', value: '', problem: null })}>
Add group
</PermissionButton>
<span>
{plural(listsCount, 'list', 'lists')} · {disabledCount} disabled · {plural(groupCount, 'group', 'groups')}
</span>
<span className={styles.spacer} />
<RouteLink to={{ section: 'settings', sub: 'Blocking' }}>More blocking settings in Settings › Blocking</RouteLink>
</div>
</div>
</Panel>
{dirty && (
<div className={styles.bar}>
<span>Unsaved changes to the block list URLs: {changes}</span>
<span className={styles.spacer} />
<Button disabled={busy} onClick={() => saved && setLines(saved)}>Discard</Button>
<PermissionButton variant="primary" disabled={busy} permission={modifyNeed} onClick={() => void save()}>
Save
</PermissionButton>
</div>
)}
</div>
<Confirm
open={askUpdate}
onClose={() => setAskUpdate(false)}
title="Update Block Lists"
label="Update"
variant="primary"
text="Are you sure to force download and update the block lists?"
busy={busy}
onConfirm={() => void updateNow()}
/>
</>
)
}
+84
View File
@@ -0,0 +1,84 @@
/*
What the three Blocking tabs share. Every value is a token of theme/tokens.css.
*/
.stack { display: flex; flex-direction: column; gap: var(--gap-block); }
.row2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-9); }
.row21 { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--s-9); }
.kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--s-9); }
/* Lists: the third card carries a date and a button, so its column never goes below
what they need on one line; with room to spare the three are equal. */
.kpis3 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(max-content, 1fr); gap: var(--s-9); }
/*
A panel whose content sits in its middle. A grid row stretches its panels to the
tallest one, and a headerless panel puts its air on top (ui/Panel `.noHeader`): the
content stayed high with a band under it. Here the body's own padding is the only
air, the same on every side, and the free height splits above and below. Written
under `.stack` for the reason `.flush` is.
*/
.stack .centred { display: flex; flex-direction: column; justify-content: center; padding-top: 0; }
/*
A figure, the same on Overview and Lists, in the Dashboard tile's order: the number,
its sub-line, its label. A figure without a sub-line keeps an empty one, a full line
high, so every row of figures has its numbers, sub-lines and labels on the same three
lines. Beside the text, on the right, whatever goes with the figure: its trend, or
the button that acts on it.
*/
.kpi { display: flex; align-items: center; justify-content: space-between; gap: var(--s-9); }
.kpiText { display: flex; flex-direction: column; gap: var(--s-1); flex: none; min-width: 0; }
.kpiValue { font-family: var(--font-mono); font-size: var(--t-7); font-weight: 600; line-height: 1.1; }
.kpiSub { font-family: var(--font-mono); font-size: var(--t-2); color: var(--faint); }
/* Two figures in one card (`Your Rules`): the column becomes a grid of two, each number
over its word, and the two rows hand their cells to it. The label spans both. */
.kpiPaired { display: grid; grid-template-columns: repeat(2, max-content); column-gap: var(--s-9); }
.kpiRow { display: contents; }
.kpiPaired > .kpiLabel { grid-column: 1 / -1; }
.kpiLabel { font-size: var(--t-1); font-weight: var(--text-weight); letter-spacing: var(--text-tracking); text-transform: uppercase; color: var(--mute); }
/*
A table inside a panel: the panel is already its frame. ui/Table draws a border,
corners and a shadow because elsewhere the table IS the panel; inside one they drew a
second frame, with rounded corners showing under the panel's header. Written under
`.stack` to outweigh ui/Table's `.wrap`, which weighs the same as one class.
*/
.stack .inPanel { border: 0; border-radius: 0; box-shadow: none; }
/* A panel without a title whose first child brings its own air (the filter bar, a
table): the padding ui/Panel adds for a headerless body would be a band above it. */
.stack .flush { padding-top: 0; }
/* `--bp-index`: a custom property cannot go inside a media query, so its value is written. */
/* `--bp-rail`. Four figures in a row left each card too narrow for its trend beside
the text; two by two they keep it. Lists keeps its two counts side by side and gives
the date and its button the whole width under them. */
@media (max-width: 1180px) {
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kpis3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kpis3 > :last-child { grid-column: 1 / -1; }
}
@media (max-width: 960px) {
.row2, .row21 { grid-template-columns: minmax(0, 1fr); }
}
/* A menu entry without its permission (`LockedItem`): the padlock beside the verb. */
.lockedItem { display: inline-flex; align-items: center; gap: var(--s-3); }
/* The add bar: one field, Block first because Enter blocks. */
.add { display: flex; align-items: flex-end; gap: var(--s-5); }
.add > :first-child { flex: 1; min-width: 0; }
/* `--bp-stack`. The figure steps down to `--t-6`, as the Dashboard's tiles do. */
@media (max-width: 560px) {
.add { flex-wrap: wrap; }
.add > :first-child { flex-basis: 100%; }
.kpiValue { font-size: var(--t-6); }
}
/* The check's sentence under the domain field when it cannot answer, with the field's
border in `--dan` (the design rule: the error goes next to its field, once). The
same pair as Lists' URL field. */
.bad[aria-invalid],
.bad[aria-invalid]:focus { border-color: var(--dan); }
.invalid { color: var(--dan); font-size: var(--t-2); margin-top: var(--s-4); }
+49
View File
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen } from '@testing-library/react'
import { Blocking } from './Blocking'
vi.mock('./Overview', () => ({ Overview: () => <p>overview</p> }))
vi.mock('./Rules', () => ({ Rules: () => <p>rules</p> }))
vi.mock('./BlockLists', () => ({
BlockLists: ({ tabs, onDirty }: { tabs?: React.ReactNode; onDirty?: (dirty: boolean) => void }) => (
<>
{tabs}
<button type="button" onClick={() => onDirty?.(true)}>edit</button>
<button type="button" onClick={() => onDirty?.(false)}>discard</button>
</>
),
}))
afterEach(() => window.history.replaceState(null, '', '/'))
describe('Blocking', () => {
it('draws the tab it is given, and Overview for anything else', () => {
const { rerender } = render(<Blocking token="T" sub="Rules" onSubChange={() => {}} permissions={undefined} />)
expect(screen.getByText('rules')).toBeInTheDocument()
rerender(<Blocking token="T" sub="zzz" onSubChange={() => {}} permissions={undefined} />)
expect(screen.getByText('overview')).toBeInTheDocument()
})
it('leaving Rules drops ?rule= from the address bar', () => {
window.history.replaceState(null, '', '/blocking/lists/?rule=blocked')
render(<Blocking token="T" sub="Lists" onSubChange={() => {}} permissions={undefined} />)
expect(window.location.search).toBe('')
})
it('leaving the section drops ?rule= too, and Rules keeps it while it is open', () => {
window.history.replaceState(null, '', '/blocking/rules/?rule=blocked')
const { unmount } = render(<Blocking token="T" sub="Rules" onSubChange={() => {}} permissions={undefined} />)
expect(window.location.search).toBe('?rule=blocked')
unmount()
expect(window.location.pathname + window.location.search).toBe('/blocking/rules/')
})
it('marks the Lists tab while Lists has unsaved changes, in its accessible name', () => {
render(<Blocking token="T" sub="Lists" onSubChange={() => {}} permissions={undefined} />)
expect(screen.getByRole('link', { name: 'Lists' })).toBeInTheDocument()
act(() => screen.getByRole('button', { name: 'edit' }).click())
expect(screen.getByRole('link', { name: 'Lists, unsaved changes' })).toHaveAttribute('aria-current', 'page')
act(() => screen.getByRole('button', { name: 'discard' }).click())
expect(screen.getByRole('link', { name: 'Lists' })).toBeInTheDocument()
})
})
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
import { SubTabs } from '../../ui/SubTabs'
import { ruleSearch } from './rules-model'
import { Overview } from './Overview'
import { Rules } from './Rules'
import { BlockLists } from './BlockLists'
import type { Permissions } from './permissions'
/*
The Blocking section, OURS: it replaces Allowed and Blocked with three tabs,
Overview, Rules and Lists. It is the second deliberate exception to
"design only, zero functionality", inside the four limits written in CONVENTIONS.md.
The section and tab names (`Blocking`, `Overview`, `Rules`, `Lists`) and the tab
list's label `Blocking sections` are OURS, and so is the Lists tab's mark while
Lists has unsaved changes, read as `unsaved changes` (2026-10-15). The mark lives
as long as the tab's edits do: leaving the tab leaves them, and the mark goes too.
*/
export const BLOCKING_TABS = ['Overview', 'Rules', 'Lists'] as const
type Tab = (typeof BLOCKING_TABS)[number]
/** Takes `?rule=` out of the bar, replacing the entry: it is not a navigation. */
function dropRule(): void {
const search = ruleSearch(window.location.search, 'all')
if (search !== window.location.search) {
window.history.replaceState(null, '', window.location.pathname + search)
}
}
export function Blocking({
token,
sub,
onSubChange,
permissions,
nodes = [],
clusterInitialised = false,
serverDomain,
}: {
token: string | null
sub: string | null
onSubChange: (t: string) => void
permissions: Permissions
nodes?: { name: string; type: string }[]
clusterInitialised?: boolean
serverDomain?: string
}) {
const active: Tab = (BLOCKING_TABS as readonly string[]).includes(sub ?? '') ? (sub as Tab) : 'Overview'
/* `?rule=` belongs to Rules. `writeRoute` keeps the search, so without this it
would follow the user into Overview and Lists. */
useEffect(() => {
if (active !== 'Rules') dropRule()
}, [active])
/*
And out of the section. Leaving Blocking from the sidebar, the Shell writes the
new route with whatever search the bar holds: `/zones/?rule=blocked`, and back
to Rules with the filter still on. This cleanup runs first: React runs the
cleanups of an unmounted tree before the effects of the tree that stays, and
the Shell's `writeRoute` is one of those.
Unmount only, not on every tab change: keyed on the tab it would also run when
the back button returns to `/blocking/rules/?rule=…`, and strip the filter Rules
is showing. StrictMode's rehearsal unmount does drop it, and Rules writes it
back from what it read on its first render (its own effect, which runs again on
the second mount).
*/
useEffect(() => dropRule, [])
const [listsDirty, setListsDirty] = useState(false)
const tabs = (
<SubTabs
label="Blocking sections"
section="blocking"
tabs={BLOCKING_TABS}
active={active}
onChoose={onSubChange}
marks={listsDirty ? { Lists: 'unsaved changes' } : undefined}
/>
)
if (active === 'Rules') {
return <Rules tabs={tabs} token={token} permissions={permissions} nodes={nodes} clusterInitialised={clusterInitialised} />
}
if (active === 'Lists') {
return (
<BlockLists
tabs={tabs}
token={token}
permissions={permissions}
clusterInitialised={clusterInitialised}
onDirty={setListsDirty}
/>
)
}
return (
<Overview
tabs={tabs}
token={token}
permissions={permissions}
nodes={nodes}
clusterInitialised={clusterInitialised}
serverDomain={serverDomain}
/>
)
}
@@ -0,0 +1,39 @@
/*
The verdict of "Check a domain", under the field it answers. Every value is a token of
theme/tokens.css. The mark is the status block's (Overview.module.css `.mark`), so the
two blocks that share the row speak the same way; its tone is the Rules column's: the
block colour for blocked, the text green for allowed, neutral for the rest.
*/
.verdict {
display: flex;
align-items: flex-start;
gap: var(--s-8);
margin-top: var(--s-9);
padding-top: var(--s-9);
border-top: 1px solid var(--line2);
}
.mark { composes: mark from './Overview.module.css'; }
.blocked { background: var(--pan2); border: 1px solid var(--line); color: var(--ch-block); }
.allowed { background: var(--ok-bg); border: 1px solid var(--ok-bd); color: var(--ok); }
.neutral { background: var(--pan2); border: 1px solid var(--line); color: var(--mute); }
.body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--s-2); }
.title { font-size: var(--t-5); font-weight: 600; }
/* Names wrap at their dots (`ui/DomainName`) and URLs after their separators
(`ui/UrlText`); a piece that alone does not fit still breaks. */
.name { font-family: var(--font-mono); color: var(--mute); overflow-wrap: break-word; }
.lists { list-style: none; margin: var(--s-3) 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--s-5); }
.lists > li { display: flex; flex-direction: column; gap: var(--s-1); min-width: 0; }
.listName { font-weight: 600; }
.url { font-family: var(--font-mono); font-size: var(--t-2); color: var(--mute); overflow-wrap: break-word; }
.fact { margin-top: var(--s-3); overflow-wrap: break-word; }
.label { color: var(--mute); }
.mono { font-family: var(--font-mono); }
.why { margin-top: var(--s-3); color: var(--mute); }
.next { margin-top: var(--s-3); }
.unread { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s-2); margin-top: var(--s-3); color: var(--mute); font-size: var(--t-2); }
/* A way onward is the user's to take: amber, as the console's other in-text links. */
.next a,
.why a { color: var(--acc-ink); }
+163
View File
@@ -0,0 +1,163 @@
import type { ReactNode } from 'react'
import { Icon, type IconName } from '../../ui/Icon'
import { RouteLink } from '../../ui/RouteLink'
import type { QuickEntry } from '../../lib/quick-lists'
import { reportedListName, type Missing, type Verdict } from './check-domain'
import { requiresText } from './permissions'
import styles from './DomainCheck.module.css'
import { DomainName } from '../../ui/DomainName'
import { UrlText } from '../../ui/UrlText'
/*
The verdict of "Check a domain", drawn under the field it answers (`check-domain.ts`
says how each one is reached). Every sentence here is OURS:
- the titles `Blocked by a block list`, `Blocked by N block lists`, `Blocked by your
rule`, `Blocked`, `Allowed by your rule`, `Not blocked`, `Not blocked: blocking is
disabled`, `Not blocked: blocking is paused` and `Cannot tell`;
- `Listed as`, `Rule`, and the links `Open Lists` and `Open Rules`;
- the two "cannot tell" sentences, which quote the server's labels `Blocking Bypass
List` and `Allow TXT Blocking Report`, and their link `Settings › Blocking`;
- under a "Not blocked", what was not read: `Your allow rules were not checked.`,
`The blocking settings were not checked.`, `Could not read your allow rules.` and
`Could not read the blocking settings.`, with the padlock's `Requires X: Y`.
The next step is not here: it is the field's own `Block` and `Allow` right above,
which act on the same name, and the tab that holds the rule or the list.
*/
type Tone = 'blocked' | 'allowed' | 'neutral'
function look(v: Verdict): { title: string; icon: IconName; tone: Tone } {
switch (v.kind) {
case 'lists':
return {
title: v.lists.length === 1 ? 'Blocked by a block list' : `Blocked by ${v.lists.length} block lists`,
icon: 'blocked',
tone: 'blocked',
}
case 'rule':
return { title: 'Blocked by your rule', icon: 'blocked', tone: 'blocked' }
case 'other':
return { title: 'Blocked', icon: 'blocked', tone: 'blocked' }
case 'allowed':
return { title: 'Allowed by your rule', icon: 'allowed', tone: 'allowed' }
case 'off':
return {
title: v.paused ? 'Not blocked: blocking is paused' : 'Not blocked: blocking is disabled',
icon: 'power',
tone: 'neutral',
}
case 'bypassed':
case 'unreported':
return { title: 'Cannot tell', icon: 'warning', tone: 'neutral' }
case 'clear':
return { title: 'Not blocked', icon: 'check', tone: 'neutral' }
}
}
function Unread({ missing, what, failed }: { missing: Missing; what: string; failed: string }) {
if ('need' in missing) {
return (
<div className={styles.unread}>
<Icon name="lock" size={13} /> {what} {requiresText(missing.need)}
</div>
)
}
return (
<div className={styles.unread}>
<Icon name="warning" size={13} /> {failed}
</div>
)
}
function Fact({ label, children }: { label: string; children: ReactNode }) {
return (
<div className={styles.fact}>
<span className={styles.label}>{label}</span> <span className={styles.mono}>{children}</span>
</div>
)
}
const BLOCKING_SETTINGS = <RouteLink to={{ section: 'settings', sub: 'Blocking' }}>Settings › Blocking</RouteLink>
export function DomainCheck({ verdict, catalog }: { verdict: Verdict; catalog: readonly QuickEntry[] }) {
const { title, icon, tone } = look(verdict)
return (
<div className={styles.verdict} data-verdict={verdict.kind}>
<span className={`${styles.mark} ${styles[tone]}`} aria-hidden="true">
<Icon name={icon} size={16} />
</span>
<div className={styles.body}>
<div className={styles.title}>{title}</div>
<div className={styles.name}><DomainName name={verdict.name} /></div>
{verdict.kind === 'lists' && (
<>
<ul className={styles.lists}>
{verdict.lists.map((url) => {
const name = reportedListName(url, catalog)
return (
<li key={url}>
{name != null && <span className={styles.listName}>{name}</span>}
<span className={styles.url}><UrlText url={url} /></span>
</li>
)
})}
</ul>
{verdict.domain.toLowerCase() !== verdict.name.toLowerCase() && (
<Fact label="Listed as"><DomainName name={verdict.domain} /></Fact>
)}
<div className={styles.next}>
<RouteLink to={{ section: 'blocking', sub: 'Lists' }}>Open Lists</RouteLink>
</div>
</>
)}
{(verdict.kind === 'rule' || verdict.kind === 'allowed') && (
<>
<Fact label="Rule"><DomainName name={verdict.domain} /></Fact>
<div className={styles.next}>
<RouteLink to={{ section: 'blocking', sub: 'Rules' }}>Open Rules</RouteLink>
</div>
</>
)}
{verdict.kind === 'other' &&
verdict.texts.map((t, i) => (
<div key={i} className={styles.url}>
{t}
</div>
))}
{verdict.kind === 'bypassed' && (
<div className={styles.why}>
This server&apos;s own address, <span className={styles.mono}>{verdict.ip}</span>, is on the Blocking
Bypass List, so the check is never blocked. {BLOCKING_SETTINGS}
</div>
)}
{verdict.kind === 'unreported' && (
<div className={styles.why}>
Allow TXT Blocking Report is off, so this server does not say when it blocks. {BLOCKING_SETTINGS}
</div>
)}
{verdict.kind === 'clear' && verdict.unread.allowed != null && (
<Unread
missing={verdict.unread.allowed}
what="Your allow rules were not checked."
failed="Could not read your allow rules."
/>
)}
{verdict.kind === 'clear' && verdict.unread.settings != null && (
<Unread
missing={verdict.unread.settings}
what="The blocking settings were not checked."
failed="Could not read the blocking settings."
/>
)}
</div>
</div>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { Locked, LockedBody, LockedItem } from './Locked'
describe('Locked', () => {
it('keeps the panel title and names the missing permission', () => {
render(<Locked title="Top Blocked Domains" need="Dashboard.canView" />)
expect(screen.getByRole('heading', { name: 'Top Blocked Domains' })).toBeInTheDocument()
expect(screen.getByText('Requires Dashboard: View')).toBeInTheDocument()
})
it('its body alone names the missing permission, without a panel', () => {
render(<LockedBody need="Allowed.canView" />)
expect(screen.getByText('Requires Allowed: View')).toBeInTheDocument()
expect(screen.queryByRole('heading')).toBeNull()
})
it('a locked menu entry stays, disabled, and its tooltip names the permission', async () => {
render(<LockedItem need="Blocked.canDelete">Blocked zones</LockedItem>)
const item = screen.getByRole('button', { name: 'Blocked zones' })
expect(item).toBeDisabled()
await userEvent.hover(item.parentElement!)
expect(screen.getByRole('tooltip')).toHaveTextContent('Requires Blocked: Delete')
})
})
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from 'react'
import { Panel, Body } from '../../ui/Panel'
import { Empty } from '../../ui/Empty'
import { Icon } from '../../ui/Icon'
import { Tooltip } from '../../ui/Tooltip'
import type { Placement } from '../../ui/tooltip-place'
import { requiresText, type Need } from './permissions'
import shared from './Blocking.module.css'
/*
The padlock of the Blocking section, written once. The console's design rule: what
the session may not see or do keeps its place and says which permission is missing.
*/
/*
The body of a slot whose READ permission is missing: the padlock and the sentence.
The sentence goes as `children`, not as `title`: `Empty compact` draws its children
only and drops the title.
*/
export function LockedBody({ need }: { need: Need }) {
return (
<Empty compact>
<Icon name="lock" size={14} /> {requiresText(need)}
</Empty>
)
}
/*
A panel whose READ permission is missing. It keeps its place and its title, so the
screen does not change shape depending on who looks.
*/
export function Locked({ title, need }: { title: string; need: Need }) {
return (
<Panel title={title}>
<Body>
<LockedBody need={need} />
</Body>
</Panel>
)
}
/*
A menu entry whose permission is missing: it stays, disabled, and explains itself
the way `PermissionButton` does (padlock plus the `Tooltip` with `Requires X: Y`),
not with a native `title`: that is the one pattern the console has for "you
cannot", and a menu item is no exception. A verb that vanishes is a verb nobody
knows exists. Used by the top tables' row menus and by Rules' foot menus.
*/
export function LockedItem({
need,
placement,
children,
}: {
need: Need
placement?: Placement
children: ReactNode
}) {
return (
<Tooltip text={requiresText(need)} placement={placement}>
<button type="button" className={shared.lockedItem} disabled>
<Icon name="lock" size={13} />
{children}
</button>
</Tooltip>
)
}
+84
View File
@@ -0,0 +1,84 @@
/* The status block: a mark, two lines, the control. */
.status { display: flex; align-items: center; gap: var(--s-9); }
.mark { display: grid; place-items: center; width: var(--s-12); height: var(--s-12); border-radius: var(--r-3); flex: none; }
.on { background: var(--ok-bg); border: 1px solid var(--ok-bd); color: var(--ok); }
.paused { background: var(--acc-bg); border: 1px solid var(--acc-bd); color: var(--acc-ink); }
.off { background: var(--pan2); border: 1px solid var(--line); color: var(--mute); }
.statusText { flex: 1; min-width: 0; }
.statusTitle { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-5); font-size: var(--t-5); font-weight: 600; }
.statusSub { color: var(--mute); margin-top: var(--s-1); }
/* Top tables: name, a bar for the share, the hits, the row menu. */
/* The name wraps at its dots only (`ui/DomainName`), never inside a label. */
.name { font-family: var(--font-mono); }
.hits { font-family: var(--font-mono); text-align: right; white-space: nowrap; }
/* The header over the hits sits where the figures end, not where they would begin.
Written to outweigh `.table thead th` of ui/Table, which sets every header left. */
thead > tr > th.hitsHead { text-align: right; }
/* A block, not the span's inline box: an inline element ignores `height`. */
.share { display: block; position: relative; height: var(--s-3); min-width: var(--s-12); border-radius: var(--r-1); background: var(--line2); }
.share > i { position: absolute; inset: 0 auto 0 0; border-radius: var(--r-1); }
.shareBlocked > i { background: var(--ch-block); }
.shareTotal > i { background: var(--ch-total); }
.time { font-family: var(--font-mono); color: var(--faint); white-space: nowrap; }
/* Where Recently Blocked's rows stack (`stack` in ui/Table: always below 560), the time
no longer has to share a line with the domain and keeps its one line. It used to
wrap there, `2026-10-` / `16` / `09:42:52`, beside a domain a few characters wide. */
.mono { font-family: var(--font-mono); }
/* The node after Recently Blocked's title: part of the title, quieter than it. */
.where { color: var(--mute); font-weight: 400; }
/*
Muted, not `--warn`: on this screen the paused mark is `--acc`, and tokens.css
measures the two ambers as indistinguishable. The sentence and its icon say it.
*/
.partial { display: flex; align-items: center; gap: var(--s-3); padding: var(--s-5) var(--s-9); color: var(--mute); }
/*
A figure's trend, beside its text and as tall as it (`.kpi` in Blocking.module.css):
the text keeps its width, so its three lines stay level with the other cards', and the
trend takes what is left, up to half the card. Size containment drops the drawing's own
100:32 shape, so a wide screen cannot make the trend, and with it the card, taller.
*/
.spark {
display: block;
flex: 1 1 0;
align-self: stretch;
min-width: var(--s-12);
max-width: 50%;
height: auto;
contain: size;
}
/*
`--bp-stack`. Two cards a row leave no room beside the text, so the trend drops to the
card's foot and runs to its edges, as the drawing has it: the body's padding
(`--s-9`, ui/Panel) is given back on three sides and the panel's rounded corners clip
it. The two cards with a trend share the first row, so no card in a row has a band
the other lacks.
*/
@media (max-width: 560px) {
.trended { flex-wrap: wrap; }
.spark {
flex-basis: calc(100% + 2 * var(--s-9));
max-width: none;
height: var(--s-12);
margin: 0 calc(-1 * var(--s-9)) calc(-1 * var(--s-9));
}
}
.sparkTotal { color: var(--ch-total); }
.sparkBlocked { color: var(--ch-block); }
/* The ring, with the share written in its hole. */
.ring { position: relative; }
.ringValue { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; font-family: var(--font-mono); font-size: var(--t-6); font-weight: 600; pointer-events: none; }
.ringLabel { display: block; font-family: var(--font-ui); font-size: var(--t-1); font-weight: var(--text-weight); letter-spacing: var(--text-tracking); text-transform: uppercase; color: var(--mute); }
/*
The status block and "Check, block or allow a domain" share a row, and the grid
stretches both to the taller one. Before any check that is what balances the row:
the status block is the shorter, and its content sits in its middle (`.centred`).
Once the check panel holds something under its field (the wait, the verdict, or why
there is none), stretching would leave the status block a tall box with one line
floating in it, so it keeps its own height instead, top-aligned. The check panel's
answer region is empty (`:empty`) until then.
*/
.checkRow:has([aria-live]:not(:empty)) > :first-child { align-self: start; }
+133
View File
@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { percentage } from '../dashboard/Dashboard'
import { REAL, realServer } from '../../test/real-blocking'
import { Overview } from './Overview'
import { wholeText } from '../../test/whole-text'
/*
The Overview fed with what the harness really answered (`test/real-blocking.ts`):
the shapes of `dashboard/stats/get`, `settings/get`, `apps/list` and `logs/query` go
through the real client and parsers, and every figure on screen is checked against
the captured answer, not against a number typed here.
*/
const drawn = vi.hoisted(() => new Map<string, { labels: string[]; datasets: { label: string; data: number[] }[] }>())
vi.mock('../dashboard/Chart', () => ({
Chart: ({ aria, data }: { aria: string; data: never }) => {
drawn.set(aria.startsWith('Blocked share') ? 'ring' : 'bars', data)
return <div role="img" aria-label={aria} />
},
}))
beforeEach(() => {
vi.spyOn(globalThis, 'fetch').mockImplementation(realServer())
})
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
drawn.clear()
})
/** The value of a figure, found by its label: a bare number can also be a hit count,
and `Blocked` is also the ring's word. A figure is value, sub-line, label. */
function figure(label: string) {
const own = screen
.getAllByText(label, { selector: 'span' })
.filter((el) => el.parentElement!.children[2] === el)
expect(own).toHaveLength(1)
return own[0].parentElement!.firstElementChild!.textContent
}
async function shows(label: string, value: string) {
await vi.waitFor(() => expect(figure(label)).toBe(value))
}
const series = (d: { datasets: { label: string; data: (number | string)[] }[] }, label: string) =>
d.datasets.find((x) => x.label === label)!.data.map(Number)
describe('Overview against the harness', () => {
it('draws the four figures of the period as the server counted them', async () => {
const s = REAL.statsLastHour.response.stats
render(<Overview token="T" permissions={undefined} />)
await shows('Total Queries', s.totalQueries.toLocaleString())
expect(figure('Blocked')).toBe(s.totalBlocked.toLocaleString())
expect(screen.getByText(`${percentage(s.totalBlocked, s.totalQueries)} of total`)).toBeInTheDocument()
expect(figure('Block List Domains')).toBe(s.blockListZones.toLocaleString())
const rules = screen.getByText('Your Rules', { selector: 'span' }).parentElement!
expect([...rules.children[0].children].map((c) => c.textContent)).toEqual([
s.blockedZones.toLocaleString(),
s.allowedZones.toLocaleString(),
])
})
it('a day of traffic: the bars split Total into Allowed and Blocked, minute by minute', async () => {
const day = REAL.statsLastDay.response
render(<Overview token="T" permissions={undefined} />)
await shows('Total Queries', REAL.statsLastHour.response.stats.totalQueries.toLocaleString())
await userEvent.click(screen.getByRole('button', { name: 'Last Day' }))
await shows('Total Queries', day.stats.totalQueries.toLocaleString())
expect(figure('Blocked')).toBe(day.stats.totalBlocked.toLocaleString())
const bars = drawn.get('bars')!
const total = series(day.mainChartData, 'Total')
const blocked = series(day.mainChartData, 'Blocked')
expect(bars.labels).toHaveLength(day.mainChartData.labels.length)
expect(series(bars, 'Blocked')).toEqual(blocked)
expect(series(bars, 'Allowed')).toEqual(total.map((t, i) => t - blocked[i]))
// The server's own totals: the series add up to the figures above them.
expect(blocked.reduce((a, b) => a + b, 0)).toBe(day.stats.totalBlocked)
expect(total.reduce((a, b) => a + b, 0)).toBe(day.stats.totalQueries)
expect(screen.getByRole('img', { name: `Blocked share: ${percentage(day.stats.totalBlocked, day.stats.totalQueries)}` }))
.toBeInTheDocument()
})
it('the top tables list the domains the server ranked, with their hits', async () => {
const day = REAL.statsLastDay.response
render(<Overview token="T" permissions={undefined} />)
await shows('Total Queries', REAL.statsLastHour.response.stats.totalQueries.toLocaleString())
await userEvent.click(screen.getByRole('button', { name: 'Last Day' }))
await shows('Total Queries', day.stats.totalQueries.toLocaleString())
for (const [title, rows] of [
['Top Blocked Domains', day.topBlockedDomains],
['Top Domains', day.topDomains],
] as const) {
const panel = screen.getByRole('heading', { name: title }).parentElement!.parentElement!
const table = within(panel).getByRole('table')
expect(within(table).getAllByRole('row')).toHaveLength(rows.length + 1)
for (const r of rows) {
const row = within(table).getByText(wholeText(r.name)).closest('tr')!
expect(row).toHaveTextContent(r.hits.toLocaleString())
}
}
})
it('reads the blocking state from the real settings', async () => {
render(<Overview token="T" permissions={undefined} />)
expect(await screen.findByText(REAL.settings.response.enableBlocking ? 'Blocking is enabled' : 'Blocking is disabled'))
.toBeInTheDocument()
})
it('Recently Blocked finds the logging app and draws the newest blocked queries', async () => {
const entries = [
...REAL.logsBlocked.response.entries,
...REAL.logsUpstreamBlocked.response.entries,
...REAL.logsUpstreamBlockedCached.response.entries,
]
.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp))
.slice(0, 10)
expect(entries.length).toBeGreaterThan(0)
render(<Overview token="T" permissions={undefined} />)
const panel = (await screen.findByRole('heading', { name: /Recently Blocked/ })).parentElement!.parentElement!
const table = await within(panel).findByRole('table')
const rows = within(table).getAllByRole('row').slice(1)
expect(rows).toHaveLength(entries.length)
rows.forEach((row, i) => {
expect(row).toHaveTextContent(entries[i].qname!)
expect(row).toHaveTextContent(entries[i].clientIpAddress)
})
})
})
+378
View File
@@ -0,0 +1,378 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as dashboard from '../../api/dashboard'
import * as settings from '../../api/settings'
import * as apps from '../../api/apps'
import * as zonelists from '../../api/zonelists'
import { SlotProvider } from '../../app/ChromeSlot'
import { Overview, blockingChart } from './Overview'
/** Your Rules: each number with the word under it, in the card's two columns. */
function rulePairs() {
const text = screen.getByText('Your Rules', { selector: 'span' }).parentElement!
const [numbers, words] = [...text.children].slice(0, 2).map((row) => [...row.children].map((c) => c.textContent))
return numbers.map((n, i) => [n, words[i]])
}
/* Every `data` the charts were handed, by chart: Chart.tsx rebuilds its canvas on a
new reference, so identity across renders is what the tests look at. */
const drawn = vi.hoisted(() => new Map<string, unknown[]>())
const legends = vi.hoisted(() => new Map<string, unknown>())
/* What each chart was told to hide, and whether it was told not to draw its own legend. */
const hiddenOf = vi.hoisted(() => new Map<string, { hidden?: ReadonlySet<string>; separate?: boolean }>())
vi.mock('../dashboard/Chart', () => ({
Chart: ({ aria, data, legendOrder, hidden, separateLegend }: {
aria: string; data: unknown; legendOrder?: readonly string[]; hidden?: ReadonlySet<string>; separateLegend?: boolean
}) => {
const key = aria.startsWith('Blocked share') ? 'ring' : 'bars'
drawn.set(key, [...(drawn.get(key) ?? []), data])
legends.set(key, legendOrder)
hiddenOf.set(key, { hidden, separate: separateLegend })
return <div role="img" aria-label={aria} />
},
}))
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
drawn.clear()
})
const STATS = {
totalQueries: 20354, totalNoError: 0, totalServerFailure: 0, totalNxDomain: 0, totalRefused: 0,
totalAuthoritative: 0, totalRecursive: 0, totalCached: 0, totalBlocked: 5310, totalDropped: 0,
totalClients: 11, zones: 5, cachedEntries: 0, allowedZones: 1, blockedZones: 1, allowListZones: 0, blockListZones: 74779,
}
const MAIN = { labels: ['a', 'b'], datasets: [
{ label: 'Total', data: [10, 30] }, { label: 'No Error', data: [5, 5] }, { label: 'Blocked', data: [4, 6] },
] }
const OK = {
kind: 'ok' as const,
data: {
stats: STATS, mainChartData: MAIN, queryResponseChartData: MAIN, queryTypeChartData: MAIN,
protocolTypeChartData: MAIN, topClients: [], topDomains: [], topBlockedDomains: [],
},
}
function serve() {
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: true } as never)
vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [] } } } as never)
return vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue(OK)
}
describe('blockingChart', () => {
/* Chart.js stacks in dataset order from the axis up: Blocked first puts it on the
baseline, as drawn, where its own series reads off a common zero. */
it('keeps only what got through and what was blocked, Blocked at the bottom of the stack', () => {
expect(blockingChart(MAIN)).toEqual({
labels: ['a', 'b'],
datasets: [{ label: 'Blocked', data: [4, 6] }, { label: 'Allowed', data: [6, 24] }],
})
})
})
describe('Overview', () => {
/* As drawn, and as the bars stack: the ring starts at the top with Blocked. */
it('the ring draws Blocked first, then what got through', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByRole('img', { name: 'Blocked share: 26.09%' })
expect(drawn.get('ring')?.at(-1)).toEqual({
labels: ['Blocked', 'Allowed'],
datasets: [{ label: 'Share', data: [5310, 20354 - 5310] }],
})
})
it('the bars read Allowed, then Blocked, in their legend', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByRole('img', { name: 'Allowed and blocked queries over time' })
expect(legends.get('bars')).toEqual(['Allowed', 'Blocked'])
})
it('the bars carry the Dashboard legend: each series with its total, and a click switches it off', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByRole('img', { name: 'Allowed and blocked queries over time' })
expect(hiddenOf.get('bars')?.separate).toBe(true)
/* MAIN: Total 10 + 30, Blocked 4 + 6, so 30 got through. In BAR_LEGEND's order. */
const allowed = screen.getByRole('button', { name: 'Allowed 30' })
const blocked = screen.getByRole('button', { name: 'Blocked 10' })
expect(allowed.compareDocumentPosition(blocked) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(allowed).toHaveAttribute('aria-pressed', 'true')
await userEvent.click(blocked)
expect(blocked).toHaveAttribute('aria-pressed', 'false')
expect([...(hiddenOf.get('bars')?.hidden ?? [])]).toEqual(['Blocked'])
})
it('draws the four figures, Blocked with its share', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
expect(await screen.findByText('20,354')).toBeInTheDocument()
expect(screen.getByText('5,310')).toBeInTheDocument()
/* The ring writes the same share in its hole: the figure is looked for in its card. */
expect(within(screen.getByText('5,310').parentElement!).getByText('26.09% of total')).toBeInTheDocument()
expect(screen.getByText('74,779')).toBeInTheDocument()
expect(rulePairs()).toEqual([['1', 'blocked'], ['1', 'allowed']])
})
it('draws the stacked bars and the blocked share', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
expect(await screen.findByRole('img', { name: 'Allowed and blocked queries over time' })).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Blocked share: 26.09%' })).toBeInTheDocument()
})
it('the period asks the server again', async () => {
const spy = serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByText('20,354')
await userEvent.click(screen.getByRole('button', { name: 'Last Day' }))
expect(spy).toHaveBeenLastCalledWith('T', 'LastDay', undefined, 'cluster')
})
it('offers the five fixed periods and not Custom', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByText('20,354')
const group = screen.getByRole('group', { name: 'Period' })
expect([...group.querySelectorAll('button')].map((b) => b.textContent)).toEqual([
'Last Hour', 'Last Day', 'Last Week', 'Last Month', 'Last Year',
])
})
it('reads the node the Dashboard remembers', async () => {
localStorage.setItem('dashboardClusterNode', 'dns2.example')
const spy = serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByText('20,354')
expect(spy).toHaveBeenCalledWith('T', 'LastHour', undefined, 'dns2.example')
})
it('with the cluster aggregate, Recently Blocked names the node it reads', async () => {
serve()
render(
<Overview
token="T"
permissions={undefined}
nodes={[{ name: 'dns1.example', type: 'Primary' }]}
clusterInitialised
serverDomain="dns1.example"
/>,
)
await screen.findByText('20,354')
expect(screen.getByRole('heading', { name: 'Recently Blocked on dns1.example' })).toBeInTheDocument()
})
it('without a cluster, Recently Blocked names no node', async () => {
serve()
render(<Overview token="T" permissions={undefined} serverDomain="dns1.example" />)
await screen.findByText('20,354')
expect(screen.getByRole('heading', { name: 'Recently Blocked' })).toBeInTheDocument()
})
it('without Dashboard.canView the figures are locked and nothing is asked', () => {
const spy = serve()
render(<Overview token="T" permissions={{ Dashboard: { canView: false, canModify: false, canDelete: false } }} />)
expect(screen.getAllByText('Requires Dashboard: View').length).toBeGreaterThan(0)
expect(spy).not.toHaveBeenCalled()
})
/* A panel without its view permission keeps its place and its title, with the padlock,
so the screen does not change shape with who looks. */
it('without Dashboard.canView the charts and both tops keep their place, locked', () => {
serve()
render(<Overview token="T" permissions={{ Dashboard: { canView: false, canModify: false, canDelete: false } }} />)
for (const title of ['Statistics', 'Queries over time', 'Blocked share', 'Top Blocked Domains', 'Top Domains']) {
/* The heading sits in the panel's header; the panel is the header's parent. */
const panel = screen.getByRole('heading', { name: title }).parentElement!.parentElement!
expect(within(panel).getByText('Requires Dashboard: View')).toBeInTheDocument()
}
expect(screen.queryByRole('table')).toBeNull()
})
/* Every figure is localised; 10,000 rules are not drawn `10000`. */
it('Your Rules is localised like the other figures', async () => {
serve().mockResolvedValue({ ...OK, data: { ...OK.data, stats: { ...STATS, blockedZones: 10000, allowedZones: 1234 } } })
render(<Overview token="T" permissions={undefined} />)
await screen.findByText((10000).toLocaleString())
expect(rulePairs()).toEqual([[(10000).toLocaleString(), 'blocked'], [(1234).toLocaleString(), 'allowed']])
})
it('while the figures travel the top tables do not claim there are no domains', () => {
serve().mockReturnValue(new Promise(() => {}))
render(<Overview token="T" permissions={undefined} />)
expect(screen.queryByText('No domains for this period.')).not.toBeInTheDocument()
expect(screen.getAllByText('—').length).toBe(4)
})
it('a first read that fails says so with the message from the server', async () => {
serve().mockResolvedValue({ kind: 'error', message: 'The server is busy.' })
render(<Overview token="T" permissions={undefined} />)
expect(await screen.findByText('The server is busy.')).toBeInTheDocument()
expect(screen.getAllByText('Could not load this data.').length).toBeGreaterThan(0)
expect(screen.queryByText('No domains for this period.')).not.toBeInTheDocument()
expect(screen.queryByText('Could not refresh.')).not.toBeInTheDocument()
})
it('a refresh that fails keeps the figures and says they are stale', async () => {
const spy = serve()
vi.spyOn(zonelists, 'addDomain').mockResolvedValue({ kind: 'ok', data: {} })
render(<Overview token="T" permissions={undefined} />)
await screen.findByText('20,354')
spy.mockResolvedValue({ kind: 'error', message: 'The server is busy.' })
await userEvent.type(screen.getByLabelText('Domain'), 'ads.example.com{Enter}')
expect(await screen.findByText('Could not refresh.')).toBeInTheDocument()
expect(screen.getByText('20,354')).toBeInTheDocument()
spy.mockResolvedValue(OK)
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('20,354')).toBeInTheDocument()
expect(screen.queryByText('Could not refresh.')).not.toBeInTheDocument()
})
it('a new period that fails does not keep the figures of the previous one', async () => {
const spy = serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByText('20,354')
spy.mockResolvedValue({ kind: 'error', message: 'The server is busy.' })
await userEvent.click(screen.getByRole('button', { name: 'Last Day' }))
expect(await screen.findByText('The server is busy.')).toBeInTheDocument()
expect(screen.queryByText('20,354')).not.toBeInTheDocument()
expect(screen.queryByText('Could not refresh.')).not.toBeInTheDocument()
})
it('a render for another reason does not hand the charts new data', async () => {
serve()
render(<Overview token="T" permissions={undefined} />)
await screen.findByText('20,354')
const bars = drawn.get('bars')!.at(-1)
const ring = drawn.get('ring')!.at(-1)
const renders = drawn.get('bars')!.length
// An empty Block raises a notice: the screen renders again, the stats do not change.
await userEvent.click(screen.getByRole('button', { name: 'Block' }))
expect(await screen.findByText('Please enter a domain name to block.')).toBeInTheDocument()
expect(drawn.get('bars')!.length).toBeGreaterThan(renders)
expect(drawn.get('bars')!.at(-1)).toBe(bars)
expect(drawn.get('ring')!.at(-1)).toBe(ring)
})
it('choosing a node asks that node and remembers it for the Dashboard', async () => {
const spy = serve()
render(
<SlotProvider>
{(slot) => (
<>
<div ref={slot} />
<Overview
token="T"
permissions={undefined}
nodes={[{ name: 'dns1.example', type: 'Primary' }, { name: 'dns2.example', type: 'Secondary' }]}
clusterInitialised
/>
</>
)}
</SlotProvider>,
)
await screen.findByText('20,354')
await userEvent.click(screen.getByRole('combobox', { name: 'Node' }))
await userEvent.click(screen.getByRole('option', { name: 'dns2.example (secondary)' }))
expect(localStorage.getItem('dashboardClusterNode')).toBe('dns2.example')
expect(spy).toHaveBeenLastCalledWith('T', 'LastHour', undefined, 'dns2.example')
})
it('a slow answer to an earlier period does not overwrite a later one', async () => {
const spy = serve()
let answerFirst: (v: typeof OK) => void = () => {}
spy.mockImplementationOnce(() => new Promise((resolve) => { answerFirst = resolve }))
spy.mockResolvedValueOnce({ kind: 'ok', data: { ...OK.data, stats: { ...STATS, totalQueries: 99999 } } })
render(<Overview token="T" permissions={undefined} />)
await userEvent.click(screen.getByRole('button', { name: 'Last Day' }))
expect(await screen.findByText('99,999')).toBeInTheDocument()
await act(async () => answerFirst(OK))
expect(screen.getByText('99,999')).toBeInTheDocument()
expect(screen.queryByText('20,354')).not.toBeInTheDocument()
})
it('a period with no queries draws zero and no empty chart', async () => {
const zero = { ...STATS, totalQueries: 0, totalBlocked: 0 }
const flat = { labels: ['a'], datasets: [{ label: 'Total', data: [0] }, { label: 'Blocked', data: [0] }] }
serve().mockResolvedValue({ kind: 'ok', data: { ...OK.data, stats: zero, mainChartData: flat } })
render(<Overview token="T" permissions={undefined} />)
expect(await screen.findByText('0% of total')).toBeInTheDocument()
expect(screen.getAllByText('No queries for this period.').length).toBe(2)
expect(screen.queryByRole('img', { name: 'Allowed and blocked queries over time' })).not.toBeInTheDocument()
})
})
/* Last Hour in real time, as on the Dashboard: the same `stats/get`, the same live
figures over it (dashboard/useLive.ts), the same minute reload. */
describe('Overview: Last Hour in real time', () => {
const counters = (q: number, blocked: number) => ({
kind: 'ok' as const,
data: {
uptimestamp: 'u1',
lifetimeCounters: {
totalQueries: q, totalNoError: 0, totalServerFailure: 0, totalNxDomain: 0, totalRefused: 0,
totalAuthoritative: 0, totalRecursive: 0, totalCached: 0, totalBlocked: blocked, totalDropped: 0,
totalClients: 1,
},
},
})
afterEach(() => vi.useRealTimers())
it('Total Queries and Blocked move with the server between reloads', async () => {
vi.useFakeTimers()
serve()
vi.spyOn(dashboard, 'getMetrics').mockResolvedValueOnce(counters(1000, 50)).mockResolvedValue(counters(1004, 52))
render(<Overview token="T" permissions={undefined} />)
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
expect(screen.getByText((20354).toLocaleString())).toBeInTheDocument()
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
expect(screen.getByText((20358).toLocaleString())).toBeInTheDocument()
expect(screen.getByText((5312).toLocaleString())).toBeInTheDocument()
})
it('reloads a minute after each answer, waits while the tab is hidden, and reloads on return', async () => {
vi.useFakeTimers()
let hidden = false
Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden })
const spy = serve()
vi.spyOn(dashboard, 'getMetrics').mockResolvedValue({ kind: 'error', message: 'not here' })
render(<Overview token="T" permissions={undefined} />)
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
expect(spy).toHaveBeenCalledTimes(1)
await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })
expect(spy).toHaveBeenCalledTimes(2)
hidden = true
document.dispatchEvent(new Event('visibilitychange'))
await act(async () => { await vi.advanceTimersByTimeAsync(180_000) })
expect(spy).toHaveBeenCalledTimes(2)
hidden = false
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'))
await vi.advanceTimersByTimeAsync(0)
})
expect(spy).toHaveBeenCalledTimes(3)
})
it('other periods neither reload nor read the counters', async () => {
vi.useFakeTimers()
const spy = serve()
const live = vi.spyOn(dashboard, 'getMetrics').mockResolvedValue(counters(1, 0))
render(<Overview token="T" permissions={undefined} />)
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
await act(async () => { screen.getByRole('button', { name: 'Last Day' }).click() })
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
spy.mockClear()
live.mockClear()
await act(async () => { await vi.advanceTimersByTimeAsync(130_000) })
expect(spy).not.toHaveBeenCalled()
expect(live).not.toHaveBeenCalled()
})
})
+471
View File
@@ -0,0 +1,471 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
getDashboardStats, RANGES, RANGE_LABEL,
type ChartData, type DashboardStats, type Range,
} from '../../api/dashboard'
import { percentage, hasData, Legend, useHidden } from '../dashboard/Dashboard'
import { Chart } from '../dashboard/Chart'
import { InChrome } from '../../app/ChromeSlot'
import { ClusterNodeSelect, AGGREGATE, type ClusterNode } from '../../ui/ClusterNodeSelect'
import { SectionHeader } from '../../ui/SectionHeader'
import { Segmented } from '../../ui/Segmented'
import { Panel, Body } from '../../ui/Panel'
import { Empty, Loading, Failure } from '../../ui/Empty'
import { Notifier } from '../../ui/Notifier'
import { noticeFromFailure, type Notice } from '../../lib/notice'
import { StaleData } from '../StaleData'
import { AddDomainBar } from './AddDomainBar'
import { StatusPanel } from './StatusPanel'
import { TopTable } from './TopTable'
import { RecentBlocked } from './RecentBlocked'
import { LAST_HOUR_REFRESH_MS, useLiveView } from '../dashboard/useLive'
import { Locked } from './Locked'
import { missing, type Permissions } from './permissions'
import shared from './Blocking.module.css'
import styles from './Overview.module.css'
/*
The Overview tab: what blocking is doing at a glance, built from what Technitium
already sends. The figures, the bars and the ring come from `dashboard/stats/get`, the
Dashboard's own call, and they share its node memory (`dashboardClusterNode`): same
data, so choosing a node here and seeing another there would be a contradiction.
The five fixed periods and not Custom: a range of your own is the Dashboard's job.
The words are OURS except `Total Queries` and `Blocked`, upstream's Dashboard tiles:
the title `Overview` and its subtitle (`OVERVIEW_SUBTITLE`), `Period`, `Block List Domains`, `Your Rules` and its `blocked` and
`allowed`, the `of total` after Blocked's share, the panel titles (`Statistics`, `Queries over time`, `Blocked share`), the
`Allowed` series, the ring's `Share` and `Blocked share: N%`, and the chart's label.
The two gap sentences are the ones this console's Dashboard already uses.
*/
/** What the tab is for, under its title. OURS (CONVENTIONS.md, deviation 2). */
export const OVERVIEW_SUBTITLE = 'How much is blocked and which domains, over the period you pick, and whether blocking is on.'
type Period = Exclude<Range, 'Custom'>
const PERIODS = RANGES.filter((r): r is Period => r !== 'Custom')
function series(main: ChartData, label: string): number[] {
return main.datasets.find((d) => d.label === label)?.data.map(Number) ?? []
}
/**
* The stacked bars: what got through (Total − Blocked) and what was blocked. Blocked
* goes FIRST because Chart.js stacks in dataset order from the axis up: on the
* baseline, as drawn, its own series reads off a common zero. The legend still reads
* "Allowed, Blocked" (`BAR_LEGEND`).
*/
export function blockingChart(main: ChartData): ChartData {
const total = series(main, 'Total')
const blocked = series(main, 'Blocked')
return {
labels: main.labels,
datasets: [
{ label: 'Blocked', data: blocked },
{ label: 'Allowed', data: total.map((t, i) => t - (blocked[i] ?? 0)) },
],
}
}
const BAR_LEGEND = ['Allowed', 'Blocked'] as const
/*
The bars with the Dashboard's own legend (`Legend`, `useHidden`): in HTML above the
canvas, each series with its total for the period, a click switching it off and a
switched-off one dimmed. Chart.js's stock legend, centred inside the canvas, said the
same two names without their figures, so the same kind of chart read two ways on two
screens. Design only: the series, their names and their order are the ones the
stock legend showed (`BAR_LEGEND`), and switching one off still hides it.
The switches reset with each answer of the server (`resetOn`), as the Dashboard's
do, and not with the live figures laid over it every 2 seconds.
*/
function Bars({ chart, resetOn }: { chart: ChartData; resetOn: unknown }) {
const { hidden, toggle } = useHidden(resetOn)
const entries = BAR_LEGEND.map((label) => ({
label,
datum: series(chart, label).reduce((a, n) => a + (Number.isFinite(n) ? n : 0), 0).toLocaleString(),
}))
return (
<>
<Legend entries={entries} hidden={hidden} onToggle={toggle} />
<Chart
type="bar"
data={chart}
legendOrder={BAR_LEGEND}
separateLegend
hidden={hidden}
aria="Allowed and blocked queries over time"
/>
</>
)
}
/* A figure's trend, beside it: decorative, so hidden from assistive technology (the
figure says the number). */
function Spark({ data, tone }: { data: number[]; tone: string }) {
if (data.length < 2) return null
const max = Math.max(...data, 1)
const step = 100 / (data.length - 1)
const points = data.map((v, i) => `${(i * step).toFixed(2)},${(30 - (v / max) * 28).toFixed(2)}`).join(' ')
return (
<svg className={`${styles.spark} ${tone}`} viewBox="0 0 100 32" preserveAspectRatio="none" aria-hidden="true">
<polyline points={`0,32 ${points} 100,32`} fill="currentColor" fillOpacity="0.18" stroke="none" />
<polyline points={points} fill="none" stroke="currentColor" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
)
}
function Kpi({
value,
sub,
label,
children,
}: {
/** Two figures side by side (`Your Rules`) come as pairs, each number over its word. */
value: string | [string, string]
sub?: string | [string, string]
label: string
children?: ReactNode
}) {
const paired = Array.isArray(value) && Array.isArray(sub)
return (
<Panel className={shared.centred}>
<Body>
<div className={`${shared.kpi}${children != null ? ` ${styles.trended}` : ''}`}>
<div className={`${shared.kpiText}${paired ? ` ${shared.kpiPaired}` : ''}`}>
{paired ? (
/* Both lines are laid on one two-column grid, so each number stands
over its own word: written as one string, the 7 of "2 · 7" sat over
the middle of "blocked · allowed". */
<>
<span className={`${shared.kpiValue} ${shared.kpiRow}`}>
<span>{value[0]}</span>
<span>{value[1]}</span>
</span>
<span className={`${shared.kpiSub} ${shared.kpiRow}`}>
<span>{sub[0]}</span>
<span>{sub[1]}</span>
</span>
</>
) : (
<>
<span className={shared.kpiValue}>{value}</span>
{/* A figure without a sub-line keeps an empty one, a full line high, so the four
labels sit on one line as the drawing has them. */}
<span className={shared.kpiSub}>{sub ?? '\u00a0'}</span>
</>
)}
<span className={shared.kpiLabel}>{label}</span>
</div>
{children}
</div>
</Body>
</Panel>
)
}
/*
A chart's gap before or without data, as the Dashboard draws it (`Placeholder`): the
detail of a failure travels once, in the notice at the top, and each panel says the
same short sentence. A period with no queries is the dashed empty state, not an empty
canvas.
*/
function Gap({ failure, loading, announce = true }: { failure: boolean; loading: boolean; announce?: boolean }) {
if (failure) return <Failure>Could not load this data.</Failure>
if (loading) return <Loading compact announce={announce} />
return <Empty compact>No queries for this period.</Empty>
}
export function Overview({
tabs,
token,
permissions,
nodes = [],
clusterInitialised = false,
serverDomain,
}: {
tabs?: ReactNode
token: string | null
permissions: Permissions
nodes?: ClusterNode[]
clusterInitialised?: boolean
serverDomain?: string
}) {
const statsNeed = missing(permissions, 'Dashboard.canView')
const [range, setRange] = useState<Period>('LastHour')
const [node, setNode] = useState<string>(() => localStorage.getItem('dashboardClusterNode') || AGGREGATE)
const [data, setData] = useState<DashboardStats | null>(null)
/** The first read of this period and node failed: there is nothing to show. */
const [failure, setFailure] = useState(false)
/** A later read failed: what is on screen is the last good data, and says so. */
const [stale, setStale] = useState(false)
const [lastGood, setLastGood] = useState<string | null>(null)
const [notice, setNotice] = useState<Notice | null>(null)
/*
Read through refs, not state, for the reason Lists.tsx gives: as a dependency of
`load`, every good read would make a new `load` and the effect below would ask
again. `asked` numbers the reads so a slow answer to an earlier period cannot land
over a later one.
*/
const hadData = useRef(false)
const asked = useRef(0)
useEffect(() => {
localStorage.setItem('dashboardClusterNode', node)
}, [node])
const load = useCallback(async () => {
if (statsNeed != null) return
const mine = ++asked.current
const r = await getDashboardStats(token, range, undefined, node)
if (mine !== asked.current) return
if (r.kind === 'ok') {
setData(r.data)
setFailure(false)
setStale(false)
setLastGood(new Date().toISOString())
hadData.current = true
return
}
/*
The split of Zones and Lists: with earlier data the stale strip reports the
failure, saying since when and offering a retry, and the notice keeps quiet so
the same failure is not said twice; with none, the notice carries the server's
message.
*/
if (hadData.current) setStale(true)
else {
setFailure(true)
setNotice(noticeFromFailure(r))
}
}, [token, range, node, statsNeed])
/*
A new period or node is a new question. What is on screen answers the previous one,
so it goes: kept, Last Hour's figures would sit under "Last Day" looking current.
*/
useEffect(() => {
hadData.current = false
setData(null)
setFailure(false)
setStale(false)
void load()
}, [load])
/*
Last Hour keeps itself current, as the Dashboard does: read again a minute after
each answer, never two at once, nothing while the tab is hidden, and at once when
it comes back. In between, the live figures below move the counts, the bars and
the ring every 2 seconds (`dashboard/useLive.ts`).
*/
useEffect(() => {
if (range !== 'LastHour' || statsNeed != null) return
let cancelled = false
let timer: number | undefined
const arm = () => {
if (!cancelled && !document.hidden) timer = window.setTimeout(() => void again(), LAST_HOUR_REFRESH_MS)
}
async function again() {
await load()
arm()
}
function onVisibility() {
if (timer != null) window.clearTimeout(timer)
timer = undefined
if (!document.hidden) void again()
}
arm()
document.addEventListener('visibilitychange', onVisibility)
return () => {
cancelled = true
if (timer != null) window.clearTimeout(timer)
document.removeEventListener('visibilitychange', onVisibility)
}
}, [range, load, statsNeed])
const view = useLiveView({
token,
lastHour: range === 'LastHour',
node,
nodes,
clusterInitialised,
data,
active: !failure && statsNeed == null,
})
const s = view?.stats
/*
Memoised on the response: Chart.tsx destroys and rebuilds its canvas on every new
`data` reference, so a notice or any other render would otherwise redraw and
re-animate both charts with the same numbers.
*/
const chart = useMemo(() => (view != null ? blockingChart(view.mainChartData) : null), [view])
const share = useMemo<ChartData | null>(
() =>
view != null
? {
/* Blocked first: the ring starts at the top with it, as drawn and as the
bars stack it on the baseline. */
labels: ['Blocked', 'Allowed'],
datasets: [{ label: 'Share', data: [view.stats.totalBlocked, view.stats.totalQueries - view.stats.totalBlocked] }],
}
: null,
[view],
)
const loading = data == null && !failure
return (
<>
<InChrome>
<ClusterNodeSelect
nodes={nodes}
initialised={clusterInitialised}
aggregate
value={node}
onChange={setNode}
/>
</InChrome>
<SectionHeader
section="Blocking"
title="Overview"
subtitle={OVERVIEW_SUBTITLE}
tabs={tabs}
actions={
<Segmented
label="Period"
options={PERIODS.map((r) => ({ id: r, label: RANGE_LABEL[r] }))}
active={range}
onChoose={setRange}
/>
}
/>
<Notifier notice={notice} onClose={() => setNotice(null)} />
<div className={shared.stack}>
{stale && <StaleData since={lastGood} onRetry={() => void load()} />}
{statsNeed != null ? (
<Locked title="Statistics" need={statsNeed} />
) : (
<div className={shared.kpis}>
<Kpi value={s ? s.totalQueries.toLocaleString() : '—'} label="Total Queries">
{view && <Spark data={series(view.mainChartData, 'Total')} tone={styles.sparkTotal} />}
</Kpi>
<Kpi
value={s ? s.totalBlocked.toLocaleString() : '—'}
sub={s ? `${percentage(s.totalBlocked, s.totalQueries)} of total` : undefined}
label="Blocked"
>
{view && <Spark data={series(view.mainChartData, 'Blocked')} tone={styles.sparkBlocked} />}
</Kpi>
<Kpi value={s ? s.blockListZones.toLocaleString() : '—'} label="Block List Domains" />
<Kpi
value={s ? [s.blockedZones.toLocaleString(), s.allowedZones.toLocaleString()] : '—'}
sub={s ? ['blocked', 'allowed'] : undefined}
label="Your Rules"
/>
</div>
)}
<div className={`${shared.row2} ${styles.checkRow}`}>
<StatusPanel token={token} permissions={permissions} onNotice={setNotice} />
<AddDomainBar
token={token}
permissions={permissions}
onNotice={setNotice}
onChanged={() => void load()}
/>
</div>
{/* Without Dashboard.View every panel fed by the stats keeps its place and its
title, locked: the screen
does not change shape with who looks. */}
{statsNeed != null ? (
<div className={shared.row21}>
<Locked title="Queries over time" need={statsNeed} />
<Locked title="Blocked share" need={statsNeed} />
</div>
) : (
<div className={shared.row21}>
<Panel title="Queries over time">
<Body>
{chart && hasData(chart) ? (
<Bars chart={chart} resetOn={data} />
) : (
<Gap failure={failure} loading={loading} />
)}
</Body>
</Panel>
<Panel title="Blocked share">
<Body>
{s && share && s.totalQueries > 0 ? (
/* No legend: the hole says which share it is, and Chart.js's own
would be a second one inside the canvas. The figure is laid over
the canvas box, whose centre is the ring's. */
<div className={styles.ring}>
<Chart
type="doughnut"
height={180}
separateLegend
data={share}
aria={`Blocked share: ${percentage(s.totalBlocked, s.totalQueries)}`}
/>
<span className={styles.ringValue} aria-hidden="true">
{percentage(s.totalBlocked, s.totalQueries)}
<span className={styles.ringLabel}>Blocked</span>
</span>
</div>
) : (
<Gap failure={failure} loading={loading} announce={false} />
)}
</Body>
</Panel>
</div>
)}
{statsNeed != null ? (
<div className={shared.row2}>
<Locked title="Top Blocked Domains" need={statsNeed} />
<Locked title="Top Domains" need={statsNeed} />
</div>
) : (
<div className={shared.row2}>
<TopTable
kind="TopBlockedDomains"
rows={data?.topBlockedDomains ?? []}
range={range}
token={token}
permissions={permissions}
failure={data == null}
onNotice={setNotice}
onChanged={() => void load()}
/>
<TopTable
kind="TopDomains"
rows={data?.topDomains ?? []}
range={range}
token={token}
permissions={permissions}
failure={data == null}
onNotice={setNotice}
onChanged={() => void load()}
/>
</div>
)}
<RecentBlocked
token={token}
permissions={permissions}
node={node}
aggregate={clusterInitialised && node === AGGREGATE}
serverDomain={serverDomain}
refreshOn={s?.totalBlocked}
/>
</div>
</>
)
}
+216
View File
@@ -0,0 +1,216 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen } from '@testing-library/react'
import * as apps from '../../api/apps'
import type { InstalledApp } from '../../api/apps'
import * as blocking from '../../api/blocking'
import { RecentBlocked } from './RecentBlocked'
import { wholeText } from '../../test/whole-text'
afterEach(() => vi.restoreAllMocks())
const LOGGER: InstalledApp = {
name: 'Query Logs (Sqlite)', description: null, version: '1', dnsApps: [{
classPath: 'QueryLogsSqlite.App', description: '', isAppRecordRequestHandler: false, recordDataTemplate: null,
isRequestController: false, isAuthoritativeRequestHandler: false, isRequestBlockingHandler: false,
isQueryLogger: true, isQueryLogs: true, isPostProcessor: false,
}],
}
function withApps(list: InstalledApp[]) {
return vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: list } } })
}
describe('RecentBlocked', () => {
it('without a logging app it says so with upstream sentence and links to Apps', async () => {
withApps([])
render(<RecentBlocked token="T" permissions={undefined} node="" aggregate={false} serverDomain="dns.test" />)
const sentence = await screen.findByText(/Please install the 'Query Logs \(Sqlite\)' DNS App/)
expect(sentence).toHaveTextContent(
"Please install the 'Query Logs (Sqlite)' DNS App or any other DNS app that supports query logging feature from the Apps section.",
)
expect(screen.getByRole('link', { name: 'Apps' })).toHaveAttribute('href', expect.stringMatching(/apps\/$/))
})
it('draws the entries of the first logging app', async () => {
withApps([LOGGER])
const spy = vi.spyOn(blocking, 'recentBlocked').mockResolvedValue({
kind: 'ok', data: { partial: false, entries: [{
rowNumber: 1, timestamp: '2026-10-01T10:00:00Z', clientIpAddress: '192.168.1.34', protocol: 'Udp',
responseType: 'Blocked', rcode: 'NoError', qname: 'app-measurement.com', qtype: 'A', qclass: 'IN', answer: null,
}] },
})
render(<RecentBlocked token="T" permissions={undefined} node="" aggregate={false} serverDomain="dns.test" />)
expect(await screen.findByText(wholeText('app-measurement.com'))).toBeInTheDocument()
expect(spy).toHaveBeenCalledWith('T', { name: 'Query Logs (Sqlite)', classPath: 'QueryLogsSqlite.App' }, '')
expect(screen.getByRole('link', { name: 'Open Query Logs' }))
.toHaveAttribute('href', expect.stringMatching(/logs\/query-logs\/$/))
})
it('with a node chosen it reads that node', async () => {
withApps([LOGGER])
const spy = vi.spyOn(blocking, 'recentBlocked').mockResolvedValue({ kind: 'ok', data: { partial: false, entries: [] } })
render(<RecentBlocked token="T" permissions={undefined} node="node2.test" aggregate={false} serverDomain="dns.test" />)
expect(await screen.findByText('No blocked queries.')).toBeInTheDocument()
expect(spy).toHaveBeenCalledWith('T', { name: 'Query Logs (Sqlite)', classPath: 'QueryLogsSqlite.App' }, 'node2.test')
})
it('with the cluster aggregate it reads the connected node and says which', async () => {
withApps([LOGGER])
const spy = vi.spyOn(blocking, 'recentBlocked').mockResolvedValue({ kind: 'ok', data: { partial: false, entries: [] } })
render(<RecentBlocked token="T" permissions={undefined} node="cluster" aggregate serverDomain="dev.cluster.test" />)
expect(await screen.findByRole('heading', { name: 'Recently Blocked on dev.cluster.test' })).toBeInTheDocument()
expect(spy).toHaveBeenCalledWith('T', { name: 'Query Logs (Sqlite)', classPath: 'QueryLogsSqlite.App' }, '')
})
it('says when one of the three classes could not be read', async () => {
withApps([LOGGER])
vi.spyOn(blocking, 'recentBlocked').mockResolvedValue({ kind: 'ok', data: { partial: true, entries: [] } })
render(<RecentBlocked token="T" permissions={undefined} node="" aggregate={false} serverDomain="dns.test" />)
expect(await screen.findByText('Some blocked queries could not be read.')).toBeInTheDocument()
})
it('a failed app list is an error with the server message, not the no-app state', async () => {
vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'error', message: 'Access was denied.' })
render(<RecentBlocked token="T" permissions={undefined} node="" aggregate={false} serverDomain="dns.test" />)
expect(await screen.findByText('Access was denied.')).toBeInTheDocument()
expect(screen.queryByText(/Please install/)).toBeNull()
})
it('without Logs.canView it is locked', () => {
const list = withApps([LOGGER])
render(
<RecentBlocked token="T" permissions={{ Logs: { canView: false, canModify: false, canDelete: false } }}
node="" aggregate={false} serverDomain="dns.test" />,
)
expect(screen.getByText('Requires Logs: View')).toBeInTheDocument()
expect(list).not.toHaveBeenCalled()
})
})
/* The Overview's Blocked figure moves in real time; the list follows it. */
describe('RecentBlocked: following the Blocked figure', () => {
const row = (n: number, qname: string) => ({
rowNumber: n, timestamp: '2026-10-01T10:00:00Z', clientIpAddress: '192.168.1.34', protocol: 'Udp',
responseType: 'Blocked', rcode: 'NoError', qname, qtype: 'A', qclass: 'IN', answer: null,
})
const props = { token: 'T', permissions: undefined, node: '', aggregate: false, serverDomain: 'dns.test' }
it('a new figure reads the list again, keeping the rows on screen while it travels', async () => {
withApps([LOGGER])
let finish!: (v: Awaited<ReturnType<typeof blocking.recentBlocked>>) => void
const spy = vi.spyOn(blocking, 'recentBlocked')
.mockResolvedValueOnce({ kind: 'ok', data: { partial: false, entries: [row(1, 'first.test')] } })
.mockReturnValueOnce(new Promise((r) => { finish = r }))
const { rerender } = render(<RecentBlocked {...props} refreshOn={5} />)
expect(await screen.findByText(wholeText('first.test'))).toBeInTheDocument()
rerender(<RecentBlocked {...props} refreshOn={6} />)
expect(spy).toHaveBeenCalledTimes(2)
expect(screen.getByText(wholeText('first.test'))).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
finish({ kind: 'ok', data: { partial: false, entries: [row(2, 'second.test'), row(1, 'first.test')] } })
expect(await screen.findByText(wholeText('second.test'))).toBeInTheDocument()
expect(listApps()).toHaveBeenCalledTimes(1)
})
it('never two reads at once: a figure that moves mid-read reads once more afterwards', async () => {
withApps([LOGGER])
let finish!: (v: Awaited<ReturnType<typeof blocking.recentBlocked>>) => void
const spy = vi.spyOn(blocking, 'recentBlocked')
.mockResolvedValueOnce({ kind: 'ok', data: { partial: false, entries: [row(1, 'first.test')] } })
.mockReturnValueOnce(new Promise((r) => { finish = r }))
.mockResolvedValue({ kind: 'ok', data: { partial: false, entries: [row(3, 'third.test')] } })
const { rerender } = render(<RecentBlocked {...props} refreshOn={5} />)
await screen.findByText(wholeText('first.test'))
rerender(<RecentBlocked {...props} refreshOn={6} />)
rerender(<RecentBlocked {...props} refreshOn={7} />)
rerender(<RecentBlocked {...props} refreshOn={8} />)
expect(spy).toHaveBeenCalledTimes(2)
finish({ kind: 'ok', data: { partial: false, entries: [row(2, 'second.test')] } })
expect(await screen.findByText(wholeText('third.test'))).toBeInTheDocument()
expect(spy).toHaveBeenCalledTimes(3)
})
it('a failed refresh leaves the last good list in place', async () => {
withApps([LOGGER])
const spy = vi.spyOn(blocking, 'recentBlocked')
.mockResolvedValueOnce({ kind: 'ok', data: { partial: false, entries: [row(1, 'first.test')] } })
.mockResolvedValue({ kind: 'error', message: 'boom' })
const { rerender } = render(<RecentBlocked {...props} refreshOn={5} />)
await screen.findByText(wholeText('first.test'))
await act(async () => rerender(<RecentBlocked {...props} refreshOn={6} />))
expect(spy).toHaveBeenCalledTimes(2)
expect(screen.getByText(wholeText('first.test'))).toBeInTheDocument()
expect(screen.queryByText('boom')).not.toBeInTheDocument()
})
it('queues a figure change while the first rows are loading', async () => {
withApps([LOGGER])
let finish!: (v: Awaited<ReturnType<typeof blocking.recentBlocked>>) => void
const spy = vi.spyOn(blocking, 'recentBlocked')
.mockReturnValueOnce(new Promise((r) => { finish = r }))
.mockResolvedValue({ kind: 'ok', data: { partial: false, entries: [row(2, 'new.test')] } })
const { rerender } = render(<RecentBlocked {...props} refreshOn={5} />)
await act(async () => {})
expect(spy).toHaveBeenCalledTimes(1)
rerender(<RecentBlocked {...props} refreshOn={6} />)
await act(async () => finish({ kind: 'ok', data: { partial: false, entries: [row(1, 'old.test')] } }))
expect(await screen.findByText(wholeText('new.test'))).toBeInTheDocument()
expect(spy).toHaveBeenCalledTimes(2)
})
it('drops an old node response and its queued refresh after changing nodes', async () => {
withApps([LOGGER])
let finish!: (v: Awaited<ReturnType<typeof blocking.recentBlocked>>) => void
const spy = vi.spyOn(blocking, 'recentBlocked')
.mockResolvedValueOnce({ kind: 'ok', data: { partial: false, entries: [row(1, 'first.test')] } })
.mockReturnValueOnce(new Promise((r) => { finish = r }))
.mockResolvedValue({ kind: 'ok', data: { partial: false, entries: [row(3, 'new-node.test')] } })
const { rerender } = render(<RecentBlocked {...props} node="old-node.test" refreshOn={5} />)
await screen.findByText(wholeText('first.test'))
rerender(<RecentBlocked {...props} node="old-node.test" refreshOn={6} />)
rerender(<RecentBlocked {...props} node="old-node.test" refreshOn={7} />)
rerender(<RecentBlocked {...props} node="new-node.test" refreshOn={7} />)
await screen.findByText(wholeText('new-node.test'))
await act(async () => finish({ kind: 'ok', data: { partial: false, entries: [row(2, 'late.test')] } }))
expect(screen.queryByText(wholeText('late.test'))).not.toBeInTheDocument()
expect(screen.getByText(wholeText('new-node.test'))).toBeInTheDocument()
expect(spy).toHaveBeenCalledTimes(3)
expect(spy.mock.calls.map((args) => args[2])).toEqual(['old-node.test', 'old-node.test', 'new-node.test'])
})
it('drops a pending refresh when Logs permission is revoked', async () => {
withApps([LOGGER])
let finish!: (v: Awaited<ReturnType<typeof blocking.recentBlocked>>) => void
const spy = vi.spyOn(blocking, 'recentBlocked')
.mockResolvedValueOnce({ kind: 'ok', data: { partial: false, entries: [row(1, 'first.test')] } })
.mockReturnValueOnce(new Promise((r) => { finish = r }))
const { rerender } = render(<RecentBlocked {...props} refreshOn={5} />)
await screen.findByText(wholeText('first.test'))
rerender(<RecentBlocked {...props} refreshOn={6} />)
rerender(<RecentBlocked {...props} refreshOn={7} />)
rerender(<RecentBlocked {...props} refreshOn={7} permissions={{ Logs: { canView: false, canModify: false, canDelete: false } }} />)
await act(async () => finish({ kind: 'ok', data: { partial: false, entries: [row(2, 'late.test')] } }))
expect(screen.getByText('Requires Logs: View')).toBeInTheDocument()
expect(spy).toHaveBeenCalledTimes(2)
expect(screen.queryByText(wholeText('late.test'))).not.toBeInTheDocument()
})
it('does not continue a queued refresh after unmount', async () => {
withApps([LOGGER])
let finish!: (v: Awaited<ReturnType<typeof blocking.recentBlocked>>) => void
const spy = vi.spyOn(blocking, 'recentBlocked')
.mockResolvedValueOnce({ kind: 'ok', data: { partial: false, entries: [row(1, 'first.test')] } })
.mockReturnValueOnce(new Promise((r) => { finish = r }))
const { rerender, unmount } = render(<RecentBlocked {...props} refreshOn={5} />)
await screen.findByText(wholeText('first.test'))
rerender(<RecentBlocked {...props} refreshOn={6} />)
rerender(<RecentBlocked {...props} refreshOn={7} />)
unmount()
await act(async () => finish({ kind: 'ok', data: { partial: false, entries: [] } }))
expect(spy).toHaveBeenCalledTimes(2)
})
})
const listApps = () => vi.mocked(apps.listApps)
+220
View File
@@ -0,0 +1,220 @@
import { useEffect, useRef, useState } from 'react'
import { listApps } from '../../api/apps'
import { recentBlocked } from '../../api/blocking'
import type { QueryLogEntry } from '../../api/logs'
import { appsWithQueryLogs } from '../logs/QueryLogs'
import { RouteLink } from '../../ui/RouteLink'
import { Panel, Body } from '../../ui/Panel'
import { Table } from '../../ui/Table'
import tbl from '../../ui/Table.module.css'
import { DomainName } from '../../ui/DomainName'
import { Chip } from '../../ui/Tag'
import { Icon } from '../../ui/Icon'
import { Empty, Loading, Failure } from '../../ui/Empty'
import { dateTime } from '../../lib/dates'
import { noticeFromFailure } from '../../lib/notice'
import { Locked } from './Locked'
import { missing, type Permissions } from './permissions'
import shared from './Blocking.module.css'
import styles from './Overview.module.css'
/*
The latest blocked queries. OURS as a panel; the data is `logs/query`, merged across
the three blocked classes (`recentBlocked`). Logs belong to ONE node: with the
cluster aggregate chosen it reads the node the console is connected to, and the
header says which, so nobody reads one node's logs as the cluster's.
The app is the first one that serves query logs, as `QueryLogs.tsx` resolves it.
Without one, the sentence is upstream's (QueryLogs.tsx, logs.js:391) split only to
link "Apps"; the empty-state title is ours. Ours too: the panel title `Recently
Blocked`, `on <node>`, `Open Query Logs`, the columns `Time`, `Client`, `Domain` and
`Type` (upstream's Query Logs table calls them otherwise), `No blocked queries.` and
`Some blocked queries could not be read.`
The Overview passes its Blocked figure as `refreshOn`. A change refreshes the rows
without clearing the previous list, serializes overlapping reads, and retains the
last good list if a background read fails. Token, node and permission changes
invalidate the entire read context.
*/
type Load =
| { kind: 'loading' }
| { kind: 'no-app' }
| { kind: 'failed'; text: string }
| { kind: 'ok'; entries: QueryLogEntry[]; partial: boolean }
export function RecentBlocked({
token,
permissions,
node,
aggregate,
serverDomain,
refreshOn,
}: {
token: string | null
permissions: Permissions
/** The Overview's node selector. */
node: string
/** The cluster aggregate is chosen: read the connected node instead. */
aggregate: boolean
/** The connected node's name, said in the header with the aggregate. */
serverDomain: string | undefined
/** Read the logs again when the Overview's Blocked figure changes. */
refreshOn?: number
}) {
const need = missing(permissions, 'Logs.canView')
const [state, setState] = useState<Load>({ kind: 'loading' })
const target = aggregate ? '' : node
const requestRefresh = useRef<(() => void) | null>(null)
const lastRefresh = useRef(refreshOn)
useEffect(() => {
if (need != null) return
let live = true
// These belong to this token/node only. A late answer cannot start a queued
// refresh for another context or after permission is revoked.
let busy = true
let again = false
let app: { name: string; classPath: string } | null = null
lastRefresh.current = refreshOn
setState({ kind: 'loading' })
async function refresh() {
if (!live || app == null) return
if (busy) {
again = true
return
}
busy = true
const r = await recentBlocked(token, app, target)
if (!live) return
if (r.kind === 'ok') setState({ kind: 'ok', ...r.data })
busy = false
if (again) {
again = false
void refresh()
}
}
const request = () => {
if (!live) return
// A figure can change while the logging app or the first rows are loading.
if (busy) again = true
else void refresh()
}
requestRefresh.current = request
void (async () => {
const a = await listApps(token)
if (!live) return
if (a.kind !== 'ok') {
busy = false
setState({ kind: 'failed', text: noticeFromFailure(a).text })
return
}
const found = appsWithQueryLogs(a.data.response.apps ?? [])[0]
if (found == null) {
busy = false
setState({ kind: 'no-app' })
return
}
app = { name: found.name, classPath: found.classPaths[0] }
const r = await recentBlocked(token, app, target)
if (!live) return
setState(r.kind === 'ok' ? { kind: 'ok', ...r.data } : { kind: 'failed', text: noticeFromFailure(r).text })
busy = false
if (again) {
again = false
void refresh()
}
})()
return () => {
live = false
if (requestRefresh.current === request) requestRefresh.current = null
}
// A new figure refreshes the existing context in the effect below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, target, need])
useEffect(() => {
if (Object.is(lastRefresh.current, refreshOn)) return
lastRefresh.current = refreshOn
if (refreshOn !== undefined) requestRefresh.current?.()
}, [refreshOn])
if (need != null) return <Locked title="Recently Blocked" need={need} />
const title = (
<>
Recently Blocked
{/* The space goes OUTSIDE the span: inside it, the accessible name is built
from the span's trimmed text and reads "Recently Blockedon …". */}
{aggregate && serverDomain != null && (
<>
{' '}
<span className={styles.where}>on {serverDomain}</span>
</>
)}
</>
)
return (
<Panel
title={title}
actions={<RouteLink to={{ section: 'logs', sub: 'Query Logs' }}>Open Query Logs</RouteLink>}
>
{state.kind === 'loading' && (
<Body>
<Loading compact />
</Body>
)}
{state.kind === 'failed' && (
<Body>
<Failure>{state.text}</Failure>
</Body>
)}
{state.kind === 'no-app' && (
<Body>
<Empty title="No query logging app">
Please install the &apos;Query Logs (Sqlite)&apos; DNS App or any other DNS app that supports
query logging feature from the <RouteLink to={{ section: 'apps', sub: null }}>Apps</RouteLink> section.
</Empty>
</Body>
)}
{state.kind === 'ok' && (
<>
{state.partial && (
<div className={styles.partial}>
<Icon name="warning" size={14} />
Some blocked queries could not be read.
</div>
)}
<Table
stack
header={
<>
<th>Time</th>
<th>Client</th>
<th>Domain</th>
<th>Type</th>
</>
}
isEmpty={state.entries.length === 0}
emptyText="No blocked queries."
columns={4}
className={shared.inPanel}
>
{state.entries.map((e) => (
<tr key={`${e.responseType}:${e.rowNumber}:${e.timestamp}`}>
<td className={styles.time}>{dateTime(e.timestamp)}</td>
<td className={styles.mono}>{e.clientIpAddress}</td>
<td className={`${styles.name} ${tbl.lead}`}><DomainName name={e.qname ?? '.'} /></td>
<td className={tbl.lead}>{e.qtype != null && <Chip>{e.qtype}</Chip>}</td>
</tr>
))}
</Table>
</>
)}
</Panel>
)
}
+25
View File
@@ -0,0 +1,25 @@
/* The bar over the table: the filter, the search, and List / Tree. */
.bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-5); padding: var(--s-7) var(--s-9); border-bottom: 1px solid var(--line2); }
.spacer { flex: 1; }
.filter { display: inline-flex; gap: var(--s-2); }
.search { width: 220px; }
.foot { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-5); padding: var(--s-7) var(--s-9); color: var(--mute); }
/* The name wraps at its dots only (`ui/DomainName`), never inside a label. */
.domain { font-family: var(--font-mono); }
.actions { text-align: right; white-space: nowrap; }
/* The embedded tree, inset from the panel's edges as the bar above it is. */
.tree { padding: var(--s-9) var(--s-9) 0; }
/* The rule kind: an icon and a word, not only a colour. */
.kind { display: inline-flex; align-items: center; gap: var(--s-2); }
.blocked { color: var(--ch-block); }
/*
`--ok`, the text green, and not `--ch-ok`, the line green: the same value in dark;
in light the series is tuned against the other ten lines and gives 4.45:1 under
the row hover, the text tone 5.0:1.
*/
.allowed { color: var(--ok); }
@media (max-width: 560px) {
.search { width: 100%; }
}
+53
View File
@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { REAL, realServer } from '../../test/real-blocking'
import { Rules } from './Rules'
import { wholeText } from '../../test/whole-text'
/*
Rules fed with the harness's real `blocked/export` and `allowed/export`
(`test/real-blocking.ts`): `text/plain`, one zone per line, read through
`readRuleExport` as in production. The counts are checked against the same
moment's `dashboard/stats/get`, which counts the same zones on its own.
*/
const lines = (t: string) => t.split(/\r?\n/).filter((l) => l !== '')
const BLOCKED = lines(REAL.blockedExport)
const ALLOWED = lines(REAL.allowedExport)
beforeEach(() => {
window.history.replaceState(null, '', '/blocking/rules/')
vi.spyOn(globalThis, 'fetch').mockImplementation(realServer())
})
afterEach(() => vi.restoreAllMocks())
describe('Rules against the harness', () => {
it('draws every exported zone once, with its kind', async () => {
render(<Rules token="T" permissions={undefined} />)
const table = await screen.findByRole('table')
const rows = within(table).getAllByRole('row').slice(1)
expect(rows).toHaveLength(BLOCKED.length + ALLOWED.length)
for (const d of BLOCKED) expect(within(table).getByText(wholeText(d)).closest('tr')).toHaveTextContent('Blocked')
for (const d of ALLOWED) expect(within(table).getByText(wholeText(d)).closest('tr')).toHaveTextContent('Allowed')
})
it('the counts agree with the figures the server gives the Overview', async () => {
const s = REAL.statsLastHour.response.stats
render(<Rules token="T" permissions={undefined} />)
await screen.findByRole('table')
expect(screen.getByRole('button', { name: `All ${s.blockedZones + s.allowedZones}` })).toBeInTheDocument()
expect(screen.getByRole('button', { name: `Blocked ${s.blockedZones}` })).toBeInTheDocument()
expect(screen.getByRole('button', { name: `Allowed ${s.allowedZones}` })).toBeInTheDocument()
})
it('the Allowed filter keeps only the allowed zones', async () => {
render(<Rules token="T" permissions={undefined} />)
await screen.findByRole('table')
await userEvent.click(screen.getByRole('button', { name: `Allowed ${ALLOWED.length}` }))
const table = screen.getByRole('table')
expect(within(table).getAllByRole('row').slice(1)).toHaveLength(ALLOWED.length)
expect(within(table).queryByText(BLOCKED[0])).toBeNull()
})
})
+473
View File
@@ -0,0 +1,473 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as blocking from '../../api/blocking'
import * as zonelists from '../../api/zonelists'
import { Rules } from './Rules'
import { wholeText } from '../../test/whole-text'
const OK = { kind: 'ok' as const, data: {} }
const P = (v: boolean, m = true, d = true) => ({ canView: v, canModify: m, canDelete: d })
beforeEach(() => window.history.replaceState(null, '', '/blocking/rules/'))
afterEach(() => vi.restoreAllMocks())
function exports(blocked: string[], allowed: string[]) {
return vi.spyOn(blocking, 'readRuleExport').mockImplementation(async (list) => ({
kind: 'ok', data: list === 'blocked' ? blocked : allowed,
}))
}
function draw(permissions?: Parameters<typeof Rules>[0]['permissions']) {
return render(<Rules token="T" permissions={permissions} />)
}
const NODES = [
{ name: 'node2.cluster.test', type: 'Secondary' },
{ name: 'dev.cluster.test', type: 'Primary' },
]
const RECORD = {
name: 'ads.test', type: 'A', ttl: 60, ttlString: '1m', disabled: false,
rData: { ipAddress: '0.0.0.0' }, dnssecStatus: 'Unknown',
lastUsedOn: '0001-01-01T00:00:00', lastModified: '0001-01-01T00:00:00', expiryTtl: 0, expiryTtlString: '0s',
}
function emptyTree() {
return vi.spyOn(zonelists, 'listNode').mockResolvedValue({
kind: 'ok',
data: { domain: '', zones: [], records: [] },
})
}
describe('Rules', () => {
it('draws both lists in one table with their kind', async () => {
exports(['ads.example.com'], ['s.youtube.com'])
draw()
const table = await screen.findByRole('table')
expect(within(table).getByText(wholeText('ads.example.com'))).toBeInTheDocument()
expect(within(table).getByText('Allowed')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'All 2' })).toHaveAttribute('aria-pressed', 'true')
})
it('reads the filter from ?rule= and writes it back', async () => {
window.history.replaceState(null, '', '/blocking/rules/?rule=allowed')
exports(['ads.example.com'], ['s.youtube.com'])
draw()
const table = await screen.findByRole('table')
expect(within(table).queryByText(wholeText('ads.example.com'))).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Blocked 1' }))
expect(window.location.search).toBe('?rule=blocked')
})
it('searches the whole set and pages 10,000 rules by 50', async () => {
exports(Array.from({ length: 10_000 }, (_, i) => `d${String(i).padStart(5, '0')}.test`), [])
draw()
const table = await screen.findByRole('table')
expect(within(table).getAllByRole('row')).toHaveLength(51)
await userEvent.type(screen.getByLabelText('Filter domains'), 'd09999')
expect(within(screen.getByRole('table')).getAllByRole('row')).toHaveLength(2)
})
/* Without Modify the rule could not be put back, so the Delete asks, as upstream. */
const NO_MODIFY = { Blocked: P(true, false), Allowed: P(true, false) }
it('Delete asks with upstream sentence and deletes from its own list', async () => {
exports(['ads.example.com'], [])
const remove = vi.spyOn(zonelists, 'deleteDomain').mockResolvedValue(OK)
draw(NO_MODIFY)
await userEvent.click(await screen.findByRole('button', { name: 'Delete ads.example.com' }))
const dialog = await screen.findByRole('dialog')
expect(dialog).toHaveTextContent("Are you sure you want to delete the blocked zone 'ads.example.com'?")
await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' }))
expect(remove).toHaveBeenCalledWith('blocked', 'T', 'ads.example.com')
expect(await screen.findByText("Blocked zone 'ads.example.com' was deleted successfully.")).toBeInTheDocument()
})
it('without Allowed.canView it reads only Blocked and locks the Allowed filter', async () => {
const read = exports(['ads.example.com'], ['never.test'])
draw({ Blocked: P(true), Allowed: P(false) })
await screen.findByRole('table')
expect(read).toHaveBeenCalledTimes(1)
expect(read).toHaveBeenCalledWith('blocked', 'T', '')
expect(screen.getByRole('button', { name: /Allowed/ })).toBeDisabled()
})
it('a locked filter shows no count: an unread list is not zero', async () => {
exports(['ads.example.com'], ['never.test'])
draw({ Blocked: P(true), Allowed: P(false) })
await screen.findByRole('table')
expect(screen.getByRole('button', { name: 'Allowed' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Blocked 1' })).toBeEnabled()
})
it('Export downloads at once, with no dialog', async () => {
exports([], [])
const exp = vi.spyOn(zonelists, 'exportDomains').mockResolvedValue({ ok: true })
draw()
await userEvent.click(await screen.findByRole('button', { name: 'Export' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Blocked zones' }))
expect(exp).toHaveBeenCalledWith('blocked', 'T')
expect(screen.queryByRole('dialog')).toBeNull()
})
it('Flush asks with upstream sentence', async () => {
exports([], [])
vi.spyOn(zonelists, 'flushList').mockResolvedValue(OK)
draw()
await userEvent.click(await screen.findByRole('button', { name: 'Flush' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Allowed zones' }))
expect(await screen.findByRole('dialog')).toHaveTextContent('Are you sure you want to flush the entire Allowed zone?')
})
/* The sentences Allowed and Blocked said from their own headers, character for
character (other-zones.js); they live in Rules' menus now. */
it('Export says which list it exported, with upstream sentences', async () => {
exports([], [])
vi.spyOn(zonelists, 'exportDomains').mockResolvedValue({ ok: true })
draw()
await userEvent.click(await screen.findByRole('button', { name: 'Export' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Blocked zones' }))
expect(await screen.findByText('Blocked zones were exported successfully.')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Export' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Allowed zones' }))
expect(await screen.findByText('Allowed zones were exported successfully.')).toBeInTheDocument()
})
it('Flush on Blocked asks with its own sentence, not the Allowed one', async () => {
exports([], [])
vi.spyOn(zonelists, 'flushList').mockResolvedValue(OK)
draw()
await userEvent.click(await screen.findByRole('button', { name: 'Flush' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Blocked zones' }))
expect(await screen.findByRole('dialog')).toHaveTextContent('Are you sure you want to flush the entire Blocked zone?')
})
it('flushing Allowed says so with upstream sentence', async () => {
exports([], [])
const flush = vi.spyOn(zonelists, 'flushList').mockResolvedValue(OK)
draw()
await userEvent.click(await screen.findByRole('button', { name: 'Flush' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Allowed zones' }))
await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Flush' }))
expect(await screen.findByText('Allowed zone was flushed successfully.')).toBeInTheDocument()
expect(flush).toHaveBeenCalledWith('allowed', 'T')
})
it('a menu entry without its permission stays, disabled, and names the permission', async () => {
exports([], [])
draw({ Blocked: P(true), Allowed: P(true, true, false) })
await userEvent.click(await screen.findByRole('button', { name: 'Flush' }))
const menu = screen.getByRole('menu')
const locked = within(menu).getByRole('button', { name: 'Allowed zones' })
expect(locked).toBeDisabled()
expect(within(menu).getByRole('menuitem', { name: 'Blocked zones' })).toBeEnabled()
await userEvent.hover(locked.parentElement!)
expect(screen.getByRole('tooltip')).toHaveTextContent('Requires Allowed: Delete')
})
it('the tree of a list the session cannot view is locked, not hidden', async () => {
exports(['ads.example.com'], [])
vi.spyOn(zonelists, 'listNode').mockResolvedValue({
kind: 'ok',
data: { domain: '', zones: [], records: [] },
})
draw({ Blocked: P(true), Allowed: P(false) })
await screen.findByRole('table')
await userEvent.click(screen.getByRole('button', { name: 'Tree' }))
await userEvent.click(screen.getByRole('button', { name: 'Allowed' }))
expect(screen.getByText('Requires Allowed: View')).toBeInTheDocument()
})
/* The tree tells the table when it deleted (onChanged), and the table reads the
PRIMARY then: coming back to the list must not re-read the connected node, which
on a secondary still has the deleted row until the cluster syncs (I1). */
it('a Delete in the tree reads the rules again, from the primary', async () => {
const read = exports(['ads.test'], [])
vi.spyOn(zonelists, 'listNode').mockResolvedValue({
kind: 'ok', data: { domain: 'ads.test', zones: [], records: [RECORD] },
})
vi.spyOn(zonelists, 'deleteDomain').mockResolvedValue(OK)
render(<Rules token="T" permissions={undefined} nodes={NODES} clusterInitialised />)
await screen.findByRole('table')
expect(read).toHaveBeenCalledTimes(2)
await userEvent.click(screen.getByRole('button', { name: 'Tree' }))
const tree = await screen.findByRole('region', { name: 'Records' })
await userEvent.click(within(tree).getByRole('button', { name: 'Delete' }))
await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' }))
await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(4))
expect(read).toHaveBeenLastCalledWith('allowed', 'T', 'dev.cluster.test')
await userEvent.click(screen.getByRole('button', { name: 'List' }))
await screen.findByRole('table')
expect(read).toHaveBeenCalledTimes(4)
})
/* One screen, one alert slot. The tree's Delete reports at the top of the
page, where the table's Delete reports: before the add bar, not inside the panel. */
it('a Delete in the tree reports through the notifier of the page', async () => {
exports(['ads.test'], [])
vi.spyOn(zonelists, 'listNode').mockResolvedValue({
kind: 'ok', data: { domain: 'ads.test', zones: [], records: [RECORD] },
})
vi.spyOn(zonelists, 'deleteDomain').mockResolvedValue(OK)
draw()
await screen.findByRole('table')
await userEvent.click(screen.getByRole('button', { name: 'Tree' }))
const tree = await screen.findByRole('region', { name: 'Records' })
await userEvent.click(within(tree).getByRole('button', { name: 'Delete' }))
await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' }))
const said = await screen.findAllByText("Blocked zone 'ads.test' was deleted successfully.")
expect(said).toHaveLength(1)
const bar = screen.getByRole('textbox', { name: 'Domain' })
expect(said[0].compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
/* Every read after a change comes from the primary node, where the change was
made; the first read stays on the connected node. */
it('after a Delete the table is read from the primary node', async () => {
const read = exports(['ads.example.com'], [])
vi.spyOn(zonelists, 'deleteDomain').mockResolvedValue(OK)
render(<Rules token="T" permissions={undefined} nodes={NODES} clusterInitialised />)
expect(await screen.findByRole('button', { name: 'Delete ads.example.com' })).toBeInTheDocument()
expect(read).toHaveBeenCalledWith('blocked', 'T', '')
expect(read).toHaveBeenCalledWith('allowed', 'T', '')
await userEvent.click(screen.getByRole('button', { name: 'Delete ads.example.com' }))
await screen.findByText("Blocked zone 'ads.example.com' was deleted successfully.")
expect(read).toHaveBeenCalledTimes(4)
expect(read).toHaveBeenCalledWith('blocked', 'T', 'dev.cluster.test')
expect(read).toHaveBeenCalledWith('allowed', 'T', 'dev.cluster.test')
})
it('after a Block from the add bar the table is read from the primary node', async () => {
const read = exports([], [])
vi.spyOn(zonelists, 'addDomain').mockResolvedValue(OK)
render(<Rules token="T" permissions={undefined} nodes={NODES} clusterInitialised />)
await screen.findByRole('table')
await userEvent.type(screen.getByRole('textbox', { name: 'Domain' }), 'new.test{Enter}')
await screen.findByText("Domain 'new.test' was added to Blocked Zone successfully.")
await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(4))
expect(read).toHaveBeenLastCalledWith('allowed', 'T', 'dev.cluster.test')
})
/* With the tree open, a Block or Allow remounts it at the added domain, read from
the primary, as upstream's blockZone/allowZone do (other-zones.js:350, 185). */
it('a Block with the tree open takes the tree to the added domain, from the primary', async () => {
exports([], [])
vi.spyOn(zonelists, 'addDomain').mockResolvedValue(OK)
const list = emptyTree()
render(<Rules token="T" permissions={undefined} nodes={NODES} clusterInitialised />)
await screen.findByRole('table')
await userEvent.click(screen.getByRole('button', { name: 'Tree' }))
await screen.findByText('0 zones')
await userEvent.type(screen.getByRole('textbox', { name: 'Domain' }), 'new.test{Enter}')
await screen.findByText("Domain 'new.test' was added to Blocked Zone successfully.")
await vi.waitFor(() => expect(list).toHaveBeenLastCalledWith('blocked', 'T', 'new.test', undefined, 'dev.cluster.test'))
})
it('an Allow with the Blocked tree open turns the tree to Allowed, at the domain', async () => {
exports([], [])
vi.spyOn(zonelists, 'addDomain').mockResolvedValue(OK)
const list = emptyTree()
draw()
await screen.findByRole('table')
await userEvent.click(screen.getByRole('button', { name: 'Tree' }))
await screen.findByText('0 zones')
await userEvent.type(screen.getByRole('textbox', { name: 'Domain' }), 'ok.test')
await userEvent.click(screen.getByRole('button', { name: 'Allow' }))
await screen.findByText("Domain 'ok.test' was added to Allowed Zone successfully.")
await vi.waitFor(() => expect(list).toHaveBeenLastCalledWith('allowed', 'T', 'ok.test', undefined, ''))
expect(screen.getByRole('button', { name: 'Allowed' })).toHaveAttribute('aria-pressed', 'true')
})
it('an Allow in list view keeps the previously chosen tree list', async () => {
exports([], [])
vi.spyOn(zonelists, 'addDomain').mockResolvedValue(OK)
const list = emptyTree()
draw()
await screen.findByRole('table')
await userEvent.type(screen.getByRole('textbox', { name: 'Domain' }), 'ok.test')
await userEvent.click(screen.getByRole('button', { name: 'Allow' }))
await screen.findByText("Domain 'ok.test' was added to Allowed Zone successfully.")
expect(list).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('button', { name: 'Tree' }))
await screen.findByText('0 zones')
expect(list).toHaveBeenLastCalledWith('blocked', 'T', '', undefined, '')
expect(within(screen.getByRole('group', { name: 'Tree' })).getByRole('button', { name: 'Blocked' }))
.toHaveAttribute('aria-pressed', 'true')
})
it('Allow without Allowed view refreshes the visible Blocked tree at its root', async () => {
const read = exports([], [])
vi.spyOn(zonelists, 'addDomain').mockResolvedValue(OK)
const list = emptyTree()
render(<Rules token="T" permissions={{ Blocked: P(true), Allowed: P(false) }} nodes={NODES} clusterInitialised />)
await screen.findByRole('table')
await userEvent.click(screen.getByRole('button', { name: 'Tree' }))
await screen.findByText('0 zones')
await userEvent.type(screen.getByRole('textbox', { name: 'Domain' }), 'ok.test')
await userEvent.click(screen.getByRole('button', { name: 'Allow' }))
await screen.findByText("Domain 'ok.test' was added to Allowed Zone successfully.")
await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(2))
expect(list).toHaveBeenLastCalledWith('blocked', 'T', '', undefined, 'dev.cluster.test')
expect(list.mock.calls.some(([kind, , domain]) => kind === 'allowed' || domain === 'ok.test')).toBe(false)
expect(read).toHaveBeenLastCalledWith('blocked', 'T', 'dev.cluster.test')
expect(within(screen.getByRole('group', { name: 'Tree' })).getByRole('button', { name: 'Blocked' }))
.toHaveAttribute('aria-pressed', 'true')
})
it('an Allow completing after the tree closes does not change its remembered list', async () => {
exports([], [])
let finish!: (outcome: typeof OK) => void
vi.spyOn(zonelists, 'addDomain').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
const list = emptyTree()
draw()
await screen.findByRole('table')
await userEvent.click(screen.getByRole('button', { name: 'Tree' }))
await screen.findByText('0 zones')
await userEvent.type(screen.getByRole('textbox', { name: 'Domain' }), 'ok.test')
await userEvent.click(screen.getByRole('button', { name: 'Allow' }))
await userEvent.click(screen.getByRole('button', { name: 'List' }))
await act(async () => finish(OK))
await screen.findByText("Domain 'ok.test' was added to Allowed Zone successfully.")
expect(list).toHaveBeenCalledTimes(1)
await userEvent.click(screen.getByRole('button', { name: 'Tree' }))
await screen.findByText('0 zones')
expect(list).toHaveBeenLastCalledWith('blocked', 'T', '', undefined, '')
})
/* An older read that answers last must not land over a newer one. */
it('a slow earlier read does not overwrite a later one', async () => {
const answers: Array<(v: { kind: 'ok'; data: string[] }) => void> = []
vi.spyOn(blocking, 'readRuleExport').mockImplementation(
() => new Promise((resolve) => { answers.push(resolve) }),
)
vi.spyOn(zonelists, 'addDomain').mockResolvedValue(OK)
draw()
await userEvent.type(screen.getByRole('textbox', { name: 'Domain' }), 'new.test{Enter}')
await vi.waitFor(() => expect(answers).toHaveLength(4))
answers[2]({ kind: 'ok', data: ['new.test'] })
answers[3]({ kind: 'ok', data: [] })
expect(await screen.findByText(wholeText('new.test'))).toBeInTheDocument()
answers[0]({ kind: 'ok', data: ['old.test'] })
answers[1]({ kind: 'ok', data: [] })
await new Promise((r) => setTimeout(r, 20))
expect(screen.queryByText(wholeText('old.test'))).toBeNull()
expect(screen.getByText(wholeText('new.test'))).toBeInTheDocument()
})
it('a failed first read says so instead of drawing an empty table', async () => {
vi.spyOn(blocking, 'readRuleExport').mockResolvedValue({ kind: 'error', message: 'Access was denied.' })
draw()
expect(await screen.findByText('Access was denied.')).toBeInTheDocument()
expect(screen.queryByText('No rules')).toBeNull()
})
/* Once the notice is dismissed the slot still says what happened, and offers
to read again, as the blocking state does (StatusPanel). */
it('a failed first read leaves Failure and Retry in the table slot', async () => {
const read = vi.spyOn(blocking, 'readRuleExport').mockResolvedValue({ kind: 'error', message: 'Access was denied.' })
draw()
expect(await screen.findByText('Could not read the rules.')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.getByText('Could not read the rules.')).toBeInTheDocument()
read.mockImplementation(async (list) => ({ kind: 'ok', data: list === 'blocked' ? ['ads.example.com'] : [] }))
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText(wholeText('ads.example.com'))).toBeInTheDocument()
expect(screen.queryByText('Could not read the rules.')).toBeNull()
})
it('?rule= naming a list the session cannot view opens on All and rewrites the bar', async () => {
window.history.replaceState(null, '', '/blocking/rules/?rule=allowed&x=1')
exports(['ads.example.com'], ['never.test'])
draw({ Blocked: P(true), Allowed: P(false) })
const table = await screen.findByRole('table')
expect(within(table).getByText(wholeText('ads.example.com'))).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'All 1' })).toHaveAttribute('aria-pressed', 'true')
expect(window.location.search).toBe('?x=1')
})
it('the tree reads from the connected node first, and from the primary after a Flush', async () => {
exports(['ads.example.com'], [])
vi.spyOn(zonelists, 'flushList').mockResolvedValue(OK)
const list = emptyTree()
render(<Rules token="T" permissions={undefined} nodes={NODES} clusterInitialised />)
await screen.findByRole('table')
await userEvent.click(screen.getByRole('button', { name: 'Tree' }))
await screen.findByText('0 zones')
expect(list).toHaveBeenLastCalledWith('blocked', 'T', '', undefined, '')
await userEvent.click(screen.getByRole('button', { name: 'Flush' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Blocked zones' }))
await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Flush' }))
await screen.findByText('Blocked zone was flushed successfully.')
expect(list).toHaveBeenLastCalledWith('blocked', 'T', '', undefined, 'dev.cluster.test')
await userEvent.click(screen.getByRole('button', { name: 'Allowed' }))
await screen.findByText('0 zones')
expect(list).toHaveBeenLastCalledWith('allowed', 'T', '', undefined, '')
})
it('the tree field is named apart from the add bar field', async () => {
exports([], [])
emptyTree()
draw()
await screen.findByRole('table')
await userEvent.click(screen.getByRole('button', { name: 'Tree' }))
expect(await screen.findByRole('textbox', { name: 'Browse domain' })).toBeInTheDocument()
expect(screen.getAllByRole('textbox', { name: 'Domain' })).toHaveLength(1)
})
it('with neither list viewable it draws the padlock, not zeros it never read', async () => {
const read = exports([], [])
draw({ Blocked: P(false), Allowed: P(false) })
expect(await screen.findByText('Requires Blocked: View')).toBeInTheDocument()
expect(screen.getByText('Requires Allowed: View')).toBeInTheDocument()
expect(read).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.queryByRole('table')).toBeNull()
expect(screen.queryByText('No rules')).toBeNull()
expect(screen.queryByText('0 rules')).toBeNull()
})
it('Import opens the upstream dialog of the chosen list', async () => {
exports([], [])
draw()
await userEvent.click(await screen.findByRole('button', { name: 'Import' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Blocked zones' }))
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByRole('heading', { name: 'Import Blocked Zones' })).toBeInTheDocument()
expect(dialog).toHaveTextContent('Enter domain names one below other to import into blocked zone:')
})
it('Delete on an allowed rule uses the Allowed sentences and list', async () => {
exports([], ['s.youtube.com'])
const remove = vi.spyOn(zonelists, 'deleteDomain').mockResolvedValue(OK)
draw(NO_MODIFY)
await userEvent.click(await screen.findByRole('button', { name: 'Delete s.youtube.com' }))
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByRole('heading', { name: 'Delete Allowed Zone' })).toBeInTheDocument()
expect(dialog).toHaveTextContent("Are you sure you want to delete the allowed zone 's.youtube.com'?")
await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' }))
expect(remove).toHaveBeenCalledWith('allowed', 'T', 's.youtube.com')
expect(await screen.findByText("Domain 's.youtube.com' was deleted from Allowed Zone successfully.")).toBeInTheDocument()
})
it('the foot count belongs to the list view, not the tree', async () => {
exports(['ads.example.com'], [])
emptyTree()
draw()
await screen.findByRole('table')
expect(screen.getByText('1 rule')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Tree' }))
await screen.findByText('0 zones')
expect(screen.queryByText('1 rule')).toBeNull()
})
it('a ?rule= value that means nothing is taken out of the bar', async () => {
window.history.replaceState(null, '', '/blocking/rules/?rule=garbage&x=1')
exports(['ads.example.com'], [])
draw()
await screen.findByRole('table')
expect(window.location.search).toBe('?x=1')
})
})
+654
View File
@@ -0,0 +1,654 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { readRuleExport } from '../../api/blocking'
import { addDomain, deleteDomain, exportDomains, flushList, type DomainList } from '../../api/zonelists'
import { SectionHeader } from '../../ui/SectionHeader'
import { Panel, Body } from '../../ui/Panel'
import { Table } from '../../ui/Table'
import { Pagination } from '../../ui/Pagination'
import { Segmented } from '../../ui/Segmented'
import { Menu } from '../../ui/Menu'
import { PermissionButton } from '../../ui/PermissionButton'
import { Confirm } from '../../ui/Confirm'
import { Notifier } from '../../ui/Notifier'
import { Toast } from '../../ui/Toast'
import { Input } from '../../ui/Field'
import { Icon } from '../../ui/Icon'
import { DomainName } from '../../ui/DomainName'
import { Failure, Loading } from '../../ui/Empty'
import { Button } from '../../ui/Button'
import { primaryNodeName } from '../../ui/ClusterNodeSelect'
import { pageWindow } from '../../lib/pagination'
import { noticeFromFailure, type Notice } from '../../lib/notice'
import { StaleData } from '../StaleData'
import { Lists, ImportDomains, type Confirmation } from '../lists/Lists'
import { LockedBody, LockedItem } from './Locked'
import { AddDomainBar } from './AddDomainBar'
import { missing, type Need, type Permissions } from './permissions'
import {
joinDeleted, modifyNeed, notRestoredNotice, restoreRules, RESTORED_MS, undoMessage, undoNeed, UNDO_MS,
type UndoState,
} from './undo'
import {
countRules, filterRules, mergeRules, pageOf, readRuleParam, ruleSearch,
type Rule, type RuleFilter,
} from './rules-model'
import shared from './Blocking.module.css'
import styles from './Rules.module.css'
/*
The Rules tab: the administrator's own blocked and allowed domains in one flat table.
The table is OURS, and so are its words:
the title `Rules` and its subtitle (`RULES_SUBTITLE`); the filter `All` and the counts beside each filter; `Rule` (column
and the filter group's label); the `Domain` column; `Filter domains`, `No rules`,
`No rules match this filter`, `1 rule` / `N rules`; `View`, `List` / `Tree` and the
`Tree` selector's label; `Browse domain`; the `Delete <domain>` labels; the menu
entries `Blocked zones` / `Allowed zones`; `Could not read the rules.` and its
`Retry`; and the titles of the Delete and Flush confirmations, which upstream asks
with a bare `confirm()`; and the undo notice's words (`undo.ts`). The names `Blocked` and
`Allowed` are upstream's tabs, and every verb is upstream's, with its sentences
(other-zones.js). The three verbs of the
foot do NOT behave alike, so they are not made alike: Import opens its dialog,
Export downloads at once with a single-use token, Flush asks first.
DELETE IN THE TABLE DOES NOT ASK; IT CAN BE UNDONE (2026-10-15, CONVENTIONS.md,
deviation 2). The row's Delete sends upstream's `blocked/delete` or `allowed/delete`
at once and leaves a notice with `Undo`, which sends `blocked/add` or `allowed/add`
for exactly the domains deleted, each to the list it came from: the same calls the
add bar makes. A second Delete while the notice is up joins it, so one Undo puts
back every rule it names. Dismissing the notice, letting it go, or leaving the tab
or the section keeps the deletions: they were made on the server already.
Without `Modify` on the rule's list the session could not put it back, so that
Delete still asks first, with upstream's sentence. Everywhere else the
confirmation stays: Flush, the tree (upstream's own screen) and the other sections.
`Tree` mounts the tree that exists today, so nothing upstream has is lost. It
reports through this page's notifier, and every change (from the table, the foot,
the add bar or the tree itself) reads the table again from the primary node and
remounts the tree from there. With the tree open and the destination viewable, a
Block or Allow opens it at the added domain, as upstream's blockZone/allowZone
do; because this one bar serves both lists, the tree also turns to the list the
domain went into (ours: upstream's verbs live on two separate pages, each with
its own tree). Otherwise the chosen list stays and its next refresh opens the root.
*/
/** What the tab is for, under its title. OURS (CONVENTIONS.md, deviation 2). */
export const RULES_SUBTITLE = 'Domains you block or allow yourself, with their subdomains. An allow rule wins over every block.'
const LABEL: Record<DomainList, string> = { blocked: 'Blocked', allowed: 'Allowed' }
export function Rules({
tabs,
token,
permissions,
nodes = [],
clusterInitialised = false,
}: {
tabs?: ReactNode
token: string | null
permissions: Permissions
nodes?: { name: string; type: string }[]
clusterInitialised?: boolean
}) {
const viewBlocked = missing(permissions, 'Blocked.canView') == null
const viewAllowed = missing(permissions, 'Allowed.canView') == null
/* Neither list can be read: there is nothing to count, so nothing is counted;
the table slot carries the padlock instead of zeros nobody read. */
const viewNone = !viewBlocked && !viewAllowed
const [rules, setRules] = useState<Rule[] | null>(null)
const [notice, setNotice] = useState<Notice | null>(null)
/* The first read failed and there is nothing to draw: the notice says why, and
the slot must not keep saying "Loading…" once it has been dismissed. */
const [failed, setFailed] = useState(false)
/* A later read failed: the table stays, and says it is no longer current. */
const [stale, setStale] = useState(false)
const [lastGood, setLastGood] = useState<string | null>(null)
const hadData = useRef(false)
/* A filter on a list the session cannot view would open locked, pressed, over an
empty table: `/allowed/` for someone who may only see Blocked. It opens on All. */
const [filter, setFilter] = useState<RuleFilter>(() => {
const asked = readRuleParam(window.location.search)
const may = asked === 'all' || (asked === 'blocked' ? viewBlocked : viewAllowed)
return may ? asked : 'all'
})
const [query, setQuery] = useState('')
const [page, setPage] = useState(1)
const [view, setView] = useState<'list' | 'tree'>('list')
const [treeList, setTreeList] = useState<DomainList>(viewBlocked ? 'blocked' : 'allowed')
/* Bumped after Block, Allow, Delete, Import and Flush: the open tree is mounted
again so it reads the lists as they are now, from the primary node, as every
read after a change does in this section. Choosing
another list or view is a fresh read again, from the connected node. */
const [generation, setGeneration] = useState(0)
const [afterChange, setAfterChange] = useState(false)
/* Where the remounted tree opens: the domain a Block or Allow just added, as
upstream's blockZone/allowZone open it (other-zones.js:350, 185); the root after
anything else. */
const [treeDomain, setTreeDomain] = useState('')
/* AddDomainBar may finish after the view or permissions changed. Its callback
must follow the tree that is visible when the request completes. */
const treeContext = useRef({ view, viewBlocked, viewAllowed })
useEffect(() => {
treeContext.current = { view, viewBlocked, viewAllowed }
}, [view, viewBlocked, viewAllowed])
const [confirmation, setConfirmation] = useState<Confirmation | null>(null)
/* The undo notice: the rules it can put back, and where it is (`undo.ts`).
`undoKey` restarts its clock when a Delete joins it or it reports. */
const [undo, setUndo] = useState<UndoState | null>(null)
const [undoKey, setUndoKey] = useState(0)
const undoButton = useRef<HTMLButtonElement>(null)
const search = useRef<HTMLInputElement>(null)
/* The row that held the focus is gone once the table is read again: the focus
goes to Undo then, and not to the page's body. */
const focusUndo = useRef(false)
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, [])
const [importing, setImporting] = useState<DomainList | null>(null)
const [busy, setBusy] = useState(false)
/*
The primary node, read through a ref for the reason Lists.tsx gives: as a
dependency of `load`, the `nodes = []` default is a new array on every render.
`asked` numbers the reads, as Overview does, so an older export that answers last
cannot land over a newer one.
*/
const primary = useRef('')
useEffect(() => {
primary.current = primaryNodeName(nodes, clusterInitialised)
}, [nodes, clusterInitialised])
const asked = useRef(0)
/* Only the lists the session may view are read: the export of the other one
would be refused by the server. The first read asks the connected node; after a
change, the PRIMARY, where the change was made (other-zones.js:269, 434): a
secondary still answers the old list until the cluster syncs. */
const load = useCallback(async (fromPrimary = false) => {
const node = fromPrimary ? primary.current : ''
const mine = ++asked.current
const read = (list: DomainList, may: boolean) => (may ? readRuleExport(list, token, node) : null)
const [b, a] = await Promise.all([read('blocked', viewBlocked), read('allowed', viewAllowed)])
if (mine !== asked.current) return
const failure = [b, a].find((o) => o != null && o.kind !== 'ok')
if (failure != null) {
if (hadData.current) setStale(true)
else {
setFailed(true)
setNotice(noticeFromFailure(failure))
}
return
}
setRules(mergeRules(b?.kind === 'ok' ? b.data : null, a?.kind === 'ok' ? a.data : null))
hadData.current = true
setFailed(false)
setStale(false)
setLastGood(new Date().toISOString())
}, [token, viewBlocked, viewAllowed])
useEffect(() => {
void load()
}, [load])
useLayoutEffect(() => {
if (!focusUndo.current) return
const active = document.activeElement
if (active != null && active !== document.body && document.contains(active)) return
focusUndo.current = false
/* The notice is drawn by sonner a moment after this render, so its button may
not exist yet: it is looked for until it does. */
let tries = 0
const focusWhenThere = () => {
if (undoButton.current != null) undoButton.current.focus()
else if (tries++ < 30) setTimeout(focusWhenThere, 16)
}
focusWhenThere()
}, [rules])
/* The bar always says the filter on screen: on choosing, and on opening when the
one it asked for fell back to All or meant nothing (`?rule=garbage`). The RAW
value is compared, because `readRuleParam` already reads nonsense as All. */
useEffect(() => {
const raw = new URLSearchParams(window.location.search).get('rule')
if (raw === (filter === 'all' ? null : filter)) return
window.history.replaceState(null, '', window.location.pathname + ruleSearch(window.location.search, filter))
}, [filter])
function choose(f: RuleFilter) {
setFilter(f)
setPage(1)
}
/** A change was made: the open tree is mounted again, from the primary, at
* `domain` when there is one to show. */
function changed(domain = '') {
setGeneration((g) => g + 1)
setAfterChange(true)
setTreeDomain(domain)
}
/* A Block or Allow from the add bar. With the tree open it turns to the list the
domain went into, when the session may view it, and opens at the domain. */
function added(list: DomainList, domain: string) {
const current = treeContext.current
const mayView = list === 'blocked' ? current.viewBlocked : current.viewAllowed
if (current.view === 'tree' && mayView) {
setTreeList(list)
changed(domain)
} else {
/* A hidden or locked destination must not navigate the other list to an
unrelated domain. Refresh its root, from the primary, as after Delete. */
changed()
}
void load(true)
}
const counts = useMemo(() => countRules(rules ?? []), [rules])
const shown = useMemo(() => filterRules(rules ?? [], filter, query), [rules, filter, query])
const current = pageOf(shown, page)
async function mutate(fn: () => Promise<{ kind: string; message?: string }>, success: Notice) {
setBusy(true)
const outcome = await fn()
setBusy(false)
if (outcome.kind !== 'ok') {
setNotice(noticeFromFailure(outcome))
return
}
changed()
await load(true)
setNotice(success)
}
function askDelete(rule: Rule) {
const isAllowed = rule.list === 'allowed'
setConfirmation({
title: isAllowed ? 'Delete Allowed Zone' : 'Delete Blocked Zone',
text: isAllowed
? `Are you sure you want to delete the allowed zone '${rule.domain}'?`
: `Are you sure you want to delete the blocked zone '${rule.domain}'?`,
label: 'Delete',
action: () =>
mutate(
() => deleteDomain(rule.list, token, rule.domain),
isAllowed
? { type: 'success', title: 'Deleted!', text: `Domain '${rule.domain}' was deleted from Allowed Zone successfully.` }
: { type: 'success', title: 'Deleted!', text: `Blocked zone '${rule.domain}' was deleted successfully.` },
),
})
}
/* Delete from the table: at once, with Undo, when the session could put the rule
back; otherwise upstream's confirmation (see the header). */
async function deleteRule(rule: Rule) {
if (missing(permissions, modifyNeed(rule.list)) != null) {
askDelete(rule)
return
}
setBusy(true)
const outcome = await deleteDomain(rule.list, token, rule.domain)
if (!mounted.current) return
setBusy(false)
if (outcome.kind !== 'ok') {
setNotice(noticeFromFailure(outcome))
return
}
setUndo((u) => joinDeleted(u, rule))
setUndoKey((k) => k + 1)
focusUndo.current = true
changed()
await load(true)
}
async function runUndo() {
if (undo == null || undo.phase !== 'deleted') return
const deleted = undo.rules
setUndo({ rules: deleted, phase: 'restoring' })
setBusy(true)
const result = await restoreRules(deleted, (r) => addDomain(r.list, token, r.domain))
if (!mounted.current) return
setBusy(false)
changed()
await load(true)
if (!mounted.current) return
if (result.failure == null) {
setUndo({ rules: deleted, phase: 'restored' })
setUndoKey((k) => k + 1)
return
}
setUndo(null)
setNotice(notRestoredNotice(deleted, result))
}
function askFlush(list: DomainList) {
const isAllowed = list === 'allowed'
setConfirmation({
title: isAllowed ? 'Flush Allowed Zone' : 'Flush Blocked Zone',
text: isAllowed
? 'Are you sure you want to flush the entire Allowed zone?'
: 'Are you sure you want to flush the entire Blocked zone?',
label: 'Flush',
action: () =>
mutate(
() => flushList(list, token),
isAllowed
? { type: 'success', title: 'Flushed!', text: 'Allowed zone was flushed successfully.' }
: { type: 'success', title: 'Flushed!', text: 'Blocked zone was flushed successfully.' },
),
})
}
async function runExport(list: DomainList) {
setBusy(true)
const r = await exportDomains(list, token)
setBusy(false)
if (!r.ok) return
setNotice({
type: 'success',
title: 'Exported!',
text: list === 'allowed' ? 'Allowed zones were exported successfully.' : 'Blocked zones were exported successfully.',
})
}
/* One menu entry per list. Without its permission it stays, disabled, and says
which one is missing (`LockedItem`). */
function entry(list: DomainList, need: Need | undefined, run: () => void, close: () => void) {
if (need != null) {
return (
<LockedItem key={list} need={need} placement="left">
{LABEL[list]} zones
</LockedItem>
)
}
return (
<button
key={list}
type="button"
onClick={() => {
close()
run()
}}
>
{LABEL[list]} zones
</button>
)
}
/* A list the session cannot read has no count: it was never read, and zero is a
figure. The chosen filter says so with `aria-pressed` alone, the console's pressed
button (amber text and border, as Glue and RRSIG in Records.tsx): a primary that is
also pressed took the pressed rule's amber TEXT over its amber fill, and the label
vanished. */
const filterButton = (f: RuleFilter, count: number | undefined, need?: Need) => (
<PermissionButton
key={f}
size="sm"
aria-pressed={filter === f}
permission={need}
onClick={() => choose(f)}
>
{f === 'all' ? 'All' : LABEL[f]}
{need == null && count != null && ` ${count}`}
</PermissionButton>
)
const treeNeed = missing(permissions, treeList === 'allowed' ? 'Allowed.canView' : 'Blocked.canView')
return (
<>
<SectionHeader section="Blocking" title="Rules" subtitle={RULES_SUBTITLE} tabs={tabs} />
<Notifier notice={notice} onClose={() => setNotice(null)} />
<div className={shared.stack}>
<AddDomainBar
token={token}
permissions={permissions}
onNotice={setNotice}
onChanged={added}
/>
{stale && <StaleData since={lastGood} onRetry={() => void load()} />}
<Panel className={shared.flush}>
<div className={styles.bar}>
{view === 'list' ? (
<>
<div className={styles.filter} role="group" aria-label="Rule">
{filterButton('all', viewNone ? undefined : counts.all)}
{filterButton('blocked', counts.blocked, missing(permissions, 'Blocked.canView'))}
{filterButton('allowed', counts.allowed, missing(permissions, 'Allowed.canView'))}
</div>
<Input
ref={search}
className={styles.search}
aria-label="Filter domains"
placeholder="Filter domains…"
value={query}
onChange={(e) => {
setQuery(e.target.value)
setPage(1)
}}
/>
</>
) : (
<Segmented
label="Tree"
options={[
{ id: 'blocked' as const, label: 'Blocked' },
{ id: 'allowed' as const, label: 'Allowed' },
]}
active={treeList}
onChoose={(l) => {
setTreeList(l)
setAfterChange(false)
setTreeDomain('')
}}
/>
)}
<span className={styles.spacer} />
<Segmented
label="View"
options={[
{ id: 'list' as const, label: 'List' },
{ id: 'tree' as const, label: 'Tree' },
]}
active={view}
onChoose={(v) => {
setView(v)
setAfterChange(false)
setTreeDomain('')
/* No read on coming back to the list: the tree says when it deleted
(`onChanged`) and the table was read again then, from the primary.
Reading the connected node here would bring back, on a secondary,
the row just deleted. */
}}
/>
</div>
{view === 'tree' ? (
treeNeed != null ? (
<Body>
<LockedBody need={treeNeed} />
</Body>
) : (
/* Inset like the rest of the panel's content: the tree and the node detail
are framed boxes of their own, and flush they doubled the panel's edge. */
<div className={styles.tree}>
<Lists
key={`${treeList}:${generation}`}
list={treeList}
token={token}
nodes={nodes}
clusterInitialised={clusterInitialised}
embedded
initialFromPrimary={afterChange}
initialDomain={treeDomain}
fieldName="Browse domain"
canDelete={missing(permissions, treeList === 'allowed' ? 'Allowed.canDelete' : 'Blocked.canDelete') == null}
onNotice={setNotice}
onChanged={() => void load(true)}
/>
</div>
)
) : viewNone ? (
<Body>
<LockedBody need="Blocked.canView" />
<LockedBody need="Allowed.canView" />
</Body>
) : rules == null ? (
failed ? (
/* The notice at the top carries the server's message; once it is
dismissed, the slot still says what happened and offers to read
again, as the blocking state does (StatusPanel). */
<Body>
<Failure>
Could not read the rules.{' '}
<Button
size="sm"
onClick={() => {
setFailed(false)
void load()
}}
>
Retry
</Button>
</Failure>
</Body>
) : (
<Loading />
)
) : (
<>
<Table
header={
<>
<th>Domain</th>
<th>Rule</th>
<th />
</>
}
isEmpty={current.rows.length === 0}
emptyText={query === '' ? 'No rules' : 'No rules match this filter'}
columns={3}
className={shared.inPanel}
>
{current.rows.map((r) => (
<tr key={`${r.list}:${r.domain}`}>
<td className={styles.domain}><DomainName name={r.domain} /></td>
<td>
<span className={`${styles.kind} ${styles[r.list]}`}>
<Icon name={r.list} size={14} />
{LABEL[r.list]}
</span>
</td>
<td className={styles.actions}>
<PermissionButton
size="sm"
variant="danger"
aria-label={`Delete ${r.domain}`}
disabled={busy}
permission={missing(permissions, r.list === 'allowed' ? 'Allowed.canDelete' : 'Blocked.canDelete')}
onClick={() => void deleteRule(r)}
>
Delete
</PermissionButton>
</td>
</tr>
))}
</Table>
{current.totalPages > 1 && (
<Pagination
window={pageWindow(current.page, current.totalPages)}
current={current.page}
last={current.totalPages}
onGoTo={setPage}
/>
)}
</>
)}
<div className={styles.foot}>
{/* The count describes the table: not the tree, and not a table that
could not be read. */}
{view === 'list' && !viewNone && (
<span>{shown.length === 1 ? '1 rule' : `${shown.length} rules`}</span>
)}
<span className={styles.spacer} />
<Menu label="Import" text="Import">
{(close) => (
<>
{entry('blocked', missing(permissions, 'Blocked.canModify'), () => setImporting('blocked'), close)}
{entry('allowed', missing(permissions, 'Allowed.canModify'), () => setImporting('allowed'), close)}
</>
)}
</Menu>
<Menu label="Export" text="Export">
{(close) => (
<>
{entry('blocked', missing(permissions, 'Blocked.canView'), () => void runExport('blocked'), close)}
{entry('allowed', missing(permissions, 'Allowed.canView'), () => void runExport('allowed'), close)}
</>
)}
</Menu>
<Menu label="Flush" text="Flush">
{(close) => (
<>
{entry('blocked', missing(permissions, 'Blocked.canDelete'), () => askFlush('blocked'), close)}
{entry('allowed', missing(permissions, 'Allowed.canDelete'), () => askFlush('allowed'), close)}
</>
)}
</Menu>
</div>
</Panel>
</div>
{/* The action's promise goes back to `Confirm`, which keeps the dialog busy
while it runs and closes it when it settles, as the lists screens do. */}
<Confirm
open={confirmation !== null}
title={confirmation?.title ?? ''}
text={confirmation?.text}
label={confirmation?.label ?? ''}
onClose={() => setConfirmation(null)}
onConfirm={() => confirmation?.action()}
/>
<Toast
message={undo == null ? null : undoMessage(undo)}
action={
undo == null || undo.phase === 'restored'
? undefined
: {
label: 'Undo',
disabled: undo.phase === 'restoring',
permission: undoNeed(permissions, undo.rules),
onClick: () => void runUndo(),
}
}
hold={undo?.phase === 'restoring'}
duration={undo?.phase === 'restored' ? RESTORED_MS : UNDO_MS}
timerKey={undoKey}
actionRef={undoButton}
returnFocus={() => search.current?.focus()}
onDismiss={() => setUndo(null)}
/>
{importing != null && (
<ImportDomains
list={importing}
open
token={token}
onClose={() => setImporting(null)}
onDone={(n) => {
setNotice(n)
changed()
void load(true)
}}
/>
)}
</>
)
}
+203
View File
@@ -0,0 +1,203 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen } from '@testing-library/react'
import userEvent, { type UserEvent } from '@testing-library/user-event'
import * as blocking from '../../api/blocking'
import * as zonelists from '../../api/zonelists'
import { Rules } from './Rules'
/*
Delete in the Rules table: at once, with Undo (Rules.tsx, the header; undo.ts).
The server is a small in-memory one, so a read after a change sees it.
*/
const OK = { kind: 'ok' as const, data: {} }
const P = (v: boolean, m = true, d = true) => ({ canView: v, canModify: m, canDelete: d })
beforeEach(() => window.history.replaceState(null, '', '/blocking/rules/'))
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
function server(blocked: string[], allowed: string[]) {
const lists = { blocked: new Set(blocked), allowed: new Set(allowed) }
vi.spyOn(blocking, 'readRuleExport').mockImplementation(async (list) => ({ kind: 'ok', data: [...lists[list]] }))
const remove = vi.spyOn(zonelists, 'deleteDomain').mockImplementation(async (list, _t, domain) => {
lists[list].delete(domain)
return OK
})
const add = vi.spyOn(zonelists, 'addDomain').mockImplementation(async (list, _t, domain) => {
lists[list].add(domain)
return OK
})
return { lists, remove, add }
}
const status = () => screen.getByRole('status')
async function deleteRule(domain: string, user: Pick<UserEvent, 'click'> = userEvent) {
await user.click(await screen.findByRole('button', { name: `Delete ${domain}` }))
}
describe('Rules: Delete with Undo', () => {
it('deletes at once, with no dialog, and says so with upstream sentence', async () => {
const { remove, lists } = server(['ads.example.com'], [])
render(<Rules token="T" permissions={undefined} />)
await deleteRule('ads.example.com')
expect(screen.queryByRole('dialog')).toBeNull()
expect(remove).toHaveBeenCalledWith('blocked', 'T', 'ads.example.com')
expect(lists.blocked.size).toBe(0)
expect(status()).toHaveTextContent("Blocked zone 'ads.example.com' was deleted successfully.")
expect(await screen.findByText('No rules')).toBeInTheDocument()
})
it('Undo puts the same domain back in the same list, with the add call', async () => {
const { add } = server([], ['s.youtube.com'])
render(<Rules token="T" permissions={undefined} />)
await deleteRule('s.youtube.com')
expect(status()).toHaveTextContent("Domain 's.youtube.com' was deleted from Allowed Zone successfully.")
await userEvent.click(await screen.findByRole('button', { name: 'Undo' }))
expect(add).toHaveBeenCalledExactlyOnceWith('allowed', 'T', 's.youtube.com')
await vi.waitFor(() => expect(status()).toHaveTextContent("'s.youtube.com' is back in Allowed."))
expect(await screen.findByRole('button', { name: 'Delete s.youtube.com' })).toBeInTheDocument()
await vi.waitFor(() => expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull())
})
it('a second Delete joins the open notice, and one Undo puts both back, each in its list', async () => {
const { add, lists } = server(['ads.example.com'], ['s.youtube.com'])
render(<Rules token="T" permissions={undefined} />)
await deleteRule('ads.example.com')
await deleteRule('s.youtube.com')
expect(status()).toHaveTextContent('2 rules were deleted.')
await userEvent.click(await screen.findByRole('button', { name: 'Undo' }))
expect(add.mock.calls).toEqual([
['blocked', 'T', 'ads.example.com'],
['allowed', 'T', 's.youtube.com'],
])
await vi.waitFor(() => expect(status()).toHaveTextContent('2 rules are back.'))
expect([...lists.blocked, ...lists.allowed]).toEqual(['ads.example.com', 's.youtube.com'])
})
it('a Delete after the notice said "back" starts a new one', async () => {
server(['a.test', 'b.test'], [])
render(<Rules token="T" permissions={undefined} />)
await deleteRule('a.test')
await userEvent.click(await screen.findByRole('button', { name: 'Undo' }))
await vi.waitFor(() => expect(status()).toHaveTextContent("'a.test' is back in Blocked."))
await deleteRule('b.test')
expect(status()).toHaveTextContent("Blocked zone 'b.test' was deleted successfully.")
})
it('an Undo that fails says which rules did not come back, and why', async () => {
const { add } = server(['a.test', 'b.test', 'c.test'], [])
add.mockResolvedValueOnce(OK).mockResolvedValueOnce({ kind: 'error', message: 'Access was denied.' })
render(<Rules token="T" permissions={undefined} />)
await deleteRule('a.test')
await deleteRule('b.test')
await deleteRule('c.test')
await userEvent.click(await screen.findByRole('button', { name: 'Undo' }))
expect(await screen.findByText('2 of 3 rules were not put back: Access was denied.')).toBeInTheDocument()
// It stops at the first refusal: the third is not asked for.
expect(add).toHaveBeenCalledTimes(2)
expect(status()).toBeEmptyDOMElement()
})
it('an expired session during Undo says so', async () => {
const { add } = server(['a.test'], [])
add.mockResolvedValue({ kind: 'invalid-token' })
render(<Rules token="T" permissions={undefined} />)
await deleteRule('a.test')
await userEvent.click(await screen.findByRole('button', { name: 'Undo' }))
expect(await screen.findByText("'a.test' was not put back: Invalid token or session expired.")).toBeInTheDocument()
})
it('a Delete that fails leaves no Undo, only the server message', async () => {
const { remove } = server(['a.test'], [])
remove.mockResolvedValue({ kind: 'error', message: 'No such zone.' })
render(<Rules token="T" permissions={undefined} />)
await deleteRule('a.test')
expect(await screen.findByText('No such zone.')).toBeInTheDocument()
expect(status()).toBeEmptyDOMElement()
})
it('dismissing the notice keeps the deletion', async () => {
const { add } = server(['a.test'], [])
render(<Rules token="T" permissions={undefined} />)
await deleteRule('a.test')
await userEvent.click(await screen.findByRole('button', { name: 'Dismiss' }))
expect(status()).toBeEmptyDOMElement()
expect(add).not.toHaveBeenCalled()
expect(screen.getByText('No rules')).toBeInTheDocument()
})
it('Escape inside the notice dismisses it', async () => {
server(['a.test'], [])
render(<Rules token="T" permissions={undefined} />)
await deleteRule('a.test')
;(await screen.findByRole('button', { name: 'Undo' })).focus()
await userEvent.keyboard('{Escape}')
expect(status()).toBeEmptyDOMElement()
})
it('the notice goes after a few seconds, and not while the pointer is on it', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const user = userEvent.setup({ delay: null, advanceTimers: vi.advanceTimersByTime })
server(['a.test'], [])
render(<Rules token="T" permissions={undefined} />)
await deleteRule('a.test', user)
await user.hover(await screen.findByRole('button', { name: 'Undo' }))
act(() => vi.advanceTimersByTime(20_000))
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument()
await user.unhover(screen.getByRole('button', { name: 'Undo' }))
// The focus went to Undo when the row went; while it stays there the clock holds.
act(() => screen.getByLabelText('Filter domains').focus())
act(() => vi.advanceTimersByTime(8_000))
await vi.waitFor(() => expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull())
})
it('a permission lost while the notice is up turns Undo off and names it', async () => {
const { add } = server(['a.test'], [])
const { rerender } = render(<Rules token="T" permissions={{ Blocked: P(true), Allowed: P(true) }} />)
await deleteRule('a.test')
rerender(<Rules token="T" permissions={{ Blocked: P(true, false), Allowed: P(true) }} />)
await screen.findByRole('button', { name: 'Undo' })
/* The new state reaches sonner's copy of the notice a moment later. */
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled())
await userEvent.hover(screen.getByRole('button', { name: 'Undo' }).parentElement!)
expect(await screen.findByText('Requires Blocked: Modify')).toBeInTheDocument()
expect(add).not.toHaveBeenCalled()
})
it('leaving the tab drops the notice and keeps the deletion', async () => {
const { add } = server(['a.test'], [])
const { unmount } = render(<Rules token="T" permissions={undefined} />)
await deleteRule('a.test')
unmount()
expect(screen.queryByRole('status')).toBeNull()
expect(add).not.toHaveBeenCalled()
})
it('the focus the deleted row held goes to Undo, and from Undo to the filter when it is done', async () => {
server(['a.test', 'b.test'], [])
render(<Rules token="T" permissions={undefined} />)
const button = await screen.findByRole('button', { name: 'Delete a.test' })
button.focus()
await userEvent.keyboard('{Enter}')
const undo = await screen.findByRole('button', { name: 'Undo' })
await vi.waitFor(() => expect(undo).toHaveFocus())
await userEvent.keyboard('{Enter}')
await vi.waitFor(() => expect(status()).toHaveTextContent("'a.test' is back in Blocked."))
await vi.waitFor(() => expect(screen.getByLabelText('Filter domains')).toHaveFocus())
})
it('without Modify on the list the Delete asks first, as upstream; with it, it does not', async () => {
const { remove } = server(['ads.test'], ['ok.test'])
render(<Rules token="T" permissions={{ Blocked: P(true), Allowed: P(true, false) }} />)
await deleteRule('ads.test')
expect(screen.queryByRole('dialog')).toBeNull()
expect(remove).toHaveBeenCalledWith('blocked', 'T', 'ads.test')
await deleteRule('ok.test')
const dialog = await screen.findByRole('dialog')
expect(dialog).toHaveTextContent("Are you sure you want to delete the allowed zone 'ok.test'?")
})
})
+165
View File
@@ -0,0 +1,165 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as settings from '../../api/settings'
import type { DnsSettings } from '../../api/settings'
import { StatusPanel } from './StatusPanel'
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
function serve(s: Partial<DnsSettings>) {
return vi.spyOn(settings, 'getSettings').mockResolvedValue(s as DnsSettings)
}
describe('StatusPanel', () => {
it('says blocking is enabled', async () => {
serve({ enableBlocking: true })
render(<StatusPanel token="T" permissions={undefined} onNotice={() => {}} />)
expect(await screen.findByText('Blocking is enabled')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Blocking options' })).toHaveTextContent('Disable')
})
it('says until when it is paused', async () => {
serve({ enableBlocking: false, temporaryDisableBlockingTill: '2099-01-01T21:15:00Z' })
render(<StatusPanel token="T" permissions={undefined} onNotice={() => {}} />)
expect(await screen.findByText('Blocking is paused')).toBeInTheDocument()
expect(screen.getByText(/^Until /)).toBeInTheDocument()
})
it('enabled, it carries the Active pill in the ok tone', async () => {
serve({ enableBlocking: true })
render(<StatusPanel token="T" permissions={undefined} onNotice={() => {}} />)
const pill = await screen.findByText('Active')
expect(pill.className).toMatch(/ok/)
expect(screen.getByText('Queries matching a block list or your rules are answered as blocked.')).toBeInTheDocument()
})
it('paused until later today, the pill gives the hour alone, in amber, and the sentence stays', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(new Date(2026, 9, 1, 20, 45, 0))
serve({ enableBlocking: false, temporaryDisableBlockingTill: new Date(2026, 9, 1, 21, 15, 0).toISOString() })
render(<StatusPanel token="T" permissions={undefined} onNotice={() => {}} />)
const pill = await screen.findByText('Until 21:15')
expect(pill.className).toMatch(/acc/)
expect(screen.getByText('Resumes automatically.')).toBeInTheDocument()
})
it('paused past today, the pill gives the date as well', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(new Date(2026, 9, 1, 23, 30, 0))
serve({ enableBlocking: false, temporaryDisableBlockingTill: new Date(2026, 9, 2, 1, 30, 0).toISOString() })
render(<StatusPanel token="T" permissions={undefined} onNotice={() => {}} />)
expect(await screen.findByText('Until 2026-10-02 01:30')).toBeInTheDocument()
})
it('paused without Settings.canModify, the pill is neutral: the padlock wins', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(new Date(2026, 9, 1, 20, 45, 0))
serve({ enableBlocking: false, temporaryDisableBlockingTill: new Date(2026, 9, 1, 21, 15, 0).toISOString() })
render(
<StatusPanel
token="T"
permissions={{ Settings: { canView: true, canModify: false, canDelete: false } }}
onNotice={() => {}}
/>,
)
const pill = await screen.findByText('Until 21:15')
expect(pill.className).not.toMatch(/acc/)
})
it('says until when to the minute, without seconds', async () => {
const till = new Date(2099, 0, 1, 21, 15, 42).toISOString()
serve({ enableBlocking: false, temporaryDisableBlockingTill: till })
render(<StatusPanel token="T" permissions={undefined} onNotice={() => {}} />)
expect(await screen.findByText('Until 2099-01-01 21:15')).toBeInTheDocument()
})
it('reads again when the pause ends, and stops saying paused', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const till = new Date(Date.now() + 60_000).toISOString()
const read = vi
.spyOn(settings, 'getSettings')
.mockResolvedValueOnce({ enableBlocking: false, temporaryDisableBlockingTill: till } as DnsSettings)
.mockResolvedValue({ enableBlocking: true } as DnsSettings)
render(<StatusPanel token="T" permissions={undefined} onNotice={() => {}} />)
expect(await screen.findByText('Blocking is paused')).toBeInTheDocument()
expect(read).toHaveBeenCalledTimes(1)
await act(() => vi.advanceTimersByTimeAsync(30_000))
expect(read).toHaveBeenCalledTimes(1)
await act(() => vi.advanceTimersByTimeAsync(35_000))
expect(read).toHaveBeenCalledTimes(2)
expect(await screen.findByText('Blocking is enabled')).toBeInTheDocument()
})
it('when the state cannot be read it says so, continuous, with a Retry that reads again', async () => {
const read = vi
.spyOn(settings, 'getSettings')
.mockResolvedValueOnce(null)
.mockResolvedValue({ enableBlocking: true } as DnsSettings)
render(<StatusPanel token="T" permissions={undefined} onNotice={() => {}} />)
expect(await screen.findByText('Could not read the blocking state.')).toBeInTheDocument()
expect(screen.queryByText('Loading…')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Blocking is enabled')).toBeInTheDocument()
expect(read).toHaveBeenCalledTimes(2)
})
it('a paused mark is amber only when the user can act on it: the padlock wins', async () => {
const till = '2099-01-01T21:15:00Z'
serve({ enableBlocking: false, temporaryDisableBlockingTill: till })
const { container, unmount } = render(
<StatusPanel token="T" permissions={undefined} onNotice={() => {}} />,
)
await screen.findByText('Blocking is paused')
expect(container.querySelector('[data-tone]')).toHaveAttribute('data-tone', 'paused')
unmount()
const locked = render(
<StatusPanel
token="T"
permissions={{ Settings: { canView: true, canModify: false, canDelete: false } }}
onNotice={() => {}}
/>,
)
expect(await screen.findByText('Blocking is paused')).toBeInTheDocument()
expect(screen.getByText(/^Until /)).toBeInTheDocument()
expect(locked.container.querySelector('[data-tone]')).toHaveAttribute('data-tone', 'off')
})
it('says it is disabled when there is no pause in the future', async () => {
serve({ enableBlocking: false, temporaryDisableBlockingTill: '2000-01-01T00:00:00Z' })
render(<StatusPanel token="T" permissions={undefined} onNotice={() => {}} />)
expect(await screen.findByText('Blocking is disabled')).toBeInTheDocument()
})
it('without Settings.canModify the control is disabled with its padlock', async () => {
serve({ enableBlocking: true })
render(
<StatusPanel
token="T"
permissions={{ Settings: { canView: true, canModify: false, canDelete: false } }}
onNotice={() => {}}
/>,
)
expect(await screen.findByRole('button', { name: /Disable/ })).toBeDisabled()
})
it('without Settings.canView the panel is locked and nothing is read', () => {
const read = serve({ enableBlocking: true })
render(
<StatusPanel
token="T"
permissions={{ Settings: { canView: false, canModify: false, canDelete: false } }}
onNotice={() => {}}
/>,
)
expect(screen.getByText('Requires Settings: View')).toBeInTheDocument()
expect(read).not.toHaveBeenCalled()
})
})
+119
View File
@@ -0,0 +1,119 @@
import { BlockingMenu } from '../dashboard/BlockingMenu'
import { Panel, Body } from '../../ui/Panel'
import { PermissionButton } from '../../ui/PermissionButton'
import { Button } from '../../ui/Button'
import { Failure, Loading } from '../../ui/Empty'
import { Icon } from '../../ui/Icon'
import { Tag } from '../../ui/Tag'
import type { Notice } from '../../lib/notice'
import { Locked } from './Locked'
import { missing, type Permissions } from './permissions'
import { untilText, useBlockingState } from './blocking-state'
import shared from './Blocking.module.css'
import styles from './Overview.module.css'
/*
Whether blocking is on, and the control to change it. The block and its sentences are
OURS (it says when blocking comes back; upstream only has the menu), and so
is the trigger's `Disable` / `Enable` (upstream's button says "Blocking"); the menu
and its eight durations are upstream's, reused whole from the Dashboard
(`BlockingMenu`, main.js:2429). It reads and writes on the node the console is
connected to, as that menu does.
Three things the block owes the rules rather than the menu:
- A read that fails says so with `Failure` and a `Retry` (the design rule: continuous =
error), instead of leaving "Loading…" on screen forever.
- A pause ends on the server by itself, so the block reads again when it should
have ended: otherwise it would keep saying "paused" for as long as the screen is
open (`blocking-state.ts`, AFTER_PAUSE_MS).
- The amber of "paused" says "yours to change". Without `Settings.canModify` it is
not, and the design rule is that the padlock wins: the mark goes neutral.
*/
export function StatusPanel({
token,
permissions,
onNotice,
}: {
token: string | null
permissions: Permissions
onNotice: (n: Notice) => void
}) {
const viewNeed = missing(permissions, 'Settings.canView')
/* The reads (when, from where, and the one when a pause ends) are
`useBlockingState`'s, shared with the sidebar's mark. A change made from the
menu below reaches it as every settings write does (`lib/settings-signal.ts`). */
const { state, retry } = useBlockingState(token, permissions)
if (viewNeed != null) return <Locked title="Blocking" need={viewNeed} />
const modifyNeed = missing(permissions, 'Settings.canModify')
if (state === 'failed') {
return (
<Panel className={shared.centred}>
<Body>
<Failure>
Could not read the blocking state.{' '}
<Button size="sm" onClick={retry}>
Retry
</Button>
</Failure>
</Body>
</Panel>
)
}
const verb = state?.kind === 'on' ? 'Disable' : 'Enable'
/* The padlock wins: a pause the user cannot lift is not drawn in amber. */
const tone = state?.kind === 'paused' && modifyNeed != null ? 'off' : state?.kind
/* Centred: the panel shares its row with the taller add bar and stretches to it. */
return (
<Panel className={shared.centred}>
<Body>
{state == null ? (
<Loading compact />
) : (
<div className={styles.status}>
<span className={`${styles.mark} ${styles[tone ?? 'off']}`} data-tone={tone}>
<Icon name={state.kind === 'on' ? 'blocked' : 'power'} size={18} />
</span>
<div className={styles.statusText}>
<div className={styles.statusTitle}>
{state.kind === 'on'
? 'Blocking is enabled'
: state.kind === 'paused'
? 'Blocking is paused'
: 'Blocking is disabled'}
{/* The state as a pill, as the drawing has it. Paused, the amber says
"yours to lift": without Settings.canModify it is neutral, because
the padlock wins. */}
{state.kind === 'on' && <Tag tone="ok">Active</Tag>}
{state.kind === 'paused' &&
(modifyNeed == null ? (
<Tag tone="acc">{untilText(state.till)}</Tag>
) : (
<Tag>{untilText(state.till)}</Tag>
))}
</div>
<div className={styles.statusSub}>
{state.kind === 'paused'
? 'Resumes automatically.'
: state.kind === 'on'
? 'Queries matching a block list or your rules are answered as blocked.'
: 'Nothing is being blocked.'}
</div>
</div>
{modifyNeed != null ? (
<PermissionButton permission={modifyNeed}>{verb}</PermissionButton>
) : (
<BlockingMenu token={token} text={verb} onNotice={onNotice} />
)}
</div>
)}
</Body>
</Panel>
)
}
+148
View File
@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as blocking from '../../api/blocking'
import type { ApiOutcome } from '../../api/client'
import type { TopEntry } from '../../api/dashboard'
import { TopTable } from './TopTable'
afterEach(() => vi.restoreAllMocks())
const ROWS: TopEntry[] = [
{ name: 'googleads.g.doubleclick.net', hits: 1204 },
{ name: 'app-measurement.com', hits: 748 },
]
const P = (v: boolean, m: boolean, d: boolean) => ({ canView: v, canModify: m, canDelete: d })
describe('TopTable', () => {
it('Allow Domain runs upstream two calls and reports with its sentence', async () => {
const allow = vi.spyOn(blocking, 'allowDomain').mockResolvedValue({ kind: 'ok', data: {} })
const onNotice = vi.fn()
const onChanged = vi.fn()
render(
<TopTable kind="TopBlockedDomains" rows={ROWS} range="LastHour" token="T"
permissions={undefined} failure={false} onNotice={onNotice} onChanged={onChanged} />,
)
await userEvent.click(screen.getByRole('button', { name: 'Actions for googleads.g.doubleclick.net' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Allow Domain' }))
expect(allow).toHaveBeenCalledWith('T', 'googleads.g.doubleclick.net')
expect(onNotice).toHaveBeenCalledWith({
type: 'success', title: 'Allowed!',
text: "Domain 'googleads.g.doubleclick.net' was added to Allowed Zone successfully.",
})
expect(onChanged).toHaveBeenCalled()
})
it('Block Domain reports with its own sentence', async () => {
const block = vi.spyOn(blocking, 'blockDomain').mockResolvedValue({ kind: 'ok', data: {} })
const onNotice = vi.fn()
render(
<TopTable kind="TopDomains" rows={ROWS} range="LastHour" token="T"
permissions={undefined} failure={false} onNotice={onNotice} onChanged={() => {}} />,
)
await userEvent.click(screen.getByRole('button', { name: 'Actions for app-measurement.com' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Block Domain' }))
expect(block).toHaveBeenCalledWith('T', 'app-measurement.com')
expect(onNotice).toHaveBeenCalledWith({
type: 'success', title: 'Blocked!',
text: "Domain 'app-measurement.com' was added to Blocked Zone successfully.",
})
})
it('a failed call reports the server message and refreshes nothing', async () => {
vi.spyOn(blocking, 'allowDomain').mockResolvedValue({ kind: 'error', message: 'Access was denied.' })
const onNotice = vi.fn()
const onChanged = vi.fn()
render(
<TopTable kind="TopBlockedDomains" rows={ROWS} range="LastHour" token="T"
permissions={undefined} failure={false} onNotice={onNotice} onChanged={onChanged} />,
)
await userEvent.click(screen.getByRole('button', { name: 'Actions for googleads.g.doubleclick.net' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Allow Domain' }))
expect(onNotice).toHaveBeenCalledWith({ type: 'danger', title: 'Error!', text: 'Access was denied.' })
expect(onChanged).not.toHaveBeenCalled()
})
it('a row cannot run twice while its two calls are in flight, and settles enabled', async () => {
let settle: (o: ApiOutcome) => void = () => {}
const allow = vi.spyOn(blocking, 'allowDomain').mockReturnValue(new Promise((r) => { settle = r }))
render(
<TopTable kind="TopBlockedDomains" rows={ROWS} range="LastHour" token="T"
permissions={undefined} failure={false} onNotice={() => {}} onChanged={() => {}} />,
)
const trigger = screen.getByRole('button', { name: 'Actions for googleads.g.doubleclick.net' })
await userEvent.click(trigger)
await userEvent.click(screen.getByRole('menuitem', { name: 'Allow Domain' }))
await userEvent.click(trigger)
const pending = screen.getByRole('button', { name: 'Allow Domain' })
expect(pending).toBeDisabled()
await userEvent.click(pending)
expect(allow).toHaveBeenCalledTimes(1)
/* Only that row: the other one can still act. */
await userEvent.click(screen.getByRole('button', { name: 'Actions for app-measurement.com' }))
expect(screen.getByRole('menuitem', { name: 'Allow Domain' })).toBeEnabled()
await act(async () => settle({ kind: 'ok', data: {} }))
await userEvent.click(trigger)
expect(screen.getByRole('menuitem', { name: 'Allow Domain' })).toBeEnabled()
})
it('a failed sequence also gives the row back', async () => {
let settle: (o: ApiOutcome) => void = () => {}
vi.spyOn(blocking, 'blockDomain').mockReturnValue(new Promise((r) => { settle = r }))
render(
<TopTable kind="TopDomains" rows={ROWS} range="LastHour" token="T"
permissions={undefined} failure={false} onNotice={() => {}} onChanged={() => {}} />,
)
const trigger = screen.getByRole('button', { name: 'Actions for app-measurement.com' })
await userEvent.click(trigger)
await userEvent.click(screen.getByRole('menuitem', { name: 'Block Domain' }))
await act(async () => settle({ kind: 'error', message: 'Access was denied.' }))
await userEvent.click(trigger)
expect(screen.getByRole('menuitem', { name: 'Block Domain' })).toBeEnabled()
})
it('Block Domain needs Allowed.canDelete AND Blocked.canModify: missing one disables it', async () => {
const user = userEvent.setup()
render(
<TopTable kind="TopDomains" rows={ROWS} range="LastHour" token="T"
permissions={{ Allowed: P(true, true, false), Blocked: P(true, true, true) }}
failure={false} onNotice={() => {}} onChanged={() => {}} />,
)
await user.click(screen.getByRole('button', { name: 'Actions for googleads.g.doubleclick.net' }))
/* `ui/Menu` keeps a disabled item as a button on purpose: announced, not reachable. */
const item = screen.getByRole('button', { name: /Block Domain/ })
expect(item).toBeDisabled()
await user.hover(item.parentElement!)
expect(await screen.findByText('Requires Allowed: Delete')).toBeInTheDocument()
})
it('Allow Domain names the missing permission of its pair', async () => {
const user = userEvent.setup()
render(
<TopTable kind="TopBlockedDomains" rows={ROWS} range="LastHour" token="T"
permissions={{ Allowed: P(true, false, true), Blocked: P(true, true, true) }}
failure={false} onNotice={() => {}} onChanged={() => {}} />,
)
await user.click(screen.getByRole('button', { name: 'Actions for googleads.g.doubleclick.net' }))
const item = screen.getByRole('button', { name: /Allow Domain/ })
expect(item).toBeDisabled()
await user.hover(item.parentElement!)
expect(await screen.findByText('Requires Allowed: Modify')).toBeInTheDocument()
})
it('zero rows is a true empty state, a failure is not', () => {
const { rerender } = render(
<TopTable kind="TopDomains" rows={[]} range="LastHour" token="T" permissions={undefined}
failure={false} onNotice={() => {}} onChanged={() => {}} />,
)
expect(screen.getByText('No domains for this period.')).toBeInTheDocument()
rerender(
<TopTable kind="TopDomains" rows={[]} range="LastHour" token="T" permissions={undefined}
failure onNotice={() => {}} onChanged={() => {}} />,
)
expect(screen.queryByText('No domains for this period.')).toBeNull()
})
})
+126
View File
@@ -0,0 +1,126 @@
import { useState } from 'react'
import type { Range, TopEntry } from '../../api/dashboard'
import { TopStats } from '../dashboard/TopStats'
import { Panel } from '../../ui/Panel'
import { Table } from '../../ui/Table'
import { DomainName } from '../../ui/DomainName'
import { Menu } from '../../ui/Menu'
import { Button } from '../../ui/Button'
import type { Notice } from '../../lib/notice'
import { useDomainAction } from '../../lib/allow-block'
import { missing, type Permissions } from './permissions'
import { LockedItem } from './Locked'
import shared from './Blocking.module.css'
import styles from './Overview.module.css'
/*
Top Blocked Domains with "Allow Domain", Top Domains with "Block Domain": upstream's
row menus (main.js:2825 and 2853), here only the blocking verb of each; the
Dashboard carries the whole menus (`dashboard/TopRowMenu`). Each is
TWO calls (other-zones.js:637, 686), so each asks for two permissions, and the
padlock names the first one missing. Upstream's: the titles, the `Domain` and
`Hits` columns and the two verbs. OURS: the `Share` column (upstream's tops have
only Domain and Hits), `No domains for this period.` (upstream says "No Data") and
the `Actions` / `Actions for <domain>` labels.
The disabled item is `LockedItem`, the section's one padlocked menu entry.
*/
type Kind = 'TopBlockedDomains' | 'TopDomains'
const TITLE: Record<Kind, string> = { TopBlockedDomains: 'Top Blocked Domains', TopDomains: 'Top Domains' }
export function TopTable({
kind,
rows,
range,
token,
permissions,
failure,
onNotice,
onChanged,
}: {
kind: Kind
rows: TopEntry[]
range: Range
token: string | null
permissions: Permissions
/** The stats never arrived: the screen says so, and this table must not claim "no domains". */
failure: boolean
onNotice: (n: Notice) => void
onChanged: () => void
}) {
const [more, setMore] = useState(false)
/* Upstream's two calls, alert included, and the row disabled while they run
(`lib/allow-block.ts`). */
const action = useDomainAction(token, onNotice)
const isBlocked = kind === 'TopBlockedDomains'
const need = isBlocked
? missing(permissions, 'Blocked.canDelete', 'Allowed.canModify')
: missing(permissions, 'Allowed.canDelete', 'Blocked.canModify')
const top = rows[0]?.hits ?? 0
const verb = isBlocked ? 'Allow Domain' : 'Block Domain'
async function act(domain: string) {
if (await action.run(domain, verb, domain)) onChanged()
}
return (
<Panel
title={TITLE[kind]}
actions={
<Button size="sm" onClick={() => setMore(true)}>
More
</Button>
}
>
<Table
header={
<>
<th>Domain</th>
<th aria-label="Share" />
<th className={styles.hitsHead}>Hits</th>
<th aria-label="Actions" />
</>
}
isEmpty={rows.length === 0}
emptyText={failure ? undefined : 'No domains for this period.'}
columns={4}
className={shared.inPanel}
>
{rows.map((r) => (
<tr key={r.name}>
<td className={styles.name}><DomainName name={r.name} /></td>
<td>
<span className={`${styles.share} ${isBlocked ? styles.shareBlocked : styles.shareTotal}`}>
<i style={{ width: `${top === 0 ? 0 : (r.hits * 100) / top}%` }} />
</span>
</td>
<td className={styles.hits}>{r.hits.toLocaleString()}</td>
<td>
<Menu label={`Actions for ${r.name}`} quiet>
{(close) =>
need == null ? (
<button
type="button"
disabled={action.isBusy(r.name)}
onClick={() => {
close()
void act(r.name)
}}
>
{verb}
</button>
) : (
<LockedItem need={need}>{verb}</LockedItem>
)
}
</Menu>
</td>
</tr>
))}
</Table>
<TopStats type={more ? kind : null} range={range} token={token} onClose={() => setMore(false)} />
</Panel>
)
}
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen } from '@testing-library/react'
import * as settings from '../../api/settings'
import * as client from '../../api/client'
import type { DnsSettings } from '../../api/settings'
import { settingsChanged } from '../../lib/settings-signal'
import { blockingMark, stateOf, useBlockingState } from './blocking-state'
import type { Permissions } from './permissions'
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
const S = (canView: boolean, canModify: boolean) => ({ Settings: { canView, canModify, canDelete: false } })
function Probe({ permissions, enabled }: { permissions?: Permissions; enabled?: boolean }) {
const { state } = useBlockingState('T', permissions, enabled)
return <output>{state == null ? 'none' : state === 'failed' ? 'failed' : state.kind}</output>
}
describe('blocking state', () => {
it('reads on, paused and off as the Overview does', () => {
expect(stateOf(true, null)).toEqual({ kind: 'on' })
expect(stateOf(false, '2099-01-01T00:00:00Z')).toEqual({ kind: 'paused', till: '2099-01-01T00:00:00Z' })
expect(stateOf(false, '2000-01-01T00:00:00Z')).toEqual({ kind: 'off' })
expect(stateOf(false, undefined)).toEqual({ kind: 'off' })
})
it('marks disabled and paused, with the time it comes back in the label', () => {
const now = new Date(2026, 9, 15, 14, 0)
const till = new Date(2026, 9, 15, 14, 30).toISOString()
expect(blockingMark({ kind: 'on' }, undefined)).toBeNull()
expect(blockingMark(null, undefined)).toBeNull()
expect(blockingMark('failed', undefined)).toBeNull()
expect(blockingMark({ kind: 'off' }, undefined)).toEqual({ text: 'Off', label: 'Blocking, disabled', tone: 'neutral' })
expect(blockingMark({ kind: 'paused', till }, undefined, now)).toEqual({
text: 'Paused', label: 'Blocking, paused until 14:30', tone: 'acc',
})
// Tomorrow carries the date; without Modify the amber goes, as the padlock wins.
const late = new Date(2026, 9, 16, 1, 30).toISOString()
expect(blockingMark({ kind: 'paused', till: late }, S(true, false), now)).toEqual({
text: 'Paused', label: 'Blocking, paused until 2026-10-16 01:30', tone: 'neutral',
})
})
it('reads again when this console writes the settings', async () => {
const read = vi.spyOn(settings, 'getSettings')
.mockResolvedValueOnce({ enableBlocking: true } as DnsSettings)
.mockResolvedValue({ enableBlocking: false } as DnsSettings)
render(<Probe />)
expect(await screen.findByText('on')).toBeInTheDocument()
act(() => settingsChanged())
expect(await screen.findByText('off')).toBeInTheDocument()
expect(read).toHaveBeenCalledTimes(2)
})
it('a successful settings/set or temporaryDisableBlocking is such a write; a failed one is not', async () => {
const heard = vi.fn()
const { onSettingsChanged } = await import('../../lib/settings-signal')
const stop = onSettingsChanged(heard)
const request = vi.spyOn(client, 'apiRequest')
request.mockResolvedValueOnce({ kind: 'error', message: 'no' })
await settings.setSettings('T', { enableBlocking: 'false' })
expect(heard).not.toHaveBeenCalled()
request.mockResolvedValueOnce({ kind: 'ok', data: { response: {}, server: 'x' } })
await settings.setSettings('T', { enableBlocking: 'false' })
request.mockResolvedValueOnce({ kind: 'ok', data: { response: { temporaryDisableBlockingTill: 'x' } } })
await settings.temporaryDisableBlocking('T', '5')
expect(heard).toHaveBeenCalledTimes(2)
stop()
})
it('reads again a second after a pause ends', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const read = vi.spyOn(settings, 'getSettings')
.mockResolvedValueOnce({ enableBlocking: false, temporaryDisableBlockingTill: new Date(Date.now() + 60_000).toISOString() } as DnsSettings)
.mockResolvedValue({ enableBlocking: true } as DnsSettings)
render(<Probe />)
expect(await screen.findByText('paused')).toBeInTheDocument()
await act(() => vi.advanceTimersByTimeAsync(60_500))
expect(read).toHaveBeenCalledTimes(1)
await act(() => vi.advanceTimersByTimeAsync(1_000))
expect(await screen.findByText('on')).toBeInTheDocument()
})
it('an older answer arriving last does not win', async () => {
let first: (s: DnsSettings) => void = () => {}
vi.spyOn(settings, 'getSettings')
.mockImplementationOnce(() => new Promise((r) => { first = r }))
.mockResolvedValue({ enableBlocking: false } as DnsSettings)
render(<Probe />)
act(() => settingsChanged())
expect(await screen.findByText('off')).toBeInTheDocument()
await act(async () => first({ enableBlocking: true } as DnsSettings))
expect(screen.getByText('off')).toBeInTheDocument()
})
it('without Settings.canView, or disabled, it reads nothing, even on a write', async () => {
const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
render(<><Probe permissions={S(false, false)} /><Probe enabled={false} /></>)
act(() => settingsChanged())
expect(screen.getAllByText('none')).toHaveLength(2)
expect(read).not.toHaveBeenCalled()
})
})
+144
View File
@@ -0,0 +1,144 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { getSettings } from '../../api/settings'
import { minuteStamp } from '../../lib/dates'
import { onSettingsChanged } from '../../lib/settings-signal'
import { missing, type Permissions } from './permissions'
/*
Whether blocking is on, read where the Overview's status block reads it
(`settings/get`: `enableBlocking` and `temporaryDisableBlockingTill`, on the node the
console is connected to). OURS, as that block is. Two places draw it, so it lives
here: the block itself (`StatusPanel.tsx`) and the sidebar's mark on Blocking
(`app/Shell.tsx`), which nothing else on the page would keep current.
When it reads:
- once on mounting, unless the session lacks `Settings.canView`, in which case it
never reads and stays `null` (the server would refuse, and the sidebar then shows
no mark rather than a guess);
- whenever this console writes the settings (`lib/settings-signal.ts`): the
Overview's and the Dashboard's Blocking menu, a Save in Settings;
- when a pause should have ended. The server's own timer is armed a hair BEFORE it
computes `till` (BlockListZoneManager.cs:993-995), so reading a second after
`till` finds blocking back on; the second also absorbs a small clock skew;
- when the browser tab is shown again after being hidden: the state may have been
changed from elsewhere meanwhile, and a hidden tab is not kept current.
Reads are numbered, so an older answer arriving last cannot overwrite a newer one.
*/
/** How long after `till` to read again. See above. */
export const AFTER_PAUSE_MS = 1000
/** `setTimeout`'s ceiling (2^31 - 1 ms, about 24.8 days). Past it the browser fires
AT ONCE, which with a far-off `till` would read in a loop; capped, it reads,
finds the pause still on and waits again. */
export const MAX_TIMEOUT_MS = 2_147_483_647
export type BlockingState = { kind: 'on' } | { kind: 'paused'; till: string } | { kind: 'off' }
export function stateOf(enable: boolean, till: string | null | undefined, now = Date.now()): BlockingState {
if (enable) return { kind: 'on' }
if (till != null && Date.parse(till) > now) return { kind: 'paused', till }
return { kind: 'off' }
}
/*
When a pause ends, as the pill says it: the hour alone when that is today, the date as
well when it is not (a three-hour pause at 23:00 ends tomorrow, and "Until 01:30"
alone would read as earlier today).
*/
export function untilText(till: string, now: Date = new Date()): string {
const stamp = minuteStamp(till)
const end = new Date(till)
const today =
end.getFullYear() === now.getFullYear() && end.getMonth() === now.getMonth() && end.getDate() === now.getDate()
return `Until ${today ? stamp.slice(11) : stamp}`
}
/** `null` while the first read travels or when nothing is read; `'failed'` when it never arrived. */
export type BlockingRead = BlockingState | 'failed' | null
export function useBlockingState(
token: string | null,
permissions: Permissions,
/** False reads nothing, as a missing `Settings.canView` does: the sidebar without
a Blocking entry to mark. */
enabled = true,
): { state: BlockingRead; reload: () => void; retry: () => void } {
const viewNeed = enabled ? missing(permissions, 'Settings.canView') : 'Settings.canView'
const [state, setState] = useState<BlockingRead>(null)
const asked = useRef(0)
const reload = useCallback(() => {
if (viewNeed != null) return
const mine = ++asked.current
void getSettings(token).then((s) => {
if (mine !== asked.current) return
setState(s == null ? 'failed' : stateOf(s.enableBlocking, s.temporaryDisableBlockingTill))
})
}, [token, viewNeed])
useEffect(() => {
// A read already travelling must not land after the permission went.
if (viewNeed != null) asked.current++
else reload()
}, [reload, viewNeed])
useEffect(() => onSettingsChanged(reload), [reload])
useEffect(() => {
const shown = () => {
if (document.visibilityState === 'visible') reload()
}
document.addEventListener('visibilitychange', shown)
return () => document.removeEventListener('visibilitychange', shown)
}, [reload])
/* Without the permission nothing is known, whatever an earlier read said. */
const known = viewNeed != null ? null : state
const till = known != null && known !== 'failed' && known.kind === 'paused' ? known.till : null
useEffect(() => {
if (till == null) return
const wait = Math.min(MAX_TIMEOUT_MS, Math.max(0, Date.parse(till) - Date.now()) + AFTER_PAUSE_MS)
const timer = setTimeout(reload, wait)
return () => clearTimeout(timer)
}, [till, reload])
const retry = useCallback(() => {
setState(null)
reload()
}, [reload])
return { state: known, reload, retry }
}
/*
The sidebar's mark on Blocking. Nothing while blocking is on, while it is unknown
or unreadable, and without `Settings.canView`. The visible word is short; the
accessible name and the title say the rest, with the time a pause ends. OURS:
`Off`, `Paused`, `Blocking, disabled`, `Blocking, paused until <time>`.
The tone follows the Overview's block: paused is amber ("yours to lift") only with
`Settings.canModify`, since the padlock wins; disabled is neutral, as the block's
mark is.
*/
export interface BlockingMark {
text: string
label: string
tone: 'acc' | 'neutral'
}
export function blockingMark(state: BlockingRead, permissions: Permissions, now = new Date()): BlockingMark | null {
if (state == null || state === 'failed' || state.kind === 'on') return null
if (state.kind === 'off') return { text: 'Off', label: 'Blocking, disabled', tone: 'neutral' }
const canLift = missing(permissions, 'Settings.canModify') == null
return {
text: 'Paused',
label: `Blocking, paused ${untilText(state.till, now).replace(/^Until/, 'until')}`,
tone: canLift ? 'acc' : 'neutral',
}
}
/** The mark for the sidebar's Blocking entry; `visible` false when there is no entry. */
export function useBlockingMark(token: string | null, permissions: Permissions, visible: boolean): BlockingMark | null {
const { state } = useBlockingState(token, permissions, visible)
return blockingMark(state, permissions)
}
+684
View File
@@ -0,0 +1,684 @@
{
"blockLists": {
"response": {
"result": {
"Metadata": {
"NameServer": "ns1.example.net (127.0.0.1)",
"Protocol": "Udp",
"DatagramSize": "400 bytes",
"RoundTripTime": "0.33 ms"
},
"EDNS": {
"UdpPayloadSize": 1232,
"ExtendedRCODE": "NxDomain",
"Version": 0,
"Flags": "None",
"Options": [
{
"Code": "EXTENDED_DNS_ERROR",
"Length": "131 bytes",
"Data": {
"InfoCode": "Blocked",
"ExtraText": "source=block-list-zone; blockListUrl=https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts; domain=app-measurement.com"
}
},
{
"Code": "EXTENDED_DNS_ERROR",
"Length": "152 bytes",
"Data": {
"InfoCode": "Blocked",
"ExtraText": "source=block-list-zone; blockListUrl=https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/pro-onlydomains.txt; domain=app-measurement.com"
}
}
]
},
"DnsClientExtendedErrors": [
{
"InfoCode": "BlockedByUpstreamDnsServer",
"ExtraText": "app-measurement.com was blocked by ns1.example.net (127.0.0.1)"
}
],
"Identifier": 0,
"IsResponse": true,
"OPCODE": "StandardQuery",
"AuthoritativeAnswer": false,
"Truncation": false,
"RecursionDesired": true,
"RecursionAvailable": false,
"Z": 0,
"AuthenticData": false,
"CheckingDisabled": false,
"RCODE": "NxDomain",
"QDCOUNT": 1,
"ANCOUNT": 0,
"NSCOUNT": 1,
"ARCOUNT": 1,
"Question": [
{
"Name": "app-measurement.com",
"Type": "A",
"Class": "IN"
}
],
"Answer": [],
"Authority": [
{
"Name": "com",
"Type": "SOA",
"Class": "IN",
"TTL": "30 (30s)",
"RDLENGTH": "49 bytes",
"RDATA": {
"PrimaryNameServer": "ns1.example.net",
"ResponsiblePerson": "hostadmin@ns1.example.net",
"Serial": 1,
"Refresh": "14400 (4h)",
"Retry": "3600 (1h)",
"Expire": "604800 (1w)",
"Minimum": "30 (30s)"
},
"DnssecStatus": "Disabled"
}
],
"Additional": [
{
"Name": "",
"Type": "OPT",
"Class": "1232",
"TTL": "0 (0s)",
"RDLENGTH": "291 bytes",
"RDATA": {
"Options": [
{
"Code": "EXTENDED_DNS_ERROR",
"Length": "131 bytes",
"Data": {
"InfoCode": "Blocked",
"ExtraText": "source=block-list-zone; blockListUrl=https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts; domain=app-measurement.com"
}
},
{
"Code": "EXTENDED_DNS_ERROR",
"Length": "152 bytes",
"Data": {
"InfoCode": "Blocked",
"ExtraText": "source=block-list-zone; blockListUrl=https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/pro-onlydomains.txt; domain=app-measurement.com"
}
}
]
},
"DnssecStatus": "Disabled"
}
]
},
"rawResponses": []
},
"server": "ns1.example.net",
"status": "ok"
},
"ownRule": {
"response": {
"result": {
"Metadata": {
"NameServer": "ns1.example.net (127.0.0.1)",
"Protocol": "Udp",
"DatagramSize": "174 bytes",
"RoundTripTime": "0.41 ms"
},
"EDNS": {
"UdpPayloadSize": 1232,
"ExtendedRCODE": "NxDomain",
"Version": 0,
"Flags": "None",
"Options": [
{
"Code": "EXTENDED_DNS_ERROR",
"Length": "55 bytes",
"Data": {
"InfoCode": "Blocked",
"ExtraText": "source=blocked-zone; domain=telemetry.example-iot.net"
}
}
]
},
"DnsClientExtendedErrors": [
{
"InfoCode": "BlockedByUpstreamDnsServer",
"ExtraText": "telemetry.example-iot.net was blocked by ns1.example.net (127.0.0.1)"
}
],
"Identifier": 0,
"IsResponse": true,
"OPCODE": "StandardQuery",
"AuthoritativeAnswer": false,
"Truncation": false,
"RecursionDesired": true,
"RecursionAvailable": false,
"Z": 0,
"AuthenticData": false,
"CheckingDisabled": false,
"RCODE": "NxDomain",
"QDCOUNT": 1,
"ANCOUNT": 0,
"NSCOUNT": 1,
"ARCOUNT": 1,
"Question": [
{
"Name": "telemetry.example-iot.net",
"Type": "A",
"Class": "IN"
}
],
"Answer": [],
"Authority": [
{
"Name": "example-iot.net",
"Type": "SOA",
"Class": "IN",
"TTL": "30 (30s)",
"RDLENGTH": "49 bytes",
"RDATA": {
"PrimaryNameServer": "ns1.example.net",
"ResponsiblePerson": "hostadmin@ns1",
"Serial": 1,
"Refresh": "14400 (4h)",
"Retry": "3600 (1h)",
"Expire": "604800 (1w)",
"Minimum": "30 (30s)"
},
"DnssecStatus": "Disabled"
}
],
"Additional": [
{
"Name": "",
"Type": "OPT",
"Class": "1232",
"TTL": "0 (0s)",
"RDLENGTH": "59 bytes",
"RDATA": {
"Options": [
{
"Code": "EXTENDED_DNS_ERROR",
"Length": "55 bytes",
"Data": {
"InfoCode": "Blocked",
"ExtraText": "source=blocked-zone; domain=telemetry.example-iot.net"
}
}
]
},
"DnssecStatus": "Disabled"
}
]
},
"rawResponses": []
},
"server": "ns1.example.net",
"status": "ok"
},
"ownRuleParent": {
"response": {
"result": {
"Metadata": {
"NameServer": "ns1.example.net (127.0.0.1)",
"Protocol": "Udp",
"DatagramSize": "178 bytes",
"RoundTripTime": "0.48 ms"
},
"EDNS": {
"UdpPayloadSize": 1232,
"ExtendedRCODE": "NxDomain",
"Version": 0,
"Flags": "None",
"Options": [
{
"Code": "EXTENDED_DNS_ERROR",
"Length": "55 bytes",
"Data": {
"InfoCode": "Blocked",
"ExtraText": "source=blocked-zone; domain=telemetry.example-iot.net"
}
}
]
},
"DnsClientExtendedErrors": [
{
"InfoCode": "BlockedByUpstreamDnsServer",
"ExtraText": "sub.telemetry.example-iot.net was blocked by ns1.example.net (127.0.0.1)"
}
],
"Identifier": 0,
"IsResponse": true,
"OPCODE": "StandardQuery",
"AuthoritativeAnswer": false,
"Truncation": false,
"RecursionDesired": true,
"RecursionAvailable": false,
"Z": 0,
"AuthenticData": false,
"CheckingDisabled": false,
"RCODE": "NxDomain",
"QDCOUNT": 1,
"ANCOUNT": 0,
"NSCOUNT": 1,
"ARCOUNT": 1,
"Question": [
{
"Name": "sub.telemetry.example-iot.net",
"Type": "A",
"Class": "IN"
}
],
"Answer": [],
"Authority": [
{
"Name": "example-iot.net",
"Type": "SOA",
"Class": "IN",
"TTL": "30 (30s)",
"RDLENGTH": "49 bytes",
"RDATA": {
"PrimaryNameServer": "ns1.example.net",
"ResponsiblePerson": "hostadmin@ns1",
"Serial": 1,
"Refresh": "14400 (4h)",
"Retry": "3600 (1h)",
"Expire": "604800 (1w)",
"Minimum": "30 (30s)"
},
"DnssecStatus": "Disabled"
}
],
"Additional": [
{
"Name": "",
"Type": "OPT",
"Class": "1232",
"TTL": "0 (0s)",
"RDLENGTH": "59 bytes",
"RDATA": {
"Options": [
{
"Code": "EXTENDED_DNS_ERROR",
"Length": "55 bytes",
"Data": {
"InfoCode": "Blocked",
"ExtraText": "source=blocked-zone; domain=telemetry.example-iot.net"
}
}
]
},
"DnssecStatus": "Disabled"
}
]
},
"rawResponses": []
},
"server": "ns1.example.net",
"status": "ok"
},
"allowedRule": {
"response": {
"result": {
"Metadata": {
"NameServer": "ns1.example.net (127.0.0.1)",
"Protocol": "Udp",
"DatagramSize": "234 bytes",
"RoundTripTime": "22.85 ms"
},
"EDNS": {
"UdpPayloadSize": 1232,
"ExtendedRCODE": "NoError",
"Version": 0,
"Flags": "None",
"Options": [
{
"Code": "EXTENDED_DNS_ERROR",
"Length": "28 bytes",
"Data": {
"InfoCode": "Synthesized",
"ExtraText": "Result from negative cache"
}
},
{
"Code": "EXTENDED_DNS_ERROR",
"Length": "28 bytes",
"Data": {
"InfoCode": "Synthesized",
"ExtraText": "Result from negative cache"
}
}
]
},
"Identifier": 0,
"IsResponse": true,
"OPCODE": "StandardQuery",
"AuthoritativeAnswer": false,
"Truncation": false,
"RecursionDesired": true,
"RecursionAvailable": true,
"Z": 0,
"AuthenticData": false,
"CheckingDisabled": false,
"RCODE": "NoError",
"QDCOUNT": 1,
"ANCOUNT": 8,
"NSCOUNT": 0,
"ARCOUNT": 1,
"Question": [
{
"Name": "s.youtube.com",
"Type": "A",
"Class": "IN"
}
],
"Answer": [
{
"Name": "s.youtube.com",
"Type": "A",
"Class": "IN",
"TTL": "152 (2m32s)",
"RDLENGTH": "4 bytes",
"RDATA": {
"IPAddress": "142.251.154.6"
},
"DnssecStatus": "Disabled"
},
{
"Name": "s.youtube.com",
"Type": "A",
"Class": "IN",
"TTL": "152 (2m32s)",
"RDLENGTH": "4 bytes",
"RDATA": {
"IPAddress": "142.251.150.6"
},
"DnssecStatus": "Disabled"
},
{
"Name": "s.youtube.com",
"Type": "A",
"Class": "IN",
"TTL": "152 (2m32s)",
"RDLENGTH": "4 bytes",
"RDATA": {
"IPAddress": "142.251.157.6"
},
"DnssecStatus": "Disabled"
},
{
"Name": "s.youtube.com",
"Type": "A",
"Class": "IN",
"TTL": "152 (2m32s)",
"RDLENGTH": "4 bytes",
"RDATA": {
"IPAddress": "142.251.156.6"
},
"DnssecStatus": "Disabled"
},
{
"Name": "s.youtube.com",
"Type": "A",
"Class": "IN",
"TTL": "152 (2m32s)",
"RDLENGTH": "4 bytes",
"RDATA": {
"IPAddress": "142.251.152.6"
},
"DnssecStatus": "Disabled"
},
{
"Name": "s.youtube.com",
"Type": "A",
"Class": "IN",
"TTL": "152 (2m32s)",
"RDLENGTH": "4 bytes",
"RDATA": {
"IPAddress": "142.251.153.6"
},
"DnssecStatus": "Disabled"
},
{
"Name": "s.youtube.com",
"Type": "A",
"Class": "IN",
"TTL": "152 (2m32s)",
"RDLENGTH": "4 bytes",
"RDATA": {
"IPAddress": "142.251.151.6"
},
"DnssecStatus": "Disabled"
},
{
"Name": "s.youtube.com",
"Type": "A",
"Class": "IN",
"TTL": "152 (2m32s)",
"RDLENGTH": "4 bytes",
"RDATA": {
"IPAddress": "142.251.155.6"
},
"DnssecStatus": "Disabled"
}
],
"Authority": [],
"Additional": [
{
"Name": "",
"Type": "OPT",
"Class": "1232",
"TTL": "0 (0s)",
"RDLENGTH": "64 bytes",
"RDATA": {
"Options": [
{
"Code": "EXTENDED_DNS_ERROR",
"Length": "28 bytes",
"Data": {
"InfoCode": "Synthesized",
"ExtraText": "Result from negative cache"
}
},
{
"Code": "EXTENDED_DNS_ERROR",
"Length": "28 bytes",
"Data": {
"InfoCode": "Synthesized",
"ExtraText": "Result from negative cache"
}
}
]
},
"DnssecStatus": "Disabled"
}
]
},
"rawResponses": []
},
"server": "ns1.example.net",
"status": "ok"
},
"notBlocked": {
"response": {
"result": {
"Metadata": {
"NameServer": "ns1.example.net (127.0.0.1)",
"Protocol": "Udp",
"DatagramSize": "72 bytes",
"RoundTripTime": "22.93 ms"
},
"EDNS": {
"UdpPayloadSize": 1232,
"ExtendedRCODE": "NoError",
"Version": 0,
"Flags": "None",
"Options": []
},
"Identifier": 0,
"IsResponse": true,
"OPCODE": "StandardQuery",
"AuthoritativeAnswer": false,
"Truncation": false,
"RecursionDesired": true,
"RecursionAvailable": true,
"Z": 0,
"AuthenticData": true,
"CheckingDisabled": false,
"RCODE": "NoError",
"QDCOUNT": 1,
"ANCOUNT": 2,
"NSCOUNT": 0,
"ARCOUNT": 1,
"Question": [
{
"Name": "example.com",
"Type": "A",
"Class": "IN"
}
],
"Answer": [
{
"Name": "example.com",
"Type": "A",
"Class": "IN",
"TTL": "167 (2m47s)",
"RDLENGTH": "4 bytes",
"RDATA": {
"IPAddress": "104.20.23.154"
},
"DnssecStatus": "Disabled"
},
{
"Name": "example.com",
"Type": "A",
"Class": "IN",
"TTL": "167 (2m47s)",
"RDLENGTH": "4 bytes",
"RDATA": {
"IPAddress": "172.66.147.243"
},
"DnssecStatus": "Disabled"
}
],
"Authority": [],
"Additional": [
{
"Name": "",
"Type": "OPT",
"Class": "1232",
"TTL": "0 (0s)",
"RDLENGTH": "0 bytes",
"RDATA": {
"Options": []
},
"DnssecStatus": "Disabled"
}
]
},
"rawResponses": []
},
"server": "ns1.example.net",
"status": "ok"
},
"nonExistent": {
"response": {
"result": {
"Metadata": {
"NameServer": "ns1.example.net (127.0.0.1)",
"Protocol": "Udp",
"DatagramSize": "104 bytes",
"RoundTripTime": "0.37 ms"
},
"EDNS": {
"UdpPayloadSize": 1232,
"ExtendedRCODE": "NxDomain",
"Version": 0,
"Flags": "None",
"Options": []
},
"Identifier": 0,
"IsResponse": true,
"OPCODE": "StandardQuery",
"AuthoritativeAnswer": true,
"Truncation": false,
"RecursionDesired": true,
"RecursionAvailable": true,
"Z": 0,
"AuthenticData": false,
"CheckingDisabled": false,
"RCODE": "NxDomain",
"QDCOUNT": 1,
"ANCOUNT": 0,
"NSCOUNT": 1,
"ARCOUNT": 1,
"Question": [
{
"Name": "this-does-not-exist-xyz123.invalid",
"Type": "A",
"Class": "IN"
}
],
"Answer": [],
"Authority": [
{
"Name": "",
"Type": "SOA",
"Class": "IN",
"TTL": "900 (15m)",
"RDLENGTH": "30 bytes",
"RDATA": {
"PrimaryNameServer": "",
"ResponsiblePerson": "nobody@invalid",
"Serial": 1,
"Refresh": "3600 (1h)",
"Retry": "1200 (20m)",
"Expire": "604800 (1w)",
"Minimum": "900 (15m)"
},
"DnssecStatus": "Disabled"
}
],
"Additional": [
{
"Name": "",
"Type": "OPT",
"Class": "1232",
"TTL": "0 (0s)",
"RDLENGTH": "0 bytes",
"RDATA": {
"Options": []
},
"DnssecStatus": "Disabled"
}
]
},
"rawResponses": []
},
"server": "ns1.example.net",
"status": "ok"
},
"invalidName": {
"server": "ns1.example.net",
"status": "error",
"errorMessage": "Invalid domain name [not a domain]: invalid character [32] was found."
},
"settings": {
"response": {
"enableBlocking": true,
"allowTxtBlockingReport": true,
"blockingType": "NxDomain",
"blockingBypassList": [],
"blockListUrls": [
"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
"https://big.oisd.nl/domainswild2",
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/pro-onlydomains.txt"
]
},
"server": "ns1.example.net",
"status": "ok"
},
"allowedExport": "clients4.google.com\ns.youtube.com\nt.co\n",
"blockedExport": "ads.example-tv.net\nmetrics.roku.com\ntelemetry.example-iot.net\ntiktokv.com\n"
}
+249
View File
@@ -0,0 +1,249 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import quickBuiltin from '../../../public/json/quick-block-lists-builtin.json'
import * as blockingApi from '../../api/blocking'
import * as dnsclient from '../../api/dnsclient'
import * as settingsApi from '../../api/settings'
import REAL from './check-domain.real.json'
import {
addressOf, allowRuleFor, checkDomain, networkContains, parseAnswer, parseReason, reportedListName,
verdictOf, type CheckInputs,
} from './check-domain'
import { moreLists } from './extra-lists'
/*
The answers in `check-domain.real.json` are what a live v15.6.1 (blocking on, NX Domain,
`Allow TXT Blocking Report` on, three block lists and four blocked and three allowed
rules) answered to `dnsClient/resolve?server=this-server&type=A&protocol=UDP&dnssec=false`
on 2026-10-15, whole. `settings` is `settings/get?node=` trimmed to its blocking
fields; `allowedExport` and `blockedExport` are the two exports as text.
*/
afterEach(() => vi.restoreAllMocks())
const result = (key: keyof typeof REAL) => (REAL[key] as { response: { result: unknown } }).response.result
const ALLOWED = REAL.allowedExport.split('\n').filter((l) => l !== '')
const SETTINGS = REAL.settings.response
function inputs(key: keyof typeof REAL, over: Partial<CheckInputs> = {}): CheckInputs {
return { answer: parseAnswer(result(key)), settings: SETTINGS, allowed: ALLOWED, ...over }
}
describe('parseAnswer, on the real answers', () => {
it('a name two lists carry: one reason per list, in the order the server sent them', () => {
const a = parseAnswer(result('blockLists'))
expect(a.name).toBe('app-measurement.com')
expect(a.reasons).toEqual([
{ source: 'list', url: 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', domain: 'app-measurement.com' },
{
source: 'list',
url: 'https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/pro-onlydomains.txt',
domain: 'app-measurement.com',
},
])
expect(a.clientIp).toBe('127.0.0.1')
})
it('a name blocked by a rule on a parent reports the rule, not the name', () => {
const a = parseAnswer(result('ownRuleParent'))
expect(a.name).toBe('sub.telemetry.example-iot.net')
expect(a.reasons).toEqual([{ source: 'rule', domain: 'telemetry.example-iot.net' }])
})
it('an allowed name and a plain one carry no Blocked option; Synthesized ones are not reasons', () => {
expect(parseAnswer(result('allowedRule')).reasons).toEqual([])
expect(parseAnswer(result('notBlocked')).reasons).toEqual([])
expect(parseAnswer(result('nonExistent')).reasons).toEqual([])
})
it('reads the OPT record when an answer has no EDNS summary', () => {
const r = structuredClone(result('ownRule')) as Record<string, unknown>
delete r.EDNS
expect(parseAnswer(r).reasons).toEqual([{ source: 'rule', domain: 'telemetry.example-iot.net' }])
})
it('survives an answer of an unexpected shape', () => {
expect(parseAnswer(null)).toEqual({ name: '.', ascii: '', reasons: [], clientIp: null })
expect(parseAnswer({ Question: 'x', EDNS: { Options: 'y' } }).reasons).toEqual([])
})
it('an IDN name is drawn as the server wrote it and compared in ASCII', () => {
const a = parseAnswer({ Question: [{ Name: 'xn--and-6ma2c.es', NameIDN: 'ñandú.es' }] })
expect(a.name).toBe('ñandú.es')
expect(a.ascii).toBe('xn--and-6ma2c.es')
})
})
describe('parseReason', () => {
it('keeps a URL that carries a semicolon whole', () => {
expect(parseReason('source=block-list-zone; blockListUrl=https://h/a;b; domain=x.test')).toEqual({
source: 'list', url: 'https://h/a;b', domain: 'x.test',
})
})
it('anything else is drawn as the server wrote it', () => {
expect(parseReason('source=some-app; group=kids')).toEqual({ source: 'other', text: 'source=some-app; group=kids' })
})
})
describe('verdictOf', () => {
it('lists', () => {
expect(verdictOf(inputs('blockLists'))).toEqual({
kind: 'lists',
name: 'app-measurement.com',
domain: 'app-measurement.com',
lists: [
'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts',
'https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/pro-onlydomains.txt',
],
})
})
it('own rule, exact and on a parent', () => {
expect(verdictOf(inputs('ownRule'))).toEqual({ kind: 'rule', name: 'telemetry.example-iot.net', domain: 'telemetry.example-iot.net' })
expect(verdictOf(inputs('ownRuleParent'))).toEqual({
kind: 'rule', name: 'sub.telemetry.example-iot.net', domain: 'telemetry.example-iot.net',
})
})
it('a reported block wins even when the settings say blocking is off', () => {
expect(verdictOf(inputs('ownRule', { settings: { enableBlocking: false } })).kind).toBe('rule')
})
it('allowed by an allow rule from the real export', () => {
expect(verdictOf(inputs('allowedRule'))).toEqual({ kind: 'allowed', name: 's.youtube.com', domain: 's.youtube.com' })
})
it('not blocked, with nothing left unread', () => {
expect(verdictOf(inputs('notBlocked'))).toEqual({ kind: 'clear', name: 'example.com', unread: {} })
expect(verdictOf(inputs('nonExistent')).kind).toBe('clear')
})
it('not blocked says which reads it did not have', () => {
const v = verdictOf(inputs('notBlocked', { settings: { need: 'Settings.canView' }, allowed: { failed: true } }))
expect(v).toEqual({
kind: 'clear', name: 'example.com', unread: { settings: { need: 'Settings.canView' }, allowed: { failed: true } },
})
})
it('without the allow rules an allowed name can only read as not blocked', () => {
const v = verdictOf(inputs('allowedRule', { allowed: { need: 'Allowed.canView' } }))
expect(v).toEqual({ kind: 'clear', name: 's.youtube.com', unread: { allowed: { need: 'Allowed.canView' } } })
})
it('blocking disabled, and paused until a time still to come', () => {
const now = Date.parse('2026-10-15T12:00:00Z')
expect(verdictOf(inputs('allowedRule', { settings: { ...SETTINGS, enableBlocking: false } }), now)).toEqual({
kind: 'off', name: 's.youtube.com', paused: false,
})
const paused = { ...SETTINGS, enableBlocking: false, temporaryDisableBlockingTill: '2026-10-15T12:05:00Z' }
expect(verdictOf(inputs('notBlocked', { settings: paused }), now)).toEqual({ kind: 'off', name: 'example.com', paused: true })
})
it('the check bypasses blocking when the bypass list covers the server address', () => {
const settings = { ...SETTINGS, blockingBypassList: ['192.168.1.0/24', '127.0.0.0/8'] }
expect(verdictOf(inputs('notBlocked', { settings }))).toEqual({ kind: 'bypassed', name: 'example.com', ip: '127.0.0.1' })
expect(verdictOf(inputs('notBlocked', { settings: { ...SETTINGS, blockingBypassList: ['10.0.0.0/8'] } })).kind).toBe('clear')
})
it('cannot tell when the server does not report what it blocks', () => {
expect(verdictOf(inputs('notBlocked', { settings: { ...SETTINGS, allowTxtBlockingReport: false } }))).toEqual({
kind: 'unreported', name: 'example.com',
})
})
it('an allow rule is still told when reports are off: it wins before any blocking', () => {
expect(verdictOf(inputs('allowedRule', { settings: { ...SETTINGS, allowTxtBlockingReport: false } })).kind).toBe('allowed')
})
it('a block reported by something else is drawn with its own text', () => {
const answer = { name: 'x.test', ascii: 'x.test', reasons: [parseReason('source=app; group=kids')], clientIp: null }
expect(verdictOf({ answer, settings: SETTINGS, allowed: [] })).toEqual({ kind: 'other', name: 'x.test', texts: ['source=app; group=kids'] })
})
})
describe('allowRuleFor', () => {
it('matches the name or a parent, and the most specific rule', () => {
expect(allowRuleFor('a.s.youtube.com', ALLOWED)).toBe('s.youtube.com')
expect(allowRuleFor('a.b.test', ['test', 'b.test'])).toBe('b.test')
expect(allowRuleFor('xs.youtube.com', ALLOWED)).toBeNull()
expect(allowRuleFor('t.co', ['T.CO.'])).toBe('t.co')
})
})
describe('the check address and the bypass list', () => {
it('addressOf', () => {
expect(addressOf('ns1.example.net (127.0.0.1)')).toBe('127.0.0.1')
expect(addressOf('dns ([::1]:53)')).toBe('::1')
expect(addressOf('dns (10.0.0.5:5353)')).toBe('10.0.0.5')
expect(addressOf('dns (::1)')).toBe('::1')
expect(addressOf(null)).toBeNull()
})
it('networkContains', () => {
expect(networkContains('127.0.0.1', '127.0.0.1')).toBe(true)
expect(networkContains('127.0.0.0/8', '127.0.0.1')).toBe(true)
expect(networkContains('0.0.0.0/0', '10.1.2.3')).toBe(true)
expect(networkContains('192.168.1.0/24', '127.0.0.1')).toBe(false)
expect(networkContains('::1', '::1')).toBe(true)
expect(networkContains('::/0', '::1')).toBe(true)
expect(networkContains('fe80::/10', 'fe80::1:2')).toBe(true)
expect(networkContains('::ffff:127.0.0.1', '::ffff:7f00:1')).toBe(true)
expect(networkContains('::/0', '127.0.0.1')).toBe(false)
expect(networkContains('nonsense', '127.0.0.1')).toBe(false)
expect(networkContains('127.0.0.1/33', '127.0.0.1')).toBe(false)
})
})
describe('reportedListName', () => {
const named = [...quickBuiltin, ...moreLists(quickBuiltin)]
it('names a reported list as the Lists tab does', () => {
expect(reportedListName('https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', named)).toBe(
'Steven Black [adware + malware]',
)
})
it('matches after normalising what the catalogue wrote', () => {
expect(reportedListName('https://lists.test/a%20b', [{ name: 'Spaced', urls: ['https://LISTS.test/a b'] }])).toBe('Spaced')
})
it('an unknown list has no name', () => {
expect(reportedListName('https://lists.test/mine.txt', named)).toBeNull()
})
})
describe('checkDomain', () => {
it('asks this server as DNS Client does, and reads the settings and the allow rules beside it', async () => {
const resolve = vi.spyOn(dnsclient, 'resolve').mockResolvedValue({ kind: 'ok', data: REAL.ownRule as never })
const settings = vi.spyOn(settingsApi, 'readSettings').mockResolvedValue({ kind: 'ok', data: SETTINGS as never })
const exp = vi.spyOn(blockingApi, 'readRuleExport').mockResolvedValue({ kind: 'ok', data: ALLOWED })
const out = await checkDomain('T', 'telemetry.example-iot.net', {})
expect(resolve).toHaveBeenCalledWith('T', {
server: 'this-server', domain: 'telemetry.example-iot.net', type: 'A', protocol: 'UDP', dnssec: false, eDnsClientSubnet: '',
})
expect(settings).toHaveBeenCalledWith('T')
expect(exp).toHaveBeenCalledWith('allowed', 'T')
expect(out.kind === 'ok' && verdictOf(out.data).kind).toBe('rule')
})
it('skips a read whose permission is missing, and a failed read only narrows the verdict', async () => {
vi.spyOn(dnsclient, 'resolve').mockResolvedValue({ kind: 'ok', data: REAL.notBlocked as never })
const settings = vi.spyOn(settingsApi, 'readSettings')
vi.spyOn(blockingApi, 'readRuleExport').mockResolvedValue({ kind: 'error', message: 'boom' })
const out = await checkDomain('T', 'example.com', { settings: 'Settings.canView' })
expect(settings).not.toHaveBeenCalled()
expect(out).toEqual({
kind: 'ok',
data: expect.objectContaining({ settings: { need: 'Settings.canView' }, allowed: { failed: true } }),
})
})
it('a failed query fails the check with the server message', async () => {
vi.spyOn(dnsclient, 'resolve').mockResolvedValue({ kind: 'error', message: REAL.invalidName.errorMessage })
vi.spyOn(settingsApi, 'readSettings').mockResolvedValue({ kind: 'ok', data: SETTINGS as never })
vi.spyOn(blockingApi, 'readRuleExport').mockResolvedValue({ kind: 'ok', data: [] })
expect(await checkDomain('T', 'not a domain', {})).toEqual({
kind: 'error', message: 'Invalid domain name [not a domain]: invalid character [32] was found.',
})
})
})
+339
View File
@@ -0,0 +1,339 @@
import type { ApiOutcome } from '../../api/client'
import { readRuleExport } from '../../api/blocking'
import { resolve } from '../../api/dnsclient'
import { readSettings } from '../../api/settings'
import type { QuickEntry } from '../../lib/quick-lists'
import { listName } from './list-lines'
/*
"Check a domain": whether this server blocks a name, and why. This module and every
sentence the verdict draws are OURS (upstream has no such check); the server's own
words appear only where they are quoted (an unknown blocking reason, its labels
`Allow TXT Blocking Report` and `Blocking Bypass List`).
HOW THE SERVER SAYS IT. The check is the DNS Client's own call, `dnsClient/resolve`
against `this-server` (dnsclient.js:166), type A over UDP, no DNSSEC validation and
no import. A query this server blocks comes back carrying an Extended DNS Error with
`InfoCode: "Blocked"` and the reason in `ExtraText`, in `result.EDNS.Options` (and
again in the OPT record of `Additional`). Its grammar is the server's, read from
v15.6.1 and measured against a live v15.6.1 on 2026-10-15
(`check-domain.real.json`):
source=blocked-zone; domain=<rule> (DnsServer.cs:4672)
source=block-list-zone; blockListUrl=<url>; domain=<entry> (BlockListZoneManager.cs:893)
One option per list that carries the name, in the lists' order. `domain` is the
entry that matched, which is a PARENT of the name asked when a rule or a list
blocks a whole zone (`sub.telemetry.example-iot.net` comes back with
`domain=telemetry.example-iot.net`). The same text is written for every blocking
type (NX Domain, Any Address, Custom Address): only the answer around it changes.
WHAT THE ANSWER CANNOT SAY, and how each gap is handled:
- The report is written ONLY with `allowTxtBlockingReport` on (on by default;
DnsServer.cs:4669, BlockListZoneManager.cs:888). With it off a blocked answer
carries nothing, so a clean answer proves nothing: the verdict is "cannot tell"
and names the setting.
- Nothing marks an answer as ALLOWED. An allow rule wins before any blocking is
looked at (`IsAllowedAsync`, DnsServer.cs:4577-4605), so the verdict reads the
rules themselves (`allowed/export`): the name or one of its parents on the list.
Like a blocked rule, an allowed one covers its subdomains (the same zone tree,
AllowedZoneManager.cs:291-297).
- With blocking disabled or paused nothing is blocked and no rule applies.
- The `Blocking Bypass List` is matched against the CLIENT address, and the check's
client is this server talking to itself: the address in `Metadata.NameServer`
(127.0.0.1 when it listens on every address, DnsServer.cs:6220-6238). If the list
covers it, the check is never blocked and says so.
- Allow lists inside the block list URLs (`!url`) and DNS apps that block without
reporting leave no trace: such a name reads as not blocked. An app that does
report with `InfoCode: "Blocked"` is drawn with its own text.
The settings and the allow rules need permissions the check itself does not
(`Settings: View`, `Allowed: View`). Without them the verdicts that depend on them
cannot be reached, and a "Not blocked" says which reads were missing.
*/
export type BlockReason =
| { source: 'rule'; domain: string }
| { source: 'list'; url: string; domain: string }
| { source: 'other'; text: string }
export interface Answer {
/** The name the server asked, as it wrote it in `Question` (IDN, if it gave one). */
name: string
/** The same name in ASCII, lower case: what rules are compared with. */
ascii: string
reasons: BlockReason[]
/** The address the server queried itself on: the check's client address. */
clientIp: string | null
}
/** A read the verdict needed and did not have: its permission, or a failure. */
export type Missing = { need: string } | { failed: true }
export interface BlockingSettings {
enableBlocking?: boolean
temporaryDisableBlockingTill?: string | null
allowTxtBlockingReport?: boolean
blockingBypassList?: string[] | null
}
export interface CheckInputs {
answer: Answer
settings: BlockingSettings | Missing
/** The allow rules, one domain per line, as `allowed/export` sends them. */
allowed: string[] | Missing
}
export type Verdict =
| { kind: 'lists'; name: string; domain: string; lists: string[] }
| { kind: 'rule'; name: string; domain: string }
| { kind: 'other'; name: string; texts: string[] }
| { kind: 'allowed'; name: string; domain: string }
| { kind: 'off'; name: string; paused: boolean }
| { kind: 'bypassed'; name: string; ip: string }
| { kind: 'unreported'; name: string }
| { kind: 'clear'; name: string; unread: { settings?: Missing; allowed?: Missing } }
function record(v: unknown): Record<string, unknown> | null {
return v != null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
}
function str(v: unknown): string | null {
return typeof v === 'string' ? v : null
}
/** The `Data` of every option, from `EDNS.Options`, or from the OPT record when an
answer has no `EDNS` summary. */
function ednsData(result: Record<string, unknown>): Record<string, unknown>[] {
let options = record(result.EDNS)?.Options
if (!Array.isArray(options)) {
const additional = Array.isArray(result.Additional) ? result.Additional : []
const opt = additional.map(record).find((r) => r?.Type === 'OPT')
options = record(opt?.RDATA)?.Options
}
if (!Array.isArray(options)) return []
return options
.map((o) => record(record(o)?.Data))
.filter((d): d is Record<string, unknown> => d != null)
}
const LIST_TEXT = /^source=block-list-zone; blockListUrl=(.*); domain=([^;]*)$/
const RULE_TEXT = /^source=blocked-zone; domain=([^;]*)$/
export function parseReason(text: string): BlockReason {
const list = LIST_TEXT.exec(text)
if (list != null) return { source: 'list', url: list[1], domain: list[2] }
const rule = RULE_TEXT.exec(text)
if (rule != null) return { source: 'rule', domain: rule[1] }
return { source: 'other', text }
}
/** `ns1.example.net (127.0.0.1)`, `([::1]:53)`, `(10.0.0.5:5353)`: the address alone. */
export function addressOf(nameServer: string | null): string | null {
if (nameServer == null) return null
const m = /\(([^()]*)\)\s*$/.exec(nameServer)
let a = (m != null ? m[1] : nameServer).trim()
const bracket = /^\[([^\]]+)\](?::\d+)?$/.exec(a)
if (bracket != null) a = bracket[1]
else if (/^[\d.]+:\d+$/.test(a)) a = a.slice(0, a.lastIndexOf(':'))
return a === '' ? null : a
}
/** The `result` of `dnsClient/resolve`, read without trusting its shape. */
export function parseAnswer(result: unknown): Answer {
const r = record(result) ?? {}
const q = record(Array.isArray(r.Question) ? r.Question[0] : null)
const ascii = (str(q?.Name) ?? '').toLowerCase()
const name = str(q?.NameIDN) ?? str(q?.Name) ?? ''
const reasons = ednsData(r)
.filter((d) => d.InfoCode === 'Blocked')
.map((d) => parseReason(str(d.ExtraText) ?? ''))
return {
name: name === '' ? '.' : name,
ascii,
reasons,
clientIp: addressOf(str(record(r.Metadata)?.NameServer)),
}
}
/* ── Addresses, for the Blocking Bypass List ─────────────────────────────────── */
function ipv4(text: string): bigint | null {
const parts = text.split('.')
if (parts.length !== 4) return null
let n = 0n
for (const p of parts) {
if (!/^\d{1,3}$/.test(p) || Number(p) > 255) return null
n = (n << 8n) | BigInt(Number(p))
}
return n
}
function ipv6(text: string): bigint | null {
let t = text.split('%')[0]
/* A dotted tail (`::ffff:1.2.3.4`) is two groups. */
const tail = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(t)
if (tail != null) {
const v4 = ipv4(tail[2])
if (v4 == null) return null
t = `${tail[1]}${(v4 >> 16n).toString(16)}:${(v4 & 0xffffn).toString(16)}`
}
const halves = t.split('::')
if (halves.length > 2) return null
const head = halves[0] === '' ? [] : halves[0].split(':')
const rest = halves.length === 2 ? (halves[1] === '' ? [] : halves[1].split(':')) : []
const fill = 8 - head.length - rest.length
if (halves.length === 1 ? head.length !== 8 : fill < 1) return null
const groups = [...head, ...Array<string>(halves.length === 2 ? fill : 0).fill('0'), ...rest]
let n = 0n
for (const g of groups) {
if (!/^[0-9a-f]{1,4}$/i.test(g)) return null
n = (n << 16n) | BigInt(parseInt(g, 16))
}
return n
}
function parseIp(text: string): { v: 4 | 6; n: bigint } | null {
const a = ipv4(text)
if (a != null) return { v: 4, n: a }
const b = text.includes(':') ? ipv6(text) : null
return b != null ? { v: 6, n: b } : null
}
/** Whether `ip` is in `network`, written as the server takes it: an address, or an
address with a prefix length (`NetworkAddress.Parse`). */
export function networkContains(network: string, ip: string): boolean {
const [base, prefixText] = network.trim().split('/')
const net = parseIp(base)
const addr = parseIp(ip)
if (net == null || addr == null || net.v !== addr.v) return false
const bits = net.v === 4 ? 32 : 128
const prefix = prefixText == null ? bits : Number(prefixText)
if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits) return false
const shift = BigInt(bits - prefix)
return net.n >> shift === addr.n >> shift
}
/* ── The verdict ─────────────────────────────────────────────────────────────── */
function isMissing(v: unknown): v is Missing {
const r = record(v)
return r != null && ('need' in r || 'failed' in r)
}
/** The most specific allow rule that covers `ascii`: the name itself or a parent. */
export function allowRuleFor(ascii: string, rules: readonly string[]): string | null {
let best: string | null = null
for (const raw of rules) {
const rule = raw.trim().toLowerCase().replace(/\.$/, '')
if (rule === '') continue
if (ascii === rule || ascii.endsWith(`.${rule}`)) {
if (best == null || rule.length > best.length) best = rule
}
}
return best
}
export function verdictOf({ answer, settings, allowed }: CheckInputs, now: number = Date.now()): Verdict {
const name = answer.name
/* A reported block is proof on its own, whatever else could be read. */
if (answer.reasons.length > 0) {
const lists = answer.reasons.filter((r) => r.source === 'list')
if (lists.length > 0) {
return { kind: 'lists', name, domain: lists[0].domain, lists: lists.map((l) => l.url) }
}
const rule = answer.reasons.find((r) => r.source === 'rule')
if (rule != null) return { kind: 'rule', name, domain: rule.domain }
return {
kind: 'other',
name,
texts: answer.reasons.map((r) => (r.source === 'other' ? r.text : '')),
}
}
const s = isMissing(settings) ? null : settings
if (s?.enableBlocking === false) {
const till = s.temporaryDisableBlockingTill
return { kind: 'off', name, paused: till != null && Date.parse(till) > now }
}
const rules = isMissing(allowed) ? null : allowed
const rule = rules != null ? allowRuleFor(answer.ascii, rules) : null
if (rule != null) return { kind: 'allowed', name, domain: rule }
if (s != null && answer.clientIp != null) {
const ip = answer.clientIp
if ((s.blockingBypassList ?? []).some((n) => networkContains(n, ip))) return { kind: 'bypassed', name, ip }
}
if (s?.allowTxtBlockingReport === false) return { kind: 'unreported', name }
const unread: { settings?: Missing; allowed?: Missing } = {}
if (isMissing(settings)) unread.settings = settings
if (isMissing(allowed)) unread.allowed = allowed
return { kind: 'clear', name, unread }
}
/*
The readable name of a list, the one the Lists tab draws (`listName` over Technitium's
catalogue and then ours). The server reports the URL as .NET's `AbsoluteUri`, which
can differ from how the catalogue writes it, so both sides are also compared after
the browser's own normalisation.
*/
export function reportedListName(url: string, catalog: readonly QuickEntry[]): string | null {
const exact = listName(url, catalog)
if (exact != null) return exact
const norm = (u: string) => {
try {
return new URL(u).href
} catch {
return u
}
}
return listName(
norm(url),
catalog.map((e) => ({ ...e, urls: e.urls.map(norm) })),
)
}
/** A read that may be skipped for its permission or fail, as the verdict takes it. */
function readOrMissing<T>(outcome: ApiOutcome<T> | { need: string }): T | Missing {
if ('need' in outcome) return outcome
return outcome.kind === 'ok' ? outcome.data : { failed: true }
}
/*
The check: the query and, beside it, the two reads the verdict needs, each skipped
when its permission is missing (`need`). All three go to the server the console is
on, with no `node`, as the Block and Allow beside the field do: rules and lists are
cluster-wide, and `Blocking Bypass List` is matched against this server's own address.
The query is a real one: like any DNS Client query to `this-server`, it is answered,
counted and logged by the server (a blocked one shows in Recently Blocked, from the
server's own address).
Only the query's failure fails the check, and its message is the server's
(`Invalid domain name [x y]: invalid character [32] was found.`, `Access was
denied.`); the other two only narrow the verdict.
*/
export async function checkDomain(
token: string | null,
domain: string,
needs: { settings?: string; allowed?: string },
): Promise<ApiOutcome<CheckInputs>> {
const [answer, settings, allowed] = await Promise.all([
resolve(token, { server: 'this-server', domain, type: 'A', protocol: 'UDP', dnssec: false, eDnsClientSubnet: '' }),
needs.settings != null ? { need: needs.settings } : readSettings(token),
needs.allowed != null ? { need: needs.allowed } : readRuleExport('allowed', token),
])
if (answer.kind !== 'ok') return answer
return {
kind: 'ok',
data: {
answer: parseAnswer(answer.data.response?.result),
settings: readOrMissing<BlockingSettings>(settings),
allowed: readOrMissing(allowed),
},
}
}
+212
View File
@@ -0,0 +1,212 @@
[
{
"name": "AdGuard DNS filter [ads + tracking]",
"urls": [
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt"
]
},
{
"name": "EasyList [ads, domains only]",
"urls": [
"https://v.firebog.net/hosts/Easylist.txt"
]
},
{
"name": "EasyPrivacy [tracking, domains only]",
"urls": [
"https://v.firebog.net/hosts/Easyprivacy.txt"
]
},
{
"name": "1Hosts Lite [ads + tracking, balanced]",
"urls": [
"https://badmojr.github.io/1Hosts/Lite/wildcards.txt"
]
},
{
"name": "1Hosts Xtra [ads + tracking, aggressive]",
"urls": [
"https://badmojr.github.io/1Hosts/Xtra/wildcards.txt"
]
},
{
"name": "HaGeZi [Pop-Up Ads]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/popupads-onlydomains.txt"
]
},
{
"name": "Frogeye [first-party trackers hidden behind CNAME]",
"urls": [
"https://hostfiles.frogeye.fr/firstparty-trackers-hosts.txt"
]
},
{
"name": "HaGeZi [Threat Intelligence Feeds, medium size for low-memory servers]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/tif.medium-onlydomains.txt"
]
},
{
"name": "Phishing Army [phishing, extended]",
"urls": [
"https://phishing.army/download/phishing_army_blocklist_extended.txt"
]
},
{
"name": "malware-filter [malware URLs from URLhaus]",
"urls": [
"https://malware-filter.gitlab.io/malware-filter/urlhaus-filter-hosts.txt"
]
},
{
"name": "malware-filter [phishing URLs]",
"urls": [
"https://malware-filter.gitlab.io/malware-filter/phishing-filter-hosts.txt"
]
},
{
"name": "HaGeZi [Fake: scams, fake shops and subscription traps]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/fake-onlydomains.txt"
]
},
{
"name": "Dandelion Sprout [anti-malware]",
"urls": [
"https://raw.githubusercontent.com/DandelionSprout/adfilt/master/Alternate%20versions%20Anti-Malware%20List/AntiMalwareHosts.txt"
]
},
{
"name": "RPiList [phishing]",
"urls": [
"https://raw.githubusercontent.com/RPiList/specials/master/Blocklisten/Phishing-Angriffe"
]
},
{
"name": "ShadowWhisperer [malware]",
"urls": [
"https://raw.githubusercontent.com/ShadowWhisperer/BlockLists/master/RAW/Malware"
]
},
{
"name": "HaGeZi [Dynamic DNS services abused by malware]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/dyndns-onlydomains.txt"
]
},
{
"name": "HaGeZi [Badware Hoster: free hosts abused by malware]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/hoster-onlydomains.txt"
]
},
{
"name": "NoCoin [browser cryptomining]",
"urls": [
"https://raw.githubusercontent.com/hoshsadiq/adblock-nocoin-list/master/hosts.txt"
]
},
{
"name": "Echap [stalkerware]",
"urls": [
"https://raw.githubusercontent.com/AssoEchap/stalkerware-indicators/master/generated/hosts"
]
},
{
"name": "HaGeZi [Native Tracker: Windows and Office]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/native.winoffice-onlydomains.txt"
]
},
{
"name": "HaGeZi [Native Tracker: Apple]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/native.apple-onlydomains.txt"
]
},
{
"name": "HaGeZi [Native Tracker: Samsung]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/native.samsung-onlydomains.txt"
]
},
{
"name": "HaGeZi [Native Tracker: Amazon]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/native.amazon-onlydomains.txt"
]
},
{
"name": "HaGeZi [Native Tracker: Xiaomi]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/native.xiaomi-onlydomains.txt"
]
},
{
"name": "HaGeZi [Native Tracker: TikTok]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/native.tiktok-onlydomains.txt"
]
},
{
"name": "HaGeZi [Native Tracker: Huawei]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/native.huawei-onlydomains.txt"
]
},
{
"name": "HaGeZi [Native Tracker: OPPO and Realme]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/native.oppo-realme-onlydomains.txt"
]
},
{
"name": "HaGeZi [Native Tracker: Vivo]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/native.vivo-onlydomains.txt"
]
},
{
"name": "HaGeZi [Native Tracker: LG webOS]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/native.lgwebos-onlydomains.txt"
]
},
{
"name": "HaGeZi [Native Tracker: Roku]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/native.roku-onlydomains.txt"
]
},
{
"name": "Perflyst [Smart TV tracking]",
"urls": [
"https://raw.githubusercontent.com/Perflyst/PiHoleBlocklist/master/SmartTV.txt"
]
},
{
"name": "HaGeZi [Gambling]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/gambling-onlydomains.txt"
]
},
{
"name": "HaGeZi [NSFW: adult content]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/nsfw-onlydomains.txt"
]
},
{
"name": "HaGeZi [DoH, VPN, TOR and proxy bypass]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/doh-vpn-proxy-bypass-onlydomains.txt"
]
},
{
"name": "HaGeZi [Anti-Piracy]",
"urls": [
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/wildcard/anti.piracy-onlydomains.txt"
]
}
]
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import builtin from '../../../public/json/quick-block-lists-builtin.json'
import { EXTRA_LISTS, hostsOf, moreLists } from './extra-lists'
import { listName } from './list-lines'
const SB = 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'
const OISD = 'https://big.oisd.nl/domainswild2'
const THEIRS = [
{ name: 'Default', urls: [SB] },
{ name: 'Steven Black [adware + malware]', urls: [SB] },
{ name: 'OISD Big [Domains (Wildcards)]', urls: [OISD] },
]
describe('moreLists', () => {
it('leaves out an entry whose every URL Technitium already offers', () => {
const ours = [
{ name: 'Same hosts file', urls: [SB] },
{ name: 'Both of theirs', urls: [SB, OISD] },
{ name: 'Ads', urls: ['https://lists.example.org/ads.txt'] },
]
expect(moreLists(THEIRS, ours).map((e) => e.name)).toEqual(['Ads'])
})
it('keeps an entry that adds at least one URL of its own', () => {
const ours = [{ name: 'Theirs and one more', urls: [SB, 'https://lists.example.org/more.txt'] }]
expect(moreLists(THEIRS, ours)).toEqual(ours)
})
it('keeps the order of ours', () => {
const ours = [
{ name: 'B', urls: ['https://b.example.org/x'] },
{ name: 'A', urls: ['https://a.example.org/x'] },
]
expect(moreLists([], ours).map((e) => e.name)).toEqual(['B', 'A'])
})
/* `Default` replaces the field by its name (applyQuickEntry), and `None` is the
fixed option that empties it: neither can be an entry of ours. */
it('never offers an entry named Default or None, nor one with no URL', () => {
const ours = [
{ name: 'default', urls: ['https://a.example.org/x'] },
{ name: 'NONE', urls: ['https://b.example.org/x'] },
{ name: 'Empty', urls: [] },
]
expect(moreLists([], ours)).toEqual([])
})
})
describe('hostsOf', () => {
it('gives each host once', () => {
expect(hostsOf({ name: 'x', urls: [SB, 'https://raw.githubusercontent.com/other', OISD] }))
.toBe('raw.githubusercontent.com big.oisd.nl')
})
it('skips what is not a URL', () => {
expect(hostsOf({ name: 'x', urls: ['not a url'] })).toBe('')
})
})
describe('listName with the console catalogue', () => {
it('names a row from an entry of ours', () => {
const more = moreLists(THEIRS, [{ name: 'Ads', urls: ['https://lists.example.org/ads.txt'] }])
expect(listName('https://lists.example.org/ads.txt', [...THEIRS, ...more])).toBe('Ads')
})
it('Technitium names a URL both carry', () => {
const ours = [{ name: 'Ours for the same file', urls: [SB] }]
expect(listName(SB, [...THEIRS, ...ours])).toBe('Steven Black [adware + malware]')
})
})
/* The bundled file itself: what Quick Add will offer. */
describe('the bundled catalogue', () => {
it('has the shape of Technitium catalogue', () => {
for (const e of EXTRA_LISTS) {
expect(typeof e.name).toBe('string')
expect(e.name.trim()).not.toBe('')
expect(e.urls.length).toBeGreaterThan(0)
for (const u of e.urls) expect(['http:', 'https:']).toContain(new URL(u).protocol)
}
})
it('has no name twice and no reserved name', () => {
const names = EXTRA_LISTS.map((e) => e.name.toLowerCase())
expect(new Set(names).size).toBe(names.length)
expect(names).not.toContain('default')
expect(names).not.toContain('none')
})
it('does not repeat a name of the built-in catalogue', () => {
const theirs = new Set(builtin.map((e) => e.name.toLowerCase()))
expect(EXTRA_LISTS.filter((e) => theirs.has(e.name.toLowerCase()))).toEqual([])
})
})
+50
View File
@@ -0,0 +1,50 @@
import type { QuickEntry } from '../../lib/quick-lists'
import bundled from './extra-lists.json'
/*
The console's own Quick Add catalogue: everything in this file and in
`extra-lists.json` is OURS, not upstream's.
Technitium's catalogue is `www/json/quick-block-lists-builtin.json` (or the
administrator's `-custom.json`), read by `lib/quick-lists.ts` and left as it is: it
belongs to the server, and `public/` only carries what the server ships. This one is
bundled in the code and offered only by Blocking › Lists, after Technitium's, under
the label `More lists` (OURS too). Settings › Blocking still offers Technitium's
alone.
Its entries have the same shape as Technitium's, and choosing one goes through the
same `applyQuick` as choosing one of theirs: it appends its URLs, skipping the ones
already there.
*/
export const EXTRA_LISTS: readonly QuickEntry[] = bundled
/* `applyQuickEntry` gives `Default` its replacing meaning by name, and the Lists tab
offers `None` as a fixed option: an entry of ours with either name would behave
as theirs, so it is never offered. */
const RESERVED = new Set(['default', 'none'])
/** The entries of ours worth offering next to `catalog`: not those whose every URL
* Technitium's catalogue already carries. */
export function moreLists(
catalog: readonly QuickEntry[],
extra: readonly QuickEntry[] = EXTRA_LISTS,
): QuickEntry[] {
const known = new Set(catalog.flatMap((e) => e.urls))
return extra.filter(
(e) => e.urls.length > 0 && !RESERVED.has(e.name.toLowerCase()) && !e.urls.every((u) => known.has(u)),
)
}
/** The hosts an entry's URLs point at, for the Quick Add search to match. */
export function hostsOf(entry: QuickEntry): string {
const hosts = new Set<string>()
for (const u of entry.urls) {
try {
hosts.add(new URL(u).host)
} catch {
/* not a URL: nothing to match */
}
}
return [...hosts].join(' ')
}
+327
View File
@@ -0,0 +1,327 @@
import { describe, expect, it } from 'vitest'
import {
addHeading, addList, addListTo, applyQuick, applyQuickTo, canMove, canToggle, changesOf, changesText, fromUrls, groupOf,
HEADING_MAX, headingEdits, headingIndexes, headingName, headingProblem, listName, moveLine, parseLine,
removeHeading, renameHeading, saveBody, sameLines, toggleLine,
} from './list-lines'
const HOSTS = 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'
describe('parseLine', () => {
it('drops an empty line', () => {
expect(parseLine('')).toBeNull()
expect(parseLine(' ')).toBeNull()
})
it('reads a block list', () => {
expect(parseLine(HOSTS)).toEqual({ raw: HOSTS, kind: 'block', url: HOSTS, enabled: true })
})
it('reads an allow list', () => {
expect(parseLine('!https://a.test/allow.txt')).toEqual({
raw: '!https://a.test/allow.txt', kind: 'allow', url: 'https://a.test/allow.txt', enabled: true,
})
})
it('trims before reading, as the server does', () => {
expect(parseLine(` ${HOSTS} `)?.raw).toBe(HOSTS)
})
it('reads a disabled block list and a disabled allow list', () => {
expect(parseLine(`#${HOSTS}`)).toMatchObject({ kind: 'block', url: HOSTS, enabled: false })
expect(parseLine('# !https://a.test/x')).toMatchObject({ kind: 'allow', url: 'https://a.test/x', enabled: false })
})
it('reads a file:// list', () => {
expect(parseLine('file:///home/folder/myblocklist.txt')).toMatchObject({ kind: 'block', enabled: true })
expect(parseLine('#file:///c:/folder/list.txt')).toMatchObject({ kind: 'block', enabled: false })
})
it('a # line that is not a list is a comment', () => {
expect(parseLine('# my lists')).toEqual({ raw: '# my lists', kind: 'comment', url: null, enabled: false })
expect(parseLine('#ftp://x.test/list')).toMatchObject({ kind: 'comment' })
})
it('a line without # is a list even when it is not a valid URL: the server treats it as one', () => {
expect(parseLine('not a url')).toMatchObject({ kind: 'block', url: 'not a url', enabled: true })
})
})
describe('toggleLine', () => {
it('disabling prefixes #, enabling removes only that # and the spaces after it', () => {
const on = parseLine('!https://a.test/x')!
const off = toggleLine(on)
expect(off).toMatchObject({ raw: '#!https://a.test/x', enabled: false, kind: 'allow' })
expect(toggleLine(off)).toEqual(on)
expect(toggleLine(parseLine(`# ${HOSTS}`)!).raw).toBe(HOSTS)
})
it('a comment does not toggle', () => {
const c = parseLine('# note')!
expect(toggleLine(c)).toBe(c)
})
it('an enabled line that is not a list URL does not toggle: it would become a comment', () => {
const l = parseLine('not a url')!
expect(toggleLine(l)).toBe(l)
})
})
describe('canToggle', () => {
it('a block URL and a disabled URL toggle, a comment and a line that is not a list URL do not', () => {
expect(canToggle(parseLine(HOSTS)!)).toBe(true)
expect(canToggle(parseLine(`#${HOSTS}`)!)).toBe(true)
expect(canToggle(parseLine('# note')!)).toBe(false)
expect(canToggle(parseLine('not a url')!)).toBe(false)
})
})
describe('addList', () => {
it('appends a block or an allow list, trimmed', () => {
const lines = addList(addList([], ` ${HOSTS} `, 'block'), 'https://a.test/x', 'allow')
expect(lines.map((l) => l.raw)).toEqual([HOSTS, '!https://a.test/x'])
})
it('ignores an empty field and a list already there', () => {
const one = addList([], HOSTS, 'block')
expect(addList(one, ' ', 'block')).toBe(one)
expect(addList(one, HOSTS, 'block')).toBe(one)
})
})
describe('applyQuick', () => {
const lines = fromUrls([HOSTS, '# note'])
it('None empties the list', () => {
expect(applyQuick(lines, 'none')).toEqual([])
})
it('Default replaces', () => {
const out = applyQuick(lines, { name: 'Default', urls: ['https://d.test/a'] })
expect(out.map((l) => l.raw)).toEqual(['https://d.test/a'])
})
it('any other entry appends without repeating a URL', () => {
const out = applyQuick(lines, { name: 'Two', urls: [HOSTS, 'https://b.test/b'] })
expect(out.map((l) => l.raw)).toEqual([HOSTS, '# note', 'https://b.test/b'])
})
})
describe('listName', () => {
const catalog = [
{ name: 'Default', urls: [HOSTS] },
{ name: 'Steven Black [adware + malware]', urls: [HOSTS] },
{ name: 'Bundle', urls: ['https://x.test/1', 'https://x.test/2'] },
]
it('names a URL by its single-URL catalog entry, never by Default or None', () => {
expect(listName(HOSTS, catalog)).toBe('Steven Black [adware + malware]')
})
it('has no name for an unknown URL or one that only appears in a bundle', () => {
expect(listName('https://x.test/1', catalog)).toBeNull()
expect(listName('https://nowhere.test', catalog)).toBeNull()
})
})
describe('saveBody', () => {
it('sends only blockListUrls, cleaned as Settings cleans it, with the node first', () => {
expect(saveBody(fromUrls([HOSTS, '#!https://a.test/x']), 'cluster')).toEqual({
node: 'cluster',
blockListUrls: `${HOSTS},#!https://a.test/x`,
})
})
it('an empty list travels as the string false', () => {
expect(saveBody([], '')).toEqual({ node: '', blockListUrls: 'false' })
})
})
describe('sameLines', () => {
it('compares the saved text, line by line', () => {
expect(sameLines(fromUrls([HOSTS]), fromUrls([` ${HOSTS} `]))).toBe(true)
expect(sameLines(fromUrls([HOSTS]), fromUrls([`#${HOSTS}`]))).toBe(false)
})
})
describe('groups', () => {
const raws = (lines: { raw: string }[]) => lines.map((l) => l.raw)
const A = 'https://a.test/a'
const B = 'https://b.test/b'
const C = 'https://c.test/c'
// One list with no group, then "Ads" with two, then "Allow" with one.
const lines = fromUrls([A, '# Ads', B, `#${C}`, '#Allow', '!https://d.test/d'])
it('a comment is a heading, and its lists run to the next heading', () => {
expect(headingIndexes(lines)).toEqual([1, 4])
expect([0, 1, 2, 3, 4, 5].map((i) => groupOf(lines, i))).toEqual([-1, 0, 0, 0, 1, 1])
expect(headingName(lines[1])).toBe('Ads')
expect(headingName(lines[4])).toBe('Allow')
})
it('a switched-off list is not a heading: it stays in its group', () => {
expect(lines[3]).toMatchObject({ kind: 'block', enabled: false })
})
it('refuses a name the server would not keep as one comment line', () => {
expect(headingProblem(' ')).toBe('Enter a group name.')
expect(headingProblem('Ads\nTrackers')).toBe('A group name is one line.')
expect(headingProblem('Ads, trackers')).toBe('A group name cannot contain a comma: the server splits the list at commas.')
expect(headingProblem('x'.repeat(HEADING_MAX + 1))).toBe(`A group name can be up to ${HEADING_MAX} characters.`)
expect(headingProblem('x'.repeat(HEADING_MAX))).toBeNull()
expect(headingProblem(HOSTS)).toBe('A group name cannot be a list URL.')
expect(headingProblem(`!${HOSTS}`)).toBe('A group name cannot be a list URL.')
expect(headingProblem('file:///etc/list.txt')).toBe('A group name cannot be a list URL.')
expect(headingProblem('Ads and trackers')).toBeNull()
})
it('a heading is saved as "# Name", and reads back as the same heading', () => {
const out = addHeading(lines, ' Family devices ')
expect(out.at(-1)).toEqual({ raw: '# Family devices', kind: 'comment', url: null, enabled: false })
expect(fromUrls(saveBody(out, '').blockListUrls.split(','))).toEqual(out)
})
it('an invalid name changes nothing', () => {
expect(addHeading(lines, 'a, b')).toBe(lines)
expect(renameHeading(lines, 1, HOSTS)).toBe(lines)
})
it('renames only a heading', () => {
expect(raws(renameHeading(lines, 1, 'Adverts'))).toEqual([A, '# Adverts', B, `#${C}`, '#Allow', '!https://d.test/d'])
expect(renameHeading(lines, 0, 'Adverts')).toBe(lines)
})
it('removing a heading keeps its lists, which join the group above', () => {
const out = removeHeading(lines, 4)
expect(raws(out)).toEqual([A, '# Ads', B, `#${C}`, '!https://d.test/d'])
expect(groupOf(out, 4)).toBe(0)
// The first heading's lists join the block with no group.
expect([1, 2].map((i) => groupOf(removeHeading(lines, 1), i))).toEqual([-1, -1])
expect(removeHeading(lines, 0)).toBe(lines)
})
it('adds a list last in the chosen group', () => {
expect(raws(addListTo(lines, 'https://e.test/e', 'block', -1))[1]).toBe('https://e.test/e')
expect(raws(addListTo(lines, 'https://e.test/e', 'allow', 0))[4]).toBe('!https://e.test/e')
expect(raws(addListTo(lines, 'https://e.test/e', 'block', 1)).at(-1)).toBe('https://e.test/e')
expect(addListTo(lines, A, 'block', 1)).toBe(lines)
})
it('Quick Add puts what it appends into the chosen group, and skips what is there', () => {
const out = applyQuickTo(lines, { name: 'Two', urls: [A, 'https://e.test/e'] }, 0)
expect(raws(out)).toEqual([A, '# Ads', B, `#${C}`, 'https://e.test/e', '#Allow', '!https://d.test/d'])
const same = applyQuickTo(lines, { name: 'One', urls: [A] }, 0)
expect(same).toBe(lines)
})
it('Quick Add None and Default keep their meaning: every line, headings included', () => {
expect(applyQuickTo(lines, 'none', 1)).toEqual([])
expect(raws(applyQuickTo(lines, { name: 'Default', urls: [HOSTS] }, 1))).toEqual([HOSTS])
})
it('moves a list one line, across a heading into the next group', () => {
// B moves up over "# Ads": it becomes the last of the block with no group.
const up = moveLine(lines, 2, -1)
expect(raws(up)).toEqual([A, B, '# Ads', `#${C}`, '#Allow', '!https://d.test/d'])
expect(groupOf(up, 1)).toBe(-1)
// The switched-off C moves down over "#Allow": it becomes the first of that group.
const down = moveLine(lines, 3, 1)
expect(raws(down)).toEqual([A, '# Ads', B, '#Allow', `#${C}`, '!https://d.test/d'])
expect(groupOf(down, 4)).toBe(1)
// Within a group it only swaps.
expect(raws(moveLine(lines, 3, -1)).slice(2, 4)).toEqual([`#${C}`, B])
})
it('a heading does not move, and the ends are the ends', () => {
expect(canMove(lines, 1, 1)).toBe(false)
expect(moveLine(lines, 1, 1)).toBe(lines)
expect(canMove(lines, 0, -1)).toBe(false)
expect(canMove(lines, 5, 1)).toBe(false)
expect(canMove(lines, 5, -1)).toBe(true)
})
it('counts heading edits: a rename is one, an addition or a removal is one', () => {
expect(headingEdits(['# A', '# B'], ['# A', '# B'])).toBe(0)
expect(headingEdits(['# A', '# B'], ['# A', '# C'])).toBe(1)
expect(headingEdits(['# A', '# B'], ['# B'])).toBe(1)
expect(headingEdits([], ['# A', '# B'])).toBe(2)
})
})
describe('changesOf, the save bar in words', () => {
const A = 'https://a.test/a.txt'
const B = 'https://b.test/b.txt'
const C = 'file:///etc/dns/c.txt'
const text = (before: string[], after: ReturnType<typeof fromUrls>) => changesText(changesOf(fromUrls(before), after))
it('says nothing when nothing changed', () => {
expect(text([A, '# G', B], fromUrls([A, '# G', B]))).toBe('')
})
it('names an addition, a removal, a switch off and a move, once each', () => {
// The screen's own sequence: add C, remove the allow list, switch A off, move B up.
let lines = fromUrls([A, '# G', B, `!${C}`])
lines = addListTo(lines, 'https://d.test/d.txt', 'block', -1)
lines = lines.filter((l) => l.raw !== `!${C}`)
lines = lines.map((l) => (l.url === A ? toggleLine(l) : l))
lines = moveLine(lines, lines.findIndex((l) => l.url === B), -1)
expect(text([A, '# G', B, `!${C}`], lines)).toBe('1 added, 1 removed, 1 switched off, 1 moved')
})
it('a switch back on, and a list that became the other kind', () => {
expect(text([`#${A}`, B], fromUrls([A, `!${B}`]))).toBe('1 switched on, 1 changed')
})
it('a list moved across a heading is moved; one moved three places is one move', () => {
expect(text(['# G', A], fromUrls([A, '# G']))).toBe('1 moved')
expect(text([A, B, C, 'https://d.test/'], fromUrls([B, C, 'https://d.test/', A]))).toBe('1 moved')
})
it('adding or removing a list moves nothing', () => {
expect(text([A, B, C], fromUrls([A, C]))).toBe('1 removed')
expect(text([A, C], addListTo(fromUrls([A, '# G', C]), B, 'block', -1).filter((l) => l.kind !== 'comment'))).toBe('1 added')
})
it('groups: a rename is a rename; ungrouping one and adding another is not', () => {
expect(text(['# Ads', A], fromUrls(['# Adverts', A]))).toBe('1 group renamed')
expect(text(['# One', A, '# Two', B], fromUrls([A, '# Two', B, '# Three']))).toBe('1 group added, 1 group removed')
expect(text(['# One', A], fromUrls([A]))).toBe('1 group removed')
expect(text([A, B], fromUrls([A, B, '# X', '# Y']))).toBe('2 groups added')
})
it('reports something exactly when the lines differ, over random edits', () => {
let seed = 7
const rand = (n: number) => {
seed = (seed * 1103515245 + 12345) % 2147483648
return seed % n
}
const urls = [A, B, C, 'https://d.test/', 'https://e.test/']
for (let round = 0; round < 400; round++) {
const saved = fromUrls(['# G1', A, B, '# G2', C, `#${'https://d.test/'}`])
let lines = saved
for (let step = 0; step < 1 + rand(5); step++) {
// Every other round only moves: an order change alone must be reported too.
const op = round % 2 === 0 ? 3 : rand(6)
const lists = lines.map((l, i) => [l, i] as const).filter(([l]) => l.kind !== 'comment')
if (op === 0) lines = addListTo(lines, urls[rand(urls.length)], rand(2) === 0 ? 'block' : 'allow', rand(3) - 1)
else if (op === 1 && lists.length > 0) {
const [, i] = lists[rand(lists.length)]
lines = lines.filter((_, j) => j !== i)
} else if (op === 2 && lists.length > 0) {
const [, i] = lists[rand(lists.length)]
lines = lines.map((l, j) => (j === i ? toggleLine(l) : l))
} else if (op === 3 && lists.length > 0) {
const [, i] = lists[rand(lists.length)]
lines = moveLine(lines, i, rand(2) === 0 ? -1 : 1)
} else if (op === 4) lines = addHeading(lines, `H${rand(3)}`)
else {
const heads = headingIndexes(lines)
if (heads.length > 0) lines = rand(2) === 0
? removeHeading(lines, heads[rand(heads.length)])
: renameHeading(lines, heads[rand(heads.length)], `R${rand(3)}`)
}
}
expect(changesText(changesOf(saved, lines)) !== '').toBe(!sameLines(saved, lines))
}
})
})
+437
View File
@@ -0,0 +1,437 @@
import { applyQuickEntry, type QuickEntry } from '../../lib/quick-lists'
import { cleanList } from '../settings/model'
/*
One line of `blockListUrls`, the field Settings > Blocking edits as a textarea and the
Lists tab draws as a table. This module is OURS: upstream has no table of lists.
The grammar is the SERVER's, not a guess (BlockListZoneManager.cs:644-647): the line is
trimmed, a line starting with `#` is skipped, and a line starting with `!` is an allow
list. A line WITHOUT `#` is therefore always a list, valid URL or not, because that is
how the server reads it. Only after a `#` do we tell apart a list the administrator
switched off from a plain comment, and that is decided by whether what follows parses
as an `http:`, `https:` or `file:` URL. The Lists tab draws a comment as a group
heading (see GROUPS below).
*/
export type LineKind = 'block' | 'allow' | 'comment'
export interface ListLine {
/** The line as it will be saved, already trimmed. */
raw: string
kind: LineKind
/** The URL without `#` and `!`. Null for a comment. */
url: string | null
/** False for a switched-off list and for a comment. */
enabled: boolean
}
const SCHEMES = new Set(['http:', 'https:', 'file:'])
function isListUrl(text: string): boolean {
try {
return SCHEMES.has(new URL(text).protocol)
} catch {
return false
}
}
export function parseLine(line: string): ListLine | null {
const raw = line.trim()
if (raw === '') return null
const disabled = raw.startsWith('#')
const body = disabled ? raw.slice(1).trim() : raw
const allow = body.startsWith('!')
const url = (allow ? body.slice(1) : body).trim()
if (disabled && !isListUrl(url)) return { raw, kind: 'comment', url: null, enabled: false }
return { raw, kind: allow ? 'allow' : 'block', url, enabled: !disabled }
}
export function fromUrls(urls: readonly string[] | null | undefined): ListLine[] {
return (urls ?? []).map(parseLine).filter((l): l is ListLine => l != null)
}
function fromText(text: string): ListLine[] {
return fromUrls(text.split('\n'))
}
function toText(lines: readonly ListLine[]): string {
return lines.map((l) => `${l.raw}\n`).join('')
}
/*
Whether a line can be switched on or off. A comment cannot, and neither can an
enabled line whose URL is not an `http:`, `https:` or `file:` URL: prefixing it
with `#` would turn it into a comment, which can never be switched back on.
A disabled line is a list URL by construction, so it can always be switched on.
*/
export function canToggle(line: ListLine): boolean {
if (line.kind === 'comment') return false
return !line.enabled || isListUrl(line.url ?? '')
}
/** Switching off prefixes `#`; switching on removes that `#` and the spaces after it. */
export function toggleLine(line: ListLine): ListLine {
if (!canToggle(line)) return line
const raw = line.enabled ? `#${line.raw}` : line.raw.replace(/^#\s*/, '')
return parseLine(raw) ?? line
}
export function addList(lines: ListLine[], url: string, kind: 'block' | 'allow'): ListLine[] {
const clean = url.trim()
if (clean === '') return lines
const raw = kind === 'allow' ? `!${clean}` : clean
if (lines.some((l) => l.raw === raw)) return lines
const line = parseLine(raw)
return line == null ? lines : [...lines, line]
}
/*
Quick Add, with upstream's semantics whole (main.js:493-529, `lib/quick-lists.ts`):
`None` empties, `Default` replaces, any other entry appends, and a URL already present
is not added twice. `None` is not an entry of the JSON catalog: Settings offers it as a
fixed option, and so does the Lists tab.
*/
export function applyQuick(lines: ListLine[], entry: QuickEntry | 'none'): ListLine[] {
if (entry === 'none') return []
return fromText(applyQuickEntry(toText(lines), entry))
}
/** The readable name of a list: the single-URL catalog entry that carries it. */
export function listName(url: string, catalog: readonly QuickEntry[]): string | null {
const hit = catalog.find(
(e) =>
e.urls.length === 1 &&
e.urls[0] === url &&
e.name.toLowerCase() !== 'default' &&
e.name.toLowerCase() !== 'none',
)
return hit?.name ?? null
}
/*
The body of the save: ONLY `blockListUrls`, cleaned exactly as Settings cleans it
(`settings/model.ts:830-839`), with `node` first as upstream always sends it
(main.js:1644). An empty list travels as the string `false`.
*/
export function saveBody(lines: ListLine[], node: string): Record<string, string> {
const blu = cleanList(toText(lines))
return { node, blockListUrls: blu.length === 0 || blu === ',' ? 'false' : blu }
}
export function sameLines(a: ListLine[], b: ListLine[]): boolean {
return a.length === b.length && a.every((l, i) => l.raw === b[i].raw)
}
/*
GROUPS, OURS. A comment line is a group heading: the lists after it belong to that
group until the next heading, and the lists before the first heading belong to no
group. Nothing is stored anywhere else: a group is its `#` line, so Settings ›
Blocking keeps showing it as the plain comment it is, and the server keeps skipping it.
ORDER DOES NOT CHANGE WHAT IS BLOCKED. Checked in the server (v15.6.1,
BlockListZoneManager.cs:754-847): `LoadBlockLists` splits the lines into allow and
block URLs, reads ALL the allow lists into one dictionary and all the block lists
into another, and a blocked domain records every URL that carries it.
`IsZoneAllowed` and `IsZoneBlocked` (:478-512) only ask whether a domain is in
them, so no list wins over another by coming first. The one trace of order is the
order in which a blocked answer NAMES the lists that carry the domain (its
`blockListUrl=` TXT and Extended DNS Error texts, :869-895). The update check
compares the lines as a set (`HasSameItems`, :517), so a Save that only moves
lines does not download anything again; a heading added, renamed or removed does,
because a heading is one of those lines.
What the server imposes on a heading, and `headingProblem` checks before it is ever
saved:
- the field travels comma-separated and the server splits it at every comma,
trimming each piece (Extensions.cs:277-307), so a comma would cut the heading in
two and the second half, without its `#`, would be read as a list URL and refused;
- a line is one line: no newline;
- after its `#` it must not read as a list URL, or it would come back as a
switched-off list (`parseLine`);
- a line is at most 255 characters (BlockListZoneManager.cs:1056), `# ` included.
And one thing it does on its own: when every line is a comment, it keeps NONE of
them (:1083, `uniqueList.Count == commentCount`). Groups with no list anywhere are
dropped by the Save; the screen shows what the server answered.
*/
/** The most a heading's name can take: 255 per line, minus `# `. */
export const HEADING_MAX = 253
/** A heading's name: what follows its `#`, with the spaces after it. */
export function headingName(line: ListLine): string {
return line.raw.replace(/^#\s*/, '')
}
/** Why a name cannot be a heading, in a sentence of OURS; `null` when it can. */
export function headingProblem(name: string): string | null {
const clean = name.trim()
if (clean === '') return 'Enter a group name.'
if (/[\r\n]/.test(clean)) return 'A group name is one line.'
if (clean.includes(',')) return 'A group name cannot contain a comma: the server splits the list at commas.'
if (clean.length > HEADING_MAX) return `A group name can be up to ${HEADING_MAX} characters.`
if (parseLine(`# ${clean}`)?.kind !== 'comment') return 'A group name cannot be a list URL.'
return null
}
/** The line a valid name is saved as: `# Name`. */
function headingLine(name: string): ListLine {
const raw = `# ${name.trim()}`
return { raw, kind: 'comment', url: null, enabled: false }
}
/** The index of each heading line, in order: a group's ordinal is its place here. */
export function headingIndexes(lines: readonly ListLine[]): number[] {
const out: number[] = []
lines.forEach((l, i) => {
if (l.kind === 'comment') out.push(i)
})
return out
}
/** The group a line belongs to: the ordinal of the last heading before it, -1 for none. */
export function groupOf(lines: readonly ListLine[], index: number): number {
let group = -1
for (let i = 0; i <= index && i < lines.length; i++) if (lines[i].kind === 'comment') group++
return group
}
/** Where a group ends: the next heading, or the end. `-1` is the block with no group. */
function groupEnd(lines: readonly ListLine[], group: number): number {
const heads = headingIndexes(lines)
const next = heads[group + 1]
return next ?? lines.length
}
function insertAt(lines: readonly ListLine[], at: number, added: readonly ListLine[]): ListLine[] {
return [...lines.slice(0, at), ...added, ...lines.slice(at)]
}
/** A new heading, last, so it opens an empty group at the bottom. Refused when invalid. */
export function addHeading(lines: ListLine[], name: string): ListLine[] {
if (headingProblem(name) != null) return lines
return [...lines, headingLine(name)]
}
export function renameHeading(lines: ListLine[], index: number, name: string): ListLine[] {
if (lines[index]?.kind !== 'comment' || headingProblem(name) != null) return lines
return lines.map((l, i) => (i === index ? headingLine(name) : l))
}
/** Only the heading goes: its lists stay where they are, so they join the group
above, or the block with no group when it was the first. */
export function removeHeading(lines: ListLine[], index: number): ListLine[] {
if (lines[index]?.kind !== 'comment') return lines
return lines.filter((_, i) => i !== index)
}
/** `addList`, into a group: last in it rather than last of all. */
export function addListTo(lines: ListLine[], url: string, kind: 'block' | 'allow', group: number): ListLine[] {
const added = addList(lines, url, kind)
if (added === lines) return lines
return insertAt(lines, groupEnd(lines, group), added.slice(lines.length))
}
/*
`applyQuick`, into a group. `None` and `Default` keep their meaning whole (they empty
or replace EVERY line, headings included, as they do Settings' textarea); any other
entry appends what it adds, and that is placed last in the group.
*/
export function applyQuickTo(lines: ListLine[], entry: QuickEntry | 'none', group: number): ListLine[] {
const out = applyQuick(lines, entry)
if (entry === 'none' || entry.name.toLowerCase() === 'default') return out
const added = out.slice(lines.length)
return added.length === 0 ? lines : insertAt(lines, groupEnd(lines, group), added)
}
/** Whether a LIST can move one place: headings stay put, and the ends are the ends. */
export function canMove(lines: readonly ListLine[], index: number, step: -1 | 1): boolean {
const target = index + step
return lines[index] != null && lines[index].kind !== 'comment' && target >= 0 && target < lines.length
}
/*
A list moves one line, so passing a heading takes it across: up over its own heading
it becomes the last of the group above, down over the next heading the first of
the group below.
*/
export function moveLine(lines: ListLine[], index: number, step: -1 | 1): ListLine[] {
if (!canMove(lines, index, step)) return lines
const out = [...lines]
;[out[index], out[index + step]] = [out[index + step], out[index]]
return out
}
/** Edits between two sequences of headings: a rename is one, as an addition or a removal is. */
export function headingEdits(a: readonly string[], b: readonly string[]): number {
let prev = Array.from({ length: b.length + 1 }, (_, j) => j)
for (let i = 1; i <= a.length; i++) {
const row = [i]
for (let j = 1; j <= b.length; j++) {
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
}
prev = row
}
return prev[b.length]
}
/*
WHAT CHANGED, OURS (2026-10-15): the Lists tab's save bar says it in words, so every
count here is one the bar prints and none is a guess.
A list is told by its URL, so switching one off is one change (`switchedOff`) and not
a removal plus an addition. Lines whose text is identical match first, by text and
then by place, the same as the count this replaced. What is left of a URL on each
side is paired in order: a pair is `switchedOff` or `switchedOn` when only the `#`
differs, `changed` otherwise (block to allow, or the same line written another way);
what has no pair is `added` or `removed`.
Headings are aligned as `headingEdits` aligns them, and on a tie an addition and a
removal are preferred to a rename, so ungrouping one heading and adding another is
not reported as a rename.
`moved` is how many lists changed place among the lines kept on both sides: the
smallest number of lists that, taken out and put back, turn the old order into the
new one. Kept headings stay put in that count (they cannot be moved), so a list that
crossed a heading counts as moved. Additions and removals move nothing.
*/
export interface ListChanges {
added: number
removed: number
switchedOn: number
switchedOff: number
changed: number
moved: number
groupsAdded: number
groupsRemoved: number
groupsRenamed: number
}
/** The heading alignment: for each heading of `b`, the index in `a` it continues, or -1. */
function alignHeadings(a: readonly string[], b: readonly string[]) {
const d: number[][] = Array.from({ length: a.length + 1 }, (_, i) =>
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
)
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
}
}
const from = Array<number>(b.length).fill(-1)
let added = 0
let removed = 0
let renamed = 0
let i = a.length
let j = b.length
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1] && d[i][j] === d[i - 1][j - 1]) {
from[--j] = --i
} else if (i > 0 && d[i][j] === d[i - 1][j] + 1) {
removed++
i--
} else if (j > 0 && d[i][j] === d[i][j - 1] + 1) {
added++
j--
} else {
renamed++
from[--j] = --i
}
}
return { from, added, removed, renamed }
}
export function changesOf(saved: readonly ListLine[], lines: readonly ListLine[]): ListChanges {
const out: ListChanges = {
added: 0, removed: 0, switchedOn: 0, switchedOff: 0, changed: 0, moved: 0,
groupsAdded: 0, groupsRemoved: 0, groupsRenamed: 0,
}
/* Every line gets a key, the same on both sides when it is the same line. */
const keyBefore: (string | null)[] = saved.map(() => null)
const keyAfter: (string | null)[] = lines.map(() => null)
const byUrl = new Map<string, [number[], number[]]>()
saved.forEach((l, i) => {
if (l.url != null) (byUrl.get(l.url) ?? byUrl.set(l.url, [[], []]).get(l.url)!)[0].push(i)
})
lines.forEach((l, i) => {
if (l.url != null) (byUrl.get(l.url) ?? byUrl.set(l.url, [[], []]).get(l.url)!)[1].push(i)
})
for (const [url, [before, after]] of byUrl) {
const restBefore: number[] = []
const restAfter = [...after]
for (const i of before) {
const k = restAfter.findIndex((j) => lines[j].raw === saved[i].raw)
if (k < 0) {
restBefore.push(i)
continue
}
keyBefore[i] = keyAfter[restAfter[k]] = `L:${url}:${i}`
restAfter.splice(k, 1)
}
const pairs = Math.min(restBefore.length, restAfter.length)
for (let p = 0; p < pairs; p++) {
const was = saved[restBefore[p]]
const now = lines[restAfter[p]]
keyBefore[restBefore[p]] = keyAfter[restAfter[p]] = `P:${url}:${p}`
if (was.kind === now.kind && was.enabled && !now.enabled) out.switchedOff++
else if (was.kind === now.kind && !was.enabled && now.enabled) out.switchedOn++
else out.changed++
}
out.removed += restBefore.length - pairs
out.added += restAfter.length - pairs
}
const headsBefore = headingIndexes(saved)
const headsAfter = headingIndexes(lines)
const heads = alignHeadings(
headsBefore.map((i) => saved[i].raw),
headsAfter.map((i) => lines[i].raw),
)
out.groupsAdded = heads.added
out.groupsRemoved = heads.removed
out.groupsRenamed = heads.renamed
heads.from.forEach((g, h) => {
if (g >= 0) keyBefore[headsBefore[g]] = keyAfter[headsAfter[h]] = `H:${g}`
})
/* The moves: a longest common subsequence of the kept keys, where a heading weighs
more than all the lists together, so every kept heading is in it. */
const kept = new Set(keyAfter.filter((k): k is string => k != null))
const a = keyBefore.filter((k): k is string => k != null && kept.has(k))
const b = keyAfter.filter((k): k is string => k != null)
const weight = (k: string) => (k.startsWith('H:') ? lines.length + 1 : 1)
let prev = Array<number>(b.length + 1).fill(0)
for (let i = 1; i <= a.length; i++) {
const row = [0]
for (let j = 1; j <= b.length; j++) {
row[j] = a[i - 1] === b[j - 1] ? prev[j - 1] + weight(a[i - 1]) : Math.max(prev[j], row[j - 1])
}
prev = row
}
const keptHeadings = b.filter((k) => k.startsWith('H:')).length
const keptLists = b.length - keptHeadings
out.moved = keptLists - (prev[b.length] - keptHeadings * (lines.length + 1))
return out
}
/** The changes in words, in the bar's order; empty when there are none. */
export function changesText(c: ListChanges): string {
const parts: string[] = []
const put = (n: number, words: string) => {
if (n > 0) parts.push(`${n} ${words}`)
}
put(c.added, 'added')
put(c.removed, 'removed')
put(c.switchedOn, 'switched on')
put(c.switchedOff, 'switched off')
put(c.changed, 'changed')
put(c.moved, 'moved')
put(c.groupsAdded, c.groupsAdded === 1 ? 'group added' : 'groups added')
put(c.groupsRenamed, c.groupsRenamed === 1 ? 'group renamed' : 'groups renamed')
put(c.groupsRemoved, c.groupsRemoved === 1 ? 'group removed' : 'groups removed')
return parts.join(', ')
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { missing, requiresText } from './permissions'
const P = (canView: boolean, canModify: boolean, canDelete: boolean) => ({ canView, canModify, canDelete })
describe('missing', () => {
it('nothing is missing without a permissions map (the session did not send one)', () => {
expect(missing(undefined, 'Blocked.canDelete')).toBeUndefined()
})
it('returns the first need the session does not have', () => {
const perms = { Blocked: P(true, true, false), Allowed: P(true, false, false) }
expect(missing(perms, 'Blocked.canView')).toBeUndefined()
expect(missing(perms, 'Blocked.canDelete', 'Allowed.canModify')).toBe('Blocked.canDelete')
expect(missing({ ...perms, Blocked: P(true, true, true) }, 'Blocked.canDelete', 'Allowed.canModify')).toBe('Allowed.canModify')
})
it('a section the map does not mention is not denied', () => {
expect(missing({}, 'Logs.canView')).toBeUndefined()
})
})
describe('requiresText', () => {
it('is PermissionButton literal', () => {
expect(requiresText('Logs.canView')).toBe('Requires Logs: View')
})
})
+26
View File
@@ -0,0 +1,26 @@
import type { Permission } from '../../app/sections'
/*
What each control of the Blocking section asks for is what the SERVER asks of the
call it makes (WebServiceOtherZonesApi.cs, WebServiceSettingsApi.cs,
WebServiceDashboardApi.cs, WebServiceLogsApi.cs). A control whose permission is
missing is drawn disabled with `PermissionButton`, never hidden.
*/
export type Permissions = Record<string, Permission> | undefined
export type Need = `${string}.can${'View' | 'Modify' | 'Delete'}`
export function missing(perms: Permissions, ...needs: Need[]): Need | undefined {
if (perms == null) return undefined
for (const need of needs) {
const [section, flag] = need.split('.') as [string, keyof Permission]
if (perms[section]?.[flag] === false) return need
}
return undefined
}
/** The same sentence `PermissionButton` draws: `Requires Logs: View`. */
export function requiresText(need: string): string {
const [section, action] = need.split('.')
return `Requires ${section}: ${(action ?? '').replace(/^can/, '')}`
}
File diff suppressed because it is too large. Load diff
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import {
countRules, filterRules, mergeRules, pageOf, readRuleParam, ruleSearch, RULES_PER_PAGE,
} from './rules-model'
describe('mergeRules', () => {
it('joins both lists, sorted by domain, and tolerates a list it could not read', () => {
expect(mergeRules(['b.test', 'a.test'], ['c.test'])).toEqual([
{ domain: 'a.test', list: 'blocked' },
{ domain: 'b.test', list: 'blocked' },
{ domain: 'c.test', list: 'allowed' },
])
expect(mergeRules(null, ['c.test'])).toEqual([{ domain: 'c.test', list: 'allowed' }])
})
})
describe('filterRules and countRules', () => {
const rules = mergeRules(['ads.example.com', 'doubleclick.net'], ['s.youtube.com'])
it('filters by list and by a case-insensitive substring', () => {
expect(filterRules(rules, 'blocked', '').map((r) => r.domain)).toEqual(['ads.example.com', 'doubleclick.net'])
expect(filterRules(rules, 'all', 'YOU').map((r) => r.domain)).toEqual(['s.youtube.com'])
expect(filterRules(rules, 'allowed', 'ads')).toEqual([])
})
it('counts each list', () => {
expect(countRules(rules)).toEqual({ all: 3, blocked: 2, allowed: 1 })
})
})
describe('pageOf', () => {
const rows = Array.from({ length: 10_000 }, (_, i) => i)
it('cuts 10,000 rows into pages and clamps a page out of range', () => {
expect(pageOf(rows, 1)).toMatchObject({ page: 1, totalPages: 200 })
expect(pageOf(rows, 1).rows).toHaveLength(RULES_PER_PAGE)
expect(pageOf(rows, 200).rows[0]).toBe(9_950)
expect(pageOf(rows, 999).page).toBe(200)
expect(pageOf([], 3)).toEqual({ rows: [], page: 1, totalPages: 1 })
})
})
describe('the rule parameter', () => {
it('reads it, defaulting to all', () => {
expect(readRuleParam('?rule=allowed')).toBe('allowed')
expect(readRuleParam('?rule=blocked&x=1')).toBe('blocked')
expect(readRuleParam('?rule=zzz')).toBe('all')
expect(readRuleParam('')).toBe('all')
})
it('writes it, keeping other parameters, and drops it for all', () => {
expect(ruleSearch('?x=1', 'blocked')).toBe('?x=1&rule=blocked')
expect(ruleSearch('?rule=blocked', 'all')).toBe('')
})
})
+71
View File
@@ -0,0 +1,71 @@
import type { DomainList } from '../../api/zonelists'
/*
The flat table of the Rules tab: the administrator's own blocked and allowed domains
in one list. OURS: upstream only has the tree.
The server does not bound the size: `allowed/export` and `blocked/export` return
`GetAllZones()` whole (WebServiceOtherZonesApi.cs:289, 494), and an import can be as
long as the administrator likes. So the table filters the WHOLE set and only then
cuts a page.
*/
export interface Rule {
domain: string
list: DomainList
}
export type RuleFilter = 'all' | DomainList
export const RULES_PER_PAGE = 50
export function mergeRules(
blocked: readonly string[] | null,
allowed: readonly string[] | null,
): Rule[] {
const rows: Rule[] = [
...(blocked ?? []).map((domain) => ({ domain, list: 'blocked' as const })),
...(allowed ?? []).map((domain) => ({ domain, list: 'allowed' as const })),
]
return rows.sort((a, b) => (a.domain === b.domain ? 0 : a.domain < b.domain ? -1 : 1))
}
export function filterRules(rules: readonly Rule[], filter: RuleFilter, query: string): Rule[] {
const q = query.trim().toLowerCase()
return rules.filter(
(r) => (filter === 'all' || r.list === filter) && (q === '' || r.domain.toLowerCase().includes(q)),
)
}
export function countRules(rules: readonly Rule[]): { all: number; blocked: number; allowed: number } {
let blocked = 0
for (const r of rules) if (r.list === 'blocked') blocked++
return { all: rules.length, blocked, allowed: rules.length - blocked }
}
export function pageOf<T>(
rows: readonly T[],
page: number,
size = RULES_PER_PAGE,
): { rows: T[]; page: number; totalPages: number } {
const totalPages = Math.max(1, Math.ceil(rows.length / size))
const current = Math.min(Math.max(1, page), totalPages)
return { rows: rows.slice((current - 1) * size, current * size), page: current, totalPages }
}
/*
The filter travels in the address bar (`?rule=allowed|blocked`) so it survives a
reload and a copied link, and so `/allowed/` and `/blocked/` can land on it.
*/
export function readRuleParam(search: string): RuleFilter {
const v = new URLSearchParams(search).get('rule')
return v === 'allowed' || v === 'blocked' ? v : 'all'
}
export function ruleSearch(search: string, filter: RuleFilter): string {
const params = new URLSearchParams(search)
if (filter === 'all') params.delete('rule')
else params.set('rule', filter)
const out = params.toString()
return out === '' ? '' : `?${out}`
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { joinDeleted, notRestoredNotice, restoreRules, undoMessage, undoNeed } from './undo'
const A = { domain: 'a.test', list: 'blocked' as const }
const B = { domain: 'b.test', list: 'allowed' as const }
const P = (m: boolean) => ({ canView: true, canModify: m, canDelete: true })
describe('undo', () => {
it('a Delete joins a notice that still offers Undo, and starts a new one otherwise', () => {
const one = joinDeleted(null, A)
expect(joinDeleted(one, B)).toEqual({ rules: [A, B], phase: 'deleted' })
expect(joinDeleted({ rules: [A], phase: 'restored' }, B)).toEqual({ rules: [B], phase: 'deleted' })
expect(joinDeleted({ rules: [A], phase: 'restoring' }, B)).toEqual({ rules: [B], phase: 'deleted' })
})
it('one rule is said with upstream sentence, several are counted', () => {
expect(undoMessage({ rules: [A], phase: 'deleted' })).toBe("Blocked zone 'a.test' was deleted successfully.")
expect(undoMessage({ rules: [B], phase: 'deleted' })).toBe("Domain 'b.test' was deleted from Allowed Zone successfully.")
expect(undoMessage({ rules: [A, B], phase: 'deleted' })).toBe('2 rules were deleted.')
expect(undoMessage({ rules: [B], phase: 'restored' })).toBe("'b.test' is back in Allowed.")
expect(undoMessage({ rules: [A, B], phase: 'restored' })).toBe('2 rules are back.')
})
it('Undo needs Modify on every list it puts a rule back in', () => {
expect(undoNeed(undefined, [A, B])).toBeUndefined()
expect(undoNeed({ Blocked: P(true), Allowed: P(false) }, [A])).toBeUndefined()
expect(undoNeed({ Blocked: P(true), Allowed: P(false) }, [A, B])).toBe('Allowed.canModify')
})
it('puts back in order and stops at the first refusal', async () => {
const asked: string[] = []
const result = await restoreRules([A, B, A], async (r) => {
asked.push(r.domain)
return asked.length === 2 ? { kind: 'error', message: 'No.' } : { kind: 'ok', data: {} }
})
expect(result).toEqual({ restored: 1, failure: { kind: 'error', message: 'No.' } })
expect(asked).toEqual(['a.test', 'b.test'])
expect(notRestoredNotice([A, B, A], result)).toEqual({
type: 'danger', title: 'Error!', text: '2 of 3 rules were not put back: No.',
})
})
})
+98
View File
@@ -0,0 +1,98 @@
import type { ApiOutcome } from '../../api/client'
import type { DomainList } from '../../api/zonelists'
import { noticeFromFailure, type Notice } from '../../lib/notice'
import { missing, type Need, type Permissions } from './permissions'
import type { Rule } from './rules-model'
/*
Undoing a Delete of the Rules table (Rules.tsx, the header). OURS, words included:
`Undo`, `N rules were deleted.`, `'<domain>' is back in Blocked.` / `… Allowed.`,
`N rules are back.`, `'<domain>' was not put back: <reason>` and
`N of M rules were not put back: <reason>`. A single deletion is announced with
upstream's own success sentence, the one its confirmed Delete shows
(other-zones.js), because that is what happened.
The notice moves through three phases: `deleted` (Undo offered), `restoring`
(Undo running: its button is off and the clock holds) and `restored` (said, and
gone after RESTORED_MS). A failure ends it instead and the page's notifier says
which rules did not come back, with the server's reason.
*/
export type UndoState = { rules: Rule[]; phase: 'deleted' | 'restoring' | 'restored' }
/** How long Undo is offered, and how long "is back" stays (a success alert's 5 s). */
export const UNDO_MS = 8000
export const RESTORED_MS = 5000
const LABEL: Record<DomainList, string> = { blocked: 'Blocked', allowed: 'Allowed' }
/** What putting a rule back asks for: `allowed/add` and `blocked/add` need Modify
(WebServiceOtherZonesApi.cs:355, 560), where the Delete needed Delete. */
export function modifyNeed(list: DomainList): Need {
return list === 'allowed' ? 'Allowed.canModify' : 'Blocked.canModify'
}
/** The permission Undo lacks, if any, for the rules it would put back. */
export function undoNeed(perms: Permissions, rules: readonly Rule[]): Need | undefined {
for (const r of rules) {
const need = missing(perms, modifyNeed(r.list))
if (need != null) return need
}
return undefined
}
/** A Delete while Undo is offered joins it; otherwise it starts a new notice. */
export function joinDeleted(current: UndoState | null, rule: Rule): UndoState {
if (current != null && current.phase === 'deleted') return { rules: [...current.rules, rule], phase: 'deleted' }
return { rules: [rule], phase: 'deleted' }
}
export function undoMessage(state: UndoState): string {
const [first] = state.rules
const n = state.rules.length
if (state.phase === 'restored') {
return n === 1 ? `'${first.domain}' is back in ${LABEL[first.list]}.` : `${n} rules are back.`
}
if (n > 1) return `${n} rules were deleted.`
// Upstream's success sentences for the confirmed Delete (other-zones.js).
return first.list === 'allowed'
? `Domain '${first.domain}' was deleted from Allowed Zone successfully.`
: `Blocked zone '${first.domain}' was deleted successfully.`
}
export interface Restore {
/** How many were put back before the first failure. */
restored: number
failure?: { kind: string; message?: string }
}
/*
One `add` after another, in the order they were deleted, stopping at the first that
fails: what stops one (an expired session, a permission taken away meanwhile) stops
the rest, and asking again would only repeat the same refusal.
*/
export async function restoreRules(
rules: readonly Rule[],
add: (rule: Rule) => Promise<ApiOutcome<unknown>>,
): Promise<Restore> {
let restored = 0
for (const rule of rules) {
const outcome = await add(rule)
if (outcome.kind !== 'ok') return { restored, failure: outcome }
restored++
}
return { restored }
}
export function notRestoredNotice(rules: readonly Rule[], result: Restore): Notice {
const reason = noticeFromFailure(result.failure ?? { kind: 'error' }).text
const left = rules.length - result.restored
return {
type: 'danger',
title: 'Error!',
text:
rules.length === 1
? `'${rules[0].domain}' was not put back: ${reason}`
: `${left} of ${rules.length} rules were not put back: ${reason}`,
}
}