Compare commits

...
2 Commits
Author SHA1 Message Date
DenozordecandClaude Opus 5.5 172e06f1ce Blocking: count Advanced Blocking, Dashboard layout and KPI cards
CI / check (push) Successful in 3m7s
Hub / check (push) Successful in 53s
CI / image (push) Successful in 1m29s
Hub / image (push) Successful in 1m19s
- 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>
2026-11-01 18:34:17 +07:00
DenozordecandClaude Opus 5.5 b396bfd317 Sidebar: keep the Blocking mark clear of the pin star
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:34:05 +07:00
41 changed files with 3129 additions and 208 deletions

No files matched your search

+44 -3
View File
@@ -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')
+1
View File
@@ -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. |
+219
View File
@@ -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)
})
})
+304
View File
@@ -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)
}
}
}
+3
View File
@@ -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',
}
+2
View File
@@ -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'),
+16
View File
@@ -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
+26
View File
@@ -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 } })
}
+3
View File
@@ -6,6 +6,9 @@ only its outline, in the entry's own ink.
*/
.mark {
margin-left: auto;
/* Clear of the pin star, which sits over the entry's end (`.star` in Shell.module.css:
24px wide at --s-3 from the edge, inside the entry's --s-5 padding), plus a --s-2 gap. */
margin-right: calc(24px + var(--s-3) - var(--s-5) + var(--s-2));
padding: 0 var(--s-3);
border: 1px solid var(--line);
border-radius: var(--r-full);
+2 -1
View File
@@ -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`)
+62
View File
@@ -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
View File
@@ -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
+30
View File
@@ -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)
})
})
+39
View File
@@ -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')
})
})
+9 -2
View File
@@ -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()
})
})
+12 -5
View File
@@ -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); }
+19 -12
View File
@@ -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}
+160
View File
@@ -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()
})
})
+10 -32
View File
@@ -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; }
+6 -11
View File
@@ -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 () => {
+7 -6
View File
@@ -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()
})
+69 -88
View File
@@ -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. */}
+3
View File
@@ -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' }))
+9 -1
View File
@@ -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')
})
})
+41 -14
View File
@@ -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}')
})
})
+202
View File
@@ -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')
})
})
+150
View File
@@ -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
}
+183 -7
View File
@@ -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); }
+66 -24
View File
@@ -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>
)
}