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