Blocking: count Advanced Blocking, Dashboard layout and KPI cards
- Status, sidebar mark and header treat a server that blocks with the Advanced Blocking app as blocking, per server behind the hub. - Overview panel summarising the app's groups; Lists and Rules show and edit the app's list URLs and rules (read, change one field, write back). - Block List Domains adds the app's list domains, counted by the hub (hub/blocking/app-lists/count, public hosts only unless HUB_LISTS_ALLOW_PRIVATE=1) or by the browser where CORS allows. - Header and KPI cards follow the Dashboard: selector in the chrome slot, Compare servers in the header actions, period under the title, StatCard tiles without sparklines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b396bfd317
commit
172e06f1ce
40 files changed
+3126
-208
No files matched your search
+44
-3
@@ -176,7 +176,7 @@ const settings = {
|
||||
cacheMaximumEntries: 10000, cacheMinimumRecordTtl: 10, cacheMaximumRecordTtl: 86400, cacheNegativeRecordTtl: 300, cacheFailureRecordTtl: 60,
|
||||
enableCachePrefetch: true, cachePrefetchEligibility: 2, cachePrefetchTrigger: 9,
|
||||
enableBlocking: true, allowTxtBlockingReport: true, temporaryDisableBlockingTill: null, blockingBypassList: [], blockingType: 'NxDomain', blockingAnswerTtl: 30,
|
||||
customBlockingAddresses: [], blockListUrls: ['https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', '# Privacy', 'https://big.oisd.nl/domainswild', 'https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt'],
|
||||
customBlockingAddresses: [], blockListUrls: ['https://lists.invalid/hosts', '# Privacy', 'https://big.oisd.nl/domainswild', 'https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt'],
|
||||
blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: iso(now + 3 * 3_600_000),
|
||||
proxy: null, forwarders: ['1.1.1.1', '9.9.9.9'], forwarderProtocol: 'Udp', concurrentForwarding: true, forwarderRetries: 3, forwarderTimeout: 2000, forwarderConcurrency: 2,
|
||||
loggingType: 'File, Console', ignoreResolverLogs: false, logQueries: false, useLocalTime: false, logFolder: 'logs', maxLogFileDays: 365, enableInMemoryStats: false, maxStatFileDays: 365,
|
||||
@@ -220,10 +220,42 @@ if (VARIANT === 'b') {
|
||||
apps = apps.filter((a) => a.name !== 'Split Horizon')
|
||||
apps = apps.map((a) => (a.name === 'Query Logs (Sqlite)' ? { ...a, version: '7.1', updateAvailable: true, updateVersion: '7.2', updateUrl: 'https://download.technitium.com/dns/apps/QueryLogsSqliteApp-v7.2.zip' } : a))
|
||||
}
|
||||
// A small fake list the mock serves itself at /lists/<name>.txt (no internet), so the Advanced Blocking
|
||||
// "Block List Domains" count has something to download. Open to any origin, as a public list host is.
|
||||
const FAKE_LISTS = {
|
||||
'hosts.txt': ['# fake hosts list', '127.0.0.1 localhost', ...Array.from({ length: 120 }, (_, i) => `0.0.0.0 ads${i}.tracker.example`)].join('\n'),
|
||||
'easylist.txt': ['[Adblock Plus 2.0]', '! fake adblock list', ...Array.from({ length: 45 }, (_, i) => `||banner${i}.adnet.example^`)].join('\n'),
|
||||
}
|
||||
const listUrl = (name) => `http://127.0.0.1:${PORT}/lists/${name}`
|
||||
const group = (over) => ({
|
||||
allowTxtBlockingReport: true, blockAsNxDomain: true, blockingAddresses: ['0.0.0.0', '::'],
|
||||
allowed: [], blocked: [], allowListUrls: [], blockListUrls: [], allowedRegex: [], blockedRegex: [],
|
||||
regexAllowListUrls: [], regexBlockListUrls: [], adblockListUrls: [], ...over,
|
||||
})
|
||||
const appConfigs = {
|
||||
'Advanced Blocking': VARIANT === 'b'
|
||||
? { enableBlocking: true, blockingAnswerTtl: 60, localGroup: 'office', networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: false, blockAsNxDomain: false }] }
|
||||
: { enableBlocking: true, blockingAnswerTtl: 30, localGroup: 'home', networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: true, blockAsNxDomain: false }] },
|
||||
? {
|
||||
enableBlocking: true, blockingAnswerTtl: 60, blockListUrlUpdateIntervalHours: 24, localEndPointGroupMap: { '127.0.0.1': 'bypass' },
|
||||
networkGroupMap: { '0.0.0.0/0': 'default', '::/0': 'default' },
|
||||
groups: [
|
||||
group({ name: 'default', enableBlocking: true, blocked: ['tracker.example'], blockedRegex: ['^ads\\.'], blockListUrls: [listUrl('hosts.txt')], adblockListUrls: [listUrl('easylist.txt')] }),
|
||||
group({ name: 'bypass', enableBlocking: false }),
|
||||
],
|
||||
}
|
||||
: {
|
||||
enableBlocking: true, blockingAnswerTtl: 30, blockListUrlUpdateIntervalHours: 24, localEndPointGroupMap: { '127.0.0.1': 'bypass' },
|
||||
networkGroupMap: { '192.168.10.0/24': 'family', '0.0.0.0/0': 'everyone', '::/0': 'everyone' },
|
||||
groups: [
|
||||
group({
|
||||
name: 'everyone', enableBlocking: true, blocked: ['ads.example.com', 'telemetry.example.net'], allowed: ['good.example.org'],
|
||||
blockListUrls: [listUrl('hosts.txt'), 'https://lists.invalid/hosts'],
|
||||
allowListUrls: ['https://example.org/allow.txt'], blockedRegex: ['^ads\\.', '^track\\d+\\.'], allowedRegex: ['^cdn\\.good\\.'],
|
||||
regexBlockListUrls: ['https://example.org/regex.txt'], adblockListUrls: [listUrl('easylist.txt')],
|
||||
}),
|
||||
group({ name: 'family', enableBlocking: true, blocked: ['games.example.com'], blockListUrls: [listUrl('hosts.txt')], regexAllowListUrls: ['https://example.org/regex-allow.txt'] }),
|
||||
group({ name: 'bypass', enableBlocking: false }),
|
||||
],
|
||||
},
|
||||
'Query Logs (Sqlite)': { enableLogging: true, maxLogDays: 365, sqliteDbPath: VARIANT === 'b' ? '/var/lib/dns/ndns/querylogs.db' : '/var/lib/dns/mdns/querylogs.db' },
|
||||
'Split Horizon': { domainGroupMap: { 'lan.example.net': 'internal' }, networkGroupMap: { '10.0.0.0/8': 'internal' } },
|
||||
}
|
||||
@@ -438,6 +470,15 @@ http.createServer((req, res) => {
|
||||
const q = new URLSearchParams(url.search)
|
||||
if ((req.headers['content-type'] ?? '').includes('application/x-www-form-urlencoded')) for (const [k, v] of new URLSearchParams(body)) q.set(k, v)
|
||||
if ((req.headers['content-type'] ?? '').includes('text/plain') && body !== '') q.set('__text', body)
|
||||
const fakeList = url.pathname.match(/^\/lists\/([\w.-]+)$/)
|
||||
if (fakeList !== null) {
|
||||
const text = FAKE_LISTS[fakeList[1]]
|
||||
res.statusCode = text === undefined ? 404 : 200
|
||||
res.setHeader('access-control-allow-origin', '*')
|
||||
res.setHeader('content-type', 'text/plain')
|
||||
res.end(text ?? 'not found')
|
||||
return
|
||||
}
|
||||
const file = fileFor(path, q)
|
||||
if (file !== undefined) {
|
||||
res.setHeader('content-type', 'text/plain')
|
||||
|
||||
@@ -30,6 +30,7 @@ Modify and Delete only where the table in `docs/hub-deploy.md` grants them.
|
||||
| `HUB_PORT` | `8080` | Listening port. |
|
||||
| `HUB_CACHE_SECONDS` | `15` | How long successful answers of the servers are cached. |
|
||||
| `HUB_TIMEOUT_MS` | `8000` | Per-request timeout towards a server. |
|
||||
| `HUB_LISTS_ALLOW_PRIVATE` | off | `1` lets the hub download the Advanced Blocking block lists (to count their domains, `hub/blocking/app-lists/count`) from private addresses. Off, only public addresses are fetched, so a hub cannot be pointed at your network. Counts are cached for 6 hours. |
|
||||
| `HUB_DOMAIN` | `hub` | Shown as the server's domain in the console. |
|
||||
| `HUB_STATIC` | `./dist` | The console's build to serve. |
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment node
|
||||
/*
|
||||
Stage 9: counting the domains in an Advanced Blocking app's block lists. The unit tests inject
|
||||
the fetch and the DNS lookup; the hub tests run against fake servers and a fake list server.
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { AppLists, DomainCounter, countDomains, publicAddress } from './app-lists.ts'
|
||||
import type { HubServer } from './config.ts'
|
||||
import type { Upstream } from './upstream.ts'
|
||||
import { fake, startHub, type Fake, type TestHub } from './fake-servers.ts'
|
||||
|
||||
const server: HubServer = { id: 'a', url: 'http://a.invalid', token: 't' }
|
||||
|
||||
const config = (groups: unknown[]) => JSON.stringify({ enableBlocking: true, groups })
|
||||
|
||||
function upstreamWith(text: string | null): Upstream {
|
||||
return { call: async () => ({ config: text }) } as unknown as Upstream
|
||||
}
|
||||
|
||||
const HOSTS = '# hosts\n127.0.0.1 localhost\n0.0.0.0 a.example\n0.0.0.0 b.example\n0.0.0.0 a.example\n'
|
||||
|
||||
function listFetch(files: Record<string, string>, seen: string[] = []): typeof fetch {
|
||||
return (async (input: URL | string) => {
|
||||
const url = String(input)
|
||||
seen.push(url)
|
||||
const body = files[url]
|
||||
return body === undefined ? new Response('nope', { status: 404 }) : new Response(body, { status: 200 })
|
||||
}) as typeof fetch
|
||||
}
|
||||
|
||||
const PUBLIC = async () => ['93.184.216.34']
|
||||
|
||||
describe('countDomains', () => {
|
||||
it('counts hosts, plain and adblock lines once each', () => {
|
||||
expect(countDomains(HOSTS)).toBe(2)
|
||||
expect(countDomains('x.example\n*.y.example\n! c\n||z.example^$third-party\n@@||ok.example^\n[Adblock Plus]\n')).toBe(3)
|
||||
})
|
||||
|
||||
it('counts across chunks that cut a line in two', () => {
|
||||
const c = new DomainCounter()
|
||||
c.push('0.0.0.0 a.exa')
|
||||
c.push('mple\n0.0.0.0 b.example')
|
||||
expect(c.end()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('publicAddress', () => {
|
||||
it('refuses loopback, private, link-local, shared and unspecified addresses', () => {
|
||||
for (const a of ['127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1', '::1', '::', 'fe80::1', 'fd00::1', '::ffff:10.0.0.1']) {
|
||||
expect(publicAddress(a), a).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts public ones', () => {
|
||||
for (const a of ['93.184.216.34', '8.8.8.8', '2606:4700:4700::1111']) expect(publicAddress(a), a).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppLists', () => {
|
||||
it('reads the block and adblock URLs of every group, strings or objects, each once, http(s) only', async () => {
|
||||
const text = config([
|
||||
{ name: 'one', blockListUrls: ['https://x.example/a', { url: 'https://x.example/b' }, 'file:///etc/hosts'], adblockListUrls: ['https://x.example/c'], regexBlockListUrls: ['https://x.example/re'] },
|
||||
{ name: 'two', blockListUrls: ['https://x.example/a'], allowListUrls: ['https://x.example/allow'] },
|
||||
])
|
||||
const lists = new AppLists(upstreamWith(text))
|
||||
expect(await lists.urlsOf(server, 'Advanced Blocking')).toEqual(['https://x.example/a', 'https://x.example/b', 'https://x.example/c'])
|
||||
})
|
||||
|
||||
it('has no URLs for a missing, empty or broken config', async () => {
|
||||
expect(await new AppLists(upstreamWith(null)).urlsOf(server, 'x')).toEqual([])
|
||||
expect(await new AppLists(upstreamWith('{nope')).urlsOf(server, 'x')).toEqual([])
|
||||
})
|
||||
|
||||
it('counts each list and totals them, and caches by URL', async () => {
|
||||
const seen: string[] = []
|
||||
const files = { 'https://x.example/a': HOSTS, 'https://x.example/b': 'c.example\n' }
|
||||
let now = 1_000
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/a', 'https://x.example/b'] }])), {
|
||||
fetch: listFetch(files, seen), lookup: PUBLIC, now: () => now,
|
||||
})
|
||||
const first = await lists.count(server, 'Advanced Blocking')
|
||||
expect(first.lists).toEqual([{ url: 'https://x.example/a', count: 2 }, { url: 'https://x.example/b', count: 1 }])
|
||||
expect(first.total).toBe(3)
|
||||
await lists.count(server, 'Advanced Blocking')
|
||||
expect(seen).toHaveLength(2)
|
||||
now += 7 * 3_600_000
|
||||
await lists.count(server, 'Advanced Blocking')
|
||||
expect(seen).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('reports a list that fails, with a reason, and keeps the others', async () => {
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/a', 'https://x.example/gone'] }])), {
|
||||
fetch: listFetch({ 'https://x.example/a': HOSTS }), lookup: PUBLIC,
|
||||
})
|
||||
const out = await lists.count(server, 'x')
|
||||
expect(out.lists[0].count).toBe(2)
|
||||
expect(out.lists[1]).toEqual({ url: 'https://x.example/gone', count: null, error: 'the list server answered 404' })
|
||||
expect(out.total).toBe(2)
|
||||
})
|
||||
|
||||
it('never fetches an address that is not public, or a name that resolves to one', async () => {
|
||||
const seen: string[] = []
|
||||
const lists = new AppLists(
|
||||
upstreamWith(config([{ name: 'g', blockListUrls: ['http://127.0.0.1/a', 'http://[::1]/a', 'http://169.254.169.254/latest', 'https://internal.example/a', 'ftp://x.example/a'] }])),
|
||||
{ fetch: listFetch({}, seen), lookup: async (h) => (h === 'internal.example' ? ['10.0.0.5'] : ['93.184.216.34']) },
|
||||
)
|
||||
const out = await lists.count(server, 'x')
|
||||
expect(seen).toEqual([])
|
||||
expect(out.lists.every((l) => l.count === null)).toBe(true)
|
||||
expect(out.lists[0].error).toBe('the address of this list is not a public one')
|
||||
})
|
||||
|
||||
it('checks every redirect, and stops after three', async () => {
|
||||
const seen: string[] = []
|
||||
const fetcher = (async (input: URL | string) => {
|
||||
const url = String(input)
|
||||
seen.push(url)
|
||||
if (url === 'https://x.example/start') return new Response(null, { status: 302, headers: { location: 'http://10.0.0.1/inner' } })
|
||||
if (url.startsWith('https://x.example/loop')) return new Response(null, { status: 302, headers: { location: 'https://x.example/loop' } })
|
||||
return new Response('a.example\n')
|
||||
}) as typeof fetch
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/start', 'https://x.example/loop'] }])), { fetch: fetcher, lookup: async (h) => (h === 'x.example' ? ['93.184.216.34'] : ['10.0.0.1']) })
|
||||
const out = await lists.count(server, 'x')
|
||||
expect(seen).not.toContain('http://10.0.0.1/inner')
|
||||
expect(out.lists[0].error).toBe('the address of this list is not a public one')
|
||||
expect(out.lists[1].error).toBe('too many redirects')
|
||||
})
|
||||
|
||||
it('refuses a list over the size cap, declared or streamed', async () => {
|
||||
const big = (async (input: URL | string) =>
|
||||
String(input).endsWith('/declared')
|
||||
? new Response('a.example\n', { headers: { 'content-length': '5000' } })
|
||||
: new Response('a.example\n'.repeat(1000))) as typeof fetch
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/declared', 'https://x.example/streamed'] }])), { fetch: big, lookup: PUBLIC, maxBytes: 1000 })
|
||||
const out = await lists.count(server, 'x')
|
||||
expect(out.lists.map((l) => l.error)).toEqual(['the list is larger than the limit', 'the list is larger than the limit'])
|
||||
})
|
||||
|
||||
it('times a slow list out', async () => {
|
||||
const slow = ((_: URL | string, init?: RequestInit) =>
|
||||
new Promise((_resolve, reject) => init?.signal?.addEventListener('abort', () => reject(Object.assign(new Error('x'), { name: 'AbortError' }))))) as typeof fetch
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/slow'] }])), { fetch: slow, lookup: PUBLIC, timeoutMs: 20 })
|
||||
expect((await lists.count(server, 'x')).lists[0].error).toBe('timed out')
|
||||
})
|
||||
|
||||
it('takes at most maxLists lists', async () => {
|
||||
const urls = Array.from({ length: 10 }, (_, i) => `https://x.example/${i}`)
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: urls }])), { fetch: listFetch({}), lookup: PUBLIC, maxLists: 3 })
|
||||
expect((await lists.count(server, 'x')).lists).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hub/blocking/app-lists/count', () => {
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let hub: TestHub
|
||||
let files: http.Server
|
||||
let base = ''
|
||||
|
||||
beforeAll(async () => {
|
||||
files = http.createServer((req, res) => {
|
||||
res.setHeader('content-type', 'text/plain')
|
||||
res.end(req.url === '/hosts' ? HOSTS : 'p.example\nq.example\nr.example\n')
|
||||
})
|
||||
await new Promise<void>((resolve) => files.listen(0, '127.0.0.1', resolve))
|
||||
base = `http://127.0.0.1:${(files.address() as AddressInfo).port}`
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
a.on('apps/config/get', () => ({
|
||||
response: { config: config([{ name: 'g', enableBlocking: true, blockListUrls: [`${base}/hosts`], adblockListUrls: [`${base}/other`] }]) },
|
||||
}))
|
||||
b.on('apps/config/get', () => ({ response: { config: null } }))
|
||||
hub = await startHub(a, b, { HUB_LISTS_ALLOW_PRIVATE: '1' })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
files.close()
|
||||
})
|
||||
|
||||
it('counts the lists of the chosen server with its read token', async () => {
|
||||
const r = await hub.call('viewer', 'hub/blocking/app-lists/count', { node: 'a', name: 'Advanced Blocking' }, 'GET')
|
||||
expect(r.code).toBe(200)
|
||||
expect(r.body.response.total).toBe(5)
|
||||
expect(r.body.response.lists).toEqual([{ url: `${base}/hosts`, count: 2 }, { url: `${base}/other`, count: 3 }])
|
||||
const read = a.calls.find((c) => c.path === 'apps/config/get')
|
||||
expect(read?.token).toBe('read-a')
|
||||
expect(read?.query.get('name')).toBe('Advanced Blocking')
|
||||
})
|
||||
|
||||
it('answers an empty list for a server whose app has no config', async () => {
|
||||
const r = await hub.call('viewer', 'hub/blocking/app-lists/count', { node: 'b' }, 'GET')
|
||||
expect(r.body.response).toEqual({ lists: [], total: 0 })
|
||||
})
|
||||
|
||||
it('needs a server', async () => {
|
||||
for (const node of ['', 'cluster', 'nope']) {
|
||||
const r = await hub.call('viewer', 'hub/blocking/app-lists/count', { node }, 'GET')
|
||||
expect(r.code).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
it('is refused without a session', async () => {
|
||||
const res = await fetch(`${hub.base}/api/hub/blocking/app-lists/count?node=a`)
|
||||
expect(((await res.json()) as { status: string }).status).toBe('invalid-token')
|
||||
})
|
||||
|
||||
it('does not fetch private addresses unless the hub is told to', async () => {
|
||||
const strict = await startHub(a, b)
|
||||
const r = await strict.call('viewer', 'hub/blocking/app-lists/count', { node: 'a' }, 'GET')
|
||||
strict.close()
|
||||
expect(r.body.response.lists.map((l: { error?: string }) => l.error)).toEqual(['the address of this list is not a public one', 'the address of this list is not a public one'])
|
||||
expect(r.body.response.total).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
How many domains the block lists of a server's Advanced Blocking app hold (stage 9). The app has
|
||||
no API that says it, so the hub reads the app's config from the server (with the READ token, like
|
||||
any `apps/config/get`), downloads each http(s) block list and counts the distinct domains.
|
||||
|
||||
Counted: a hosts line (`0.0.0.0 example.com`, every name after the address), a plain domain
|
||||
(`example.com`, `*.example.com`) and an Adblock Plus domain rule (`||example.com^`). Not counted:
|
||||
blank lines, comments (`#`, `!`, `[`), Adblock exceptions (`@@`) and regular expressions. The
|
||||
console has the same rules for the no-hub case (`src/lib/list-count.ts`); the two are kept in step
|
||||
by hand and tested on the same sample.
|
||||
|
||||
The hub fetches a URL an administrator typed into a config, so it is careful where it goes:
|
||||
- only `http:` and `https:`, no credentials in the URL;
|
||||
- the host must resolve to public addresses only (no loopback, private, link-local, carrier-grade
|
||||
NAT, multicast or unspecified ones, IPv4 or IPv6). Redirects are followed by hand, up to three,
|
||||
and checked at every step. `HUB_LISTS_ALLOW_PRIVATE=1` lifts this for lists kept on the LAN.
|
||||
The check resolves the name and the fetch resolves it again, so a name that changes between the
|
||||
two is not caught; the hub is not a place an attacker can write DNS for;
|
||||
- a timeout and a size cap per list, a cap on how many lists, and four downloads at a time.
|
||||
|
||||
Results are cached per URL for several hours (a failure for minutes), so opening the Overview
|
||||
does not download a hundred megabytes again.
|
||||
*/
|
||||
import { lookup as dnsLookup } from 'node:dns/promises'
|
||||
import { isIP } from 'node:net'
|
||||
import type { HubServer } from './config.ts'
|
||||
import type { Upstream } from './upstream.ts'
|
||||
|
||||
export interface ListCount {
|
||||
url: string
|
||||
count: number | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ListCounts {
|
||||
lists: ListCount[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AppListsOptions {
|
||||
fetch?: typeof fetch
|
||||
/** Resolves a host to its addresses. */
|
||||
lookup?: (host: string) => Promise<string[]>
|
||||
now?: () => number
|
||||
allowPrivate?: boolean
|
||||
/** How long a counted list is remembered (default 6 hours). */
|
||||
ttlMs?: number
|
||||
/** How long a failure is remembered (default 5 minutes). */
|
||||
failureTtlMs?: number
|
||||
timeoutMs?: number
|
||||
maxBytes?: number
|
||||
maxLists?: number
|
||||
}
|
||||
|
||||
const HOURS = 3_600_000
|
||||
const MAX_CACHED = 500
|
||||
const MAX_REDIRECTS = 3
|
||||
const CONCURRENCY = 4
|
||||
|
||||
/* --- counting --- */
|
||||
|
||||
const ADDRESS = /^(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9a-f:]*:[0-9a-f:]*)$/i
|
||||
const NAME = /^[a-z0-9_]([a-z0-9_.-]*[a-z0-9_])?$/i
|
||||
const OWN = new Set(['localhost', 'localhost.localdomain', 'local', 'broadcasthost', '0.0.0.0'])
|
||||
|
||||
function accept(raw: string, into: Set<string>): void {
|
||||
const name = raw.replace(/^\*\./, '').replace(/\.$/, '').toLowerCase()
|
||||
if (name === '' || OWN.has(name) || name.startsWith('ip6-') || !NAME.test(name)) return
|
||||
into.add(name)
|
||||
}
|
||||
|
||||
function countLine(line: string, into: Set<string>): void {
|
||||
const t = line.trim()
|
||||
if (t === '' || t.startsWith('#') || t.startsWith('!') || t.startsWith('[') || t.startsWith('@@') || t.startsWith('/')) return
|
||||
if (t.startsWith('||')) {
|
||||
const end = t.search(/[\^$/]/)
|
||||
accept(end < 0 ? t.slice(2) : t.slice(2, end), into)
|
||||
return
|
||||
}
|
||||
const tokens = t.split('#')[0].trim().split(/\s+/)
|
||||
if (tokens.length > 1 && ADDRESS.test(tokens[0])) for (const name of tokens.slice(1)) accept(name, into)
|
||||
else if (tokens.length === 1) accept(tokens[0], into)
|
||||
}
|
||||
|
||||
/* Counts as text arrives in pieces, so a big list is never held whole. */
|
||||
export class DomainCounter {
|
||||
#domains = new Set<string>()
|
||||
#rest = ''
|
||||
|
||||
push(chunk: string): void {
|
||||
const lines = (this.#rest + chunk).split(/\r?\n/)
|
||||
this.#rest = lines.pop() ?? ''
|
||||
for (const line of lines) countLine(line, this.#domains)
|
||||
}
|
||||
|
||||
end(): number {
|
||||
countLine(this.#rest, this.#domains)
|
||||
this.#rest = ''
|
||||
return this.#domains.size
|
||||
}
|
||||
}
|
||||
|
||||
export function countDomains(text: string): number {
|
||||
const c = new DomainCounter()
|
||||
c.push(text)
|
||||
return c.end()
|
||||
}
|
||||
|
||||
/* --- where a fetch may go --- */
|
||||
|
||||
function ipv4Allowed(a: number, b: number): boolean {
|
||||
if (a === 0 || a === 10 || a === 127 || a >= 224) return false
|
||||
if (a === 100 && b >= 64 && b <= 127) return false
|
||||
if (a === 169 && b === 254) return false
|
||||
if (a === 172 && b >= 16 && b <= 31) return false
|
||||
if (a === 192 && b === 168) return false
|
||||
if (a === 192 && b === 0) return false
|
||||
if (a === 198 && (b === 18 || b === 19)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Whether an address is one a list may be fetched from: public unicast only. */
|
||||
export function publicAddress(address: string): boolean {
|
||||
const kind = isIP(address)
|
||||
if (kind === 4) {
|
||||
const [a, b] = address.split('.').map(Number)
|
||||
return ipv4Allowed(a, b)
|
||||
}
|
||||
if (kind === 6) {
|
||||
const a = address.toLowerCase()
|
||||
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(a)
|
||||
if (mapped !== null) return publicAddress(mapped[1])
|
||||
if (a === '::' || a === '::1') return false
|
||||
const first = parseInt(a.split(':')[0] || '0', 16)
|
||||
if ((first & 0xfe00) === 0xfc00) return false // fc00::/7 unique local
|
||||
if ((first & 0xffc0) === 0xfe80) return false // fe80::/10 link local
|
||||
if ((first & 0xff00) === 0xff00) return false // multicast
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function defaultLookup(host: string): Promise<string[]> {
|
||||
return (await dnsLookup(host, { all: true })).map((r) => r.address)
|
||||
}
|
||||
|
||||
class Refused extends Error {}
|
||||
|
||||
/* --- the service --- */
|
||||
|
||||
interface Cached {
|
||||
until: number
|
||||
result: ListCount
|
||||
}
|
||||
|
||||
export class AppLists {
|
||||
#upstream: Upstream
|
||||
#fetch: typeof fetch
|
||||
#lookup: (host: string) => Promise<string[]>
|
||||
#now: () => number
|
||||
#allowPrivate: boolean
|
||||
#ttl: number
|
||||
#failureTtl: number
|
||||
#timeout: number
|
||||
#maxBytes: number
|
||||
#maxLists: number
|
||||
#cache = new Map<string, Cached>()
|
||||
#inflight = new Map<string, Promise<ListCount>>()
|
||||
|
||||
constructor(upstream: Upstream, options: AppListsOptions = {}) {
|
||||
this.#upstream = upstream
|
||||
this.#fetch = options.fetch ?? fetch
|
||||
this.#lookup = options.lookup ?? defaultLookup
|
||||
this.#now = options.now ?? Date.now
|
||||
this.#allowPrivate = options.allowPrivate ?? false
|
||||
this.#ttl = options.ttlMs ?? 6 * HOURS
|
||||
this.#failureTtl = options.failureTtlMs ?? 5 * 60_000
|
||||
this.#timeout = options.timeoutMs ?? 20_000
|
||||
this.#maxBytes = options.maxBytes ?? 64 * 1024 * 1024
|
||||
this.#maxLists = options.maxLists ?? 100
|
||||
}
|
||||
|
||||
/** The distinct block list URLs (plain and Adblock) of the app's config on one server. */
|
||||
async urlsOf(server: HubServer, appName: string): Promise<string[]> {
|
||||
const response = (await this.#upstream.call(server, 'apps/config/get', { name: appName })) as { config?: string | null } | null
|
||||
const text = response?.config
|
||||
if (typeof text !== 'string' || text.trim() === '') return []
|
||||
let root: unknown
|
||||
try {
|
||||
root = JSON.parse(text)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const groups = root !== null && typeof root === 'object' && Array.isArray((root as { groups?: unknown }).groups) ? ((root as { groups: unknown[] }).groups) : []
|
||||
const urls = new Set<string>()
|
||||
for (const g of groups) {
|
||||
if (g === null || typeof g !== 'object') continue
|
||||
for (const field of ['blockListUrls', 'adblockListUrls']) {
|
||||
const list = (g as Record<string, unknown>)[field]
|
||||
if (!Array.isArray(list)) continue
|
||||
for (const e of list) {
|
||||
const url = typeof e === 'string' ? e : e !== null && typeof e === 'object' && typeof (e as { url?: unknown }).url === 'string' ? (e as { url: string }).url : ''
|
||||
if (/^https?:\/\//i.test(url.trim())) urls.add(url.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...urls]
|
||||
}
|
||||
|
||||
async count(server: HubServer, appName: string): Promise<ListCounts> {
|
||||
const urls = (await this.urlsOf(server, appName)).slice(0, this.#maxLists)
|
||||
const lists: ListCount[] = new Array(urls.length)
|
||||
let next = 0
|
||||
const worker = async () => {
|
||||
while (next < urls.length) {
|
||||
const i = next++
|
||||
lists[i] = await this.countUrl(urls[i])
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, urls.length) }, worker))
|
||||
return { lists, total: lists.reduce((n, l) => n + (l.count ?? 0), 0) }
|
||||
}
|
||||
|
||||
countUrl(url: string): Promise<ListCount> {
|
||||
const hit = this.#cache.get(url)
|
||||
if (hit !== undefined && hit.until > this.#now()) return Promise.resolve(hit.result)
|
||||
const running = this.#inflight.get(url)
|
||||
if (running !== undefined) return running
|
||||
const job = this.#download(url).then((result) => {
|
||||
if (this.#cache.size >= MAX_CACHED) this.#prune()
|
||||
this.#cache.set(url, { until: this.#now() + (result.count === null ? this.#failureTtl : this.#ttl), result })
|
||||
return result
|
||||
})
|
||||
this.#inflight.set(url, job)
|
||||
void job.finally(() => this.#inflight.delete(url))
|
||||
return job
|
||||
}
|
||||
|
||||
#prune(): void {
|
||||
const now = this.#now()
|
||||
for (const [key, entry] of this.#cache) if (entry.until <= now) this.#cache.delete(key)
|
||||
while (this.#cache.size >= MAX_CACHED) this.#cache.delete(this.#cache.keys().next().value as string)
|
||||
}
|
||||
|
||||
async #allowed(url: URL): Promise<void> {
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Refused('only http and https lists are read')
|
||||
if (url.username !== '' || url.password !== '') throw new Refused('a list address may not carry credentials')
|
||||
if (this.#allowPrivate) return
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '')
|
||||
const addresses = isIP(host) !== 0 ? [host] : await this.#lookup(host)
|
||||
if (addresses.length === 0 || !addresses.every(publicAddress)) throw new Refused('the address of this list is not a public one')
|
||||
}
|
||||
|
||||
async #download(start: string): Promise<ListCount> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), this.#timeout)
|
||||
try {
|
||||
let url = new URL(start)
|
||||
for (let hop = 0; ; hop++) {
|
||||
await this.#allowed(url)
|
||||
const res = await this.#fetch(url, { redirect: 'manual', signal: controller.signal, headers: { accept: 'text/plain, */*' } })
|
||||
if (res.status >= 300 && res.status < 400 && res.headers.get('location') !== null) {
|
||||
void res.body?.cancel()
|
||||
if (hop >= MAX_REDIRECTS) throw new Refused('too many redirects')
|
||||
url = new URL(res.headers.get('location') as string, url)
|
||||
continue
|
||||
}
|
||||
if (!res.ok) {
|
||||
void res.body?.cancel()
|
||||
throw new Refused(`the list server answered ${res.status}`)
|
||||
}
|
||||
const declared = Number(res.headers.get('content-length') ?? 0)
|
||||
if (declared > this.#maxBytes) {
|
||||
void res.body?.cancel()
|
||||
throw new Refused('the list is larger than the limit')
|
||||
}
|
||||
const counter = new DomainCounter()
|
||||
const decoder = new TextDecoder()
|
||||
let bytes = 0
|
||||
if (res.body !== null) {
|
||||
const reader = res.body.getReader()
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
bytes += value.byteLength
|
||||
if (bytes > this.#maxBytes) {
|
||||
void reader.cancel()
|
||||
throw new Refused('the list is larger than the limit')
|
||||
}
|
||||
counter.push(decoder.decode(value, { stream: true }))
|
||||
}
|
||||
counter.push(decoder.decode())
|
||||
}
|
||||
return { url: start, count: counter.end() }
|
||||
}
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Refused ? e.message : e instanceof Error && e.name === 'AbortError' ? 'timed out' : 'could not be downloaded'
|
||||
return { url: start, count: null, error: message }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export interface HubConfig {
|
||||
webhookUrl: string | null
|
||||
/** Signs the notices with HMAC-SHA256 (HUB_WEBHOOK_SECRET); null when not set. Never logged. */
|
||||
webhookSecret: string | null
|
||||
/** Lets the block list counter fetch from private addresses (HUB_LISTS_ALLOW_PRIVATE=1), for lists kept on the LAN. */
|
||||
listsAllowPrivate: boolean
|
||||
/** Shown as `dnsServerDomain` (and `clusterDomain`) in the session. */
|
||||
domain: string
|
||||
/** The console's static build. */
|
||||
@@ -111,6 +113,7 @@ export function parseConfig(env: Env): HubConfig {
|
||||
timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1),
|
||||
webhookUrl: webhookUrl === '' ? null : webhookUrl,
|
||||
webhookSecret: (env.HUB_WEBHOOK_SECRET ?? '') === '' ? null : (env.HUB_WEBHOOK_SECRET as string),
|
||||
listsAllowPrivate: ['1', 'true'].includes((env.HUB_LISTS_ALLOW_PRIVATE ?? '').trim().toLowerCase()),
|
||||
domain: env.HUB_DOMAIN?.trim() || 'hub',
|
||||
staticDir: env.HUB_STATIC?.trim() || './dist',
|
||||
}
|
||||
|
||||
@@ -185,6 +185,8 @@ export const ROUTES: Record<string, Route> = {
|
||||
'hub/settings/baseline/save': writeAll('admin'),
|
||||
'hub/settings/overrides/save': writeAll('admin'),
|
||||
'hub/settings/apply': writeAll('admin'),
|
||||
// The domains in an Advanced Blocking app's block lists, counted by the hub (app-lists.ts). One server's app.
|
||||
'hub/blocking/app-lists/count': readOne(),
|
||||
'hub/blocking/lists/compare': read('admin'),
|
||||
'hub/blocking/lists/preview': read('admin'),
|
||||
'hub/blocking/lists/baseline/import': writeAll('admin'),
|
||||
|
||||
@@ -35,6 +35,7 @@ import { Drift } from './drift.ts'
|
||||
import { Webhook, type WebhookOptions } from './webhook.ts'
|
||||
import { Profiles } from './profiles.ts'
|
||||
import { SettingsConfig } from './settings-config.ts'
|
||||
import { AppLists } from './app-lists.ts'
|
||||
import { BLOCKING_FIELDS } from './settings-sync.ts'
|
||||
import { ZoneUserError, ZonesSync, buildZoneMatrix } from './zones-sync.ts'
|
||||
import {
|
||||
@@ -167,6 +168,7 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
const appsConfig = new AppsConfig(config, upstream)
|
||||
const zonesSync = new ZonesSync(config, upstream)
|
||||
const settingsConfig = new SettingsConfig(config, upstream)
|
||||
const appLists = new AppLists(upstream, { allowPrivate: config.listsAllowPrivate })
|
||||
const profiles = new Profiles(config, upstream, appsConfig, settingsConfig, writer)
|
||||
const byId = new Map(config.servers.map((s) => [s.id, s]))
|
||||
|
||||
@@ -917,6 +919,20 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
await profilesRead(res, apiPath, params, who, ip)
|
||||
return
|
||||
}
|
||||
if (apiPath === 'hub/blocking/app-lists/count') {
|
||||
const server = byId.get(params.node ?? '')
|
||||
if (server === undefined) {
|
||||
send(res, 400, fail('Choose a server: the lists are counted for one server at a time.'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
send(res, 200, ok(await appLists.count(server, params.name || 'Advanced Blocking')))
|
||||
} catch (e) {
|
||||
if (!(e instanceof UpstreamError)) throw e
|
||||
send(res, 200, fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) {
|
||||
await settingsRead(res, apiPath, params, who, ip)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
|
||||
/*
|
||||
`hub/blocking/app-lists/count`: how many domains the block lists of the Advanced Blocking app
|
||||
hold on one server. The hub downloads them (the app has no API for it) and caches the counts
|
||||
for hours. A list it could not read has `count: null` and the reason in `error`.
|
||||
*/
|
||||
export interface ListCount {
|
||||
url: string
|
||||
count: number | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ListCounts {
|
||||
lists: ListCount[]
|
||||
/** The sum of the lists that were counted. */
|
||||
total: number
|
||||
}
|
||||
|
||||
export function getAppListCounts(
|
||||
token: string | null,
|
||||
name: string,
|
||||
node: string,
|
||||
): Promise<ApiOutcome<{ response: ListCounts }>> {
|
||||
return apiRequest('hub/blocking/app-lists/count', { token, body: { name, node } })
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import styles from './BlockingMark.module.css'
|
||||
|
||||
/*
|
||||
The sidebar's mark on Blocking: whether blocking is disabled or paused, from
|
||||
anywhere in the console. OURS; what it says and when it reads are in
|
||||
anywhere in the console. Nothing while the Advanced Blocking app is blocking in the
|
||||
built-in blocking's place. OURS; what it says and when it reads are in
|
||||
`screens/blocking/blocking-state.ts`. The only count or state the sidebar carries.
|
||||
|
||||
The link's accessible name gains the sentence (`Blocking, paused until 14:30`)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { Shell, type ShellSession } from './Shell'
|
||||
import { ThemeProvider } from '../theme/ThemeProvider'
|
||||
import * as client from '../api/client'
|
||||
import * as settings from '../api/settings'
|
||||
import type { DnsSettings } from '../api/settings'
|
||||
import { forgetRoot } from './route'
|
||||
import { AB_CONFIG, serveAdvancedBlocking } from '../screens/blocking/advanced-blocking.fixture'
|
||||
|
||||
/*
|
||||
The sidebar's mark and the header's status when the built-in blocking is off but the Advanced
|
||||
Blocking app blocks (`blocking-state.ts`): no OFF mark, no attention; and as before when the app
|
||||
is not blocking.
|
||||
*/
|
||||
|
||||
function session(): ShellSession {
|
||||
const all = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs']
|
||||
return {
|
||||
token: 'tok',
|
||||
displayName: 'Administrator',
|
||||
username: 'admin',
|
||||
info: {
|
||||
version: '15.6',
|
||||
uptimestamp: '2026-10-15T08:00:00Z',
|
||||
dnsServerDomain: 'dns.example.net',
|
||||
permissions: Object.fromEntries(all.map((s) => [s, { canView: true, canModify: true, canDelete: true }])),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const mount = () => render(<ThemeProvider><Shell session={session()} onLogout={() => {}} /></ThemeProvider>)
|
||||
const entry = () => within(screen.getByRole('navigation', { name: 'Sections' })).getByRole('link', { name: /^Blocking/ })
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
forgetRoot()
|
||||
window.history.replaceState(null, '', '/about/')
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: {} } } as never)
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('Shell: the Blocking mark with the Advanced Blocking app', () => {
|
||||
it('built-in off, the app blocking: no mark', async () => {
|
||||
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
|
||||
const { get } = serveAdvancedBlocking()
|
||||
mount()
|
||||
await vi.waitFor(() => expect(get).toHaveBeenCalled())
|
||||
// Let the answer land: the mark must stay away, not just not have come yet.
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking'))
|
||||
expect(entry()).not.toHaveTextContent('Off')
|
||||
expect(entry()).not.toHaveAttribute('title')
|
||||
})
|
||||
|
||||
it('built-in off, the app installed but not blocking: Off, as before', async () => {
|
||||
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
|
||||
serveAdvancedBlocking({ ...AB_CONFIG, enableBlocking: false })
|
||||
mount()
|
||||
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking, disabled'))
|
||||
})
|
||||
})
|
||||
+8
-2
@@ -37,7 +37,7 @@ import { HubServerIds } from '../lib/hub-all'
|
||||
import { FooterLinks } from '../ui/FooterLinks'
|
||||
import { Versions } from './Versions'
|
||||
import { BlockingMark } from './BlockingMark'
|
||||
import { useBlockingMark } from '../screens/blocking/blocking-state'
|
||||
import { useBlockingMark, useHubBlockingMark } from '../screens/blocking/blocking-state'
|
||||
import { useHubWarnings } from '../api/hub-warnings'
|
||||
import { refreshHubDrift, resetHubDrift, setHubDriftCount } from '../api/hub-drift'
|
||||
import { DriftStatus } from './DriftStatus'
|
||||
@@ -280,7 +280,13 @@ export function Shell({
|
||||
|
||||
const current = sections.find((s) => s.id === active) ?? sections[0]
|
||||
/* Whether blocking is off or paused, marked on its entry (`BlockingMark.tsx`). */
|
||||
const blockingNow = useBlockingMark(session.token, permissions, sections.some((s) => s.id === 'blocking'), hub)
|
||||
/* Behind a hub the Advanced Blocking app is asked on the first server (the one `settings/get` answers for). */
|
||||
const markNode = hub ? (session.info?.clusterNodes?.[0]?.name ?? '') : ''
|
||||
const showMark = sections.some((s) => s.id === 'blocking')
|
||||
const singleMark = useBlockingMark(session.token, permissions, showMark && !hub, hub, markNode)
|
||||
/* Behind a hub, every server counts: the mark names the ones that block by nothing. */
|
||||
const hubNow = useHubBlockingMark(session.token, permissions, showMark && hub, hubServers)
|
||||
const blockingNow = hub ? hubNow : singleMark
|
||||
|
||||
/*
|
||||
The jumps other screens' row menus make (`app/handoff.ts`). The section switch
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { countDomains } from './list-count'
|
||||
|
||||
describe('countDomains', () => {
|
||||
it('counts hosts lines, skipping comments and the hosts file own names', () => {
|
||||
const text = [
|
||||
'# Title: a list',
|
||||
'127.0.0.1 localhost',
|
||||
'::1 ip6-localhost',
|
||||
'0.0.0.0 ads.example.com',
|
||||
'0.0.0.0 track.example.com # inline',
|
||||
'0.0.0.0 ads.example.com',
|
||||
'',
|
||||
].join('\r\n')
|
||||
expect(countDomains(text)).toBe(2)
|
||||
})
|
||||
|
||||
it('counts plain domains and wildcards once each', () => {
|
||||
expect(countDomains('a.example\nB.example\n*.c.example\na.example\n! note\n')).toBe(3)
|
||||
})
|
||||
|
||||
it('counts adblock domain rules, not exceptions, options-only lines or regexes', () => {
|
||||
const text = ['[Adblock Plus 2.0]', '! Title', '||ads.example.com^', '||t.example.net^$third-party', '@@||ok.example.com^', '/banner[0-9]+/', '##.ad'].join('\n')
|
||||
expect(countDomains(text)).toBe(2)
|
||||
})
|
||||
|
||||
it('counts several names on one hosts line', () => {
|
||||
expect(countDomains('0.0.0.0 a.example b.example')).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
How many distinct domains a block list file holds. OURS, for the Blocking Overview's
|
||||
"Block List Domains" figure when the Advanced Blocking app is in use: the app has no API
|
||||
that says it, so the lists are downloaded and counted. The hub has its own copy of this
|
||||
function (`hub/app-lists.ts`), kept in step by hand because the hub does not share code with
|
||||
the console build; both are tested against the same sample.
|
||||
|
||||
Counted: a hosts line (`0.0.0.0 example.com`, every name after the address), a plain
|
||||
domain (`example.com`, `*.example.com`), and an Adblock Plus domain rule (`||example.com^`,
|
||||
with or without options). Not counted: blank lines, comments (`#`, `!`, `[Adblock Plus]`),
|
||||
Adblock exceptions (`@@`), regular expressions, and the hosts file's own names (`localhost`,
|
||||
`broadcasthost`, `ip6-*`).
|
||||
*/
|
||||
const ADDRESS = /^(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9a-f:]*:[0-9a-f:]*)$/i
|
||||
const NAME = /^[a-z0-9_]([a-z0-9_.-]*[a-z0-9_])?$/i
|
||||
const OWN = new Set(['localhost', 'localhost.localdomain', 'local', 'broadcasthost', '0.0.0.0'])
|
||||
|
||||
function accept(raw: string, into: Set<string>): void {
|
||||
const name = raw.replace(/^\*\./, '').replace(/\.$/, '').toLowerCase()
|
||||
if (name === '' || OWN.has(name) || name.startsWith('ip6-') || !NAME.test(name)) return
|
||||
into.add(name)
|
||||
}
|
||||
|
||||
export function countDomains(text: string): number {
|
||||
const domains = new Set<string>()
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (t === '' || t.startsWith('#') || t.startsWith('!') || t.startsWith('[') || t.startsWith('@@') || t.startsWith('/')) continue
|
||||
if (t.startsWith('||')) {
|
||||
const end = t.search(/[\^$/]/)
|
||||
accept(end < 0 ? t.slice(2) : t.slice(2, end), domains)
|
||||
continue
|
||||
}
|
||||
const tokens = t.split('#')[0].trim().split(/\s+/)
|
||||
if (tokens.length > 1 && ADDRESS.test(tokens[0])) for (const name of tokens.slice(1)) accept(name, domains)
|
||||
else if (tokens.length === 1) accept(tokens[0], domains)
|
||||
}
|
||||
return domains.size
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { HubWritable } from '../../lib/hub-write'
|
||||
import { AdvancedBlockingEntries } from './AdvancedBlockingEntries'
|
||||
import { AB_CONFIG, serveAdvancedBlocking, written } from './advanced-blocking.fixture'
|
||||
import type { Json } from './advanced-blocking'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const groupOf = (root: Json, name: string) => (root.groups as Json[]).find((g) => g.name === name)!
|
||||
|
||||
function lists(permissions: Record<string, unknown> | undefined = undefined, onNotice = vi.fn()) {
|
||||
return render(<AdvancedBlockingEntries kind="lists" token="T" permissions={permissions as never} onNotice={onNotice} />)
|
||||
}
|
||||
|
||||
function inHub(node: string, writable: string[]) {
|
||||
return render(
|
||||
<HubMode.Provider value>
|
||||
<HubWritable.Provider value={writable}>
|
||||
<AdvancedBlockingEntries kind="lists" token="T" permissions={undefined} node={node} onNotice={() => {}} />
|
||||
</HubWritable.Provider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Advanced Blocking lists', () => {
|
||||
it('lists the urls of every group, labelled by group and type', async () => {
|
||||
serveAdvancedBlocking()
|
||||
lists()
|
||||
expect(await screen.findByText('https://lists.example/hosts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Advanced Blocking lists')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://lists.example/allow')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://lists.example/easy')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('everyone').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Adblock list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('draws nothing when the app is not installed', async () => {
|
||||
const { get } = serveAdvancedBlocking(AB_CONFIG, false)
|
||||
const { container } = lists()
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds a list: reads the config, changes only that group field, writes the whole text back', async () => {
|
||||
const { get, set } = serveAdvancedBlocking()
|
||||
const onNotice = vi.fn()
|
||||
lists(undefined, onNotice)
|
||||
await screen.findByText('https://lists.example/hosts')
|
||||
await userEvent.type(screen.getByLabelText('List URL'), 'https://lists.example/new')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add list' }))
|
||||
await waitFor(() => expect(set).toHaveBeenCalledTimes(1))
|
||||
expect(set.mock.calls[0][1]).toBe('Advanced Blocking')
|
||||
const root = written(set)
|
||||
expect(groupOf(root, 'everyone').blockListUrls).toEqual(['https://lists.example/hosts', 'https://lists.example/more', 'https://lists.example/new'])
|
||||
expect(groupOf(root, 'everyone').blocked).toEqual(['ads.example.com'])
|
||||
expect(groupOf(root, 'family')).toEqual(groupOf(AB_CONFIG, 'family'))
|
||||
expect(root.futureKey).toEqual({ keep: ['me'] })
|
||||
expect(Object.keys(root)).toEqual(Object.keys(AB_CONFIG))
|
||||
// Read again just before the write.
|
||||
expect(get.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
await waitFor(() => expect(onNotice).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })))
|
||||
})
|
||||
|
||||
it('refuses a value that is not a URL, and a repeat, without writing', async () => {
|
||||
const { set } = serveAdvancedBlocking()
|
||||
lists()
|
||||
await screen.findByText('https://lists.example/hosts')
|
||||
await userEvent.type(screen.getByLabelText('List URL'), 'not a url')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add list' }))
|
||||
expect(screen.getByText('Enter a URL starting with http://, https:// or file://.')).toBeInTheDocument()
|
||||
await userEvent.clear(screen.getByLabelText('List URL'))
|
||||
await userEvent.type(screen.getByLabelText('List URL'), 'https://lists.example/hosts')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add list' }))
|
||||
expect(screen.getByText('This list is already in the group.')).toBeInTheDocument()
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes a list', async () => {
|
||||
const { set } = serveAdvancedBlocking()
|
||||
lists()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Remove https://lists.example/more' }))
|
||||
await waitFor(() => expect(set).toHaveBeenCalled())
|
||||
expect(groupOf(written(set), 'everyone').blockListUrls).toEqual(['https://lists.example/hosts'])
|
||||
})
|
||||
|
||||
it('edits a list in place', async () => {
|
||||
const { set } = serveAdvancedBlocking()
|
||||
lists()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Edit https://lists.example/allow' }))
|
||||
const field = screen.getByRole('textbox', { name: 'Edit https://lists.example/allow' })
|
||||
await userEvent.clear(field)
|
||||
await userEvent.type(field, 'https://lists.example/allow2')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(set).toHaveBeenCalled())
|
||||
expect(groupOf(written(set), 'everyone').allowListUrls).toEqual(['https://lists.example/allow2'])
|
||||
})
|
||||
|
||||
it('writes back in the form it read: an object entry keeps its other keys', async () => {
|
||||
const config = { ...AB_CONFIG, groups: [{ name: 'g', enableBlocking: true, blockListUrls: [{ url: 'https://a.example/l', interval: 6 }] }] }
|
||||
const { set } = serveAdvancedBlocking(config)
|
||||
lists()
|
||||
await userEvent.type(await screen.findByLabelText('List URL'), 'https://b.example/l')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add list' }))
|
||||
await waitFor(() => expect(set).toHaveBeenCalled())
|
||||
expect(groupOf(written(set), 'g').blockListUrls).toEqual([{ url: 'https://a.example/l', interval: 6 }, { url: 'https://b.example/l' }])
|
||||
})
|
||||
|
||||
it('without Apps.canModify the controls stay, switched off and named', async () => {
|
||||
serveAdvancedBlocking()
|
||||
lists({ Apps: { canView: true, canModify: false, canDelete: false } })
|
||||
await screen.findByText('https://lists.example/hosts')
|
||||
expect(screen.getByRole('button', { name: 'Add list' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Remove https://lists.example/more' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('without Apps.canView it reads nothing', () => {
|
||||
const { get } = serveAdvancedBlocking()
|
||||
const { container } = lists({ Apps: { canView: false, canModify: false, canDelete: false } })
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('says so, with Retry, when the config cannot be read', async () => {
|
||||
const { get } = serveAdvancedBlocking()
|
||||
get.mockResolvedValueOnce({ kind: 'error', message: 'Boom.' } as never)
|
||||
lists()
|
||||
expect(await screen.findByText(/Could not read the Advanced Blocking config/)).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('https://lists.example/hosts')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('behind a hub with no single server chosen it reads nothing and offers no write', () => {
|
||||
const { get } = serveAdvancedBlocking()
|
||||
const { container } = inHub('', ['a'])
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('behind a hub, a server the hub cannot write to is read only and says why', async () => {
|
||||
const { set } = serveAdvancedBlocking()
|
||||
inHub('a', [])
|
||||
await screen.findByText('https://lists.example/hosts')
|
||||
expect(screen.getByRole('button', { name: 'Add list' })).toBeDisabled()
|
||||
expect(screen.getByText(/No admin token configured on the hub/)).toBeInTheDocument()
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('behind a hub, a writable server is read and written with its name', async () => {
|
||||
const { get, set } = serveAdvancedBlocking()
|
||||
inHub('a', ['a'])
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Remove https://lists.example/more' }))
|
||||
await waitFor(() => expect(set).toHaveBeenCalled())
|
||||
expect(set.mock.calls[0][3]).toBe('a')
|
||||
expect(get.mock.calls.every((c) => c[2] === 'a')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Advanced Blocking rules', () => {
|
||||
const rules = () => render(<AdvancedBlockingEntries kind="rules" token="T" permissions={undefined} onNotice={() => {}} />)
|
||||
|
||||
it('lists the typed-in rules and adds a blocked regex without touching the other fields', async () => {
|
||||
const { set } = serveAdvancedBlocking()
|
||||
rules()
|
||||
expect(await screen.findByText('ads.example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('games.example.com')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('combobox', { name: 'Type' }))
|
||||
await userEvent.click(await screen.findByRole('option', { name: 'Blocked regex' }))
|
||||
await userEvent.type(screen.getByLabelText('Domain or regex'), '^track')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add rule' }))
|
||||
await waitFor(() => expect(set).toHaveBeenCalled())
|
||||
const g = groupOf(written(set), 'everyone')
|
||||
expect(g.blockedRegex).toEqual(['^ads\\.', '^track'])
|
||||
expect(g.blocked).toEqual(['ads.example.com'])
|
||||
})
|
||||
|
||||
it('refuses a domain with spaces', async () => {
|
||||
const { set } = serveAdvancedBlocking()
|
||||
rules()
|
||||
await userEvent.type(await screen.findByLabelText('Domain or regex'), 'two words')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add rule' }))
|
||||
expect(screen.getByText('A domain has no spaces.')).toBeInTheDocument()
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('filters', async () => {
|
||||
serveAdvancedBlocking()
|
||||
rules()
|
||||
await userEvent.type(await screen.findByLabelText('Filter Advanced Blocking rules'), 'games')
|
||||
expect(screen.queryByText('ads.example.com')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('games.example.com')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Panel, Body } from '../../ui/Panel'
|
||||
import { DataTable } from '../../ui/DataTable'
|
||||
import { Field, Input, Select } from '../../ui/Field'
|
||||
import { PermissionButton } from '../../ui/PermissionButton'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { Failure } from '../../ui/Empty'
|
||||
import { Icon } from '../../ui/Icon'
|
||||
import { Pagination } from '../../ui/Pagination'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { UrlText } from '../../ui/UrlText'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
import { useHubWrite } from '../../lib/hub-write'
|
||||
import { pageWindow } from '../../lib/pagination'
|
||||
import type { Notice } from '../../lib/notice'
|
||||
import {
|
||||
URL_FIELDS, RULE_FIELDS, addEntry, editEntry, entriesOf, entryText, groupName, groupsOf, isUrl, removeEntry,
|
||||
APP_NAME, type GroupField, type Json,
|
||||
} from './advanced-blocking'
|
||||
import { changeAppConfig, useAdvancedBlocking } from './advanced-blocking-state'
|
||||
import { missing, type Permissions } from './permissions'
|
||||
import { pageOf } from './rules-model'
|
||||
import shared from './Blocking.module.css'
|
||||
|
||||
/*
|
||||
The Advanced Blocking app's own lists (Lists tab) or rules (Rules tab), listed per group
|
||||
under the built-in ones and labelled as the app's. OURS. One component for both because
|
||||
they differ only in the fields they hold and in what a value must look like.
|
||||
|
||||
Every change is a read-modify-write of the app's whole config (`changeAppConfig`): it
|
||||
reads the config, changes ONLY the touched group's field and writes it back, so nothing
|
||||
the console does not know about is lost. Writes are saved at once, one per click, unlike
|
||||
the built-in lists, which wait for Save: the app has no "unsaved" state to hold them in.
|
||||
|
||||
Writing needs `Apps.canModify`, and behind a hub a single server the hub can write to
|
||||
(`useHubWrite`); without them the controls stay, switched off, and say why.
|
||||
*/
|
||||
|
||||
type Row = { key: string; group: string; field: GroupField; index: number; value: string; off: boolean }
|
||||
|
||||
const WITH_WHITESPACE = /\s/
|
||||
|
||||
/* Where an entry is NOW, in the config read just before the write: by its text, so an edit made elsewhere since the page loaded cannot make the index point at another entry. */
|
||||
function locate(root: Json, row: Row): number {
|
||||
const g = groupsOf(root).find((x) => groupName(x) === row.group)
|
||||
return g == null ? -1 : entriesOf(g, row.field).findIndex((e) => entryText(e) === row.value)
|
||||
}
|
||||
|
||||
export function AdvancedBlockingEntries({
|
||||
kind,
|
||||
token,
|
||||
permissions,
|
||||
node = '',
|
||||
onNotice,
|
||||
}: {
|
||||
kind: 'lists' | 'rules'
|
||||
token: string | null
|
||||
permissions: Permissions
|
||||
/** Behind a hub: the server in view. Empty elsewhere. */
|
||||
node?: string
|
||||
onNotice: (n: Notice) => void
|
||||
}) {
|
||||
const ab = useAdvancedBlocking(token, permissions, node)
|
||||
const modifyNeed = missing(permissions, 'Apps.canModify')
|
||||
const write = useHubWrite(node, modifyNeed == null)
|
||||
const fields = kind === 'lists' ? URL_FIELDS : RULE_FIELDS
|
||||
const noun = kind === 'lists' ? 'list' : 'rule'
|
||||
|
||||
const [group, setGroup] = useState('')
|
||||
const [field, setField] = useState<GroupField>(fields[0].field)
|
||||
const [text, setText] = useState('')
|
||||
const [problem, setProblem] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [editing, setEditing] = useState<{ key: string; value: string } | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const root: Json | null = ab.state.kind === 'ready' ? ab.state.root : null
|
||||
const appName = ab.state.kind === 'ready' ? ab.state.app.name : APP_NAME
|
||||
const groups = useMemo(() => (root == null ? [] : groupsOf(root)), [root])
|
||||
const rows = useMemo<Row[]>(() => {
|
||||
const out: Row[] = []
|
||||
for (const g of groups) {
|
||||
for (const { field: f } of fields) {
|
||||
entriesOf(g, f).forEach((e, index) => {
|
||||
out.push({ key: `${groupName(g)}\n${f}\n${index}`, group: groupName(g), field: f, index, value: entryText(e), off: g.enableBlocking !== true })
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [groups, fields])
|
||||
|
||||
if (ab.state.kind === 'none') return null
|
||||
|
||||
const title = `${APP_NAME} ${kind}`
|
||||
if (ab.state.kind === 'loading') {
|
||||
return (
|
||||
<Panel title={title}>
|
||||
<Body>
|
||||
<div className="flex flex-col gap-2" role="status" aria-label={`Loading ${APP_NAME} ${kind}`}>
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
</Body>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
if (ab.state.kind === 'failed') {
|
||||
return (
|
||||
<Panel title={title}>
|
||||
<Body>
|
||||
<Failure>
|
||||
Could not read the {APP_NAME} config. {ab.state.message}{' '}
|
||||
<Button size="sm" onClick={ab.retry}>Retry</Button>
|
||||
</Failure>
|
||||
</Body>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const chosen = groups.some((g) => groupName(g) === group) ? group : groupName(groups[0] ?? {})
|
||||
const chosenField = fields.find((f) => f.field === field) ?? fields[0]
|
||||
const off = !write.allowed || busy
|
||||
const labelOf = (f: GroupField) => (fields as readonly { field: GroupField; label: string }[]).find((x) => x.field === f)?.label ?? f
|
||||
|
||||
function wrong(value: string, f: GroupField): string | null {
|
||||
if (value === '') return `Enter a ${kind === 'lists' ? 'list URL' : 'value'}.`
|
||||
if (kind === 'lists') return isUrl(value) ? null : 'Enter a URL starting with http://, https:// or file://.'
|
||||
const regex = f === 'blockedRegex' || f === 'allowedRegex'
|
||||
return !regex && WITH_WHITESPACE.test(value) ? 'A domain has no spaces.' : null
|
||||
}
|
||||
|
||||
async function run(change: (r: Json) => Json | null, done: string) {
|
||||
setBusy(true)
|
||||
const r = await changeAppConfig(token, appName, node, change)
|
||||
setBusy(false)
|
||||
if (!r.ok) {
|
||||
onNotice(r.notice)
|
||||
return false
|
||||
}
|
||||
onNotice({ type: 'success', title: 'App Config Saved!', text: done })
|
||||
return true
|
||||
}
|
||||
|
||||
async function add() {
|
||||
const value = text.trim()
|
||||
const bad = wrong(value, chosenField.field)
|
||||
if (bad != null) return setProblem(bad)
|
||||
const g = groups.find((x) => groupName(x) === chosen)
|
||||
if (g != null && entriesOf(g, chosenField.field).some((e) => entryText(e) === value)) {
|
||||
return setProblem(`This ${noun} is already in the group.`)
|
||||
}
|
||||
const ok = await run((r) => addEntry(r, chosen, chosenField.field, value), `Added to the group ${chosen} of ${appName}.`)
|
||||
if (ok) {
|
||||
setText('')
|
||||
setProblem(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function save(row: Row) {
|
||||
const value = (editing?.value ?? '').trim()
|
||||
const bad = wrong(value, row.field)
|
||||
if (bad != null) return setProblem(bad)
|
||||
const ok = await run((r) => (locate(r, row) < 0 ? null : editEntry(r, row.group, row.field, locate(r, row), value)), `Changed in the group ${row.group} of ${appName}.`)
|
||||
if (ok) {
|
||||
setEditing(null)
|
||||
setProblem(null)
|
||||
}
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
const shown = q === '' ? rows : rows.filter((r) => r.value.toLowerCase().includes(q) || r.group.toLowerCase().includes(q))
|
||||
const current = pageOf(shown, page)
|
||||
|
||||
return (
|
||||
<Panel title={title} description={`${noun === 'list' ? 'Lists' : 'Rules'} of the ${appName} app, by group. Changes are saved to the app's config at once.`}>
|
||||
<Body>
|
||||
<div className="flex flex-col gap-4">
|
||||
<HubWriteHint write={write} />
|
||||
{groups.length === 0 ? (
|
||||
<p className="m-0 text-sm text-muted-foreground">The app has no group yet. Add one in its config in Apps.</p>
|
||||
) : (
|
||||
<div className={shared.entryAdd}>
|
||||
<Field label="Group">
|
||||
{(id) => (
|
||||
<Select id={id} value={chosen} disabled={off} onChange={(e) => setGroup(e.target.value)}>
|
||||
{groups.map((g) => (
|
||||
<option key={groupName(g)} value={groupName(g)}>{groupName(g) || 'Unnamed'}</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Type">
|
||||
{(id) => (
|
||||
<Select id={id} value={chosenField.field} disabled={off} onChange={(e) => setField(e.target.value as GroupField)}>
|
||||
{fields.map((f) => (
|
||||
<option key={f.field} value={f.field}>{f.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={kind === 'lists' ? 'List URL' : 'Domain or regex'}>
|
||||
{(id) => (
|
||||
<Input
|
||||
id={id}
|
||||
mono
|
||||
className={shared.bad}
|
||||
placeholder={kind === 'lists' ? 'https://…/hosts' : chosenField.field.endsWith('Regex') ? '^ads\\.' : 'example.com'}
|
||||
value={text}
|
||||
disabled={off}
|
||||
aria-invalid={problem != null || undefined}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value)
|
||||
setProblem(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void add()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<PermissionButton variant="primary" permission={modifyNeed} disabled={off} onClick={() => void add()}>
|
||||
{kind === 'lists' ? 'Add list' : 'Add rule'}
|
||||
</PermissionButton>
|
||||
{problem != null && <p className={shared.invalid}>{problem}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Body>
|
||||
{rows.length > 0 && (
|
||||
<div className={shared.entryFilter}>
|
||||
<Input
|
||||
aria-label={`Filter ${APP_NAME} ${kind}`}
|
||||
placeholder={`Filter ${kind}…`}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DataTable<Row>
|
||||
data={current.rows}
|
||||
getRowId={(r) => r.key}
|
||||
emptyText={rows.length === 0 ? `No ${APP_NAME} ${kind}` : `No ${kind} match this filter`}
|
||||
className={shared.inPanel}
|
||||
columns={[
|
||||
{ id: 'group', header: 'Group', cell: (r) => <span className={r.off ? shared.dim : undefined}>{r.group || 'Unnamed'}{r.off && <> <Tag>Off</Tag></>}</span> },
|
||||
{
|
||||
id: 'type',
|
||||
header: 'Type',
|
||||
cell: (r) => {
|
||||
const f = (fields as readonly { field: GroupField; kind: string }[]).find((x) => x.field === r.field)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icon name={f?.kind === 'allow' ? 'allowed' : 'blocked'} size={14} />
|
||||
{labelOf(r.field)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'value',
|
||||
header: kind === 'lists' ? 'List' : 'Rule',
|
||||
cell: (r) =>
|
||||
editing?.key === r.key ? (
|
||||
<Input
|
||||
autoFocus
|
||||
mono
|
||||
aria-label={`Edit ${r.value}`}
|
||||
value={editing.value}
|
||||
disabled={busy}
|
||||
aria-invalid={problem != null || undefined}
|
||||
onChange={(e) => {
|
||||
setEditing({ key: r.key, value: e.target.value })
|
||||
setProblem(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void save(r)
|
||||
if (e.key === 'Escape') setEditing(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
kind === 'lists' ? <UrlText url={r.value} /> : <span className={shared.entryValue}>{r.value}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
td: () => ({ className: shared.entryActions }),
|
||||
cell: (r) =>
|
||||
editing?.key === r.key ? (
|
||||
<span className="inline-flex gap-2">
|
||||
<Button size="sm" variant="primary" disabled={busy} onClick={() => void save(r)}>Save</Button>
|
||||
<Button size="sm" disabled={busy} onClick={() => { setEditing(null); setProblem(null) }}>Cancel</Button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex gap-2">
|
||||
<PermissionButton size="sm" permission={modifyNeed} disabled={off} aria-label={`Edit ${r.value}`}
|
||||
onClick={() => { setEditing({ key: r.key, value: r.value }); setProblem(null) }}>
|
||||
Edit
|
||||
</PermissionButton>
|
||||
<PermissionButton size="sm" variant="danger" permission={modifyNeed} disabled={off} aria-label={`Remove ${r.value}`}
|
||||
onClick={() => void run((root2) => (locate(root2, r) < 0 ? null : removeEntry(root2, r.group, r.field, locate(root2, r))), `Removed from the group ${r.group} of ${appName}.`)}>
|
||||
Remove
|
||||
</PermissionButton>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{current.totalPages > 1 && (
|
||||
<Pagination window={pageWindow(current.page, current.totalPages)} current={current.page} last={current.totalPages} onGoTo={setPage} />
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Panel, Body } from '../../ui/Panel'
|
||||
import { DataTable } from '../../ui/DataTable'
|
||||
import { Empty, Failure } from '../../ui/Empty'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { RouteLink } from '../../ui/RouteLink'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { APP_NAME, summarize, type GroupSummary } from './advanced-blocking'
|
||||
import type { AppState } from './advanced-blocking-state'
|
||||
import shared from './Blocking.module.css'
|
||||
import styles from './Overview.module.css'
|
||||
|
||||
/*
|
||||
The Advanced Blocking app at a glance, on the Overview. OURS: the console only knew the
|
||||
built-in blocking, and a server that blocks with the app showed none of it. It is drawn
|
||||
only when the app is installed on the server in view, and says: whether the app is on,
|
||||
each group with its own switch, how much each holds (list URLs of the four kinds, rules
|
||||
typed in by hand), and the networks that map to it. The lists and the rules themselves
|
||||
are in the Lists and Rules tabs, labelled per group; the app's own settings (the TTL, the
|
||||
groups' other options) stay in the Apps section, which the link opens.
|
||||
*/
|
||||
|
||||
const sum = (g: GroupSummary, ...fields: (keyof GroupSummary['counts'])[]) =>
|
||||
fields.reduce((n, f) => n + g.counts[f], 0)
|
||||
|
||||
export function AdvancedBlockingPanel({ ab: { state, retry } }: { ab: { state: AppState; retry: () => void } }) {
|
||||
/* Not installed, not readable, or no single server (hub, "All servers"): nothing to say. */
|
||||
if (state.kind === 'none') return null
|
||||
|
||||
const link = <RouteLink to={{ section: 'apps', sub: null }}>Open Advanced Blocking in Apps</RouteLink>
|
||||
|
||||
if (state.kind === 'loading') {
|
||||
return (
|
||||
<Panel title={APP_NAME}>
|
||||
<Body>
|
||||
<div className="flex flex-col gap-2" role="status" aria-label={`Loading ${APP_NAME}`}>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
</Body>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.kind === 'failed') {
|
||||
return (
|
||||
<Panel title={APP_NAME}>
|
||||
<Body>
|
||||
<Failure>
|
||||
Could not read the {APP_NAME} config. {state.message}{' '}
|
||||
<Button size="sm" onClick={retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</Failure>
|
||||
</Body>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const summary = summarize(state.root)
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={APP_NAME}
|
||||
actions={
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag tone={summary.enabled ? 'ok' : 'neutral'}>{summary.enabled ? 'Enabled' : 'Disabled'}</Tag>
|
||||
<span className="text-xs">{link}</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{summary.groups.length === 0 ? (
|
||||
<Body>
|
||||
<Empty title="No groups" actions={<span className="text-sm">{link}</span>}>
|
||||
The app has no group yet, so it blocks nothing. Add one in its config.
|
||||
</Empty>
|
||||
</Body>
|
||||
) : (
|
||||
<DataTable<GroupSummary>
|
||||
data={summary.groups}
|
||||
getRowId={(g) => g.name}
|
||||
className={shared.inPanel}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: 'Group',
|
||||
cell: (g) => (
|
||||
<span className={styles.groupCell}>
|
||||
<span className={styles.groupName}>{g.name || 'Unnamed'}</span>
|
||||
<Tag tone={g.enabled ? 'ok' : 'neutral'}>{g.enabled ? 'Blocking' : 'Off'}</Tag>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ id: 'block', header: 'Block lists', cell: (g) => g.counts.blockListUrls },
|
||||
{ id: 'allow', header: 'Allow lists', cell: (g) => g.counts.allowListUrls },
|
||||
{ id: 'regex', header: 'Regex lists', cell: (g) => sum(g, 'regexBlockListUrls', 'regexAllowListUrls') },
|
||||
{ id: 'adblock', header: 'Adblock lists', cell: (g) => g.counts.adblockListUrls },
|
||||
{ id: 'blocked', header: 'Blocked domains', cell: (g) => g.counts.blocked },
|
||||
{ id: 'allowed', header: 'Allowed domains', cell: (g) => g.counts.allowed },
|
||||
{ id: 'rules', header: 'Regex rules', cell: (g) => sum(g, 'blockedRegex', 'allowedRegex') },
|
||||
{
|
||||
id: 'networks',
|
||||
header: 'Networks',
|
||||
cell: (g) => {
|
||||
const all = [...g.networks, ...g.endpoints.map((e) => `${e} (local)`)]
|
||||
return all.length === 0 ? <span className="text-muted-foreground">None</span> : <span className={styles.nets}>{all.join(', ')}</span>
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import * as apps from '../../api/apps'
|
||||
import * as settings from '../../api/settings'
|
||||
import * as dashboard from '../../api/dashboard'
|
||||
import * as appLists from '../../api/app-lists'
|
||||
import * as client from '../../api/client'
|
||||
import { SlotProvider } from '../../app/ChromeSlot'
|
||||
import { Shell, type ShellSession } from '../../app/Shell'
|
||||
import { ThemeProvider } from '../../theme/ThemeProvider'
|
||||
import { forgetRoot } from '../../app/route'
|
||||
import { HubMode, AGGREGATE } from '../../ui/ClusterNodeSelect'
|
||||
import { HubNode } from '../../lib/hub-write'
|
||||
import { Overview } from './Overview'
|
||||
import { StatusPanel } from './StatusPanel'
|
||||
import { AB_CONFIG } from './advanced-blocking.fixture'
|
||||
import { appServersLine, hubMark, summariseServers, type ServerBlocking } from './blocking-state'
|
||||
import type { Json } from './advanced-blocking'
|
||||
|
||||
/*
|
||||
"All servers" behind a hub: blocking is judged server by server, the Advanced Blocking app counted
|
||||
(`blocking-state.ts`, `useAllServersBlocking`), and the Block List Domains figure adds the app lists
|
||||
of every server.
|
||||
*/
|
||||
|
||||
vi.mock('../dashboard/Chart', () => ({ Chart: ({ aria }: { aria: string }) => <div role="img" aria-label={aria} /> }))
|
||||
|
||||
const row = (server: string, method: ServerBlocking['method'], installed = false, blocking = false): ServerBlocking => ({
|
||||
server, method, appInstalled: installed, appBlocking: blocking,
|
||||
})
|
||||
|
||||
describe('summariseServers and hubMark', () => {
|
||||
it('says nothing new when every server blocks with the built-in blocking', () => {
|
||||
const rows = [row('mdns', 'builtin'), row('ndns', 'builtin')]
|
||||
expect(summariseServers(rows)).toBeNull()
|
||||
expect(hubMark(rows, undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('every server blocks by the app only', () => {
|
||||
const rows = [row('mdns', 'app', true, true), row('ndns', 'app', true, true)]
|
||||
expect(summariseServers(rows)).toEqual({ title: 'Blocking by Advanced Blocking', sub: 'The built-in blocking is off on every server.', active: true })
|
||||
expect(hubMark(rows, undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('names which servers use which when mixed', () => {
|
||||
const rows = [row('mdns', 'builtin'), row('ndns', 'app', true, true), row('xdns', 'both', true, true)]
|
||||
expect(summariseServers(rows)).toEqual({
|
||||
title: 'Blocking is enabled',
|
||||
sub: 'Built-in on mdns; Advanced Blocking on ndns; Built-in and Advanced Blocking on xdns.',
|
||||
active: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('names the server that blocks by nothing, in the block and in the mark', () => {
|
||||
const rows = [row('mdns', 'builtin'), row('ndns', 'nothing', true, false)]
|
||||
expect(summariseServers(rows)).toEqual({ title: 'Blocking is off on ndns', sub: 'Built-in on mdns.', active: false })
|
||||
expect(hubMark(rows, undefined)).toEqual({ text: 'Off', label: 'Blocking, disabled on ndns', tone: 'neutral' })
|
||||
})
|
||||
|
||||
it('a pause is named too, and no server at all blocking keeps the single wording', () => {
|
||||
expect(hubMark([row('mdns', 'builtin'), row('ndns', 'paused')], undefined)?.label).toBe('Blocking, paused on ndns')
|
||||
expect(summariseServers([row('mdns', 'nothing'), row('ndns', 'nothing')])).toBeNull()
|
||||
})
|
||||
|
||||
it('a server that could not be read is not guessed at', () => {
|
||||
expect(summariseServers([row('mdns', 'app', true, true), row('ndns', 'unknown')])).toBeNull()
|
||||
expect(hubMark([row('mdns', 'builtin'), row('ndns', 'unknown')], undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('the app line lists where it is installed and where it is idle', () => {
|
||||
expect(appServersLine([row('mdns', 'app', true, true), row('ndns', 'builtin', true, false), row('x', 'builtin')])).toBe(
|
||||
'Advanced Blocking: on mdns, ndns (not blocking on ndns). Choose a server for details.',
|
||||
)
|
||||
expect(appServersLine([row('mdns', 'builtin')])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/* Each server's settings and app config, by node ("cluster", the aggregate, is answered by the first server, as the hub does). */
|
||||
function servers(spec: Record<string, { builtIn: boolean; app: Json | null }>) {
|
||||
const of = (node: string) => spec[node] ?? Object.values(spec)[0]
|
||||
vi.spyOn(settings, 'getSettings').mockImplementation((async (_t: unknown, node: string) => ({ enableBlocking: of(node).builtIn })) as never)
|
||||
vi.spyOn(apps, 'listApps').mockImplementation((async (_t: unknown, node: string) => ({
|
||||
kind: 'ok',
|
||||
data: { response: { apps: of(node).app == null ? [] : [{ name: 'Advanced Blocking', description: '', version: '1', dnsApps: [{ classPath: 'AdvancedBlocking.App' }] }] } },
|
||||
})) as never)
|
||||
vi.spyOn(apps, 'getAppConfig').mockImplementation((async (_t: unknown, _n: string, node: string) => ({
|
||||
kind: 'ok',
|
||||
data: { response: { config: JSON.stringify(of(node).app) } },
|
||||
})) as never)
|
||||
}
|
||||
|
||||
const SERVERS = ['mdns', 'ndns']
|
||||
const status = () =>
|
||||
render(
|
||||
<HubMode.Provider value>
|
||||
<HubNode.Provider value={AGGREGATE}>
|
||||
<StatusPanel token="T" permissions={undefined} onNotice={() => {}} servers={SERVERS} />
|
||||
</HubNode.Provider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('StatusPanel with All servers chosen', () => {
|
||||
it('every server blocks only by the app: not "disabled"', async () => {
|
||||
servers({ mdns: { builtIn: false, app: AB_CONFIG }, ndns: { builtIn: false, app: AB_CONFIG } })
|
||||
status()
|
||||
expect(await screen.findByText('Blocking by Advanced Blocking')).toBeInTheDocument()
|
||||
expect(screen.getByText('The built-in blocking is off on every server.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Nothing is being blocked.')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Advanced Blocking: on mdns, ndns. Choose a server for details.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('mixed: names which server uses which', async () => {
|
||||
servers({ mdns: { builtIn: true, app: null }, ndns: { builtIn: false, app: AB_CONFIG } })
|
||||
status()
|
||||
expect(await screen.findByText('Built-in on mdns; Advanced Blocking on ndns.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Blocking is enabled')).toBeInTheDocument()
|
||||
expect(screen.getByText('Advanced Blocking: on ndns. Choose a server for details.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('a server that blocks by nothing is named', async () => {
|
||||
servers({ mdns: { builtIn: true, app: null }, ndns: { builtIn: false, app: { ...AB_CONFIG, enableBlocking: false } } })
|
||||
status()
|
||||
expect(await screen.findByText('Blocking is off on ndns')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Active')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/not blocking on ndns/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('asks each server with its own node', async () => {
|
||||
servers({ mdns: { builtIn: false, app: AB_CONFIG }, ndns: { builtIn: false, app: AB_CONFIG } })
|
||||
status()
|
||||
await screen.findByText('Blocking by Advanced Blocking')
|
||||
expect(vi.mocked(apps.getAppConfig).mock.calls.map((c) => c[2]).sort()).toEqual(['mdns', 'ndns'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Overview, All servers: Block List Domains', () => {
|
||||
const STATS = {
|
||||
totalQueries: 5000, totalNoError: 0, totalServerFailure: 0, totalNxDomain: 0, totalRefused: 0,
|
||||
totalAuthoritative: 0, totalRecursive: 0, totalCached: 0, totalBlocked: 100, totalDropped: 0,
|
||||
totalClients: 1, zones: 5, cachedEntries: 0, allowedZones: 0, blockedZones: 0, allowListZones: 0, blockListZones: 1000,
|
||||
}
|
||||
const MAIN = { labels: ['a'], datasets: [{ label: 'Total', data: [1] }, { label: 'Blocked', data: [1] }] }
|
||||
const tile = (label: string) => screen.getAllByText(label).find((el) => /_k_/.test(el.className))!.parentElement!
|
||||
|
||||
function mount() {
|
||||
servers({ mdns: { builtIn: false, app: AB_CONFIG }, ndns: { builtIn: false, app: AB_CONFIG } })
|
||||
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { stats: STATS, mainChartData: MAIN, queryResponseChartData: MAIN, queryTypeChartData: MAIN, protocolTypeChartData: MAIN, topClients: [], topDomains: [], topBlockedDomains: [] },
|
||||
} as never)
|
||||
return render(
|
||||
<HubMode.Provider value>
|
||||
<SlotProvider>
|
||||
{(ref) => (
|
||||
<>
|
||||
<div ref={ref} />
|
||||
<Overview token="T" permissions={undefined} nodes={SERVERS.map((name) => ({ name, type: 'Primary' }))} clusterInitialised />
|
||||
</>
|
||||
)}
|
||||
</SlotProvider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
it('adds the app lists of every server, once per server', async () => {
|
||||
const count = vi.spyOn(appLists, 'getAppListCounts').mockImplementation((async (_t: unknown, _n: string, node: string) => ({
|
||||
kind: 'ok',
|
||||
data: { response: { lists: [{ url: 'https://l.example/shared', count: node === 'mdns' ? 10 : 20 }], total: 0 } },
|
||||
})) as never)
|
||||
mount()
|
||||
await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,030')).toBeInTheDocument())
|
||||
expect(within(tile('Block List Domains')).getByText('1,000 built-in, 30 Advanced Blocking')).toBeInTheDocument()
|
||||
expect(count.mock.calls.map((c) => c[2]).sort()).toEqual(['mdns', 'ndns'])
|
||||
})
|
||||
|
||||
it('a list that was not counted makes it "at least", and says so', async () => {
|
||||
vi.spyOn(appLists, 'getAppListCounts').mockImplementation((async (_t: unknown, _n: string, node: string) => ({
|
||||
kind: 'ok',
|
||||
data: { response: { lists: [{ url: 'https://l.example/x', count: node === 'mdns' ? 10 : null, error: 'timed out' }], total: 10 } },
|
||||
})) as never)
|
||||
mount()
|
||||
await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,010+')).toBeInTheDocument())
|
||||
expect(within(tile('Block List Domains')).getByText(/1 list not counted/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('servers without lists add nothing and the tile stays the built-in figure', async () => {
|
||||
vi.spyOn(appLists, 'getAppListCounts').mockResolvedValue({ kind: 'ok', data: { response: { lists: [], total: 0 } } } as never)
|
||||
mount()
|
||||
await screen.findByText('5,000')
|
||||
await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,000')).toBeInTheDocument())
|
||||
expect(within(tile('Block List Domains')).queryByText(/Advanced Blocking/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Shell behind a hub', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
forgetRoot()
|
||||
window.history.replaceState(null, '', '/about/')
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: {} } } as never)
|
||||
})
|
||||
|
||||
function session(): ShellSession {
|
||||
const all = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs']
|
||||
return {
|
||||
token: 'tok',
|
||||
displayName: 'Administrator',
|
||||
username: 'admin',
|
||||
info: {
|
||||
version: '1.0.0',
|
||||
uptimestamp: '2026-10-15T08:00:00Z',
|
||||
dnsServerDomain: 'hub',
|
||||
hub: true,
|
||||
clusterNodes: SERVERS.map((name) => ({ name, type: 'Primary' })),
|
||||
permissions: Object.fromEntries(all.map((s) => [s, { canView: true, canModify: true, canDelete: true }])),
|
||||
},
|
||||
} as never
|
||||
}
|
||||
|
||||
const entry = () => within(screen.getByRole('navigation', { name: 'Sections' })).getByRole('link', { name: /^Blocking/ })
|
||||
const mount = () => render(<ThemeProvider><Shell session={session()} onLogout={() => {}} /></ThemeProvider>)
|
||||
|
||||
it('no mark when every server blocks, one way or the other', async () => {
|
||||
servers({ mdns: { builtIn: true, app: null }, ndns: { builtIn: false, app: AB_CONFIG } })
|
||||
mount()
|
||||
await vi.waitFor(() => expect(vi.mocked(apps.getAppConfig)).toHaveBeenCalled())
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(entry()).toHaveAccessibleName('Blocking')
|
||||
})
|
||||
|
||||
it('Off, naming the server that blocks by nothing', async () => {
|
||||
servers({ mdns: { builtIn: true, app: null }, ndns: { builtIn: false, app: null } })
|
||||
mount()
|
||||
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking, disabled on ndns'))
|
||||
expect(entry()).toHaveTextContent('Off')
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type ListLine,
|
||||
} from './list-lines'
|
||||
import { Locked } from './Locked'
|
||||
import { AdvancedBlockingEntries } from './AdvancedBlockingEntries'
|
||||
import { hostsOf, moreLists } from './extra-lists'
|
||||
import { missing, switchNeed, type Permissions } from './permissions'
|
||||
import shared from './Blocking.module.css'
|
||||
@@ -60,6 +61,8 @@ every alert of the update and the save. Everything else is OURS:
|
||||
`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 Advanced Blocking panel under the table (`AdvancedBlockingEntries.tsx`): the app's own
|
||||
list URLs by group, when the app is installed. Saved at once, not with this tab's Save;
|
||||
- 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.
|
||||
@@ -200,6 +203,7 @@ function HeadingEditor({
|
||||
|
||||
export function BlockLists({
|
||||
tabs,
|
||||
actions,
|
||||
token,
|
||||
permissions,
|
||||
clusterInitialised = false,
|
||||
@@ -207,6 +211,7 @@ export function BlockLists({
|
||||
allServers = false,
|
||||
}: {
|
||||
tabs?: ReactNode
|
||||
actions?: ReactNode
|
||||
token: string | null
|
||||
permissions: Permissions
|
||||
clusterInitialised?: boolean
|
||||
@@ -370,7 +375,7 @@ export function BlockLists({
|
||||
if (viewNeed != null) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Blocking" subtitle={LISTS_SUBTITLE} tabs={tabs} />
|
||||
<SectionHeader title="Blocking" subtitle={LISTS_SUBTITLE} tabs={tabs} actions={actions} />
|
||||
<Locked title="Block Lists" need={viewNeed} />
|
||||
</>
|
||||
)
|
||||
@@ -583,7 +588,7 @@ export function BlockLists({
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Blocking" subtitle={LISTS_SUBTITLE} tabs={tabs} />
|
||||
<SectionHeader title="Blocking" subtitle={LISTS_SUBTITLE} tabs={tabs} actions={actions} />
|
||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||
|
||||
<div className={shared.stack}>
|
||||
@@ -875,6 +880,8 @@ export function BlockLists({
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<AdvancedBlockingEntries kind="lists" token={token} permissions={permissions} node={hubNode} onNotice={setNotice} />
|
||||
|
||||
{dirty && (
|
||||
<div className={styles.bar}>
|
||||
<span>Unsaved changes to the block list URLs: {changes}</span>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as blocking from '../../api/blocking'
|
||||
import * as settings from '../../api/settings'
|
||||
import * as dashboard from '../../api/dashboard'
|
||||
import * as quick from '../../lib/quick-lists'
|
||||
import { BlockLists } from './BlockLists'
|
||||
import { Rules } from './Rules'
|
||||
import { AB_CONFIG, serveAdvancedBlocking } from './advanced-blocking.fixture'
|
||||
|
||||
/* The Lists and Rules tabs carry the Advanced Blocking app's own entries under the built-in ones. */
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function builtIn() {
|
||||
vi.spyOn(blocking, 'readRuleExport').mockResolvedValue({ kind: 'ok', data: ['own.example'] })
|
||||
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'error', message: 'n/a' })
|
||||
vi.spyOn(quick, 'loadQuickList').mockResolvedValue([])
|
||||
vi.spyOn(settings, 'readSettings').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { blockListUrls: ['https://built-in.example/list'], blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: null } as never,
|
||||
})
|
||||
}
|
||||
|
||||
describe('Lists tab', () => {
|
||||
it('shows the app lists by group under the built-in ones', async () => {
|
||||
builtIn()
|
||||
serveAdvancedBlocking()
|
||||
render(<BlockLists token="T" permissions={undefined} />)
|
||||
expect(await screen.findByText('https://built-in.example/list')).toBeInTheDocument()
|
||||
expect(await screen.findByText('https://lists.example/hosts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Advanced Blocking lists')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has no Advanced Blocking panel without the app', async () => {
|
||||
builtIn()
|
||||
serveAdvancedBlocking(AB_CONFIG, false)
|
||||
render(<BlockLists token="T" permissions={undefined} />)
|
||||
expect(await screen.findByText('https://built-in.example/list')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Advanced Blocking lists')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rules tab', () => {
|
||||
it('shows the app rules by group under the built-in ones', async () => {
|
||||
builtIn()
|
||||
serveAdvancedBlocking()
|
||||
render(<Rules token="T" permissions={undefined} />)
|
||||
expect(await screen.findByText('ads.example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('Advanced Blocking rules')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { SlotProvider } from '../../app/ChromeSlot'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { HubWritable } from '../../lib/hub-write'
|
||||
import { Blocking } from './Blocking'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
|
||||
/*
|
||||
Where the Blocking header's pieces go, as the Dashboard's: the server selector up in the chrome
|
||||
slot (once), the comparison of the servers among the header's actions, the tabs under the
|
||||
header, and the hub's "choose a server" line under it all.
|
||||
*/
|
||||
|
||||
vi.mock('./Overview', () => ({ Overview: () => <p>overview</p> }))
|
||||
vi.mock('./BlockLists', () => ({ BlockLists: () => <p>lists</p> }))
|
||||
vi.mock('../settings/SettingsCompare', () => ({ CompareServersButton: () => <button type="button">Compare servers</button> }))
|
||||
vi.mock('./Rules', () => ({
|
||||
Rules: ({ tabs, actions }: { tabs?: React.ReactNode; actions?: React.ReactNode }) => (
|
||||
<SectionHeader title="Blocking" actions={actions} tabs={tabs} />
|
||||
),
|
||||
}))
|
||||
vi.mock('../../api/settings', () => ({ getSettings: async () => ({ enableBlocking: true }) }))
|
||||
vi.mock('../../api/dashboard', () => ({ getDashboardStats: async () => ({ kind: 'error', message: 'x' }) }))
|
||||
|
||||
afterEach(() => window.history.replaceState(null, '', '/'))
|
||||
|
||||
const NODES = [{ name: 'a', type: 'Primary' }, { name: 'b', type: 'Secondary' }]
|
||||
const ADMIN = { Settings: { canView: true, canModify: true, canDelete: true } }
|
||||
|
||||
function mount(writable: string[]) {
|
||||
return render(
|
||||
<HubMode.Provider value>
|
||||
<HubWritable.Provider value={writable}>
|
||||
<SlotProvider>
|
||||
{(ref) => (
|
||||
<>
|
||||
<div data-testid="slot" ref={ref} />
|
||||
<div data-testid="page">
|
||||
<Blocking token="T" sub="Rules" onSubChange={() => {}} permissions={ADMIN as never} nodes={NODES} clusterInitialised />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SlotProvider>
|
||||
</HubWritable.Provider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Blocking header behind a hub', () => {
|
||||
it('puts the one selector in the chrome slot, not in the page', () => {
|
||||
mount(['a', 'b'])
|
||||
expect(within(screen.getByTestId('slot')).getAllByText('Server')).toHaveLength(1)
|
||||
expect(within(screen.getByTestId('page')).queryByText('Server')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('puts Compare servers among the header actions and the tabs under the header', () => {
|
||||
mount(['a', 'b'])
|
||||
const page = within(screen.getByTestId('page'))
|
||||
expect(page.getByRole('button', { name: 'Compare servers' })).toBeInTheDocument()
|
||||
expect(page.getByRole('link', { name: 'Overview' })).toBeInTheDocument()
|
||||
const heading = page.getByRole('heading', { name: 'Blocking' })
|
||||
// The comparison is after the title, in the same row; the tabs come after both.
|
||||
expect(heading.compareDocumentPosition(page.getByRole('button', { name: 'Compare servers' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(page.getByRole('button', { name: 'Compare servers' }).compareDocumentPosition(page.getByRole('link', { name: 'Overview' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('says why the writes are off under the tabs, not above the title', () => {
|
||||
mount([])
|
||||
const page = screen.getByTestId('page')
|
||||
const hint = within(page).getByRole('status', { name: '' })
|
||||
expect(hint).toHaveTextContent('No admin token configured on the hub')
|
||||
const tabs = within(page).getByRole('link', { name: 'Overview' })
|
||||
expect(tabs.compareDocumentPosition(hint) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,9 @@
|
||||
/*
|
||||
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); }
|
||||
.stack { display: flex; flex-direction: column; gap: var(--s-8); }
|
||||
.row2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-8); }
|
||||
.row21 { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--s-8); }
|
||||
/* 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); }
|
||||
@@ -53,7 +52,6 @@ second frame, with rounded corners showing under the panel's header. Written und
|
||||
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; }
|
||||
}
|
||||
@@ -86,3 +84,12 @@ second frame, with rounded corners showing under the panel's header. Written und
|
||||
/* The canvas's state strip above the tabs. */
|
||||
.banner { display:flex; flex-wrap:wrap; align-items:center; gap: var(--s-7); padding: var(--s-7) var(--s-9); margin-bottom: var(--s-9); border:1px solid var(--line); border-radius: var(--r-3); background: color-mix(in oklch, var(--pan2) 50%, transparent); font-size: 13px; color: var(--mute); }
|
||||
.bannerAct { margin-left: auto; }
|
||||
|
||||
/* The Advanced Blocking panels of the Lists and Rules tabs: the add row, the filter, a value and its buttons. */
|
||||
.entryAdd { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--s-5); }
|
||||
.entryAdd > :nth-child(3) { flex: 1 1 16rem; min-width: 0; }
|
||||
.entryAdd > p { flex-basis: 100%; margin: 0; }
|
||||
.entryFilter { padding: 0 var(--s-9) var(--s-5); }
|
||||
.entryValue { font-family: var(--font-mono); overflow-wrap: break-word; }
|
||||
.entryActions { text-align: right; white-space: nowrap; }
|
||||
.dim { color: var(--mute); }
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { InChrome } from '../../app/ChromeSlot'
|
||||
import { CompareServersButton } from '../settings/SettingsCompare'
|
||||
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
@@ -76,14 +77,17 @@ function Banner({ token, permissions, node }: { token: string | null; permission
|
||||
return (
|
||||
<div className={shared.banner} role="status">
|
||||
{state.kind === 'on' && <Tag tone="ok">Enabled</Tag>}
|
||||
{state.kind === 'app' && <Tag tone="ok">Advanced Blocking</Tag>}
|
||||
{state.kind === 'paused' && <Tag tone="acc">{untilText(state.till)}</Tag>}
|
||||
{state.kind === 'off' && <Tag>Disabled</Tag>}
|
||||
<span>
|
||||
{state.kind === 'on'
|
||||
? 'Blocking is applied to every query'
|
||||
: state.kind === 'paused'
|
||||
? 'Blocking is paused and resumes automatically'
|
||||
: 'Nothing is being blocked'}
|
||||
: state.kind === 'app'
|
||||
? 'The built-in blocking is off; the Advanced Blocking app is blocking'
|
||||
: state.kind === 'paused'
|
||||
? 'Blocking is paused and resumes automatically'
|
||||
: 'Nothing is being blocked'}
|
||||
</span>
|
||||
{canModify && (
|
||||
<span className={shared.bannerAct}>
|
||||
@@ -151,18 +155,18 @@ export function Blocking({
|
||||
|
||||
const counts = useCounts(token, permissions, hubNode)
|
||||
|
||||
/* The header's actions, where the Dashboard puts its Refresh: the comparison of the servers. */
|
||||
const actions =
|
||||
hub && permissions?.Settings?.canModify === true ? <CompareServersButton token={token} scope="blocking" /> : undefined
|
||||
|
||||
const tabs = (
|
||||
<>
|
||||
{hub && permissions?.Settings?.canModify === true && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CompareServersButton token={token} scope="blocking" />
|
||||
</div>
|
||||
)}
|
||||
{/* The server selector goes up into the chrome slot, as the Dashboard's does. Overview
|
||||
keeps its own there (it also offers "All servers"); these two tabs share this one. */}
|
||||
{hub && active !== 'Overview' && (
|
||||
<>
|
||||
<InChrome>
|
||||
<ClusterNodeSelect nodes={nodes} initialised={clusterInitialised} value={chosen} onChange={setChosen} />
|
||||
<HubWriteHint write={write} />
|
||||
</>
|
||||
</InChrome>
|
||||
)}
|
||||
{active !== 'Overview' && <Banner token={token} permissions={effective} node={hubNode} />}
|
||||
<SubTabs
|
||||
@@ -174,13 +178,14 @@ export function Blocking({
|
||||
marks={listsDirty ? { Lists: 'unsaved changes' } : undefined}
|
||||
counts={counts}
|
||||
/>
|
||||
{hub && active !== 'Overview' && <HubWriteHint write={write} />}
|
||||
</>
|
||||
)
|
||||
|
||||
if (active === 'Rules') {
|
||||
return (
|
||||
<HubNode.Provider value={hubNode}>
|
||||
<Rules tabs={tabs} token={token} permissions={effective} nodes={nodes} clusterInitialised={clusterInitialised} />
|
||||
<Rules tabs={tabs} actions={actions} token={token} permissions={effective} nodes={nodes} clusterInitialised={clusterInitialised} />
|
||||
</HubNode.Provider>
|
||||
)
|
||||
}
|
||||
@@ -189,6 +194,7 @@ export function Blocking({
|
||||
<HubNode.Provider value={hubNode}>
|
||||
<BlockLists
|
||||
tabs={tabs}
|
||||
actions={actions}
|
||||
token={token}
|
||||
permissions={effective}
|
||||
clusterInitialised={clusterInitialised}
|
||||
@@ -201,6 +207,7 @@ export function Blocking({
|
||||
return (
|
||||
<Overview
|
||||
tabs={tabs}
|
||||
actions={actions}
|
||||
token={token}
|
||||
permissions={permissions}
|
||||
nodes={nodes}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor, 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 appLists from '../../api/app-lists'
|
||||
import { SlotProvider } from '../../app/ChromeSlot'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { Overview } from './Overview'
|
||||
import { forgetListCounts } from './app-list-count'
|
||||
import { AB_CONFIG, serveAdvancedBlocking } from './advanced-blocking.fixture'
|
||||
|
||||
/* The Advanced Blocking panel and the app's share of "Block List Domains" on the Overview. */
|
||||
|
||||
vi.mock('../dashboard/Chart', () => ({ Chart: ({ aria }: { aria: string }) => <div role="img" aria-label={aria} /> }))
|
||||
|
||||
const STATS = {
|
||||
totalQueries: 5000, totalNoError: 0, totalServerFailure: 0, totalNxDomain: 0, totalRefused: 0,
|
||||
totalAuthoritative: 0, totalRecursive: 0, totalCached: 0, totalBlocked: 100, totalDropped: 0,
|
||||
totalClients: 1, zones: 5, cachedEntries: 0, allowedZones: 0, blockedZones: 0, allowListZones: 0, blockListZones: 1000,
|
||||
}
|
||||
const MAIN = { labels: ['a'], datasets: [{ label: 'Total', data: [1] }, { label: 'Blocked', data: [1] }] }
|
||||
const OK = {
|
||||
kind: 'ok' as const,
|
||||
data: { stats: STATS, mainChartData: MAIN, queryResponseChartData: MAIN, queryTypeChartData: MAIN, protocolTypeChartData: MAIN, topClients: [], topDomains: [], topBlockedDomains: [] },
|
||||
}
|
||||
|
||||
const text = (lines: string[]) => lines.join('\n')
|
||||
const LISTS: Record<string, string> = {
|
||||
'https://lists.example/hosts': text(['0.0.0.0 a.example', '0.0.0.0 b.example']),
|
||||
'https://lists.example/more': text(['c.example', 'd.example', 'e.example']),
|
||||
'https://lists.example/easy': text(['||f.example^']),
|
||||
}
|
||||
|
||||
function fetchLists(fail: string[] = []) {
|
||||
return vi.spyOn(globalThis, 'fetch').mockImplementation((async (input: string) => {
|
||||
if (fail.includes(String(input))) throw new TypeError('Failed to fetch')
|
||||
const body = LISTS[String(input)]
|
||||
return body === undefined ? new Response('', { status: 404 }) : new Response(body)
|
||||
}) as never)
|
||||
}
|
||||
|
||||
function mount() {
|
||||
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as never)
|
||||
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue(OK)
|
||||
return render(<SlotProvider>{(ref) => <><div ref={ref} /><Overview token="T" permissions={undefined} /></>}</SlotProvider>)
|
||||
}
|
||||
|
||||
/* The list downloads among the calls fetch got (the client's own API calls go through it too). */
|
||||
const downloaded = (f: { mock: { calls: unknown[][] } }) => f.mock.calls.map((c) => String(c[0])).filter((u) => u.startsWith('https://lists.example'))
|
||||
|
||||
const tile = (label: string) => screen.getAllByText(label).find((el) => /_k_/.test(el.className))!.parentElement!
|
||||
|
||||
beforeEach(() => forgetListCounts())
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('Overview with Advanced Blocking', () => {
|
||||
it('summarises the app: its switch, each group with its state, its counts and its networks', async () => {
|
||||
serveAdvancedBlocking()
|
||||
fetchLists()
|
||||
mount()
|
||||
const panel = (await screen.findByRole('heading', { name: 'Advanced Blocking', level: 2 })).closest('[data-slot=frame]') as HTMLElement
|
||||
const w = within(panel)
|
||||
expect(await w.findByText('Enabled')).toBeInTheDocument()
|
||||
const rows = await w.findAllByRole('row')
|
||||
const everyone = rows.find((r) => within(r).queryByText('everyone'))!
|
||||
expect(within(everyone).getByText('Blocking')).toBeInTheDocument()
|
||||
const cells = within(everyone).getAllByRole('cell').map((c) => c.textContent)
|
||||
// Group, block lists, allow lists, regex lists, adblock lists, blocked, allowed, regex rules, networks.
|
||||
expect(cells.slice(1)).toEqual(['2', '1', '0', '1', '1', '1', '1', '0.0.0.0/0'])
|
||||
const family = rows.find((r) => within(r).queryByText('family'))!
|
||||
expect(within(family).getByText('Off')).toBeInTheDocument()
|
||||
expect(within(family).getByText('192.168.10.0/24')).toBeInTheDocument()
|
||||
expect(w.getByRole('link', { name: 'Open Advanced Blocking in Apps' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has no panel when the app is not installed', async () => {
|
||||
serveAdvancedBlocking(AB_CONFIG, false)
|
||||
mount()
|
||||
await screen.findByText('5,000')
|
||||
expect(screen.queryByRole('heading', { name: 'Advanced Blocking', level: 2 })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('the tile shows the built-in count at once, then adds the app lists downloaded by the page', async () => {
|
||||
serveAdvancedBlocking()
|
||||
fetchLists()
|
||||
mount()
|
||||
expect(await screen.findByText('5,000')).toBeInTheDocument()
|
||||
await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,006')).toBeInTheDocument())
|
||||
expect(within(tile('Block List Domains')).getByText('1,000 built-in, 6 Advanced Blocking')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('a list the page cannot read (CORS) makes the figure "at least" and is said, not counted as zero', async () => {
|
||||
serveAdvancedBlocking()
|
||||
fetchLists(['https://lists.example/more'])
|
||||
mount()
|
||||
await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,003+')).toBeInTheDocument())
|
||||
expect(within(tile('Block List Domains')).getByText(/1,000 built-in, 3\+ Advanced Blocking \(1 list not counted\)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('without the app the tile is the built-in figure alone', async () => {
|
||||
serveAdvancedBlocking(AB_CONFIG, false)
|
||||
const fetched = fetchLists()
|
||||
mount()
|
||||
expect(await screen.findByText('5,000')).toBeInTheDocument()
|
||||
expect(within(tile('Block List Domains')).queryByText(/Advanced Blocking/)).not.toBeInTheDocument()
|
||||
expect(downloaded(fetched)).toEqual([])
|
||||
})
|
||||
|
||||
it('behind a hub the hub counts the lists', async () => {
|
||||
serveAdvancedBlocking()
|
||||
const fetched = fetchLists()
|
||||
const hubCount = vi.spyOn(appLists, 'getAppListCounts').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { response: { lists: [{ url: 'https://lists.example/hosts', count: 40 }, { url: 'https://lists.example/more', count: null, error: 'timed out' }, { url: 'https://lists.example/easy', count: 2 }], total: 42 } },
|
||||
} as never)
|
||||
localStorage.setItem('dashboardClusterNode', 'a')
|
||||
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as never)
|
||||
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue(OK)
|
||||
render(
|
||||
<HubMode.Provider value>
|
||||
<SlotProvider>{(ref) => <><div ref={ref} /><Overview token="T" permissions={undefined} nodes={[{ name: 'a', type: 'Primary' }]} clusterInitialised /></>}</SlotProvider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,042+')).toBeInTheDocument())
|
||||
expect(hubCount).toHaveBeenCalledWith('T', 'Advanced Blocking', 'a')
|
||||
expect(downloaded(fetched)).toEqual([])
|
||||
expect(within(tile('Block List Domains')).getByText(/1 list not counted/)).toBeInTheDocument()
|
||||
await userEvent.hover(within(tile('Block List Domains')).getByText(/1 list not counted/))
|
||||
})
|
||||
|
||||
it('with "All servers" chosen behind a hub there is no panel and the page downloads no list (the hub counts them)', async () => {
|
||||
serveAdvancedBlocking()
|
||||
vi.spyOn(appLists, 'getAppListCounts').mockResolvedValue({ kind: 'ok', data: { response: { lists: [], total: 0 } } } as never)
|
||||
const fetched = fetchLists()
|
||||
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as never)
|
||||
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue(OK)
|
||||
render(
|
||||
<HubMode.Provider value>
|
||||
<SlotProvider>{(ref) => <><div ref={ref} /><Overview token="T" permissions={undefined} nodes={[{ name: 'a', type: 'Primary' }]} clusterInitialised /></>}</SlotProvider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
await screen.findByText('5,000')
|
||||
expect(screen.queryByRole('heading', { name: 'Advanced Blocking', level: 2 })).not.toBeInTheDocument()
|
||||
expect(downloaded(fetched)).toEqual([])
|
||||
})
|
||||
|
||||
it('shows the Dashboard tiles and one server selector, in the chrome slot', async () => {
|
||||
serveAdvancedBlocking(AB_CONFIG, false)
|
||||
const { container } = mount()
|
||||
await screen.findByText('5,000')
|
||||
expect(container.querySelectorAll('[data-slot=frame-panel]').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByRole('group', { name: 'Server' })).not.toBeInTheDocument()
|
||||
for (const label of ['Total Queries', 'Blocked', 'Block List Domains', 'Your Rules']) expect(tile(label)).toBeInTheDocument()
|
||||
expect(container.querySelector('polyline')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -33,39 +33,12 @@ 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;
|
||||
/* The KPI strip's cells: the Dashboard's tile (Dashboard.module.css), four to a row while they fit. */
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
padding: 0;
|
||||
}
|
||||
/*
|
||||
`--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; }
|
||||
@@ -82,3 +55,8 @@ floating in it, so it keeps its own height instead, top-aligned. The check panel
|
||||
answer region is empty (`:empty`) until then.
|
||||
*/
|
||||
.checkRow:has([aria-live]:not(:empty)) > :first-child { align-self: start; }
|
||||
|
||||
/* The Advanced Blocking panel: a group's name with its state, and the networks that map to it. */
|
||||
.groupCell { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-3); }
|
||||
.groupName { font-weight: 600; }
|
||||
.nets { font-family: var(--font-mono); font-size: var(--t-2); overflow-wrap: break-word; }
|
||||
@@ -33,13 +33,11 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
/** 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. */
|
||||
and `Blocked` is also the ring's word. A tile is label, then a line holding the figure. */
|
||||
function figure(label: string) {
|
||||
const own = screen
|
||||
.getAllByText(label, { selector: 'span' })
|
||||
.filter((el) => el.parentElement!.children[2] === el)
|
||||
const own = screen.getAllByText(label).filter((el) => /_k_/.test(el.className))
|
||||
expect(own).toHaveLength(1)
|
||||
return own[0].parentElement!.firstElementChild!.textContent
|
||||
return own[0].nextElementSibling!.firstElementChild!.textContent
|
||||
}
|
||||
|
||||
async function shows(label: string, value: string) {
|
||||
@@ -55,13 +53,10 @@ describe('Overview against the harness', () => {
|
||||
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(screen.getAllByText(percentage(s.totalBlocked, s.totalQueries)).length).toBeGreaterThan(0)
|
||||
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(),
|
||||
])
|
||||
expect(figure('Your Rules')).toBe(s.blockedZones.toLocaleString())
|
||||
expect(screen.getByText(`blocked · ${s.allowedZones.toLocaleString()} allowed`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('a day of traffic: the bars split Total into Allowed and Blocked, minute by minute', async () => {
|
||||
|
||||
@@ -8,11 +8,12 @@ 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. */
|
||||
/** Your Rules: the blocked count as the tile's figure, the allowed one in its context line. */
|
||||
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]])
|
||||
const label = screen.getAllByText('Your Rules').find((el) => /_k_/.test(el.className))!
|
||||
const line = label.nextElementSibling!
|
||||
const allowed = /^blocked · (.*) allowed$/.exec(line.children[1].textContent ?? '')![1]
|
||||
return [[line.children[0].textContent, 'blocked'], [allowed, 'allowed']]
|
||||
}
|
||||
|
||||
/* Every `data` the charts were handed, by chart: Chart.tsx rebuilds its canvas on a
|
||||
@@ -114,7 +115,7 @@ describe('Overview', () => {
|
||||
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(within(screen.getByText('5,310').parentElement!).getByText('26.09%')).toBeInTheDocument()
|
||||
expect(screen.getByText('74,779')).toBeInTheDocument()
|
||||
expect(rulePairs()).toEqual([['1', 'blocked'], ['1', 'allowed']])
|
||||
})
|
||||
@@ -301,7 +302,7 @@ describe('Overview', () => {
|
||||
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(await screen.findByText('0%')).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()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { KpiTile, StatCard } from '../dashboard/Figures'
|
||||
import { FIGURES, TOTAL, num2 } from '../dashboard/metrics'
|
||||
import dash from '../dashboard/Dashboard.module.css'
|
||||
import {
|
||||
getDashboardStats, RANGES, RANGE_LABEL,
|
||||
type ChartData, type DashboardStats, type Range,
|
||||
@@ -16,6 +20,9 @@ import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||||
import { StaleData } from '../StaleData'
|
||||
import { AddDomainBar } from './AddDomainBar'
|
||||
import { StatusPanel } from './StatusPanel'
|
||||
import { AdvancedBlockingPanel } from './AdvancedBlockingPanel'
|
||||
import { useAdvancedBlocking } from './advanced-blocking-state'
|
||||
import { useAppListCount, useAllServersListCount, type AppCount } from './app-list-count'
|
||||
import { TopTable } from './TopTable'
|
||||
import { RecentBlocked } from './RecentBlocked'
|
||||
import { LAST_HOUR_REFRESH_MS, useLiveView } from '../dashboard/useLive'
|
||||
@@ -71,6 +78,8 @@ export function blockingChart(main: ChartData): ChartData {
|
||||
}
|
||||
}
|
||||
|
||||
const BLOCKED = FIGURES.find((m) => m.k === 'totalBlocked')!
|
||||
|
||||
const BAR_LEGEND = ['Allowed', 'Blocked'] as const
|
||||
|
||||
/*
|
||||
@@ -105,67 +114,21 @@ function Bars({ chart, resetOn }: { chart: ChartData; resetOn: unknown }) {
|
||||
)
|
||||
}
|
||||
|
||||
/* 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(' ')
|
||||
/*
|
||||
The line under "Block List Domains" when the Advanced Blocking app has block lists: how the
|
||||
figure splits, or that the app's part is still being counted or could not be. With lists
|
||||
that could not be counted the figure is "at least" (a trailing +) and the line says how many.
|
||||
*/
|
||||
function AppMeta({ built, count }: { built: number; count: AppCount | null }) {
|
||||
if (count == null) return null
|
||||
if (count.phase === 'loading') return <span className={styles.appMeta}>Counting Advanced Blocking lists…</span>
|
||||
if (count.phase === 'failed') return <span className={styles.appMeta}>Advanced Blocking lists could not be counted</span>
|
||||
const missed = count.lists.filter((l) => l.count == null)
|
||||
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>
|
||||
<span className={styles.appMeta} title={missed.map((l) => `${l.url}: ${l.error ?? 'not counted'}`).join(', ') || undefined}>
|
||||
{num2(built)} built-in, {num2(count.total)}{count.uncounted > 0 ? '+' : ''} Advanced Blocking
|
||||
{count.uncounted > 0 && ` (${count.uncounted} ${count.uncounted === 1 ? 'list' : 'lists'} not counted)`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -183,6 +146,7 @@ function Gap({ failure, loading, announce = true }: { failure: boolean; loading:
|
||||
|
||||
export function Overview({
|
||||
tabs,
|
||||
actions,
|
||||
token,
|
||||
permissions: rolePermissions,
|
||||
nodes = [],
|
||||
@@ -190,6 +154,7 @@ export function Overview({
|
||||
serverDomain,
|
||||
}: {
|
||||
tabs?: ReactNode
|
||||
actions?: ReactNode
|
||||
token: string | null
|
||||
permissions: Permissions
|
||||
nodes?: ClusterNode[]
|
||||
@@ -213,6 +178,12 @@ export function Overview({
|
||||
const allServers =
|
||||
hub && node === AGGREGATE && missing(rolePermissions, 'Blocked.canModify', 'Allowed.canModify') == null
|
||||
const barPermissions = allServers ? rolePermissions : permissions
|
||||
/* The Advanced Blocking app of the server in view: its panel, and its lists' domains in the figure. */
|
||||
const ab = useAdvancedBlocking(token, rolePermissions, hub ? node : '')
|
||||
const everywhere = hub && node === AGGREGATE
|
||||
const single = useAppListCount(token, hub ? node : '', ab.state)
|
||||
const summed = useAllServersListCount(token, everywhere ? nodes.map((n) => n.name) : [])
|
||||
const appCount = everywhere ? summed : single
|
||||
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)
|
||||
@@ -347,21 +318,21 @@ export function Overview({
|
||||
onChange={setNode}
|
||||
/>
|
||||
</InChrome>
|
||||
<HubWriteHint write={write} allServers={allServers} />
|
||||
|
||||
<SectionHeader
|
||||
title="Blocking"
|
||||
subtitle={OVERVIEW_SUBTITLE}
|
||||
tabs={tabs}
|
||||
actions={
|
||||
{/* As the Dashboard's head: the title with its actions, the period in its own row
|
||||
under it, then the tabs. */}
|
||||
<div className={dash.head}>
|
||||
<SectionHeader title="Blocking" subtitle={OVERVIEW_SUBTITLE} actions={actions} />
|
||||
<div className={dash.period}>
|
||||
<Segmented
|
||||
label="Period"
|
||||
options={PERIODS.map((r) => ({ id: r, label: RANGE_LABEL[r] }))}
|
||||
active={range}
|
||||
onChoose={setRange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{tabs}
|
||||
<HubWriteHint write={write} allServers={allServers} />
|
||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||
|
||||
<div className={shared.stack}>
|
||||
@@ -370,28 +341,36 @@ export function Overview({
|
||||
{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>
|
||||
/* The Dashboard's KPI strip: a Frame of tiles, no trends (the chart below has the series). */
|
||||
<Frame>
|
||||
<FramePanel className={styles.tiles}>
|
||||
<StatCard m={TOTAL} s={s} total={s?.totalQueries ?? 0} changes={null} against="" />
|
||||
<StatCard m={BLOCKED} s={s} total={s?.totalQueries ?? 0} changes={null} against="" />
|
||||
<KpiTile
|
||||
label="Block List Domains"
|
||||
icon="logs"
|
||||
colour="var(--ch-block)"
|
||||
figure={s ? `${num2(s.blockListZones + (appCount?.phase === 'done' ? appCount.total : 0))}${appCount?.phase === 'done' && appCount.uncounted > 0 ? '+' : ''}` : '—'}
|
||||
noData={!s}
|
||||
wrap
|
||||
>
|
||||
{s ? <AppMeta built={s.blockListZones} count={appCount} /> : ' '}
|
||||
</KpiTile>
|
||||
<KpiTile
|
||||
label="Your Rules"
|
||||
icon="edit"
|
||||
colour="var(--ch-ok)"
|
||||
figure={s ? num2(s.blockedZones) : '—'}
|
||||
noData={!s}
|
||||
>
|
||||
{s ? <span className={dash.p}>blocked · {num2(s.allowedZones)} allowed</span> : ' '}
|
||||
</KpiTile>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)}
|
||||
|
||||
<div className={`${shared.row2} ${styles.checkRow}`}>
|
||||
<StatusPanel token={token} permissions={barPermissions} onNotice={setNotice} />
|
||||
<StatusPanel token={token} permissions={barPermissions} onNotice={setNotice} servers={nodes.map((n) => n.name)} />
|
||||
<AddDomainBar
|
||||
token={token}
|
||||
permissions={barPermissions}
|
||||
@@ -400,6 +379,8 @@ export function Overview({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdvancedBlockingPanel ab={ab} />
|
||||
|
||||
{/* 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. */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 apps from '../../api/apps'
|
||||
import * as blocking from '../../api/blocking'
|
||||
import * as zonelists from '../../api/zonelists'
|
||||
import { Rules } from './Rules'
|
||||
@@ -366,6 +367,8 @@ describe('Rules', () => {
|
||||
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.' })
|
||||
// No app to list: the Advanced Blocking panel has its own Retry, and this test is about the table's.
|
||||
vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [] } } } as never)
|
||||
draw()
|
||||
expect(await screen.findByText('Could not read the rules.')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
@@ -24,6 +24,7 @@ import { StaleData } from '../StaleData'
|
||||
import { Lists, ImportDomains, type Confirmation } from '../lists/Lists'
|
||||
import { LockedBody, LockedItem } from './Locked'
|
||||
import { AddDomainBar } from './AddDomainBar'
|
||||
import { AdvancedBlockingEntries } from './AdvancedBlockingEntries'
|
||||
import { missing, type Need, type Permissions } from './permissions'
|
||||
import {
|
||||
joinDeleted, modifyNeed, notRestoredNotice, restoreRules, RESTORED_MS, undoMessage, undoNeed, UNDO_MS,
|
||||
@@ -62,6 +63,9 @@ 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.
|
||||
|
||||
Under the table, when the Advanced Blocking app is installed, its own rules by group (blocked and
|
||||
allowed domains, blocked and allowed regex): `AdvancedBlockingEntries.tsx`.
|
||||
|
||||
`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
|
||||
@@ -79,12 +83,14 @@ const LABEL: Record<DomainList, string> = { blocked: 'Blocked', allowed: 'Allowe
|
||||
|
||||
export function Rules({
|
||||
tabs,
|
||||
actions,
|
||||
token,
|
||||
permissions,
|
||||
nodes = [],
|
||||
clusterInitialised = false,
|
||||
}: {
|
||||
tabs?: ReactNode
|
||||
actions?: ReactNode
|
||||
token: string | null
|
||||
permissions: Permissions
|
||||
nodes?: { name: string; type: string }[]
|
||||
@@ -379,7 +385,7 @@ export function Rules({
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Blocking" subtitle={RULES_SUBTITLE} tabs={tabs} />
|
||||
<SectionHeader title="Blocking" subtitle={RULES_SUBTITLE} tabs={tabs} actions={actions} />
|
||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||
|
||||
<div className={shared.stack}>
|
||||
@@ -586,6 +592,8 @@ export function Rules({
|
||||
</Menu>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<AdvancedBlockingEntries kind="rules" token={token} permissions={permissions} node={hubNode} onNotice={setNotice} />
|
||||
</div>
|
||||
|
||||
{/* The action's promise goes back to `Confirm`, which keeps the dialog busy
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as settings from '../../api/settings'
|
||||
import type { DnsSettings } from '../../api/settings'
|
||||
import { StatusPanel } from './StatusPanel'
|
||||
import { blockingMark } from './blocking-state'
|
||||
import { AB_CONFIG, serveAdvancedBlocking } from './advanced-blocking.fixture'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const serve = (s: Partial<DnsSettings>) => vi.spyOn(settings, 'getSettings').mockResolvedValue(s as DnsSettings)
|
||||
const mount = (permissions: Record<string, unknown> | undefined = undefined) =>
|
||||
render(<StatusPanel token="T" permissions={permissions as never} onNotice={() => {}} />)
|
||||
|
||||
describe('StatusPanel with the Advanced Blocking app', () => {
|
||||
it('built-in off but the app blocking: says so, Active, and still offers Enable', async () => {
|
||||
serve({ enableBlocking: false })
|
||||
serveAdvancedBlocking()
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking by Advanced Blocking')).toBeInTheDocument()
|
||||
expect(screen.getByText('Active').className).toMatch(/ok/)
|
||||
expect(screen.getByText(/The built-in blocking is off/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Nothing is being blocked.')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Blocking options' })).toHaveTextContent('Enable')
|
||||
})
|
||||
|
||||
it('built-in paused and the app blocking: the app wins, and the pause is mentioned', async () => {
|
||||
serve({ enableBlocking: false, temporaryDisableBlockingTill: '2099-01-01T21:15:00Z' })
|
||||
serveAdvancedBlocking()
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking by Advanced Blocking')).toBeInTheDocument()
|
||||
expect(screen.getByText(/The built-in blocking is paused \(until /)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('the app installed but its switch off: as before, blocking is disabled', async () => {
|
||||
serve({ enableBlocking: false })
|
||||
serveAdvancedBlocking({ ...AB_CONFIG, enableBlocking: false })
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking is disabled')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nothing is being blocked.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('the app installed with every group off: as before', async () => {
|
||||
serve({ enableBlocking: false })
|
||||
serveAdvancedBlocking({ ...AB_CONFIG, groups: [{ name: 'g', enableBlocking: false }] })
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking is disabled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('the app not installed: as before', async () => {
|
||||
serve({ enableBlocking: false })
|
||||
serveAdvancedBlocking(AB_CONFIG, false)
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking is disabled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('paused with the app not blocking keeps the paused wording', async () => {
|
||||
serve({ enableBlocking: false, temporaryDisableBlockingTill: '2099-01-01T21:15:00Z' })
|
||||
serveAdvancedBlocking(AB_CONFIG, false)
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking is paused')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('built-in on: the app is not even asked', async () => {
|
||||
serve({ enableBlocking: true })
|
||||
const { get } = serveAdvancedBlocking()
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking is enabled')).toBeInTheDocument()
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('without Apps.canView the app is not asked and the block says what the settings say', async () => {
|
||||
serve({ enableBlocking: false })
|
||||
const { get } = serveAdvancedBlocking()
|
||||
mount({ Apps: { canView: false, canModify: false, canDelete: false } })
|
||||
expect(await screen.findByText('Blocking is disabled')).toBeInTheDocument()
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the sidebar mark', () => {
|
||||
it('has none while the app blocks in the built-in blocking place', () => {
|
||||
expect(blockingMark({ kind: 'app' }, undefined)).toBeNull()
|
||||
expect(blockingMark({ kind: 'app', till: '2099-01-01T00:00:00Z' }, undefined)).toBeNull()
|
||||
expect(blockingMark({ kind: 'off' }, undefined)?.text).toBe('Off')
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,9 @@ import type { Notice } from '../../lib/notice'
|
||||
import { Locked } from './Locked'
|
||||
import { missing, switchNeed, type Permissions } from './permissions'
|
||||
import { AllServersActions } from './AllServersActions'
|
||||
import { untilText, useBlockingState } from './blocking-state'
|
||||
import { appServersLine, summariseServers, untilText, useAllServersBlocking, useBlockingState } from './blocking-state'
|
||||
import { AGGREGATE } from '../../ui/ClusterNodeSelect'
|
||||
import { APP_NAME } from './advanced-blocking'
|
||||
import shared from './Blocking.module.css'
|
||||
import styles from './Overview.module.css'
|
||||
|
||||
@@ -32,6 +34,9 @@ Three things the block owes the rules rather than the menu:
|
||||
- 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).
|
||||
- With the built-in blocking off and the Advanced Blocking app blocking in its place
|
||||
(`kind: 'app'`), the block says so, with the `Active` pill, and still offers the
|
||||
built-in blocking's own `Enable`.
|
||||
- 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.
|
||||
*/
|
||||
@@ -40,7 +45,10 @@ export function StatusPanel({
|
||||
token,
|
||||
permissions,
|
||||
onNotice,
|
||||
servers = [],
|
||||
}: {
|
||||
/** Behind a hub: every server, for the "All servers" wording. */
|
||||
servers?: readonly string[]
|
||||
token: string | null
|
||||
permissions: Permissions
|
||||
onNotice: (n: Notice) => void
|
||||
@@ -56,6 +64,11 @@ export function StatusPanel({
|
||||
servers" chosen it opens the flow, and the list actions for several servers sit beside it. */
|
||||
const hub = useContext(HubMode)
|
||||
const all = useContext(HubAll)
|
||||
/* "All servers": each server's own blocking, the app's included (`blocking-state.ts`). */
|
||||
const everywhere = hub && node === AGGREGATE
|
||||
const rows = useAllServersBlocking(token, permissions, servers, everywhere && viewNeed == null)
|
||||
const summary = everywhere ? summariseServers(rows) : null
|
||||
const appLine = everywhere ? appServersLine(rows) : null
|
||||
|
||||
if (viewNeed != null) return <Locked title="Blocking" need={viewNeed} />
|
||||
|
||||
@@ -78,7 +91,10 @@ export function StatusPanel({
|
||||
|
||||
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
|
||||
const tone =
|
||||
summary != null
|
||||
? summary.active ? 'on' : 'off'
|
||||
: state?.kind === 'paused' && modifyNeed != null ? 'off' : state?.kind === 'app' ? 'on' : state?.kind
|
||||
|
||||
/* Centred: the panel shares its row with the taller add bar and stretches to it. */
|
||||
return (
|
||||
@@ -90,20 +106,24 @@ export function StatusPanel({
|
||||
) : (
|
||||
<div className={styles.status}>
|
||||
<span className={`${styles.mark} ${styles[tone ?? 'off']}`} data-tone={tone}>
|
||||
<Icon name={state.kind === 'on' ? 'blocked' : 'power'} size={18} />
|
||||
<Icon name={summary?.active ?? (state.kind === 'on' || state.kind === 'app') ? 'blocked' : 'power'} size={18} />
|
||||
</span>
|
||||
<div className={styles.statusText}>
|
||||
<div className={styles.statusTitle}>
|
||||
{state.kind === 'on'
|
||||
{summary != null
|
||||
? summary.title
|
||||
: state.kind === 'on'
|
||||
? 'Blocking is enabled'
|
||||
: state.kind === 'paused'
|
||||
? 'Blocking is paused'
|
||||
: 'Blocking is disabled'}
|
||||
: state.kind === 'app'
|
||||
? `Blocking by ${APP_NAME}`
|
||||
: 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' &&
|
||||
{(summary?.active ?? (state.kind === 'on' || state.kind === 'app')) && <Tag tone="ok">Active</Tag>}
|
||||
{summary == null && state.kind === 'paused' &&
|
||||
(modifyNeed == null ? (
|
||||
<Tag tone="acc">{untilText(state.till)}</Tag>
|
||||
) : (
|
||||
@@ -111,11 +131,17 @@ export function StatusPanel({
|
||||
))}
|
||||
</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.'}
|
||||
{summary != null
|
||||
? summary.sub
|
||||
: state.kind === 'app'
|
||||
? state.till != null
|
||||
? `The built-in blocking is paused (${untilText(state.till).toLowerCase()}); queries are blocked by the app's groups.`
|
||||
: "The built-in blocking is off; queries are blocked by the app's groups."
|
||||
: 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 ? (
|
||||
@@ -125,6 +151,7 @@ export function StatusPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state != null && appLine != null && <p className="m-0 text-xs text-muted-foreground">{appLine}</p>}
|
||||
{state != null && all !== null && modifyNeed == null && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AllServersActions token={token} />
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { getAppConfig, listApps, setAppConfig, type InstalledApp } from '../../api/apps'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||||
import { findApp, parseConfig, serialize, type Json } from './advanced-blocking'
|
||||
import { missing, type Permissions } from './permissions'
|
||||
|
||||
/*
|
||||
Whether this server has the Advanced Blocking app and what its config says. OURS. Four
|
||||
places read it (the sidebar's mark, the status block, the app's panel and the Lists and
|
||||
Rules tabs), so it lives here once.
|
||||
|
||||
It reads `apps/list` and then `apps/config/get`, on the node the console is connected to
|
||||
(behind a hub: the server it names; the hub refuses a config read without one, so with
|
||||
"All servers" chosen nothing is read and the state is `none`). Without `Apps.canView`
|
||||
nothing is read either: the server would refuse, and the answer is `none`, as for an app
|
||||
that is not installed. Reads are numbered, so an older answer cannot overwrite a newer one.
|
||||
|
||||
A write made from this console (`saveAppConfig`) tells the other readers on the page
|
||||
(`appConfigChanged`), as the settings do (`lib/settings-signal.ts`).
|
||||
*/
|
||||
|
||||
export type AppState =
|
||||
| { kind: 'loading' }
|
||||
/** Not installed, not readable, or no single server to ask. */
|
||||
| { kind: 'none' }
|
||||
| { kind: 'failed'; message: string }
|
||||
| { kind: 'ready'; app: InstalledApp; root: Json; text: string | null }
|
||||
|
||||
type Listener = () => void
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
export function onAppConfigChanged(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function appConfigChanged(): void {
|
||||
for (const l of [...listeners]) l()
|
||||
}
|
||||
|
||||
/** One read: the installed app and its config, or why there is none. */
|
||||
export async function readAppState(token: string | null, node: string): Promise<AppState> {
|
||||
const listed = await listApps(token, node)
|
||||
if (listed.kind !== 'ok') return { kind: 'failed', message: noticeFromFailure(listed).text }
|
||||
const app = findApp(listed.data.response?.apps)
|
||||
if (app == null) return { kind: 'none' }
|
||||
const got = await getAppConfig(token, app.name, node)
|
||||
if (got.kind !== 'ok') return { kind: 'failed', message: noticeFromFailure(got).text }
|
||||
const text = got.data.response?.config ?? null
|
||||
const parsed = parseConfig(text)
|
||||
if (!parsed.ok) return { kind: 'failed', message: 'The Advanced Blocking config is not valid JSON.' }
|
||||
return { kind: 'ready', app, root: parsed.root, text }
|
||||
}
|
||||
|
||||
export function useAdvancedBlocking(
|
||||
token: string | null,
|
||||
permissions: Permissions,
|
||||
/** Behind a hub: the server to ask. Empty elsewhere. */
|
||||
node = '',
|
||||
/** False reads nothing (the app is not wanted, e.g. the built-in blocking is already on). */
|
||||
enabled = true,
|
||||
): { state: AppState; reload: () => void; retry: () => void } {
|
||||
const hub = useContext(HubMode)
|
||||
const viewNeed = missing(permissions, 'Apps.canView')
|
||||
const single = !hub || (node !== '' && node !== 'cluster')
|
||||
const active = enabled && viewNeed == null && single
|
||||
const [state, setState] = useState<AppState>({ kind: 'loading' })
|
||||
const asked = useRef(0)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!active) return
|
||||
const mine = ++asked.current
|
||||
void readAppState(token, node).then((s) => {
|
||||
if (mine === asked.current) setState(s)
|
||||
})
|
||||
}, [active, token, node])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
asked.current++
|
||||
return
|
||||
}
|
||||
setState({ kind: 'loading' })
|
||||
reload()
|
||||
}, [active, reload])
|
||||
|
||||
useEffect(() => (active ? onAppConfigChanged(reload) : undefined), [active, reload])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setState({ kind: 'loading' })
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
return { state: active ? state : { kind: 'none' }, reload, retry }
|
||||
}
|
||||
|
||||
/*
|
||||
Writes the whole config back and tells the page. The caller has changed only the field it
|
||||
meant to; `serialize` keeps every other key. Resolves to a notice when it failed.
|
||||
*/
|
||||
export async function saveAppConfig(
|
||||
token: string | null,
|
||||
name: string,
|
||||
root: Json,
|
||||
node = '',
|
||||
): Promise<{ ok: true } | { ok: false; notice: Notice }> {
|
||||
const r = await setAppConfig(token, name, serialize(root), node)
|
||||
if (r.kind !== 'ok') return { ok: false, notice: noticeFromFailure(r) }
|
||||
appConfigChanged()
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/*
|
||||
One change to the app's config, read-modify-write. The config is read again just before
|
||||
(so an edit made elsewhere since the page loaded is not overwritten with an old copy),
|
||||
`change` returns the new root or `null` when what it meant to change is gone (a group that
|
||||
was renamed meanwhile), and the whole text goes back with `apps/config/set`.
|
||||
*/
|
||||
export async function changeAppConfig(
|
||||
token: string | null,
|
||||
name: string,
|
||||
node: string,
|
||||
change: (root: Json) => Json | null,
|
||||
): Promise<{ ok: true } | { ok: false; notice: Notice }> {
|
||||
const got = await getAppConfig(token, name, node)
|
||||
if (got.kind !== 'ok') return { ok: false, notice: noticeFromFailure(got) }
|
||||
const parsed = parseConfig(got.data.response?.config ?? null)
|
||||
if (!parsed.ok) return { ok: false, notice: { type: 'danger', title: 'Error!', text: 'The Advanced Blocking config is not valid JSON.' } }
|
||||
const next = change(parsed.root)
|
||||
if (next == null) return { ok: false, notice: { type: 'warning', title: 'Not saved', text: 'That group no longer exists in the Advanced Blocking config.' } }
|
||||
return saveAppConfig(token, name, next, node)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { vi } from 'vitest'
|
||||
import * as apps from '../../api/apps'
|
||||
import type { Json } from './advanced-blocking'
|
||||
|
||||
/* A server that has the Advanced Blocking app, with this config. Shared by the tests of the Blocking section. */
|
||||
export const AB_CONFIG: Json = {
|
||||
enableBlocking: true,
|
||||
blockingAnswerTtl: 30,
|
||||
futureKey: { keep: ['me'] },
|
||||
localEndPointGroupMap: { '127.0.0.1': 'bypass' },
|
||||
networkGroupMap: { '192.168.10.0/24': 'family', '0.0.0.0/0': 'everyone' },
|
||||
groups: [
|
||||
{
|
||||
name: 'everyone',
|
||||
enableBlocking: true,
|
||||
blocked: ['ads.example.com'],
|
||||
allowed: ['good.example.org'],
|
||||
blockListUrls: ['https://lists.example/hosts', 'https://lists.example/more'],
|
||||
allowListUrls: ['https://lists.example/allow'],
|
||||
blockedRegex: ['^ads\\.'],
|
||||
adblockListUrls: ['https://lists.example/easy'],
|
||||
},
|
||||
{ name: 'family', enableBlocking: false, blocked: ['games.example.com'] },
|
||||
],
|
||||
}
|
||||
|
||||
export function serveAdvancedBlocking(config: Json | null = AB_CONFIG, installed = true) {
|
||||
const list = installed
|
||||
? [{ name: 'Advanced Blocking', description: '', version: '10', dnsApps: [{ classPath: 'AdvancedBlocking.App' }] }]
|
||||
: [{ name: 'Split Horizon', description: '', version: '1', dnsApps: [{ classPath: 'SplitHorizon.App' }] }]
|
||||
vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: list } } } as never)
|
||||
const get = vi.spyOn(apps, 'getAppConfig').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { response: { config: config == null ? null : JSON.stringify(config) } },
|
||||
} as never)
|
||||
const set = vi.spyOn(apps, 'setAppConfig').mockResolvedValue({ kind: 'ok', data: { status: 'ok' } } as never)
|
||||
return { get, set }
|
||||
}
|
||||
|
||||
/** The config the last `setAppConfig` was given, parsed. */
|
||||
export function written(set: ReturnType<typeof serveAdvancedBlocking>['set']): Json {
|
||||
return JSON.parse(set.mock.calls.at(-1)![2] as string) as Json
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { InstalledApp } from '../../api/apps'
|
||||
import {
|
||||
addEntry, blocksNow, countableUrls, editEntry, findApp, parseConfig, removeEntry, serialize, summarize, type Json,
|
||||
} from './advanced-blocking'
|
||||
|
||||
const CONFIG: Json = {
|
||||
enableBlocking: true,
|
||||
blockingAnswerTtl: 30,
|
||||
futureKey: { keep: ['me'] },
|
||||
localEndPointGroupMap: { '127.0.0.1': 'bypass' },
|
||||
networkGroupMap: { '10.0.0.0/8': 'family', '0.0.0.0/0': 'everyone' },
|
||||
groups: [
|
||||
{
|
||||
name: 'everyone',
|
||||
enableBlocking: true,
|
||||
blockAsNxDomain: true,
|
||||
blocked: ['a.example'],
|
||||
blockListUrls: ['https://x.example/hosts', { url: 'https://x.example/obj', note: 'kept' }],
|
||||
adblockListUrls: ['https://x.example/easy'],
|
||||
regexBlockListUrls: ['https://x.example/re'],
|
||||
blockedRegex: ['^ads'],
|
||||
},
|
||||
{ name: 'family', enableBlocking: false, blockListUrls: ['https://x.example/hosts'] },
|
||||
],
|
||||
}
|
||||
|
||||
const app = (name: string, classPath = 'Other.App'): InstalledApp => ({
|
||||
name, description: null, version: '1', dnsApps: [{ classPath } as InstalledApp['dnsApps'][number]],
|
||||
})
|
||||
|
||||
describe('findApp', () => {
|
||||
it('finds the app by its name or by its class', () => {
|
||||
expect(findApp([app('Split Horizon'), app('Advanced Blocking')])?.name).toBe('Advanced Blocking')
|
||||
expect(findApp([app('Renamed', 'AdvancedBlocking.App')])?.name).toBe('Renamed')
|
||||
expect(findApp([app('Split Horizon')])).toBeUndefined()
|
||||
expect(findApp(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseConfig', () => {
|
||||
it('reads an object, treats an emptied config as empty, and refuses anything else', () => {
|
||||
expect(parseConfig('{"enableBlocking":true}')).toEqual({ ok: true, root: { enableBlocking: true } })
|
||||
expect(parseConfig(null)).toEqual({ ok: true, root: {} })
|
||||
expect(parseConfig(' ')).toEqual({ ok: true, root: {} })
|
||||
expect(parseConfig('[1]')).toEqual({ ok: false })
|
||||
expect(parseConfig('{nope')).toEqual({ ok: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('blocksNow', () => {
|
||||
it('needs the app on and at least one group on', () => {
|
||||
expect(blocksNow(CONFIG)).toBe(true)
|
||||
expect(blocksNow({ ...CONFIG, enableBlocking: false })).toBe(false)
|
||||
expect(blocksNow({ ...CONFIG, groups: [{ name: 'g', enableBlocking: false }] })).toBe(false)
|
||||
expect(blocksNow({ enableBlocking: true })).toBe(false)
|
||||
expect(blocksNow({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarize', () => {
|
||||
it('counts what each group holds and names the networks that map to it', () => {
|
||||
const s = summarize(CONFIG)
|
||||
expect(s.enabled).toBe(true)
|
||||
expect(s.blocking).toBe(true)
|
||||
expect(s.groups.map((g) => [g.name, g.enabled])).toEqual([['everyone', true], ['family', false]])
|
||||
const g = s.groups[0]
|
||||
expect(g.counts.blockListUrls).toBe(2)
|
||||
expect(g.counts.adblockListUrls).toBe(1)
|
||||
expect(g.counts.regexBlockListUrls).toBe(1)
|
||||
expect(g.counts.allowListUrls).toBe(0)
|
||||
expect(g.counts.blocked).toBe(1)
|
||||
expect(g.counts.blockedRegex).toBe(1)
|
||||
expect(g.networks).toEqual(['0.0.0.0/0'])
|
||||
expect(s.groups[1].networks).toEqual(['10.0.0.0/8'])
|
||||
})
|
||||
|
||||
it('tells a local end point from a network', () => {
|
||||
const s = summarize({ ...CONFIG, groups: [{ name: 'bypass', enableBlocking: true }] })
|
||||
expect(s.groups[0].endpoints).toEqual(['127.0.0.1'])
|
||||
expect(s.groups[0].networks).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countableUrls', () => {
|
||||
it('lists block and adblock URLs once, strings and objects, http(s) only', () => {
|
||||
expect(countableUrls({ groups: [...(CONFIG.groups as unknown[]), { name: 'x', blockListUrls: ['file:///l'] }] })).toEqual([
|
||||
'https://x.example/hosts', 'https://x.example/obj', 'https://x.example/easy',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('changing a group', () => {
|
||||
it('adds in the form the list already has, and touches nothing else', () => {
|
||||
const next = addEntry(CONFIG, 'everyone', 'blockListUrls', 'https://x.example/new')
|
||||
const list = (next.groups as Json[])[0].blockListUrls
|
||||
expect(list).toEqual(['https://x.example/hosts', { url: 'https://x.example/obj', note: 'kept' }, { url: 'https://x.example/new' }])
|
||||
expect(next.futureKey).toEqual({ keep: ['me'] })
|
||||
expect((next.groups as Json[])[1]).toEqual((CONFIG.groups as Json[])[1])
|
||||
expect(Object.keys(next)).toEqual(Object.keys(CONFIG))
|
||||
expect(Object.keys((next.groups as Json[])[0])).toEqual(Object.keys((CONFIG.groups as Json[])[0]))
|
||||
// The original is not changed.
|
||||
expect((CONFIG.groups as Json[])[0].blockListUrls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('adds a plain string to a list of strings, and starts a list the group did not have at its end', () => {
|
||||
const next = addEntry(CONFIG, 'family', 'allowedRegex', '^x$')
|
||||
expect((next.groups as Json[])[1]).toEqual({ name: 'family', enableBlocking: false, blockListUrls: ['https://x.example/hosts'], allowedRegex: ['^x$'] })
|
||||
expect(Object.keys((next.groups as Json[])[1]).at(-1)).toBe('allowedRegex')
|
||||
expect(((addEntry(CONFIG, 'family', 'blockListUrls', 'https://y.example') as Json).groups as Json[])[1].blockListUrls).toEqual(['https://x.example/hosts', 'https://y.example'])
|
||||
})
|
||||
|
||||
it('removes one entry and edits one keeping the other keys of an object entry', () => {
|
||||
const removed = removeEntry(CONFIG, 'everyone', 'blockListUrls', 0)
|
||||
expect((removed.groups as Json[])[0].blockListUrls).toEqual([{ url: 'https://x.example/obj', note: 'kept' }])
|
||||
const edited = editEntry(CONFIG, 'everyone', 'blockListUrls', 1, 'https://x.example/moved')
|
||||
expect((edited.groups as Json[])[0].blockListUrls).toEqual(['https://x.example/hosts', { url: 'https://x.example/moved', note: 'kept' }])
|
||||
})
|
||||
|
||||
it('leaves the config alone for a group that is not there', () => {
|
||||
expect(addEntry(CONFIG, 'gone', 'blocked', 'a.example')).toBe(CONFIG)
|
||||
})
|
||||
|
||||
it('writes two-space JSON that reads back the same', () => {
|
||||
expect(JSON.parse(serialize(CONFIG))).toEqual(CONFIG)
|
||||
expect(serialize({ a: 1 })).toBe('{\n "a": 1\n}')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { InstalledApp } from '../../api/apps'
|
||||
|
||||
/*
|
||||
The Advanced Blocking DNS app, as the Blocking section reads and writes it. OURS: the
|
||||
section only knew the built-in blocking, and a server that blocks with this app (built-in
|
||||
switched off) looked unprotected. The app's config is a JSON text that
|
||||
`apps/config/get` returns and `apps/config/set` takes whole, so this file is the
|
||||
pure part: finding the app, reading the config, summarising it, and changing ONE field of
|
||||
ONE group while every other key stays where it was.
|
||||
|
||||
The config (AdvancedBlocking.App, `BlockingApp` config):
|
||||
|
||||
{ enableBlocking, blockingAnswerTtl, blockListUrlUpdateIntervalHours,
|
||||
localEndPointGroupMap: { "127.0.0.1": "bypass" },
|
||||
networkGroupMap: { "0.0.0.0/0": "everyone" },
|
||||
groups: [ { name, enableBlocking, allowTxtBlockingReport, blockAsNxDomain,
|
||||
blockingAddresses, allowed, blocked, allowListUrls, blockListUrls,
|
||||
allowedRegex, blockedRegex, regexAllowListUrls, regexBlockListUrls,
|
||||
adblockListUrls } ] }
|
||||
|
||||
Any field may be missing, and a list URL may be a plain string or (newer versions) an
|
||||
object with a `url`. What is read in one form is written back in that form, and keys this
|
||||
file does not know are never touched.
|
||||
*/
|
||||
|
||||
export const APP_NAME = 'Advanced Blocking'
|
||||
const CLASS_PATH = 'AdvancedBlocking.App'
|
||||
|
||||
export type Json = Record<string, unknown>
|
||||
|
||||
/** The five fields of a group that hold list URLs, with the words the console uses for them. */
|
||||
export const URL_FIELDS = [
|
||||
{ field: 'blockListUrls', label: 'Block list', kind: 'block' },
|
||||
{ field: 'allowListUrls', label: 'Allow list', kind: 'allow' },
|
||||
{ field: 'regexBlockListUrls', label: 'Regex block list', kind: 'block' },
|
||||
{ field: 'regexAllowListUrls', label: 'Regex allow list', kind: 'allow' },
|
||||
{ field: 'adblockListUrls', label: 'Adblock list', kind: 'block' },
|
||||
] as const
|
||||
|
||||
/** The four fields of a group that hold rules typed in by hand. */
|
||||
export const RULE_FIELDS = [
|
||||
{ field: 'blocked', label: 'Blocked domain', kind: 'block', regex: false },
|
||||
{ field: 'allowed', label: 'Allowed domain', kind: 'allow', regex: false },
|
||||
{ field: 'blockedRegex', label: 'Blocked regex', kind: 'block', regex: true },
|
||||
{ field: 'allowedRegex', label: 'Allowed regex', kind: 'allow', regex: true },
|
||||
] as const
|
||||
|
||||
export type UrlField = (typeof URL_FIELDS)[number]['field']
|
||||
export type RuleField = (typeof RULE_FIELDS)[number]['field']
|
||||
export type GroupField = UrlField | RuleField
|
||||
|
||||
const isObject = (v: unknown): v is Json => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/** The installed app, if this server has it (by name, or by the class it carries). */
|
||||
export function findApp(apps: readonly InstalledApp[] | undefined): InstalledApp | undefined {
|
||||
return (apps ?? []).find(
|
||||
(a) => a.name === APP_NAME || (a.dnsApps ?? []).some((d) => d.classPath === CLASS_PATH),
|
||||
)
|
||||
}
|
||||
|
||||
export type Parsed = { ok: true; root: Json } | { ok: false }
|
||||
|
||||
/** `config: null` (an emptied config) is a config with nothing in it; text that is not an object is unreadable. */
|
||||
export function parseConfig(text: string | null | undefined): Parsed {
|
||||
if (text == null || text.trim() === '') return { ok: true, root: {} }
|
||||
try {
|
||||
const value: unknown = JSON.parse(text)
|
||||
return isObject(value) ? { ok: true, root: value } : { ok: false }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function serialize(root: Json): string {
|
||||
return JSON.stringify(root, null, 2)
|
||||
}
|
||||
|
||||
export function groupsOf(root: Json): Json[] {
|
||||
return Array.isArray(root.groups) ? root.groups.filter(isObject) : []
|
||||
}
|
||||
|
||||
export const groupName = (g: Json): string => (typeof g.name === 'string' ? g.name : '')
|
||||
|
||||
/** A list entry's URL: the string itself, or the `url` of an object. */
|
||||
export function entryText(entry: unknown): string {
|
||||
if (typeof entry === 'string') return entry
|
||||
if (isObject(entry) && typeof entry.url === 'string') return entry.url
|
||||
return ''
|
||||
}
|
||||
|
||||
export function entriesOf(group: Json, field: GroupField): unknown[] {
|
||||
const v = group[field]
|
||||
return Array.isArray(v) ? v : []
|
||||
}
|
||||
|
||||
/** Whether the app blocks anything: its own switch is on and at least one group's is. */
|
||||
export function blocksNow(root: Json): boolean {
|
||||
return root.enableBlocking === true && groupsOf(root).some((g) => g.enableBlocking === true)
|
||||
}
|
||||
|
||||
export interface GroupSummary {
|
||||
name: string
|
||||
enabled: boolean
|
||||
/** Entries per field, for every field of URL_FIELDS and RULE_FIELDS. */
|
||||
counts: Record<GroupField, number>
|
||||
/** The networks (and local end points) that map to this group. */
|
||||
networks: string[]
|
||||
endpoints: string[]
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
enabled: boolean
|
||||
blocking: boolean
|
||||
groups: GroupSummary[]
|
||||
}
|
||||
|
||||
/** The keys of a map whose value is `name`. */
|
||||
function mapped(map: unknown, name: string): string[] {
|
||||
if (!isObject(map)) return []
|
||||
return Object.entries(map)
|
||||
.filter(([, v]) => v === name)
|
||||
.map(([k]) => k)
|
||||
}
|
||||
|
||||
export function summarize(root: Json): Summary {
|
||||
const groups = groupsOf(root).map((g): GroupSummary => {
|
||||
const name = groupName(g)
|
||||
const counts = {} as Record<GroupField, number>
|
||||
for (const { field } of URL_FIELDS) counts[field] = entriesOf(g, field).length
|
||||
for (const { field } of RULE_FIELDS) counts[field] = entriesOf(g, field).length
|
||||
return {
|
||||
name,
|
||||
enabled: g.enableBlocking === true,
|
||||
counts,
|
||||
networks: mapped(root.networkGroupMap, name),
|
||||
endpoints: mapped(root.localEndPointGroupMap, name),
|
||||
}
|
||||
})
|
||||
return { enabled: root.enableBlocking === true, blocking: blocksNow(root), groups }
|
||||
}
|
||||
|
||||
/*
|
||||
The block list URLs the app downloads that count towards "Block List Domains": the plain
|
||||
block lists, the regex ones are patterns and not domains and are left out, the adblock
|
||||
ones are domains. Each once, in the order they appear.
|
||||
*/
|
||||
export function countableUrls(root: Json): string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const g of groupsOf(root)) {
|
||||
for (const field of ['blockListUrls', 'adblockListUrls'] as const) {
|
||||
for (const e of entriesOf(g, field)) {
|
||||
const url = entryText(e).trim()
|
||||
if (/^https?:\/\//i.test(url)) seen.add(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
/*
|
||||
Changing a group. Every function returns a NEW root: the groups array and the one group
|
||||
are copied, every other key keeps its value and its place. A field the group did not have
|
||||
is added at its end; one it had is replaced where it stands.
|
||||
*/
|
||||
export function withGroupField(root: Json, name: string, field: GroupField, values: unknown[]): Json {
|
||||
const groups = Array.isArray(root.groups) ? root.groups : []
|
||||
const at = groups.findIndex((g) => isObject(g) && groupName(g) === name)
|
||||
if (at < 0) return root
|
||||
const next = groups.map((g, i) => (i === at ? { ...(g as Json), [field]: values } : g))
|
||||
return { ...root, groups: next }
|
||||
}
|
||||
|
||||
/** A new entry in the form the list already has: an object with a `url` when its entries are objects. */
|
||||
export function newEntry(existing: readonly unknown[], text: string): unknown {
|
||||
return existing.some(isObject) ? { url: text } : text
|
||||
}
|
||||
|
||||
/** The entry with another text, keeping every other key of an object entry. */
|
||||
export function replaceText(entry: unknown, text: string): unknown {
|
||||
return isObject(entry) ? { ...entry, url: text } : text
|
||||
}
|
||||
|
||||
export function addEntry(root: Json, name: string, field: GroupField, text: string): Json {
|
||||
const group = groupsOf(root).find((g) => groupName(g) === name)
|
||||
if (group == null) return root
|
||||
const list = entriesOf(group, field)
|
||||
return withGroupField(root, name, field, [...list, newEntry(list, text)])
|
||||
}
|
||||
|
||||
export function removeEntry(root: Json, name: string, field: GroupField, index: number): Json {
|
||||
const group = groupsOf(root).find((g) => groupName(g) === name)
|
||||
if (group == null) return root
|
||||
return withGroupField(root, name, field, entriesOf(group, field).filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
export function editEntry(root: Json, name: string, field: GroupField, index: number, text: string): Json {
|
||||
const group = groupsOf(root).find((g) => groupName(g) === name)
|
||||
if (group == null) return root
|
||||
return withGroupField(root, name, field, entriesOf(group, field).map((e, i) => (i === index ? replaceText(e, text) : e)))
|
||||
}
|
||||
|
||||
export const isUrl = (text: string): boolean => /^(https?|file):\/\/\S+$/i.test(text)
|
||||
@@ -0,0 +1,28 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BROWSER_MAX_BYTES, countInBrowser, forgetListCounts } from './app-list-count'
|
||||
|
||||
beforeEach(() => forgetListCounts())
|
||||
|
||||
describe('countInBrowser', () => {
|
||||
it('counts a list and remembers it for the session', async () => {
|
||||
const fetcher = vi.fn(async () => new Response('0.0.0.0 a.example\n0.0.0.0 b.example\n'))
|
||||
expect(await countInBrowser('https://l.example/a', fetcher as never)).toEqual({ url: 'https://l.example/a', count: 2 })
|
||||
await countInBrowser('https://l.example/a', fetcher as never)
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports a list the browser may not read as not counted, never as zero', async () => {
|
||||
const blocked = vi.fn(async () => {
|
||||
throw new TypeError('Failed to fetch')
|
||||
})
|
||||
const r = await countInBrowser('https://l.example/cors', blocked as never)
|
||||
expect(r.count).toBeNull()
|
||||
expect(r.error).toMatch(/CORS/)
|
||||
})
|
||||
|
||||
it('reports an error status and an oversize list', async () => {
|
||||
expect((await countInBrowser('https://l.example/404', (async () => new Response('', { status: 404 })) as never)).error).toBe('status 404')
|
||||
const big = (async () => new Response('a.example', { headers: { 'content-length': String(BROWSER_MAX_BYTES + 1) } })) as never
|
||||
expect((await countInBrowser('https://l.example/big', big)).error).toBe('too large')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { getAppListCounts, type ListCount } from '../../api/app-lists'
|
||||
import { countDomains } from '../../lib/list-count'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { APP_NAME, countableUrls } from './advanced-blocking'
|
||||
import type { AppState } from './advanced-blocking-state'
|
||||
|
||||
/*
|
||||
The domains in the Advanced Blocking app's block lists, for the "Block List Domains"
|
||||
figure. OURS. The app has no API for this count, so the lists are downloaded and counted:
|
||||
|
||||
- behind a hub, by the hub (`hub/blocking/app-lists/count`), which caches them for hours;
|
||||
- without one, by this page with \`fetch\`. A list whose server does not allow it (CORS), is
|
||||
too big or does not answer is reported as not counted, never as zero. The answers are kept
|
||||
in memory for the session.
|
||||
|
||||
It never holds the page up: the figure shows the built-in count at once and this arrives
|
||||
later. Lists shared by several groups are counted once; a domain in two lists counts in both.
|
||||
*/
|
||||
|
||||
export type AppCount =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'failed' }
|
||||
| { phase: 'done'; total: number; lists: ListCount[]; uncounted: number }
|
||||
|
||||
/** What a download may take, so a huge or slow file cannot hold the page. */
|
||||
export const BROWSER_TIMEOUT_MS = 20_000
|
||||
export const BROWSER_MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
const session = new Map<string, ListCount>()
|
||||
|
||||
/** For the tests. */
|
||||
export function forgetListCounts(): void {
|
||||
session.clear()
|
||||
}
|
||||
|
||||
export async function countInBrowser(url: string, fetcher: typeof fetch = fetch): Promise<ListCount> {
|
||||
const hit = session.get(url)
|
||||
if (hit != null) return hit
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), BROWSER_TIMEOUT_MS)
|
||||
let result: ListCount
|
||||
try {
|
||||
const res = await fetcher(url, { signal: controller.signal, credentials: 'omit', referrerPolicy: 'no-referrer' })
|
||||
if (!res.ok) throw new Error(`status ${res.status}`)
|
||||
const declared = Number(res.headers.get('content-length') ?? 0)
|
||||
if (declared > BROWSER_MAX_BYTES) throw new Error('too large')
|
||||
const text = await res.text()
|
||||
if (text.length > BROWSER_MAX_BYTES) throw new Error('too large')
|
||||
result = { url, count: countDomains(text) }
|
||||
} catch (e) {
|
||||
const aborted = e instanceof Error && e.name === 'AbortError'
|
||||
result = {
|
||||
url,
|
||||
count: null,
|
||||
error: aborted ? 'timed out' : e instanceof TypeError ? 'blocked by the list server (CORS) or unreachable' : e instanceof Error ? e.message : 'failed',
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
session.set(url, result)
|
||||
return result
|
||||
}
|
||||
|
||||
export function useAppListCount(token: string | null, node: string, state: AppState): AppCount | null {
|
||||
const hub = useContext(HubMode)
|
||||
const urls = state.kind === 'ready' ? countableUrls(state.root) : []
|
||||
const name = state.kind === 'ready' ? state.app.name : APP_NAME
|
||||
const key = JSON.stringify(urls)
|
||||
const [count, setCount] = useState<AppCount | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const wanted = JSON.parse(key) as string[]
|
||||
if (wanted.length === 0) {
|
||||
setCount(null)
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
setCount({ phase: 'loading' })
|
||||
const finish = (lists: ListCount[]) => {
|
||||
if (!live) return
|
||||
// Only the lists asked for, so a stale answer from the hub cannot add a list the config dropped.
|
||||
const asked = lists.filter((l) => wanted.includes(l.url))
|
||||
const total = asked.reduce((n, l) => n + (l.count ?? 0), 0)
|
||||
setCount({ phase: 'done', total, lists: asked, uncounted: wanted.length - asked.filter((l) => l.count != null).length })
|
||||
}
|
||||
if (hub) {
|
||||
void getAppListCounts(token, name, node).then((r) => {
|
||||
if (!live) return
|
||||
if (r.kind !== 'ok') setCount({ phase: 'failed' })
|
||||
else finish(r.data.response.lists ?? [])
|
||||
})
|
||||
} else {
|
||||
void Promise.all(wanted.map((u) => countInBrowser(u))).then(finish)
|
||||
}
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [key, hub, token, node, name])
|
||||
|
||||
return state.kind === 'ready' ? count : null
|
||||
}
|
||||
|
||||
/*
|
||||
"All servers" behind a hub: each server's app lists are counted by the hub and the figures are
|
||||
added up. A list shared by two servers is counted once per server, as the built-in figure is added
|
||||
across servers. A server without the app (or with no block lists) answers an empty list and adds
|
||||
nothing; when no server has any list there is nothing to say (`null`). `servers` empty means this
|
||||
is not the all-servers view and nothing is read.
|
||||
*/
|
||||
export function useAllServersListCount(token: string | null, servers: readonly string[]): AppCount | null {
|
||||
const key = JSON.stringify(servers)
|
||||
const [count, setCount] = useState<AppCount | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const names = JSON.parse(key) as string[]
|
||||
if (names.length === 0) {
|
||||
setCount(null)
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
setCount({ phase: 'loading' })
|
||||
void Promise.all(names.map((n) => getAppListCounts(token, APP_NAME, n))).then((answers) => {
|
||||
if (!live) return
|
||||
const lists: ListCount[] = []
|
||||
let failed = 0
|
||||
for (const r of answers) {
|
||||
if (r.kind !== 'ok') failed++
|
||||
else lists.push(...(r.data.response.lists ?? []))
|
||||
}
|
||||
if (failed === answers.length) setCount({ phase: 'failed' })
|
||||
else if (lists.length === 0 && failed === 0) setCount(null)
|
||||
else {
|
||||
const counted = lists.filter((l) => l.count != null)
|
||||
setCount({
|
||||
phase: 'done',
|
||||
total: counted.reduce((n, l) => n + (l.count ?? 0), 0),
|
||||
lists,
|
||||
/* A server that did not answer may have lists; the figure is "at least" then. */
|
||||
uncounted: lists.length - counted.length + failed,
|
||||
})
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [key, token])
|
||||
|
||||
return count
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { getSettings } from '../../api/settings'
|
||||
import { minuteStamp } from '../../lib/dates'
|
||||
import { onSettingsChanged } from '../../lib/settings-signal'
|
||||
import { missing, switchNeed, type Permissions } from './permissions'
|
||||
import { blocksNow } from './advanced-blocking'
|
||||
import { onAppConfigChanged, readAppState, useAdvancedBlocking, type AppState } from './advanced-blocking-state'
|
||||
|
||||
/*
|
||||
Whether blocking is on, read where the Overview's status block reads it
|
||||
@@ -20,6 +22,8 @@ When it reads:
|
||||
- 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 Advanced Blocking app's config is written from this console, since the answer
|
||||
below depends on it;
|
||||
- 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.
|
||||
@@ -32,7 +36,14 @@ export const AFTER_PAUSE_MS = 1000
|
||||
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' }
|
||||
/*
|
||||
`app` is OURS: the built-in blocking is off (or paused) but the Advanced Blocking app is
|
||||
installed, switched on and has a group that blocks, so queries ARE being blocked. `till`
|
||||
is the end of the built-in pause when that is what the built-in blocking is doing. The app
|
||||
is only asked about when the built-in blocking is not on, and an app that is missing,
|
||||
unreadable or not blocking leaves the state as the settings said it.
|
||||
*/
|
||||
export type BlockingState = { kind: 'on' } | { kind: 'paused'; till: string } | { kind: 'off' } | { kind: 'app'; till?: string }
|
||||
|
||||
export function stateOf(enable: boolean, till: string | null | undefined, now = Date.now()): BlockingState {
|
||||
if (enable) return { kind: 'on' }
|
||||
@@ -66,7 +77,7 @@ export function useBlockingState(
|
||||
node = '',
|
||||
): { state: BlockingRead; reload: () => void; retry: () => void } {
|
||||
const viewNeed = enabled ? missing(permissions, 'Settings.canView') : 'Settings.canView'
|
||||
const [state, setState] = useState<BlockingRead>(null)
|
||||
const [base, setState] = useState<BlockingRead>(null)
|
||||
const asked = useRef(0)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
@@ -95,7 +106,7 @@ export function useBlockingState(
|
||||
}, [reload])
|
||||
|
||||
/* Without the permission nothing is known, whatever an earlier read said. */
|
||||
const known = viewNeed != null ? null : state
|
||||
const known = viewNeed != null ? null : base
|
||||
const till = known != null && known !== 'failed' && known.kind === 'paused' ? known.till : null
|
||||
useEffect(() => {
|
||||
if (till == null) return
|
||||
@@ -109,7 +120,13 @@ export function useBlockingState(
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
return { state: known, reload, retry }
|
||||
/* The app is asked only when the built-in blocking is off or paused. */
|
||||
const builtInOff = known != null && known !== 'failed' && known.kind !== 'on' && known.kind !== 'app'
|
||||
const app = useAdvancedBlocking(token, permissions, node, builtInOff)
|
||||
const viaApp = builtInOff && app.state.kind === 'ready' && blocksNow(app.state.root)
|
||||
const state: BlockingRead = viaApp ? { kind: 'app', ...(till != null ? { till } : {}) } : known
|
||||
|
||||
return { state, reload, retry }
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -129,7 +146,7 @@ export interface BlockingMark {
|
||||
}
|
||||
|
||||
export function blockingMark(state: BlockingRead, permissions: Permissions, now = new Date(), hub = false): BlockingMark | null {
|
||||
if (state == null || state === 'failed' || state.kind === 'on') return null
|
||||
if (state == null || state === 'failed' || state.kind === 'on' || state.kind === 'app') return null
|
||||
if (state.kind === 'off') return { text: 'Off', label: 'Blocking, disabled', tone: 'neutral' }
|
||||
const canLift = switchNeed(permissions, hub) == null
|
||||
return {
|
||||
@@ -140,7 +157,166 @@ export function blockingMark(state: BlockingRead, permissions: Permissions, now
|
||||
}
|
||||
|
||||
/** 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, hub = false): BlockingMark | null {
|
||||
const { state } = useBlockingState(token, permissions, visible)
|
||||
export function useBlockingMark(token: string | null, permissions: Permissions, visible: boolean, hub = false, node = ''): BlockingMark | null {
|
||||
const { state } = useBlockingState(token, permissions, visible, node)
|
||||
return blockingMark(state, permissions, new Date(), hub)
|
||||
}
|
||||
|
||||
/*
|
||||
Behind a hub, all servers together. OURS. "Is blocking on" is a question about each server: its
|
||||
built-in blocking, or the Advanced Blocking app blocking in its place. With "All servers" chosen,
|
||||
and for the sidebar's mark (which has no single server to ask), every server is asked for both
|
||||
(`settings/get` and the app's config, each with its node: the hub refuses a config read without
|
||||
one) and the answers are put together by `summariseServers` and `hubMark`.
|
||||
|
||||
`method` is how a server blocks: `builtin`, `app`, `both`, or not at all (`nothing`; `paused` when
|
||||
the built-in blocking is paused and the app does not make up for it). A server whose settings could
|
||||
not be read is `unknown` and is left out of both summaries rather than guessed at.
|
||||
*/
|
||||
export type Method = 'builtin' | 'app' | 'both' | 'paused' | 'nothing' | 'unknown'
|
||||
|
||||
export interface ServerBlocking {
|
||||
server: string
|
||||
method: Method
|
||||
/** The app is installed here (blocking or not). */
|
||||
appInstalled: boolean
|
||||
appBlocking: boolean
|
||||
}
|
||||
|
||||
export async function readServerBlocking(
|
||||
token: string | null,
|
||||
server: string,
|
||||
canSettings: boolean,
|
||||
canApps: boolean,
|
||||
): Promise<ServerBlocking> {
|
||||
const [settings, app] = await Promise.all([
|
||||
canSettings ? getSettings(token, server) : Promise.resolve(null),
|
||||
canApps ? readAppState(token, server) : Promise.resolve<AppState>({ kind: 'none' }),
|
||||
])
|
||||
const appInstalled = app.kind === 'ready'
|
||||
const appBlocking = app.kind === 'ready' && blocksNow(app.root)
|
||||
const built = settings == null ? null : stateOf(settings.enableBlocking, settings.temporaryDisableBlockingTill)
|
||||
let method: Method
|
||||
if (built == null) method = appBlocking ? 'app' : 'unknown'
|
||||
else if (built.kind === 'on') method = appBlocking ? 'both' : 'builtin'
|
||||
else if (appBlocking) method = 'app'
|
||||
else method = built.kind === 'paused' ? 'paused' : 'nothing'
|
||||
return { server, method, appInstalled, appBlocking }
|
||||
}
|
||||
|
||||
export function useAllServersBlocking(
|
||||
token: string | null,
|
||||
permissions: Permissions,
|
||||
servers: readonly string[],
|
||||
enabled: boolean,
|
||||
): ServerBlocking[] | null {
|
||||
const canSettings = missing(permissions, 'Settings.canView') == null
|
||||
const canApps = missing(permissions, 'Apps.canView') == null
|
||||
const key = JSON.stringify(servers)
|
||||
const [rows, setRows] = useState<ServerBlocking[] | null>(null)
|
||||
const asked = useRef(0)
|
||||
const active = enabled && canSettings && servers.length > 0
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!active) return
|
||||
const mine = ++asked.current
|
||||
void Promise.all((JSON.parse(key) as string[]).map((s) => readServerBlocking(token, s, canSettings, canApps))).then((r) => {
|
||||
if (mine === asked.current) setRows(r)
|
||||
})
|
||||
}, [active, key, token, canSettings, canApps])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
asked.current++
|
||||
return
|
||||
}
|
||||
reload()
|
||||
}, [active, reload])
|
||||
useEffect(() => (active ? onSettingsChanged(reload) : undefined), [active, reload])
|
||||
useEffect(() => (active ? onAppConfigChanged(reload) : undefined), [active, reload])
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const shown = () => {
|
||||
if (document.visibilityState === 'visible') reload()
|
||||
}
|
||||
document.addEventListener('visibilitychange', shown)
|
||||
return () => document.removeEventListener('visibilitychange', shown)
|
||||
}, [active, reload])
|
||||
|
||||
return active ? rows : null
|
||||
}
|
||||
|
||||
const names = (list: readonly string[]) => list.join(', ')
|
||||
|
||||
/* "Built-in on a, b; Advanced Blocking on c" for the servers that block. */
|
||||
function methodsText(rows: readonly ServerBlocking[]): string {
|
||||
const on = (m: Method) => rows.filter((r) => r.method === m).map((r) => r.server)
|
||||
const parts: string[] = []
|
||||
if (on('builtin').length > 0) parts.push(`Built-in on ${names(on('builtin'))}`)
|
||||
if (on('app').length > 0) parts.push(`Advanced Blocking on ${names(on('app'))}`)
|
||||
if (on('both').length > 0) parts.push(`Built-in and Advanced Blocking on ${names(on('both'))}`)
|
||||
return `${parts.join('; ')}.`
|
||||
}
|
||||
|
||||
export interface ServersSummary {
|
||||
title: string
|
||||
sub: string
|
||||
/** Every server blocks, one way or another. */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/*
|
||||
What the status block says for all servers, or `null` when the single-server wording (the first
|
||||
server's settings) is already right: every server blocks with the built-in blocking, none blocks at
|
||||
all, or something could not be read. Otherwise: every server blocks and some by the app, or some
|
||||
server blocks by nothing and the block names it.
|
||||
*/
|
||||
export function summariseServers(rows: readonly ServerBlocking[] | null): ServersSummary | null {
|
||||
if (rows == null || rows.length === 0 || rows.some((r) => r.method === 'unknown')) return null
|
||||
const gaps = rows.filter((r) => r.method === 'nothing' || r.method === 'paused')
|
||||
const blocking = rows.filter((r) => !gaps.includes(r))
|
||||
if (gaps.length === rows.length) return null
|
||||
if (gaps.length > 0) {
|
||||
return {
|
||||
title: `Blocking is off on ${names(gaps.map((r) => r.server))}`,
|
||||
sub: methodsText(blocking),
|
||||
active: false,
|
||||
}
|
||||
}
|
||||
if (rows.every((r) => r.method === 'builtin')) return null
|
||||
if (rows.every((r) => r.method === 'app')) {
|
||||
return { title: 'Blocking by Advanced Blocking', sub: 'The built-in blocking is off on every server.', active: true }
|
||||
}
|
||||
return { title: 'Blocking is enabled', sub: methodsText(rows), active: true }
|
||||
}
|
||||
|
||||
/* The sidebar's mark behind a hub: only when some server blocks by nothing, and it says which. */
|
||||
export function hubMark(rows: readonly ServerBlocking[] | null, permissions: Permissions): BlockingMark | null {
|
||||
if (rows == null) return null
|
||||
const off = rows.filter((r) => r.method === 'nothing').map((r) => r.server)
|
||||
if (off.length > 0) return { text: 'Off', label: `Blocking, disabled on ${names(off)}`, tone: 'neutral' }
|
||||
const paused = rows.filter((r) => r.method === 'paused').map((r) => r.server)
|
||||
if (paused.length === 0) return null
|
||||
return {
|
||||
text: 'Paused',
|
||||
label: `Blocking, paused on ${names(paused)}`,
|
||||
tone: switchNeed(permissions, true) == null ? 'acc' : 'neutral',
|
||||
}
|
||||
}
|
||||
|
||||
export function useHubBlockingMark(
|
||||
token: string | null,
|
||||
permissions: Permissions,
|
||||
visible: boolean,
|
||||
servers: readonly string[],
|
||||
): BlockingMark | null {
|
||||
return hubMark(useAllServersBlocking(token, permissions, servers, visible), permissions)
|
||||
}
|
||||
|
||||
/* The line under the status for all servers: where the app is installed. `null` when nowhere. */
|
||||
export function appServersLine(rows: readonly ServerBlocking[] | null): string | null {
|
||||
const have = (rows ?? []).filter((r) => r.appInstalled)
|
||||
if (have.length === 0) return null
|
||||
const idle = have.filter((r) => !r.appBlocking).map((r) => r.server)
|
||||
return `Advanced Blocking: on ${names(have.map((r) => r.server))}${idle.length > 0 ? ` (not blocking on ${names(idle)})` : ''}. Choose a server for details.`
|
||||
}
|
||||
@@ -88,6 +88,8 @@ parent's size.
|
||||
/* The share of the total, beside its figure. */
|
||||
.p { font: 400 var(--t-2)/1.3 var(--font-mono); color: var(--mute); white-space: nowrap; }
|
||||
.ctx { min-width:0; white-space: nowrap; }
|
||||
/* A context line that is a sentence (Blocking's split of Block List Domains): its own row, wrapping. */
|
||||
.ctxWrap { flex: 1 1 100%; white-space: normal; }
|
||||
/* The change: muted like the share, its size in ink, and never red or green
|
||||
(Figures.tsx). */
|
||||
.change { font: 400 var(--t-2)/1.3 var(--font-mono); font-variant-numeric: tabular-nums; color: var(--mute); }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Stats } from '../../api/dashboard'
|
||||
import { num2, percentage, type Metric } from './metrics'
|
||||
import { formatChange, type Changes } from './compare'
|
||||
@@ -68,6 +69,57 @@ const GLYPH: Record<string, IconName> = {
|
||||
totalCached: 'cache', totalBlocked: 'blocked', totalDropped: 'power',
|
||||
}
|
||||
|
||||
/*
|
||||
The structure of a KPI cell, written once: the icon tile in the figure's colour, its
|
||||
name, its figure and the context line beside it. `StatCard` fills it for the
|
||||
Dashboard's figures; the Blocking Overview fills it for the two figures that are not a
|
||||
`Metric` (Block List Domains, Your Rules).
|
||||
*/
|
||||
export function KpiTile({
|
||||
label,
|
||||
icon,
|
||||
colour,
|
||||
figure,
|
||||
noData = false,
|
||||
wrap = false,
|
||||
children,
|
||||
onOpen,
|
||||
}: {
|
||||
label: string
|
||||
icon: IconName
|
||||
/** A colour token's value, e.g. `var(--ch-block)`. */
|
||||
colour: string
|
||||
figure: string
|
||||
/** The figure is a dash, not a value. */
|
||||
noData?: boolean
|
||||
/** The context line is a sentence: it takes its own row under the figure and may wrap. */
|
||||
wrap?: boolean
|
||||
/** The context line: a share, a change, a sentence. */
|
||||
children?: ReactNode
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.tile} style={{ ['--tc' as string]: colour }}>
|
||||
<span className={styles.ico} aria-hidden="true"><Icon name={icon} size={18} /></span>
|
||||
<div className={styles.text}>
|
||||
<div className={styles.k}>{label}</div>
|
||||
{/* With no data it is NOT a zero: it is a dash, and dimmed. A zero is a
|
||||
true figure, and here it would be a lie. */}
|
||||
<div className={styles.line}>
|
||||
<Figure className={`${styles.v}${noData ? ` ${styles.noData}` : ''}`} text={figure} />
|
||||
<div className={wrap ? `${styles.ctx} ${styles.ctxWrap}` : styles.ctx}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Upstream's card is a link to Query Logs (v15.6.2). Here a button laid over
|
||||
the whole card, so the card's own reading order stays as it is; its name
|
||||
is the card's label and its title upstream's menu text. */}
|
||||
{onOpen && (
|
||||
<button type="button" className={styles.open} aria-label={label} title="Show Query Logs" onClick={onOpen} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
m,
|
||||
s,
|
||||
@@ -90,29 +142,19 @@ export function StatCard({
|
||||
const share = m.pct && s ? percentage(s[m.k], total) : null
|
||||
const ratio = s ? changes?.[m.k] : undefined
|
||||
return (
|
||||
<div className={styles.tile} style={{ ['--tc' as string]: colour }}>
|
||||
<span className={styles.ico} aria-hidden="true"><Icon name={GLYPH[m.k] ?? 'dashboard'} size={18} /></span>
|
||||
<div className={styles.text}>
|
||||
<div className={styles.k}>{m.label}</div>
|
||||
{/* With no data it is NOT a zero: it is a dash, and dimmed. A zero is a
|
||||
true figure, and here it would be a lie. */}
|
||||
<div className={styles.line}>
|
||||
<Figure className={`${styles.v}${s ? '' : ` ${styles.noData}`}`} text={s ? num2(s[m.k]) : '—'} />
|
||||
{/* The hint beside the figure: its share of the total and its change. */}
|
||||
<div className={styles.ctx}>
|
||||
{share != null && <span className={styles.p}>{share}</span>}
|
||||
{share != null && ratio != null && ' '}
|
||||
<Change ratio={ratio} against={against} long={m.k === 'totalQueries'} />
|
||||
{share == null && ratio == null && ' '}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Upstream's card is a link to Query Logs (v15.6.2). Here a button laid over
|
||||
the whole card, so the card's own reading order stays as it is; its name
|
||||
is the card's label and its title upstream's menu text. */}
|
||||
{onOpen && (
|
||||
<button type="button" className={styles.open} aria-label={m.label} title="Show Query Logs" onClick={onOpen} />
|
||||
)}
|
||||
</div>
|
||||
<KpiTile
|
||||
label={m.label}
|
||||
icon={GLYPH[m.k] ?? 'dashboard'}
|
||||
colour={colour}
|
||||
figure={s ? num2(s[m.k]) : '—'}
|
||||
noData={!s}
|
||||
onOpen={onOpen}
|
||||
>
|
||||
{/* The hint beside the figure: its share of the total and its change. */}
|
||||
{share != null && <span className={styles.p}>{share}</span>}
|
||||
{share != null && ratio != null && ' '}
|
||||
<Change ratio={ratio} against={against} long={m.k === 'totalQueries'} />
|
||||
{share == null && ratio == null && ' '}
|
||||
</KpiTile>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user