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:
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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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); }
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 'Query Logs (Sqlite)' 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>
|
||||
)
|
||||
}
|
||||
@@ -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%; }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'?")
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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(' ')
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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(', ')
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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.',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user