Compare commits
3
Commits
9742f0fe69
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba3cdd08d2 | ||
|
|
172e06f1ce | ||
|
|
b396bfd317 |
No files matched your search
+44
-3
@@ -176,7 +176,7 @@ const settings = {
|
||||
cacheMaximumEntries: 10000, cacheMinimumRecordTtl: 10, cacheMaximumRecordTtl: 86400, cacheNegativeRecordTtl: 300, cacheFailureRecordTtl: 60,
|
||||
enableCachePrefetch: true, cachePrefetchEligibility: 2, cachePrefetchTrigger: 9,
|
||||
enableBlocking: true, allowTxtBlockingReport: true, temporaryDisableBlockingTill: null, blockingBypassList: [], blockingType: 'NxDomain', blockingAnswerTtl: 30,
|
||||
customBlockingAddresses: [], blockListUrls: ['https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', '# Privacy', 'https://big.oisd.nl/domainswild', 'https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt'],
|
||||
customBlockingAddresses: [], blockListUrls: ['https://lists.invalid/hosts', '# Privacy', 'https://big.oisd.nl/domainswild', 'https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt'],
|
||||
blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: iso(now + 3 * 3_600_000),
|
||||
proxy: null, forwarders: ['1.1.1.1', '9.9.9.9'], forwarderProtocol: 'Udp', concurrentForwarding: true, forwarderRetries: 3, forwarderTimeout: 2000, forwarderConcurrency: 2,
|
||||
loggingType: 'File, Console', ignoreResolverLogs: false, logQueries: false, useLocalTime: false, logFolder: 'logs', maxLogFileDays: 365, enableInMemoryStats: false, maxStatFileDays: 365,
|
||||
@@ -220,10 +220,42 @@ if (VARIANT === 'b') {
|
||||
apps = apps.filter((a) => a.name !== 'Split Horizon')
|
||||
apps = apps.map((a) => (a.name === 'Query Logs (Sqlite)' ? { ...a, version: '7.1', updateAvailable: true, updateVersion: '7.2', updateUrl: 'https://download.technitium.com/dns/apps/QueryLogsSqliteApp-v7.2.zip' } : a))
|
||||
}
|
||||
// A small fake list the mock serves itself at /lists/<name>.txt (no internet), so the Advanced Blocking
|
||||
// "Block List Domains" count has something to download. Open to any origin, as a public list host is.
|
||||
const FAKE_LISTS = {
|
||||
'hosts.txt': ['# fake hosts list', '127.0.0.1 localhost', ...Array.from({ length: 120 }, (_, i) => `0.0.0.0 ads${i}.tracker.example`)].join('\n'),
|
||||
'easylist.txt': ['[Adblock Plus 2.0]', '! fake adblock list', ...Array.from({ length: 45 }, (_, i) => `||banner${i}.adnet.example^`)].join('\n'),
|
||||
}
|
||||
const listUrl = (name) => `http://127.0.0.1:${PORT}/lists/${name}`
|
||||
const group = (over) => ({
|
||||
allowTxtBlockingReport: true, blockAsNxDomain: true, blockingAddresses: ['0.0.0.0', '::'],
|
||||
allowed: [], blocked: [], allowListUrls: [], blockListUrls: [], allowedRegex: [], blockedRegex: [],
|
||||
regexAllowListUrls: [], regexBlockListUrls: [], adblockListUrls: [], ...over,
|
||||
})
|
||||
const appConfigs = {
|
||||
'Advanced Blocking': VARIANT === 'b'
|
||||
? { enableBlocking: true, blockingAnswerTtl: 60, localGroup: 'office', networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: false, blockAsNxDomain: false }] }
|
||||
: { enableBlocking: true, blockingAnswerTtl: 30, localGroup: 'home', networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: true, blockAsNxDomain: false }] },
|
||||
? {
|
||||
enableBlocking: true, blockingAnswerTtl: 60, blockListUrlUpdateIntervalHours: 24, localEndPointGroupMap: { '127.0.0.1': 'bypass' },
|
||||
networkGroupMap: { '0.0.0.0/0': 'default', '::/0': 'default' },
|
||||
groups: [
|
||||
group({ name: 'default', enableBlocking: true, blocked: ['tracker.example'], blockedRegex: ['^ads\\.'], blockListUrls: [listUrl('hosts.txt')], adblockListUrls: [listUrl('easylist.txt')] }),
|
||||
group({ name: 'bypass', enableBlocking: false }),
|
||||
],
|
||||
}
|
||||
: {
|
||||
enableBlocking: true, blockingAnswerTtl: 30, blockListUrlUpdateIntervalHours: 24, localEndPointGroupMap: { '127.0.0.1': 'bypass' },
|
||||
networkGroupMap: { '192.168.10.0/24': 'family', '0.0.0.0/0': 'everyone', '::/0': 'everyone' },
|
||||
groups: [
|
||||
group({
|
||||
name: 'everyone', enableBlocking: true, blocked: ['ads.example.com', 'telemetry.example.net'], allowed: ['good.example.org'],
|
||||
blockListUrls: [listUrl('hosts.txt'), 'https://lists.invalid/hosts'],
|
||||
allowListUrls: ['https://example.org/allow.txt'], blockedRegex: ['^ads\\.', '^track\\d+\\.'], allowedRegex: ['^cdn\\.good\\.'],
|
||||
regexBlockListUrls: ['https://example.org/regex.txt'], adblockListUrls: [listUrl('easylist.txt')],
|
||||
}),
|
||||
group({ name: 'family', enableBlocking: true, blocked: ['games.example.com'], blockListUrls: [listUrl('hosts.txt')], regexAllowListUrls: ['https://example.org/regex-allow.txt'] }),
|
||||
group({ name: 'bypass', enableBlocking: false }),
|
||||
],
|
||||
},
|
||||
'Query Logs (Sqlite)': { enableLogging: true, maxLogDays: 365, sqliteDbPath: VARIANT === 'b' ? '/var/lib/dns/ndns/querylogs.db' : '/var/lib/dns/mdns/querylogs.db' },
|
||||
'Split Horizon': { domainGroupMap: { 'lan.example.net': 'internal' }, networkGroupMap: { '10.0.0.0/8': 'internal' } },
|
||||
}
|
||||
@@ -438,6 +470,15 @@ http.createServer((req, res) => {
|
||||
const q = new URLSearchParams(url.search)
|
||||
if ((req.headers['content-type'] ?? '').includes('application/x-www-form-urlencoded')) for (const [k, v] of new URLSearchParams(body)) q.set(k, v)
|
||||
if ((req.headers['content-type'] ?? '').includes('text/plain') && body !== '') q.set('__text', body)
|
||||
const fakeList = url.pathname.match(/^\/lists\/([\w.-]+)$/)
|
||||
if (fakeList !== null) {
|
||||
const text = FAKE_LISTS[fakeList[1]]
|
||||
res.statusCode = text === undefined ? 404 : 200
|
||||
res.setHeader('access-control-allow-origin', '*')
|
||||
res.setHeader('content-type', 'text/plain')
|
||||
res.end(text ?? 'not found')
|
||||
return
|
||||
}
|
||||
const file = fileFor(path, q)
|
||||
if (file !== undefined) {
|
||||
res.setHeader('content-type', 'text/plain')
|
||||
|
||||
@@ -30,6 +30,7 @@ Modify and Delete only where the table in `docs/hub-deploy.md` grants them.
|
||||
| `HUB_PORT` | `8080` | Listening port. |
|
||||
| `HUB_CACHE_SECONDS` | `15` | How long successful answers of the servers are cached. |
|
||||
| `HUB_TIMEOUT_MS` | `8000` | Per-request timeout towards a server. |
|
||||
| `HUB_LISTS_ALLOW_PRIVATE` | off | `1` lets the hub download the Advanced Blocking block lists (to count their domains, `hub/blocking/app-lists/count`) from private addresses. Off, only public addresses are fetched, so a hub cannot be pointed at your network. Counts are cached for 6 hours. |
|
||||
| `HUB_DOMAIN` | `hub` | Shown as the server's domain in the console. |
|
||||
| `HUB_STATIC` | `./dist` | The console's build to serve. |
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment node
|
||||
/*
|
||||
Stage 9: counting the domains in an Advanced Blocking app's block lists. The unit tests inject
|
||||
the fetch and the DNS lookup; the hub tests run against fake servers and a fake list server.
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { AppLists, DomainCounter, countDomains, publicAddress } from './app-lists.ts'
|
||||
import type { HubServer } from './config.ts'
|
||||
import type { Upstream } from './upstream.ts'
|
||||
import { fake, startHub, type Fake, type TestHub } from './fake-servers.ts'
|
||||
|
||||
const server: HubServer = { id: 'a', url: 'http://a.invalid', token: 't' }
|
||||
|
||||
const config = (groups: unknown[]) => JSON.stringify({ enableBlocking: true, groups })
|
||||
|
||||
function upstreamWith(text: string | null): Upstream {
|
||||
return { call: async () => ({ config: text }) } as unknown as Upstream
|
||||
}
|
||||
|
||||
const HOSTS = '# hosts\n127.0.0.1 localhost\n0.0.0.0 a.example\n0.0.0.0 b.example\n0.0.0.0 a.example\n'
|
||||
|
||||
function listFetch(files: Record<string, string>, seen: string[] = []): typeof fetch {
|
||||
return (async (input: URL | string) => {
|
||||
const url = String(input)
|
||||
seen.push(url)
|
||||
const body = files[url]
|
||||
return body === undefined ? new Response('nope', { status: 404 }) : new Response(body, { status: 200 })
|
||||
}) as typeof fetch
|
||||
}
|
||||
|
||||
const PUBLIC = async () => ['93.184.216.34']
|
||||
|
||||
describe('countDomains', () => {
|
||||
it('counts hosts, plain and adblock lines once each', () => {
|
||||
expect(countDomains(HOSTS)).toBe(2)
|
||||
expect(countDomains('x.example\n*.y.example\n! c\n||z.example^$third-party\n@@||ok.example^\n[Adblock Plus]\n')).toBe(3)
|
||||
})
|
||||
|
||||
it('counts across chunks that cut a line in two', () => {
|
||||
const c = new DomainCounter()
|
||||
c.push('0.0.0.0 a.exa')
|
||||
c.push('mple\n0.0.0.0 b.example')
|
||||
expect(c.end()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('publicAddress', () => {
|
||||
it('refuses loopback, private, link-local, shared and unspecified addresses', () => {
|
||||
for (const a of ['127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1', '::1', '::', 'fe80::1', 'fd00::1', '::ffff:10.0.0.1']) {
|
||||
expect(publicAddress(a), a).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts public ones', () => {
|
||||
for (const a of ['93.184.216.34', '8.8.8.8', '2606:4700:4700::1111']) expect(publicAddress(a), a).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppLists', () => {
|
||||
it('reads the block and adblock URLs of every group, strings or objects, each once, http(s) only', async () => {
|
||||
const text = config([
|
||||
{ name: 'one', blockListUrls: ['https://x.example/a', { url: 'https://x.example/b' }, 'file:///etc/hosts'], adblockListUrls: ['https://x.example/c'], regexBlockListUrls: ['https://x.example/re'] },
|
||||
{ name: 'two', blockListUrls: ['https://x.example/a'], allowListUrls: ['https://x.example/allow'] },
|
||||
])
|
||||
const lists = new AppLists(upstreamWith(text))
|
||||
expect(await lists.urlsOf(server, 'Advanced Blocking')).toEqual(['https://x.example/a', 'https://x.example/b', 'https://x.example/c'])
|
||||
})
|
||||
|
||||
it('has no URLs for a missing, empty or broken config', async () => {
|
||||
expect(await new AppLists(upstreamWith(null)).urlsOf(server, 'x')).toEqual([])
|
||||
expect(await new AppLists(upstreamWith('{nope')).urlsOf(server, 'x')).toEqual([])
|
||||
})
|
||||
|
||||
it('counts each list and totals them, and caches by URL', async () => {
|
||||
const seen: string[] = []
|
||||
const files = { 'https://x.example/a': HOSTS, 'https://x.example/b': 'c.example\n' }
|
||||
let now = 1_000
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/a', 'https://x.example/b'] }])), {
|
||||
fetch: listFetch(files, seen), lookup: PUBLIC, now: () => now,
|
||||
})
|
||||
const first = await lists.count(server, 'Advanced Blocking')
|
||||
expect(first.lists).toEqual([{ url: 'https://x.example/a', count: 2 }, { url: 'https://x.example/b', count: 1 }])
|
||||
expect(first.total).toBe(3)
|
||||
await lists.count(server, 'Advanced Blocking')
|
||||
expect(seen).toHaveLength(2)
|
||||
now += 7 * 3_600_000
|
||||
await lists.count(server, 'Advanced Blocking')
|
||||
expect(seen).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('reports a list that fails, with a reason, and keeps the others', async () => {
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/a', 'https://x.example/gone'] }])), {
|
||||
fetch: listFetch({ 'https://x.example/a': HOSTS }), lookup: PUBLIC,
|
||||
})
|
||||
const out = await lists.count(server, 'x')
|
||||
expect(out.lists[0].count).toBe(2)
|
||||
expect(out.lists[1]).toEqual({ url: 'https://x.example/gone', count: null, error: 'the list server answered 404' })
|
||||
expect(out.total).toBe(2)
|
||||
})
|
||||
|
||||
it('never fetches an address that is not public, or a name that resolves to one', async () => {
|
||||
const seen: string[] = []
|
||||
const lists = new AppLists(
|
||||
upstreamWith(config([{ name: 'g', blockListUrls: ['http://127.0.0.1/a', 'http://[::1]/a', 'http://169.254.169.254/latest', 'https://internal.example/a', 'ftp://x.example/a'] }])),
|
||||
{ fetch: listFetch({}, seen), lookup: async (h) => (h === 'internal.example' ? ['10.0.0.5'] : ['93.184.216.34']) },
|
||||
)
|
||||
const out = await lists.count(server, 'x')
|
||||
expect(seen).toEqual([])
|
||||
expect(out.lists.every((l) => l.count === null)).toBe(true)
|
||||
expect(out.lists[0].error).toBe('the address of this list is not a public one')
|
||||
})
|
||||
|
||||
it('checks every redirect, and stops after three', async () => {
|
||||
const seen: string[] = []
|
||||
const fetcher = (async (input: URL | string) => {
|
||||
const url = String(input)
|
||||
seen.push(url)
|
||||
if (url === 'https://x.example/start') return new Response(null, { status: 302, headers: { location: 'http://10.0.0.1/inner' } })
|
||||
if (url.startsWith('https://x.example/loop')) return new Response(null, { status: 302, headers: { location: 'https://x.example/loop' } })
|
||||
return new Response('a.example\n')
|
||||
}) as typeof fetch
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/start', 'https://x.example/loop'] }])), { fetch: fetcher, lookup: async (h) => (h === 'x.example' ? ['93.184.216.34'] : ['10.0.0.1']) })
|
||||
const out = await lists.count(server, 'x')
|
||||
expect(seen).not.toContain('http://10.0.0.1/inner')
|
||||
expect(out.lists[0].error).toBe('the address of this list is not a public one')
|
||||
expect(out.lists[1].error).toBe('too many redirects')
|
||||
})
|
||||
|
||||
it('refuses a list over the size cap, declared or streamed', async () => {
|
||||
const big = (async (input: URL | string) =>
|
||||
String(input).endsWith('/declared')
|
||||
? new Response('a.example\n', { headers: { 'content-length': '5000' } })
|
||||
: new Response('a.example\n'.repeat(1000))) as typeof fetch
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/declared', 'https://x.example/streamed'] }])), { fetch: big, lookup: PUBLIC, maxBytes: 1000 })
|
||||
const out = await lists.count(server, 'x')
|
||||
expect(out.lists.map((l) => l.error)).toEqual(['the list is larger than the limit', 'the list is larger than the limit'])
|
||||
})
|
||||
|
||||
it('times a slow list out', async () => {
|
||||
const slow = ((_: URL | string, init?: RequestInit) =>
|
||||
new Promise((_resolve, reject) => init?.signal?.addEventListener('abort', () => reject(Object.assign(new Error('x'), { name: 'AbortError' }))))) as typeof fetch
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: ['https://x.example/slow'] }])), { fetch: slow, lookup: PUBLIC, timeoutMs: 20 })
|
||||
expect((await lists.count(server, 'x')).lists[0].error).toBe('timed out')
|
||||
})
|
||||
|
||||
it('takes at most maxLists lists', async () => {
|
||||
const urls = Array.from({ length: 10 }, (_, i) => `https://x.example/${i}`)
|
||||
const lists = new AppLists(upstreamWith(config([{ name: 'g', blockListUrls: urls }])), { fetch: listFetch({}), lookup: PUBLIC, maxLists: 3 })
|
||||
expect((await lists.count(server, 'x')).lists).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hub/blocking/app-lists/count', () => {
|
||||
let a: Fake
|
||||
let b: Fake
|
||||
let hub: TestHub
|
||||
let files: http.Server
|
||||
let base = ''
|
||||
|
||||
beforeAll(async () => {
|
||||
files = http.createServer((req, res) => {
|
||||
res.setHeader('content-type', 'text/plain')
|
||||
res.end(req.url === '/hosts' ? HOSTS : 'p.example\nq.example\nr.example\n')
|
||||
})
|
||||
await new Promise<void>((resolve) => files.listen(0, '127.0.0.1', resolve))
|
||||
base = `http://127.0.0.1:${(files.address() as AddressInfo).port}`
|
||||
a = await fake()
|
||||
b = await fake()
|
||||
a.on('apps/config/get', () => ({
|
||||
response: { config: config([{ name: 'g', enableBlocking: true, blockListUrls: [`${base}/hosts`], adblockListUrls: [`${base}/other`] }]) },
|
||||
}))
|
||||
b.on('apps/config/get', () => ({ response: { config: null } }))
|
||||
hub = await startHub(a, b, { HUB_LISTS_ALLOW_PRIVATE: '1' })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
hub.close()
|
||||
a.server.close()
|
||||
b.server.close()
|
||||
files.close()
|
||||
})
|
||||
|
||||
it('counts the lists of the chosen server with its read token', async () => {
|
||||
const r = await hub.call('viewer', 'hub/blocking/app-lists/count', { node: 'a', name: 'Advanced Blocking' }, 'GET')
|
||||
expect(r.code).toBe(200)
|
||||
expect(r.body.response.total).toBe(5)
|
||||
expect(r.body.response.lists).toEqual([{ url: `${base}/hosts`, count: 2 }, { url: `${base}/other`, count: 3 }])
|
||||
const read = a.calls.find((c) => c.path === 'apps/config/get')
|
||||
expect(read?.token).toBe('read-a')
|
||||
expect(read?.query.get('name')).toBe('Advanced Blocking')
|
||||
})
|
||||
|
||||
it('answers an empty list for a server whose app has no config', async () => {
|
||||
const r = await hub.call('viewer', 'hub/blocking/app-lists/count', { node: 'b' }, 'GET')
|
||||
expect(r.body.response).toEqual({ lists: [], total: 0 })
|
||||
})
|
||||
|
||||
it('needs a server', async () => {
|
||||
for (const node of ['', 'cluster', 'nope']) {
|
||||
const r = await hub.call('viewer', 'hub/blocking/app-lists/count', { node }, 'GET')
|
||||
expect(r.code).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
it('is refused without a session', async () => {
|
||||
const res = await fetch(`${hub.base}/api/hub/blocking/app-lists/count?node=a`)
|
||||
expect(((await res.json()) as { status: string }).status).toBe('invalid-token')
|
||||
})
|
||||
|
||||
it('does not fetch private addresses unless the hub is told to', async () => {
|
||||
const strict = await startHub(a, b)
|
||||
const r = await strict.call('viewer', 'hub/blocking/app-lists/count', { node: 'a' }, 'GET')
|
||||
strict.close()
|
||||
expect(r.body.response.lists.map((l: { error?: string }) => l.error)).toEqual(['the address of this list is not a public one', 'the address of this list is not a public one'])
|
||||
expect(r.body.response.total).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
How many domains the block lists of a server's Advanced Blocking app hold (stage 9). The app has
|
||||
no API that says it, so the hub reads the app's config from the server (with the READ token, like
|
||||
any `apps/config/get`), downloads each http(s) block list and counts the distinct domains.
|
||||
|
||||
Counted: a hosts line (`0.0.0.0 example.com`, every name after the address), a plain domain
|
||||
(`example.com`, `*.example.com`) and an Adblock Plus domain rule (`||example.com^`). Not counted:
|
||||
blank lines, comments (`#`, `!`, `[`), Adblock exceptions (`@@`) and regular expressions. The
|
||||
console has the same rules for the no-hub case (`src/lib/list-count.ts`); the two are kept in step
|
||||
by hand and tested on the same sample.
|
||||
|
||||
The hub fetches a URL an administrator typed into a config, so it is careful where it goes:
|
||||
- only `http:` and `https:`, no credentials in the URL;
|
||||
- the host must resolve to public addresses only (no loopback, private, link-local, carrier-grade
|
||||
NAT, multicast or unspecified ones, IPv4 or IPv6). Redirects are followed by hand, up to three,
|
||||
and checked at every step. `HUB_LISTS_ALLOW_PRIVATE=1` lifts this for lists kept on the LAN.
|
||||
The check resolves the name and the fetch resolves it again, so a name that changes between the
|
||||
two is not caught; the hub is not a place an attacker can write DNS for;
|
||||
- a timeout and a size cap per list, a cap on how many lists, and four downloads at a time.
|
||||
|
||||
Results are cached per URL for several hours (a failure for minutes), so opening the Overview
|
||||
does not download a hundred megabytes again.
|
||||
*/
|
||||
import { lookup as dnsLookup } from 'node:dns/promises'
|
||||
import { isIP } from 'node:net'
|
||||
import type { HubServer } from './config.ts'
|
||||
import type { Upstream } from './upstream.ts'
|
||||
|
||||
export interface ListCount {
|
||||
url: string
|
||||
count: number | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ListCounts {
|
||||
lists: ListCount[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AppListsOptions {
|
||||
fetch?: typeof fetch
|
||||
/** Resolves a host to its addresses. */
|
||||
lookup?: (host: string) => Promise<string[]>
|
||||
now?: () => number
|
||||
allowPrivate?: boolean
|
||||
/** How long a counted list is remembered (default 6 hours). */
|
||||
ttlMs?: number
|
||||
/** How long a failure is remembered (default 5 minutes). */
|
||||
failureTtlMs?: number
|
||||
timeoutMs?: number
|
||||
maxBytes?: number
|
||||
maxLists?: number
|
||||
}
|
||||
|
||||
const HOURS = 3_600_000
|
||||
const MAX_CACHED = 500
|
||||
const MAX_REDIRECTS = 3
|
||||
const CONCURRENCY = 4
|
||||
|
||||
/* --- counting --- */
|
||||
|
||||
const ADDRESS = /^(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9a-f:]*:[0-9a-f:]*)$/i
|
||||
const NAME = /^[a-z0-9_]([a-z0-9_.-]*[a-z0-9_])?$/i
|
||||
const OWN = new Set(['localhost', 'localhost.localdomain', 'local', 'broadcasthost', '0.0.0.0'])
|
||||
|
||||
function accept(raw: string, into: Set<string>): void {
|
||||
const name = raw.replace(/^\*\./, '').replace(/\.$/, '').toLowerCase()
|
||||
if (name === '' || OWN.has(name) || name.startsWith('ip6-') || !NAME.test(name)) return
|
||||
into.add(name)
|
||||
}
|
||||
|
||||
function countLine(line: string, into: Set<string>): void {
|
||||
const t = line.trim()
|
||||
if (t === '' || t.startsWith('#') || t.startsWith('!') || t.startsWith('[') || t.startsWith('@@') || t.startsWith('/')) return
|
||||
if (t.startsWith('||')) {
|
||||
const end = t.search(/[\^$/]/)
|
||||
accept(end < 0 ? t.slice(2) : t.slice(2, end), into)
|
||||
return
|
||||
}
|
||||
const tokens = t.split('#')[0].trim().split(/\s+/)
|
||||
if (tokens.length > 1 && ADDRESS.test(tokens[0])) for (const name of tokens.slice(1)) accept(name, into)
|
||||
else if (tokens.length === 1) accept(tokens[0], into)
|
||||
}
|
||||
|
||||
/* Counts as text arrives in pieces, so a big list is never held whole. */
|
||||
export class DomainCounter {
|
||||
#domains = new Set<string>()
|
||||
#rest = ''
|
||||
|
||||
push(chunk: string): void {
|
||||
const lines = (this.#rest + chunk).split(/\r?\n/)
|
||||
this.#rest = lines.pop() ?? ''
|
||||
for (const line of lines) countLine(line, this.#domains)
|
||||
}
|
||||
|
||||
end(): number {
|
||||
countLine(this.#rest, this.#domains)
|
||||
this.#rest = ''
|
||||
return this.#domains.size
|
||||
}
|
||||
}
|
||||
|
||||
export function countDomains(text: string): number {
|
||||
const c = new DomainCounter()
|
||||
c.push(text)
|
||||
return c.end()
|
||||
}
|
||||
|
||||
/* --- where a fetch may go --- */
|
||||
|
||||
function ipv4Allowed(a: number, b: number): boolean {
|
||||
if (a === 0 || a === 10 || a === 127 || a >= 224) return false
|
||||
if (a === 100 && b >= 64 && b <= 127) return false
|
||||
if (a === 169 && b === 254) return false
|
||||
if (a === 172 && b >= 16 && b <= 31) return false
|
||||
if (a === 192 && b === 168) return false
|
||||
if (a === 192 && b === 0) return false
|
||||
if (a === 198 && (b === 18 || b === 19)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Whether an address is one a list may be fetched from: public unicast only. */
|
||||
export function publicAddress(address: string): boolean {
|
||||
const kind = isIP(address)
|
||||
if (kind === 4) {
|
||||
const [a, b] = address.split('.').map(Number)
|
||||
return ipv4Allowed(a, b)
|
||||
}
|
||||
if (kind === 6) {
|
||||
const a = address.toLowerCase()
|
||||
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(a)
|
||||
if (mapped !== null) return publicAddress(mapped[1])
|
||||
if (a === '::' || a === '::1') return false
|
||||
const first = parseInt(a.split(':')[0] || '0', 16)
|
||||
if ((first & 0xfe00) === 0xfc00) return false // fc00::/7 unique local
|
||||
if ((first & 0xffc0) === 0xfe80) return false // fe80::/10 link local
|
||||
if ((first & 0xff00) === 0xff00) return false // multicast
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function defaultLookup(host: string): Promise<string[]> {
|
||||
return (await dnsLookup(host, { all: true })).map((r) => r.address)
|
||||
}
|
||||
|
||||
class Refused extends Error {}
|
||||
|
||||
/* --- the service --- */
|
||||
|
||||
interface Cached {
|
||||
until: number
|
||||
result: ListCount
|
||||
}
|
||||
|
||||
export class AppLists {
|
||||
#upstream: Upstream
|
||||
#fetch: typeof fetch
|
||||
#lookup: (host: string) => Promise<string[]>
|
||||
#now: () => number
|
||||
#allowPrivate: boolean
|
||||
#ttl: number
|
||||
#failureTtl: number
|
||||
#timeout: number
|
||||
#maxBytes: number
|
||||
#maxLists: number
|
||||
#cache = new Map<string, Cached>()
|
||||
#inflight = new Map<string, Promise<ListCount>>()
|
||||
|
||||
constructor(upstream: Upstream, options: AppListsOptions = {}) {
|
||||
this.#upstream = upstream
|
||||
this.#fetch = options.fetch ?? fetch
|
||||
this.#lookup = options.lookup ?? defaultLookup
|
||||
this.#now = options.now ?? Date.now
|
||||
this.#allowPrivate = options.allowPrivate ?? false
|
||||
this.#ttl = options.ttlMs ?? 6 * HOURS
|
||||
this.#failureTtl = options.failureTtlMs ?? 5 * 60_000
|
||||
this.#timeout = options.timeoutMs ?? 20_000
|
||||
this.#maxBytes = options.maxBytes ?? 64 * 1024 * 1024
|
||||
this.#maxLists = options.maxLists ?? 100
|
||||
}
|
||||
|
||||
/** The distinct block list URLs (plain and Adblock) of the app's config on one server. */
|
||||
async urlsOf(server: HubServer, appName: string): Promise<string[]> {
|
||||
const response = (await this.#upstream.call(server, 'apps/config/get', { name: appName })) as { config?: string | null } | null
|
||||
const text = response?.config
|
||||
if (typeof text !== 'string' || text.trim() === '') return []
|
||||
let root: unknown
|
||||
try {
|
||||
root = JSON.parse(text)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const groups = root !== null && typeof root === 'object' && Array.isArray((root as { groups?: unknown }).groups) ? ((root as { groups: unknown[] }).groups) : []
|
||||
const urls = new Set<string>()
|
||||
for (const g of groups) {
|
||||
if (g === null || typeof g !== 'object') continue
|
||||
for (const field of ['blockListUrls', 'adblockListUrls']) {
|
||||
const list = (g as Record<string, unknown>)[field]
|
||||
if (!Array.isArray(list)) continue
|
||||
for (const e of list) {
|
||||
const url = typeof e === 'string' ? e : e !== null && typeof e === 'object' && typeof (e as { url?: unknown }).url === 'string' ? (e as { url: string }).url : ''
|
||||
if (/^https?:\/\//i.test(url.trim())) urls.add(url.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...urls]
|
||||
}
|
||||
|
||||
async count(server: HubServer, appName: string): Promise<ListCounts> {
|
||||
const urls = (await this.urlsOf(server, appName)).slice(0, this.#maxLists)
|
||||
const lists: ListCount[] = new Array(urls.length)
|
||||
let next = 0
|
||||
const worker = async () => {
|
||||
while (next < urls.length) {
|
||||
const i = next++
|
||||
lists[i] = await this.countUrl(urls[i])
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, urls.length) }, worker))
|
||||
return { lists, total: lists.reduce((n, l) => n + (l.count ?? 0), 0) }
|
||||
}
|
||||
|
||||
countUrl(url: string): Promise<ListCount> {
|
||||
const hit = this.#cache.get(url)
|
||||
if (hit !== undefined && hit.until > this.#now()) return Promise.resolve(hit.result)
|
||||
const running = this.#inflight.get(url)
|
||||
if (running !== undefined) return running
|
||||
const job = this.#download(url).then((result) => {
|
||||
if (this.#cache.size >= MAX_CACHED) this.#prune()
|
||||
this.#cache.set(url, { until: this.#now() + (result.count === null ? this.#failureTtl : this.#ttl), result })
|
||||
return result
|
||||
})
|
||||
this.#inflight.set(url, job)
|
||||
void job.finally(() => this.#inflight.delete(url))
|
||||
return job
|
||||
}
|
||||
|
||||
#prune(): void {
|
||||
const now = this.#now()
|
||||
for (const [key, entry] of this.#cache) if (entry.until <= now) this.#cache.delete(key)
|
||||
while (this.#cache.size >= MAX_CACHED) this.#cache.delete(this.#cache.keys().next().value as string)
|
||||
}
|
||||
|
||||
async #allowed(url: URL): Promise<void> {
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Refused('only http and https lists are read')
|
||||
if (url.username !== '' || url.password !== '') throw new Refused('a list address may not carry credentials')
|
||||
if (this.#allowPrivate) return
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '')
|
||||
const addresses = isIP(host) !== 0 ? [host] : await this.#lookup(host)
|
||||
if (addresses.length === 0 || !addresses.every(publicAddress)) throw new Refused('the address of this list is not a public one')
|
||||
}
|
||||
|
||||
async #download(start: string): Promise<ListCount> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), this.#timeout)
|
||||
try {
|
||||
let url = new URL(start)
|
||||
for (let hop = 0; ; hop++) {
|
||||
await this.#allowed(url)
|
||||
const res = await this.#fetch(url, { redirect: 'manual', signal: controller.signal, headers: { accept: 'text/plain, */*' } })
|
||||
if (res.status >= 300 && res.status < 400 && res.headers.get('location') !== null) {
|
||||
void res.body?.cancel()
|
||||
if (hop >= MAX_REDIRECTS) throw new Refused('too many redirects')
|
||||
url = new URL(res.headers.get('location') as string, url)
|
||||
continue
|
||||
}
|
||||
if (!res.ok) {
|
||||
void res.body?.cancel()
|
||||
throw new Refused(`the list server answered ${res.status}`)
|
||||
}
|
||||
const declared = Number(res.headers.get('content-length') ?? 0)
|
||||
if (declared > this.#maxBytes) {
|
||||
void res.body?.cancel()
|
||||
throw new Refused('the list is larger than the limit')
|
||||
}
|
||||
const counter = new DomainCounter()
|
||||
const decoder = new TextDecoder()
|
||||
let bytes = 0
|
||||
if (res.body !== null) {
|
||||
const reader = res.body.getReader()
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
bytes += value.byteLength
|
||||
if (bytes > this.#maxBytes) {
|
||||
void reader.cancel()
|
||||
throw new Refused('the list is larger than the limit')
|
||||
}
|
||||
counter.push(decoder.decode(value, { stream: true }))
|
||||
}
|
||||
counter.push(decoder.decode())
|
||||
}
|
||||
return { url: start, count: counter.end() }
|
||||
}
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Refused ? e.message : e instanceof Error && e.name === 'AbortError' ? 'timed out' : 'could not be downloaded'
|
||||
return { url: start, count: null, error: message }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export interface HubConfig {
|
||||
webhookUrl: string | null
|
||||
/** Signs the notices with HMAC-SHA256 (HUB_WEBHOOK_SECRET); null when not set. Never logged. */
|
||||
webhookSecret: string | null
|
||||
/** Lets the block list counter fetch from private addresses (HUB_LISTS_ALLOW_PRIVATE=1), for lists kept on the LAN. */
|
||||
listsAllowPrivate: boolean
|
||||
/** Shown as `dnsServerDomain` (and `clusterDomain`) in the session. */
|
||||
domain: string
|
||||
/** The console's static build. */
|
||||
@@ -111,6 +113,7 @@ export function parseConfig(env: Env): HubConfig {
|
||||
timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1),
|
||||
webhookUrl: webhookUrl === '' ? null : webhookUrl,
|
||||
webhookSecret: (env.HUB_WEBHOOK_SECRET ?? '') === '' ? null : (env.HUB_WEBHOOK_SECRET as string),
|
||||
listsAllowPrivate: ['1', 'true'].includes((env.HUB_LISTS_ALLOW_PRIVATE ?? '').trim().toLowerCase()),
|
||||
domain: env.HUB_DOMAIN?.trim() || 'hub',
|
||||
staticDir: env.HUB_STATIC?.trim() || './dist',
|
||||
}
|
||||
|
||||
@@ -53,6 +53,23 @@ describe('mergeStats counters', () => {
|
||||
expect(Object.keys(merged.stats)).toEqual(Object.keys(stats()))
|
||||
})
|
||||
|
||||
it('sums what each server holds: zones, cache, allowed, blocked and the two lists', () => {
|
||||
const a = response({ stats: stats({ zones: 3, cachedEntries: 400, allowedZones: 1, blockedZones: 2, allowListZones: 1, blockListZones: 1000 }) })
|
||||
const b = response({ stats: stats({ zones: 5, cachedEntries: 50, allowedZones: 4, blockedZones: 6, allowListZones: 2, blockListZones: 10 }) })
|
||||
const merged = mergeStats([a, b])
|
||||
expect(merged.stats).toMatchObject({ zones: 8, cachedEntries: 450, allowedZones: 5, blockedZones: 8, allowListZones: 3, blockListZones: 1010 })
|
||||
})
|
||||
|
||||
it('counts a numeric string, and a server that left a field out as none', () => {
|
||||
const a = response({ stats: { ...stats(), zones: '4' as unknown as number } })
|
||||
const b = response({ stats: { totalQueries: 1 } })
|
||||
const merged = mergeStats([a, b])
|
||||
expect(merged.stats.zones).toBe(4)
|
||||
expect(merged.stats.allowedZones).toBe(1)
|
||||
expect(merged.stats.totalQueries).toBe(101)
|
||||
expect(mergeStats([b]).stats.blockListZones).toBe(0)
|
||||
})
|
||||
|
||||
it('returns a single response with the same numbers', () => {
|
||||
const merged = mergeStats([response()])
|
||||
expect(merged.stats).toEqual(stats())
|
||||
|
||||
+16
-1
@@ -162,6 +162,16 @@ export function mergeTopResponses(
|
||||
return { topDomains: [] }
|
||||
}
|
||||
|
||||
/* A counter as a number: a number, or a numeric string some servers send. Anything else is not a counter. */
|
||||
function counter(value: unknown): number | null {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) return Number(value)
|
||||
return null
|
||||
}
|
||||
|
||||
/* What a server holds, as the "Server" panel of the Dashboard counts it. */
|
||||
const HELD = ['zones', 'cachedEntries', 'allowedZones', 'blockedZones', 'allowListZones', 'blockListZones']
|
||||
|
||||
/*
|
||||
`dashboard/stats/get`. Every numeric counter in `stats` is summed. That includes
|
||||
`totalClients` (distinct clients), which over-counts a client seen by both servers:
|
||||
@@ -174,9 +184,14 @@ export function mergeStats(responses: DashboardStats[]): DashboardStats {
|
||||
const stats: Record<string, number> = {}
|
||||
for (const r of responses) {
|
||||
for (const [key, value] of Object.entries(r.stats ?? {})) {
|
||||
if (typeof value === 'number') stats[key] = (stats[key] ?? 0) + value
|
||||
const n = counter(value)
|
||||
if (n !== null) stats[key] = (stats[key] ?? 0) + n
|
||||
}
|
||||
}
|
||||
/* The Dashboard's "Server" panel counts what each server holds (zones, cache, allow and block
|
||||
lists). Those are sums too, and a server that left one out counts as none, so the field is
|
||||
never missing from the total. */
|
||||
for (const key of HELD) stats[key] ??= 0
|
||||
|
||||
const merged: DashboardStats = { ...first, stats }
|
||||
const main = mergeChart(responses.map((r) => r.mainChartData), true)
|
||||
|
||||
@@ -185,6 +185,8 @@ export const ROUTES: Record<string, Route> = {
|
||||
'hub/settings/baseline/save': writeAll('admin'),
|
||||
'hub/settings/overrides/save': writeAll('admin'),
|
||||
'hub/settings/apply': writeAll('admin'),
|
||||
// The domains in an Advanced Blocking app's block lists, counted by the hub (app-lists.ts). One server's app.
|
||||
'hub/blocking/app-lists/count': readOne(),
|
||||
'hub/blocking/lists/compare': read('admin'),
|
||||
'hub/blocking/lists/preview': read('admin'),
|
||||
'hub/blocking/lists/baseline/import': writeAll('admin'),
|
||||
|
||||
@@ -35,6 +35,7 @@ import { Drift } from './drift.ts'
|
||||
import { Webhook, type WebhookOptions } from './webhook.ts'
|
||||
import { Profiles } from './profiles.ts'
|
||||
import { SettingsConfig } from './settings-config.ts'
|
||||
import { AppLists } from './app-lists.ts'
|
||||
import { BLOCKING_FIELDS } from './settings-sync.ts'
|
||||
import { ZoneUserError, ZonesSync, buildZoneMatrix } from './zones-sync.ts'
|
||||
import {
|
||||
@@ -167,6 +168,7 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
const appsConfig = new AppsConfig(config, upstream)
|
||||
const zonesSync = new ZonesSync(config, upstream)
|
||||
const settingsConfig = new SettingsConfig(config, upstream)
|
||||
const appLists = new AppLists(upstream, { allowPrivate: config.listsAllowPrivate })
|
||||
const profiles = new Profiles(config, upstream, appsConfig, settingsConfig, writer)
|
||||
const byId = new Map(config.servers.map((s) => [s.id, s]))
|
||||
|
||||
@@ -917,6 +919,20 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
await profilesRead(res, apiPath, params, who, ip)
|
||||
return
|
||||
}
|
||||
if (apiPath === 'hub/blocking/app-lists/count') {
|
||||
const server = byId.get(params.node ?? '')
|
||||
if (server === undefined) {
|
||||
send(res, 400, fail('Choose a server: the lists are counted for one server at a time.'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
send(res, 200, ok(await appLists.count(server, params.name || 'Advanced Blocking')))
|
||||
} catch (e) {
|
||||
if (!(e instanceof UpstreamError)) throw e
|
||||
send(res, 200, fail(e.kind === 'unreachable' ? `${server.id} did not answer: ${e.message}` : `${server.id}: ${e.message}`))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) {
|
||||
await settingsRead(res, apiPath, params, who, ip)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
|
||||
/*
|
||||
`hub/blocking/app-lists/count`: how many domains the block lists of the Advanced Blocking app
|
||||
hold on one server. The hub downloads them (the app has no API for it) and caches the counts
|
||||
for hours. A list it could not read has `count: null` and the reason in `error`.
|
||||
*/
|
||||
export interface ListCount {
|
||||
url: string
|
||||
count: number | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ListCounts {
|
||||
lists: ListCount[]
|
||||
/** The sum of the lists that were counted. */
|
||||
total: number
|
||||
}
|
||||
|
||||
export function getAppListCounts(
|
||||
token: string | null,
|
||||
name: string,
|
||||
node: string,
|
||||
): Promise<ApiOutcome<{ response: ListCounts }>> {
|
||||
return apiRequest('hub/blocking/app-lists/count', { token, body: { name, node } })
|
||||
}
|
||||
@@ -6,6 +6,9 @@ only its outline, in the entry's own ink.
|
||||
*/
|
||||
.mark {
|
||||
margin-left: auto;
|
||||
/* Clear of the pin star, which sits over the entry's end (`.star` in Shell.module.css:
|
||||
24px wide at --s-3 from the edge, inside the entry's --s-5 padding), plus a --s-2 gap. */
|
||||
margin-right: calc(24px + var(--s-3) - var(--s-5) + var(--s-2));
|
||||
padding: 0 var(--s-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-full);
|
||||
|
||||
@@ -4,7 +4,8 @@ import styles from './BlockingMark.module.css'
|
||||
|
||||
/*
|
||||
The sidebar's mark on Blocking: whether blocking is disabled or paused, from
|
||||
anywhere in the console. OURS; what it says and when it reads are in
|
||||
anywhere in the console. Nothing while the Advanced Blocking app is blocking in the
|
||||
built-in blocking's place. OURS; what it says and when it reads are in
|
||||
`screens/blocking/blocking-state.ts`. The only count or state the sidebar carries.
|
||||
|
||||
The link's accessible name gains the sentence (`Blocking, paused until 14:30`)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { Shell, type ShellSession } from './Shell'
|
||||
import { ThemeProvider } from '../theme/ThemeProvider'
|
||||
import * as client from '../api/client'
|
||||
import * as settings from '../api/settings'
|
||||
import type { DnsSettings } from '../api/settings'
|
||||
import { forgetRoot } from './route'
|
||||
import { AB_CONFIG, serveAdvancedBlocking } from '../screens/blocking/advanced-blocking.fixture'
|
||||
|
||||
/*
|
||||
The sidebar's mark and the header's status when the built-in blocking is off but the Advanced
|
||||
Blocking app blocks (`blocking-state.ts`): no OFF mark, no attention; and as before when the app
|
||||
is not blocking.
|
||||
*/
|
||||
|
||||
function session(): ShellSession {
|
||||
const all = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs']
|
||||
return {
|
||||
token: 'tok',
|
||||
displayName: 'Administrator',
|
||||
username: 'admin',
|
||||
info: {
|
||||
version: '15.6',
|
||||
uptimestamp: '2026-10-15T08:00:00Z',
|
||||
dnsServerDomain: 'dns.example.net',
|
||||
permissions: Object.fromEntries(all.map((s) => [s, { canView: true, canModify: true, canDelete: true }])),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const mount = () => render(<ThemeProvider><Shell session={session()} onLogout={() => {}} /></ThemeProvider>)
|
||||
const entry = () => within(screen.getByRole('navigation', { name: 'Sections' })).getByRole('link', { name: /^Blocking/ })
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
forgetRoot()
|
||||
window.history.replaceState(null, '', '/about/')
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: {} } } as never)
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('Shell: the Blocking mark with the Advanced Blocking app', () => {
|
||||
it('built-in off, the app blocking: no mark', async () => {
|
||||
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
|
||||
const { get } = serveAdvancedBlocking()
|
||||
mount()
|
||||
await vi.waitFor(() => expect(get).toHaveBeenCalled())
|
||||
// Let the answer land: the mark must stay away, not just not have come yet.
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking'))
|
||||
expect(entry()).not.toHaveTextContent('Off')
|
||||
expect(entry()).not.toHaveAttribute('title')
|
||||
})
|
||||
|
||||
it('built-in off, the app installed but not blocking: Off, as before', async () => {
|
||||
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
|
||||
serveAdvancedBlocking({ ...AB_CONFIG, enableBlocking: false })
|
||||
mount()
|
||||
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking, disabled'))
|
||||
})
|
||||
})
|
||||
@@ -173,7 +173,7 @@ it to hang a control off would be undoing that in order to follow a drawing.
|
||||
`:empty` is what keeps it from taking up room when nobody uses it: without it,
|
||||
seven of eight screens would gain a blank gap.
|
||||
*/
|
||||
.chromeSlot { display: flex; align-items: center; gap: var(--s-6); margin-bottom: var(--s-8); }
|
||||
.chromeSlot { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-6); margin-bottom: var(--s-8); }
|
||||
.chromeSlot:empty { display: none; }
|
||||
|
||||
/*
|
||||
|
||||
+8
-2
@@ -37,7 +37,7 @@ import { HubServerIds } from '../lib/hub-all'
|
||||
import { FooterLinks } from '../ui/FooterLinks'
|
||||
import { Versions } from './Versions'
|
||||
import { BlockingMark } from './BlockingMark'
|
||||
import { useBlockingMark } from '../screens/blocking/blocking-state'
|
||||
import { useBlockingMark, useHubBlockingMark } from '../screens/blocking/blocking-state'
|
||||
import { useHubWarnings } from '../api/hub-warnings'
|
||||
import { refreshHubDrift, resetHubDrift, setHubDriftCount } from '../api/hub-drift'
|
||||
import { DriftStatus } from './DriftStatus'
|
||||
@@ -280,7 +280,13 @@ export function Shell({
|
||||
|
||||
const current = sections.find((s) => s.id === active) ?? sections[0]
|
||||
/* Whether blocking is off or paused, marked on its entry (`BlockingMark.tsx`). */
|
||||
const blockingNow = useBlockingMark(session.token, permissions, sections.some((s) => s.id === 'blocking'), hub)
|
||||
/* Behind a hub the Advanced Blocking app is asked on the first server (the one `settings/get` answers for). */
|
||||
const markNode = hub ? (session.info?.clusterNodes?.[0]?.name ?? '') : ''
|
||||
const showMark = sections.some((s) => s.id === 'blocking')
|
||||
const singleMark = useBlockingMark(session.token, permissions, showMark && !hub, hub, markNode)
|
||||
/* Behind a hub, every server counts: the mark names the ones that block by nothing. */
|
||||
const hubNow = useHubBlockingMark(session.token, permissions, showMark && hub, hubServers)
|
||||
const blockingNow = hub ? hubNow : singleMark
|
||||
|
||||
/*
|
||||
The jumps other screens' row menus make (`app/handoff.ts`). The section switch
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { countDomains } from './list-count'
|
||||
|
||||
describe('countDomains', () => {
|
||||
it('counts hosts lines, skipping comments and the hosts file own names', () => {
|
||||
const text = [
|
||||
'# Title: a list',
|
||||
'127.0.0.1 localhost',
|
||||
'::1 ip6-localhost',
|
||||
'0.0.0.0 ads.example.com',
|
||||
'0.0.0.0 track.example.com # inline',
|
||||
'0.0.0.0 ads.example.com',
|
||||
'',
|
||||
].join('\r\n')
|
||||
expect(countDomains(text)).toBe(2)
|
||||
})
|
||||
|
||||
it('counts plain domains and wildcards once each', () => {
|
||||
expect(countDomains('a.example\nB.example\n*.c.example\na.example\n! note\n')).toBe(3)
|
||||
})
|
||||
|
||||
it('counts adblock domain rules, not exceptions, options-only lines or regexes', () => {
|
||||
const text = ['[Adblock Plus 2.0]', '! Title', '||ads.example.com^', '||t.example.net^$third-party', '@@||ok.example.com^', '/banner[0-9]+/', '##.ad'].join('\n')
|
||||
expect(countDomains(text)).toBe(2)
|
||||
})
|
||||
|
||||
it('counts several names on one hosts line', () => {
|
||||
expect(countDomains('0.0.0.0 a.example b.example')).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
How many distinct domains a block list file holds. OURS, for the Blocking Overview's
|
||||
"Block List Domains" figure when the Advanced Blocking app is in use: the app has no API
|
||||
that says it, so the lists are downloaded and counted. The hub has its own copy of this
|
||||
function (`hub/app-lists.ts`), kept in step by hand because the hub does not share code with
|
||||
the console build; both are tested against the same sample.
|
||||
|
||||
Counted: a hosts line (`0.0.0.0 example.com`, every name after the address), a plain
|
||||
domain (`example.com`, `*.example.com`), and an Adblock Plus domain rule (`||example.com^`,
|
||||
with or without options). Not counted: blank lines, comments (`#`, `!`, `[Adblock Plus]`),
|
||||
Adblock exceptions (`@@`), regular expressions, and the hosts file's own names (`localhost`,
|
||||
`broadcasthost`, `ip6-*`).
|
||||
*/
|
||||
const ADDRESS = /^(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9a-f:]*:[0-9a-f:]*)$/i
|
||||
const NAME = /^[a-z0-9_]([a-z0-9_.-]*[a-z0-9_])?$/i
|
||||
const OWN = new Set(['localhost', 'localhost.localdomain', 'local', 'broadcasthost', '0.0.0.0'])
|
||||
|
||||
function accept(raw: string, into: Set<string>): void {
|
||||
const name = raw.replace(/^\*\./, '').replace(/\.$/, '').toLowerCase()
|
||||
if (name === '' || OWN.has(name) || name.startsWith('ip6-') || !NAME.test(name)) return
|
||||
into.add(name)
|
||||
}
|
||||
|
||||
export function countDomains(text: string): number {
|
||||
const domains = new Set<string>()
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (t === '' || t.startsWith('#') || t.startsWith('!') || t.startsWith('[') || t.startsWith('@@') || t.startsWith('/')) continue
|
||||
if (t.startsWith('||')) {
|
||||
const end = t.search(/[\^$/]/)
|
||||
accept(end < 0 ? t.slice(2) : t.slice(2, end), domains)
|
||||
continue
|
||||
}
|
||||
const tokens = t.split('#')[0].trim().split(/\s+/)
|
||||
if (tokens.length > 1 && ADDRESS.test(tokens[0])) for (const name of tokens.slice(1)) accept(name, domains)
|
||||
else if (tokens.length === 1) accept(tokens[0], domains)
|
||||
}
|
||||
return domains.size
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function AppCard({
|
||||
<li className={styles.gridRow} aria-label={app.name}>
|
||||
<div className={styles.nameCell}>
|
||||
<h2>{app.name}</h2>
|
||||
{app.description && <p className={styles.desc}>{app.description}</p>}
|
||||
{app.description && <p className={`${styles.desc} line-clamp-2`} title={app.description}>{app.description}</p>}
|
||||
</div>
|
||||
|
||||
<div className={styles.mono}>
|
||||
|
||||
@@ -18,7 +18,8 @@ import { StoreApps } from './StoreApps'
|
||||
import { Panel } from '../../ui/Panel'
|
||||
import { PageTabs } from '../../ui/PageTabs'
|
||||
import { UpdateApp } from './UpdateApp'
|
||||
import { Empty, Failure, Loading } from '../../ui/Empty'
|
||||
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||
import { Menu } from '../../ui/Menu'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { useSort } from '../../ui/Table'
|
||||
import { Icon } from '../../ui/Icon'
|
||||
@@ -266,9 +267,16 @@ export function Apps({
|
||||
{hubView === 'store' ? 'Back to apps' : 'App Store'}
|
||||
</Button>
|
||||
<Button onClick={() => setFileDialog(true)}>Install from file</Button>
|
||||
<Button onClick={() => void exportFile()}>Export baselines</Button>
|
||||
<Button onClick={() => setImportDialog(true)}>Import baselines</Button>
|
||||
{isAdmin && <Button onClick={() => setProfilesDialog(true)}>Profiles</Button>}
|
||||
{/* The three that move a whole set of apps share one menu: five buttons made a row of their own. */}
|
||||
<Menu label="Baselines" text="Baselines">
|
||||
{(close) => (
|
||||
<>
|
||||
<button type="button" onClick={() => { close(); void exportFile() }}>Export baselines</button>
|
||||
<button type="button" onClick={() => { close(); setImportDialog(true) }}>Import baselines</button>
|
||||
{isAdmin && <button type="button" onClick={() => { close(); setProfilesDialog(true) }}>Profiles</button>}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
@@ -347,12 +355,12 @@ export function Apps({
|
||||
{tab === 'store' && mayChange ? (
|
||||
<StoreApps token={token} node={requestNode} onChanged={() => void reload()} />
|
||||
) : apps === null ? (
|
||||
<Loading />
|
||||
<LoadingRegion height="h-48">Loading…</LoadingRegion>
|
||||
) : failure ? (
|
||||
/* Solid and with no invitation: whether there are apps is not known, so
|
||||
the store is not offered. The server's message goes in the notice
|
||||
above. */
|
||||
<Failure>Could not load the installed apps.</Failure>
|
||||
<FailureRetry onRetry={() => void reload()}>Could not load the installed apps.</FailureRetry>
|
||||
) : apps.length === 0 ? (
|
||||
<Empty
|
||||
title="No apps installed"
|
||||
|
||||
@@ -232,12 +232,12 @@ describe('Export and import of baselines', () => {
|
||||
it('offers both to writers only', async () => {
|
||||
const { unmount } = mountApps(false)
|
||||
await screen.findByText('No apps installed')
|
||||
expect(screen.queryByRole('button', { name: 'Export baselines' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Import baselines' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Baselines' })).not.toBeInTheDocument()
|
||||
unmount()
|
||||
mountApps(true)
|
||||
expect(await screen.findByRole('button', { name: 'Export baselines' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Import baselines' })).toBeInTheDocument()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Baselines' }))
|
||||
expect(await screen.findByRole('menuitem', { name: 'Export baselines' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Import baselines' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('downloads the file the hub sends', async () => {
|
||||
@@ -246,7 +246,8 @@ describe('Export and import of baselines', () => {
|
||||
const create = vi.fn(() => 'blob:x')
|
||||
Object.assign(URL, { createObjectURL: create, revokeObjectURL: vi.fn() })
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Export baselines' }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Baselines' }))
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: 'Export baselines' }))
|
||||
await waitFor(() => expect(click).toHaveBeenCalled())
|
||||
expect(exported).toHaveBeenCalledWith('t')
|
||||
expect(create).toHaveBeenCalled()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getAppsMatrix, getDrift, listStoreApps, runHubApps, type AppsMatrixData
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { DataTable, type Col } from '../../ui/DataTable'
|
||||
import { Empty, Failure, Loading } from '../../ui/Empty'
|
||||
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||
import { Panel } from '../../ui/Panel'
|
||||
import { Segmented } from '../../ui/Segmented'
|
||||
import { Menu } from '../../ui/Menu'
|
||||
@@ -75,7 +75,15 @@ function ConfigState({ app, data, onOpen }: { app: MatrixApp; data: AppsMatrixDa
|
||||
|
||||
function ServerCell({ app, id, data, drifted }: { app: MatrixApp; id: string; data: AppsMatrixData; drifted: boolean }) {
|
||||
const cell = app.servers[id]
|
||||
if (data.servers.find((s) => s.id === id)?.ok === false) return <span className="text-sm text-muted-foreground">Unknown</span>
|
||||
if (data.servers.find((s) => s.id === id)?.ok === false) {
|
||||
return (
|
||||
<Tooltip text={`${id} did not answer`} placement="top">
|
||||
<span className="text-sm text-muted-foreground" tabIndex={0}>
|
||||
Unknown
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
if (cell === undefined || !cell.installed) return <span className="text-sm text-muted-foreground">Not installed</span>
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-2 text-sm">
|
||||
@@ -135,30 +143,20 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
|
||||
)
|
||||
}, [data])
|
||||
|
||||
const retry = (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setFailure(null)
|
||||
setData(null)
|
||||
void load()
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (failure !== null && data === null) {
|
||||
return (
|
||||
<Failure>
|
||||
<span className="flex flex-col items-center gap-3">
|
||||
<span>Could not load the apps of the servers. {failure}</span>
|
||||
{retry}
|
||||
</span>
|
||||
</Failure>
|
||||
<FailureRetry
|
||||
onRetry={() => {
|
||||
setFailure(null)
|
||||
setData(null)
|
||||
void load()
|
||||
}}
|
||||
>
|
||||
Could not load the apps of the servers. {failure}
|
||||
</FailureRetry>
|
||||
)
|
||||
}
|
||||
if (data === null) return <Loading>Loading the apps of every server…</Loading>
|
||||
if (data === null) return <LoadingRegion height="h-64">Loading the apps of every server…</LoadingRegion>
|
||||
|
||||
if (data.apps.length === 0) {
|
||||
return (
|
||||
@@ -249,7 +247,11 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
|
||||
cell: (a) => (
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">{a.name}</span>
|
||||
{a.description !== '' && <span className="text-xs text-muted-foreground">{a.description}</span>}
|
||||
{a.description !== '' && (
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground" title={a.description}>
|
||||
{a.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -98,7 +98,7 @@ export function HubStore({
|
||||
</Tag>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{app.description}</span>
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground" title={app.description}>{app.description}</span>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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,321 @@
|
||||
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 { 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'
|
||||
import tbl from '../../ui/Table.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 whitespace-nowrap">
|
||||
<Icon name={f?.kind === 'allow' ? 'allowed' : 'blocked'} size={14} />
|
||||
{labelOf(r.field)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'value',
|
||||
header: kind === 'lists' ? 'List' : 'Rule',
|
||||
td: () => ({ className: shared.entryCell }),
|
||||
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'
|
||||
? <span className={shared.entryUrl} title={r.value}>{r.value}</span>
|
||||
: <span className={shared.entryValue}>{r.value}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
td: () => ({ className: `${shared.entryActions} ${tbl.actionsCell}` }),
|
||||
cell: (r) =>
|
||||
editing?.key === r.key ? (
|
||||
<div className={tbl.actions}>
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div className={tbl.actions}>
|
||||
<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>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{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')
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,8 @@ import { PermissionButton } from '../../ui/PermissionButton'
|
||||
import { Confirm } from '../../ui/Confirm'
|
||||
import { Notifier } from '../../ui/Notifier'
|
||||
import { Failure, Loading } from '../../ui/Empty'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { KpiTile } from '../dashboard/Figures'
|
||||
import { RouteLink } from '../../ui/RouteLink'
|
||||
import { Icon } from '../../ui/Icon'
|
||||
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
type ListLine,
|
||||
} from './list-lines'
|
||||
import { Locked } from './Locked'
|
||||
import { AdvancedBlockingEntries } from './AdvancedBlockingEntries'
|
||||
import { hostsOf, moreLists } from './extra-lists'
|
||||
import { missing, switchNeed, type Permissions } from './permissions'
|
||||
import shared from './Blocking.module.css'
|
||||
@@ -60,6 +63,8 @@ every alert of the update and the save. Everything else is OURS:
|
||||
`removed`; `list-lines.ts`, WHAT CHANGED), `Discard`, and `Save`, shortened from
|
||||
upstream's "Save Settings" (index.html:2460); and the Lists tab's mark while there
|
||||
are unsaved changes, read as `unsaved changes` (`onDirty`, `Blocking.tsx`);
|
||||
- the Advanced Blocking panel under the table (`AdvancedBlockingEntries.tsx`): the app's own
|
||||
list URLs by group, when the app is installed. Saved at once, not with this tab's Save;
|
||||
- the two `Could not read …` sentences and the link to Settings › Blocking;
|
||||
- Quick Add's search (`Search lists`, `No lists match`) and its second catalogue, the
|
||||
console's own (`extra-lists.ts`), under `More lists` after Technitium's.
|
||||
@@ -200,6 +205,7 @@ function HeadingEditor({
|
||||
|
||||
export function BlockLists({
|
||||
tabs,
|
||||
actions,
|
||||
token,
|
||||
permissions,
|
||||
clusterInitialised = false,
|
||||
@@ -207,6 +213,7 @@ export function BlockLists({
|
||||
allServers = false,
|
||||
}: {
|
||||
tabs?: ReactNode
|
||||
actions?: ReactNode
|
||||
token: string | null
|
||||
permissions: Permissions
|
||||
clusterInitialised?: boolean
|
||||
@@ -370,7 +377,7 @@ export function BlockLists({
|
||||
if (viewNeed != null) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Blocking" subtitle={LISTS_SUBTITLE} tabs={tabs} />
|
||||
<SectionHeader title="Blocking" subtitle={LISTS_SUBTITLE} tabs={tabs} actions={actions} />
|
||||
<Locked title="Block Lists" need={viewNeed} />
|
||||
</>
|
||||
)
|
||||
@@ -583,7 +590,7 @@ export function BlockLists({
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Blocking" subtitle={LISTS_SUBTITLE} tabs={tabs} />
|
||||
<SectionHeader title="Blocking" subtitle={LISTS_SUBTITLE} tabs={tabs} actions={actions} />
|
||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||
|
||||
<div className={shared.stack}>
|
||||
@@ -594,50 +601,39 @@ export function BlockLists({
|
||||
<Locked title="Allow List Domains" need={statsNeed} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Panel className={shared.centred}><Body>
|
||||
<div className={shared.kpiText}>
|
||||
{countsFailed ? (
|
||||
<Failure>{COUNTS_FAILED}</Failure>
|
||||
) : (
|
||||
<span className={`${shared.kpiValue}${reloading != null ? ` ${styles.stale}` : ''}`}>
|
||||
{counts ? counts.block.toLocaleString() : '—'}
|
||||
</span>
|
||||
)}
|
||||
{reloading != null ? (
|
||||
<span className={styles.pending}>{UPDATING}</span>
|
||||
) : (
|
||||
<span className={shared.kpiSub}>{'\u00a0'}</span>
|
||||
)}
|
||||
<span className={shared.kpiLabel}>Block List Domains</span>
|
||||
</div>
|
||||
</Body></Panel>
|
||||
<Panel className={shared.centred}><Body>
|
||||
<div className={shared.kpiText}>
|
||||
{countsFailed ? (
|
||||
<Failure>{COUNTS_FAILED}</Failure>
|
||||
) : (
|
||||
<span className={`${shared.kpiValue}${reloading != null ? ` ${styles.stale}` : ''}`}>
|
||||
{counts ? counts.allow.toLocaleString() : '—'}
|
||||
</span>
|
||||
)}
|
||||
{reloading != null ? (
|
||||
<span className={styles.pending}>{UPDATING}</span>
|
||||
) : (
|
||||
<span className={shared.kpiSub}>{'\u00a0'}</span>
|
||||
)}
|
||||
<span className={shared.kpiLabel}>Allow List Domains</span>
|
||||
</div>
|
||||
</Body></Panel>
|
||||
</>
|
||||
/* The Dashboard's KPI look, as the Overview draws it: a Frame of tiles. */
|
||||
<Frame className={shared.kpiPair}>
|
||||
<FramePanel className={shared.kpiTiles}>
|
||||
<KpiTile
|
||||
label="Block List Domains"
|
||||
icon="logs"
|
||||
colour="var(--ch-block)"
|
||||
figure={counts && !countsFailed ? counts.block.toLocaleString() : '\u2014'}
|
||||
noData={counts == null || countsFailed}
|
||||
wrap
|
||||
>
|
||||
{countsFailed ? COUNTS_FAILED : reloading != null ? <span className={styles.pending}>{UPDATING}</span> : ' '}
|
||||
</KpiTile>
|
||||
<KpiTile
|
||||
label="Allow List Domains"
|
||||
icon="check"
|
||||
colour="var(--ch-ok)"
|
||||
figure={counts && !countsFailed ? counts.allow.toLocaleString() : '\u2014'}
|
||||
noData={counts == null || countsFailed}
|
||||
wrap
|
||||
>
|
||||
{countsFailed ? COUNTS_FAILED : reloading != null ? <span className={styles.pending}>{UPDATING}</span> : ' '}
|
||||
</KpiTile>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)}
|
||||
<Panel className={shared.centred}><Body>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className={shared.kpi}>
|
||||
<div className={shared.kpiText}>
|
||||
<span className={shared.kpiValue}>{loading ? '—' : nextUpdateText(next)}</span>
|
||||
<span className={shared.kpiSub}>{'\u00a0'}</span>
|
||||
<span className={shared.kpiLabel}>
|
||||
{/* Ordinary type: a date or "Not Scheduled" is a sentence, not a figure to read from afar. */}
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{loading ? '\u2014' : nextUpdateText(next)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Next update{interval != null && interval > 0 ? ` · every ${interval} h` : ''}
|
||||
</span>
|
||||
</div>
|
||||
@@ -875,6 +871,8 @@ export function BlockLists({
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<AdvancedBlockingEntries kind="lists" token={token} permissions={permissions} node={hubNode} onNotice={setNotice} />
|
||||
|
||||
{dirty && (
|
||||
<div className={styles.bar}>
|
||||
<span>Unsaved changes to the block list URLs: {changes}</span>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as blocking from '../../api/blocking'
|
||||
import * as settings from '../../api/settings'
|
||||
import * as dashboard from '../../api/dashboard'
|
||||
import * as quick from '../../lib/quick-lists'
|
||||
import { BlockLists } from './BlockLists'
|
||||
import { Rules } from './Rules'
|
||||
import { AB_CONFIG, serveAdvancedBlocking } from './advanced-blocking.fixture'
|
||||
|
||||
/* The Lists and Rules tabs carry the Advanced Blocking app's own entries under the built-in ones. */
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function builtIn() {
|
||||
vi.spyOn(blocking, 'readRuleExport').mockResolvedValue({ kind: 'ok', data: ['own.example'] })
|
||||
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'error', message: 'n/a' })
|
||||
vi.spyOn(quick, 'loadQuickList').mockResolvedValue([])
|
||||
vi.spyOn(settings, 'readSettings').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { blockListUrls: ['https://built-in.example/list'], blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: null } as never,
|
||||
})
|
||||
}
|
||||
|
||||
describe('Lists tab', () => {
|
||||
it('shows the app lists by group under the built-in ones', async () => {
|
||||
builtIn()
|
||||
serveAdvancedBlocking()
|
||||
render(<BlockLists token="T" permissions={undefined} />)
|
||||
expect(await screen.findByText('https://built-in.example/list')).toBeInTheDocument()
|
||||
expect(await screen.findByText('https://lists.example/hosts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Advanced Blocking lists')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has no Advanced Blocking panel without the app', async () => {
|
||||
builtIn()
|
||||
serveAdvancedBlocking(AB_CONFIG, false)
|
||||
render(<BlockLists token="T" permissions={undefined} />)
|
||||
expect(await screen.findByText('https://built-in.example/list')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Advanced Blocking lists')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rules tab', () => {
|
||||
it('shows the app rules by group under the built-in ones', async () => {
|
||||
builtIn()
|
||||
serveAdvancedBlocking()
|
||||
render(<Rules token="T" permissions={undefined} />)
|
||||
expect(await screen.findByText('ads.example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('Advanced Blocking rules')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { SlotProvider } from '../../app/ChromeSlot'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { HubWritable } from '../../lib/hub-write'
|
||||
import { Blocking } from './Blocking'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
|
||||
/*
|
||||
Where the Blocking header's pieces go, as the Dashboard's: the server selector up in the chrome
|
||||
slot (once), the comparison of the servers among the header's actions, the tabs under the
|
||||
header, and the hub's "choose a server" line under it all.
|
||||
*/
|
||||
|
||||
vi.mock('./Overview', () => ({ Overview: () => <p>overview</p> }))
|
||||
vi.mock('./BlockLists', () => ({ BlockLists: () => <p>lists</p> }))
|
||||
vi.mock('../settings/SettingsCompare', () => ({ CompareServersButton: () => <button type="button">Compare servers</button> }))
|
||||
vi.mock('./Rules', () => ({
|
||||
Rules: ({ tabs, actions }: { tabs?: React.ReactNode; actions?: React.ReactNode }) => (
|
||||
<SectionHeader title="Blocking" actions={actions} tabs={tabs} />
|
||||
),
|
||||
}))
|
||||
vi.mock('../../api/settings', () => ({ getSettings: async () => ({ enableBlocking: true }) }))
|
||||
vi.mock('../../api/dashboard', () => ({ getDashboardStats: async () => ({ kind: 'error', message: 'x' }) }))
|
||||
|
||||
afterEach(() => window.history.replaceState(null, '', '/'))
|
||||
|
||||
const NODES = [{ name: 'a', type: 'Primary' }, { name: 'b', type: 'Secondary' }]
|
||||
const ADMIN = { Settings: { canView: true, canModify: true, canDelete: true } }
|
||||
|
||||
function mount(writable: string[]) {
|
||||
return render(
|
||||
<HubMode.Provider value>
|
||||
<HubWritable.Provider value={writable}>
|
||||
<SlotProvider>
|
||||
{(ref) => (
|
||||
<>
|
||||
<div data-testid="slot" ref={ref} />
|
||||
<div data-testid="page">
|
||||
<Blocking token="T" sub="Rules" onSubChange={() => {}} permissions={ADMIN as never} nodes={NODES} clusterInitialised />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SlotProvider>
|
||||
</HubWritable.Provider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Blocking header behind a hub', () => {
|
||||
it('puts the one selector in the chrome slot, not in the page', () => {
|
||||
mount(['a', 'b'])
|
||||
expect(within(screen.getByTestId('slot')).getAllByText('Server')).toHaveLength(1)
|
||||
expect(within(screen.getByTestId('page')).queryByText('Server')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('puts Compare servers among the header actions and the tabs under the header', () => {
|
||||
mount(['a', 'b'])
|
||||
const page = within(screen.getByTestId('page'))
|
||||
expect(page.getByRole('button', { name: 'Compare servers' })).toBeInTheDocument()
|
||||
expect(page.getByRole('link', { name: 'Overview' })).toBeInTheDocument()
|
||||
const heading = page.getByRole('heading', { name: 'Blocking' })
|
||||
// The comparison is after the title, in the same row; the tabs come after both.
|
||||
expect(heading.compareDocumentPosition(page.getByRole('button', { name: 'Compare servers' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(page.getByRole('button', { name: 'Compare servers' }).compareDocumentPosition(page.getByRole('link', { name: 'Overview' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('says why the writes are off under the tabs, not above the title', () => {
|
||||
mount([])
|
||||
const page = screen.getByTestId('page')
|
||||
const hint = within(page).getByRole('status', { name: '' })
|
||||
expect(hint).toHaveTextContent('No admin token configured on the hub')
|
||||
const tabs = within(page).getByRole('link', { name: 'Overview' })
|
||||
expect(tabs.compareDocumentPosition(hint) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,15 @@
|
||||
/*
|
||||
What the three Blocking tabs share. Every value is a token of theme/tokens.css.
|
||||
*/
|
||||
.stack { display: flex; flex-direction: column; gap: var(--gap-block); }
|
||||
.row2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-9); }
|
||||
.row21 { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--s-9); }
|
||||
.kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--s-9); }
|
||||
.stack { display: flex; flex-direction: column; gap: var(--s-8); }
|
||||
.row2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-8); }
|
||||
.row21 { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--s-8); }
|
||||
/* Lists: the third card carries a date and a button, so its column never goes below
|
||||
what they need on one line; with room to spare the three are equal. */
|
||||
.kpis3 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(max-content, 1fr); gap: var(--s-9); }
|
||||
/* Lists: the two list figures are one Frame of tiles, as the Overview draws its strip. */
|
||||
.kpiPair { grid-column: span 2; }
|
||||
.kpiTiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); padding: 0; }
|
||||
|
||||
/*
|
||||
A panel whose content sits in its middle. A grid row stretches its panels to the
|
||||
@@ -53,7 +55,6 @@ second frame, with rounded corners showing under the panel's header. Written und
|
||||
the text; two by two they keep it. Lists keeps its two counts side by side and gives
|
||||
the date and its button the whole width under them. */
|
||||
@media (max-width: 1180px) {
|
||||
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.kpis3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.kpis3 > :last-child { grid-column: 1 / -1; }
|
||||
}
|
||||
@@ -85,4 +86,20 @@ second frame, with rounded corners showing under the panel's header. Written und
|
||||
|
||||
/* The canvas's state strip above the tabs. */
|
||||
.banner { display:flex; flex-wrap:wrap; align-items:center; gap: var(--s-7); padding: var(--s-7) var(--s-9); margin-bottom: var(--s-9); border:1px solid var(--line); border-radius: var(--r-3); background: color-mix(in oklch, var(--pan2) 50%, transparent); font-size: 13px; color: var(--mute); }
|
||||
.bannerAct { margin-left: auto; }
|
||||
.bannerAct { margin-left: auto; flex: none; }
|
||||
/* The sentence is what wraps, not the action beside it. */
|
||||
.bannerText { flex: 1 1 14rem; min-width: 0; }
|
||||
|
||||
/* 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; }
|
||||
/* A list URL on one line; the whole of it is the title. */
|
||||
/* The value column takes what the others leave, and no more: without `max-width: 0` a
|
||||
long URL widens the table past its panel and pushes Edit and Remove out of view. */
|
||||
.entryCell { width: 100%; max-width: 0; }
|
||||
.entryUrl { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); }
|
||||
.entryActions { text-align: right; white-space: nowrap; }
|
||||
.dim { color: var(--mute); }
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { InChrome } from '../../app/ChromeSlot'
|
||||
import { CompareServersButton } from '../settings/SettingsCompare'
|
||||
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
@@ -76,14 +77,17 @@ function Banner({ token, permissions, node }: { token: string | null; permission
|
||||
return (
|
||||
<div className={shared.banner} role="status">
|
||||
{state.kind === 'on' && <Tag tone="ok">Enabled</Tag>}
|
||||
{state.kind === 'app' && <Tag tone="ok">Advanced Blocking</Tag>}
|
||||
{state.kind === 'paused' && <Tag tone="acc">{untilText(state.till)}</Tag>}
|
||||
{state.kind === 'off' && <Tag>Disabled</Tag>}
|
||||
<span>
|
||||
<span className={shared.bannerText}>
|
||||
{state.kind === 'on'
|
||||
? 'Blocking is applied to every query'
|
||||
: state.kind === 'paused'
|
||||
? 'Blocking is paused and resumes automatically'
|
||||
: 'Nothing is being blocked'}
|
||||
: state.kind === 'app'
|
||||
? 'The built-in blocking is off; the Advanced Blocking app is blocking'
|
||||
: state.kind === 'paused'
|
||||
? 'Blocking is paused and resumes automatically'
|
||||
: 'Nothing is being blocked'}
|
||||
</span>
|
||||
{canModify && (
|
||||
<span className={shared.bannerAct}>
|
||||
@@ -151,18 +155,18 @@ export function Blocking({
|
||||
|
||||
const counts = useCounts(token, permissions, hubNode)
|
||||
|
||||
/* The header's actions, where the Dashboard puts its Refresh: the comparison of the servers. */
|
||||
const actions =
|
||||
hub && permissions?.Settings?.canModify === true ? <CompareServersButton token={token} scope="blocking" /> : undefined
|
||||
|
||||
const tabs = (
|
||||
<>
|
||||
{hub && permissions?.Settings?.canModify === true && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CompareServersButton token={token} scope="blocking" />
|
||||
</div>
|
||||
)}
|
||||
{/* The server selector goes up into the chrome slot, as the Dashboard's does. Overview
|
||||
keeps its own there (it also offers "All servers"); these two tabs share this one. */}
|
||||
{hub && active !== 'Overview' && (
|
||||
<>
|
||||
<InChrome>
|
||||
<ClusterNodeSelect nodes={nodes} initialised={clusterInitialised} value={chosen} onChange={setChosen} />
|
||||
<HubWriteHint write={write} />
|
||||
</>
|
||||
</InChrome>
|
||||
)}
|
||||
{active !== 'Overview' && <Banner token={token} permissions={effective} node={hubNode} />}
|
||||
<SubTabs
|
||||
@@ -174,13 +178,14 @@ export function Blocking({
|
||||
marks={listsDirty ? { Lists: 'unsaved changes' } : undefined}
|
||||
counts={counts}
|
||||
/>
|
||||
{hub && active !== 'Overview' && <HubWriteHint write={write} />}
|
||||
</>
|
||||
)
|
||||
|
||||
if (active === 'Rules') {
|
||||
return (
|
||||
<HubNode.Provider value={hubNode}>
|
||||
<Rules tabs={tabs} token={token} permissions={effective} nodes={nodes} clusterInitialised={clusterInitialised} />
|
||||
<Rules tabs={tabs} actions={actions} token={token} permissions={effective} nodes={nodes} clusterInitialised={clusterInitialised} />
|
||||
</HubNode.Provider>
|
||||
)
|
||||
}
|
||||
@@ -189,6 +194,7 @@ export function Blocking({
|
||||
<HubNode.Provider value={hubNode}>
|
||||
<BlockLists
|
||||
tabs={tabs}
|
||||
actions={actions}
|
||||
token={token}
|
||||
permissions={effective}
|
||||
clusterInitialised={clusterInitialised}
|
||||
@@ -201,6 +207,7 @@ export function Blocking({
|
||||
return (
|
||||
<Overview
|
||||
tabs={tabs}
|
||||
actions={actions}
|
||||
token={token}
|
||||
permissions={permissions}
|
||||
nodes={nodes}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as dashboard from '../../api/dashboard'
|
||||
import * as settings from '../../api/settings'
|
||||
import * as appLists from '../../api/app-lists'
|
||||
import { SlotProvider } from '../../app/ChromeSlot'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { Overview } from './Overview'
|
||||
import { forgetListCounts } from './app-list-count'
|
||||
import { AB_CONFIG, serveAdvancedBlocking } from './advanced-blocking.fixture'
|
||||
|
||||
/* The Advanced Blocking panel and the app's share of "Block List Domains" on the Overview. */
|
||||
|
||||
vi.mock('../dashboard/Chart', () => ({ Chart: ({ aria }: { aria: string }) => <div role="img" aria-label={aria} /> }))
|
||||
|
||||
const STATS = {
|
||||
totalQueries: 5000, totalNoError: 0, totalServerFailure: 0, totalNxDomain: 0, totalRefused: 0,
|
||||
totalAuthoritative: 0, totalRecursive: 0, totalCached: 0, totalBlocked: 100, totalDropped: 0,
|
||||
totalClients: 1, zones: 5, cachedEntries: 0, allowedZones: 0, blockedZones: 0, allowListZones: 0, blockListZones: 1000,
|
||||
}
|
||||
const MAIN = { labels: ['a'], datasets: [{ label: 'Total', data: [1] }, { label: 'Blocked', data: [1] }] }
|
||||
const OK = {
|
||||
kind: 'ok' as const,
|
||||
data: { stats: STATS, mainChartData: MAIN, queryResponseChartData: MAIN, queryTypeChartData: MAIN, protocolTypeChartData: MAIN, topClients: [], topDomains: [], topBlockedDomains: [] },
|
||||
}
|
||||
|
||||
const text = (lines: string[]) => lines.join('\n')
|
||||
const LISTS: Record<string, string> = {
|
||||
'https://lists.example/hosts': text(['0.0.0.0 a.example', '0.0.0.0 b.example']),
|
||||
'https://lists.example/more': text(['c.example', 'd.example', 'e.example']),
|
||||
'https://lists.example/easy': text(['||f.example^']),
|
||||
}
|
||||
|
||||
function fetchLists(fail: string[] = []) {
|
||||
return vi.spyOn(globalThis, 'fetch').mockImplementation((async (input: string) => {
|
||||
if (fail.includes(String(input))) throw new TypeError('Failed to fetch')
|
||||
const body = LISTS[String(input)]
|
||||
return body === undefined ? new Response('', { status: 404 }) : new Response(body)
|
||||
}) as never)
|
||||
}
|
||||
|
||||
function mount() {
|
||||
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as never)
|
||||
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue(OK)
|
||||
return render(<SlotProvider>{(ref) => <><div ref={ref} /><Overview token="T" permissions={undefined} /></>}</SlotProvider>)
|
||||
}
|
||||
|
||||
/* The list downloads among the calls fetch got (the client's own API calls go through it too). */
|
||||
const downloaded = (f: { mock: { calls: unknown[][] } }) => f.mock.calls.map((c) => String(c[0])).filter((u) => u.startsWith('https://lists.example'))
|
||||
|
||||
const tile = (label: string) => screen.getAllByText(label).find((el) => /_k_/.test(el.className))!.parentElement!
|
||||
|
||||
beforeEach(() => forgetListCounts())
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('Overview with Advanced Blocking', () => {
|
||||
it('summarises the app: its switch, each group with its state, its counts and its networks', async () => {
|
||||
serveAdvancedBlocking()
|
||||
fetchLists()
|
||||
mount()
|
||||
const panel = (await screen.findByRole('heading', { name: 'Advanced Blocking', level: 2 })).closest('[data-slot=frame]') as HTMLElement
|
||||
const w = within(panel)
|
||||
expect(await w.findByText('Enabled')).toBeInTheDocument()
|
||||
const rows = await w.findAllByRole('row')
|
||||
const everyone = rows.find((r) => within(r).queryByText('everyone'))!
|
||||
expect(within(everyone).getByText('Blocking')).toBeInTheDocument()
|
||||
const cells = within(everyone).getAllByRole('cell').map((c) => c.textContent)
|
||||
// Group, block lists, allow lists, regex lists, adblock lists, blocked, allowed, regex rules, networks.
|
||||
expect(cells.slice(1)).toEqual(['2', '1', '0', '1', '1', '1', '1', '0.0.0.0/0'])
|
||||
const family = rows.find((r) => within(r).queryByText('family'))!
|
||||
expect(within(family).getByText('Off')).toBeInTheDocument()
|
||||
expect(within(family).getByText('192.168.10.0/24')).toBeInTheDocument()
|
||||
expect(w.getByRole('link', { name: 'Open Advanced Blocking in Apps' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has no panel when the app is not installed', async () => {
|
||||
serveAdvancedBlocking(AB_CONFIG, false)
|
||||
mount()
|
||||
await screen.findByText('5,000')
|
||||
expect(screen.queryByRole('heading', { name: 'Advanced Blocking', level: 2 })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('the tile shows the built-in count at once, then adds the app lists downloaded by the page', async () => {
|
||||
serveAdvancedBlocking()
|
||||
fetchLists()
|
||||
mount()
|
||||
expect(await screen.findByText('5,000')).toBeInTheDocument()
|
||||
await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,006')).toBeInTheDocument())
|
||||
expect(within(tile('Block List Domains')).getByText('1,000 built-in, 6 Advanced Blocking')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('a list the page cannot read (CORS) makes the figure "at least" and is said, not counted as zero', async () => {
|
||||
serveAdvancedBlocking()
|
||||
fetchLists(['https://lists.example/more'])
|
||||
mount()
|
||||
await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,003+')).toBeInTheDocument())
|
||||
expect(within(tile('Block List Domains')).getByText(/1,000 built-in, 3\+ Advanced Blocking \(1 list not counted\)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('without the app the tile is the built-in figure alone', async () => {
|
||||
serveAdvancedBlocking(AB_CONFIG, false)
|
||||
const fetched = fetchLists()
|
||||
mount()
|
||||
expect(await screen.findByText('5,000')).toBeInTheDocument()
|
||||
expect(within(tile('Block List Domains')).queryByText(/Advanced Blocking/)).not.toBeInTheDocument()
|
||||
expect(downloaded(fetched)).toEqual([])
|
||||
})
|
||||
|
||||
it('behind a hub the hub counts the lists', async () => {
|
||||
serveAdvancedBlocking()
|
||||
const fetched = fetchLists()
|
||||
const hubCount = vi.spyOn(appLists, 'getAppListCounts').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { response: { lists: [{ url: 'https://lists.example/hosts', count: 40 }, { url: 'https://lists.example/more', count: null, error: 'timed out' }, { url: 'https://lists.example/easy', count: 2 }], total: 42 } },
|
||||
} as never)
|
||||
localStorage.setItem('dashboardClusterNode', 'a')
|
||||
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as never)
|
||||
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue(OK)
|
||||
render(
|
||||
<HubMode.Provider value>
|
||||
<SlotProvider>{(ref) => <><div ref={ref} /><Overview token="T" permissions={undefined} nodes={[{ name: 'a', type: 'Primary' }]} clusterInitialised /></>}</SlotProvider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
await waitFor(() => expect(within(tile('Block List Domains')).getByText('1,042+')).toBeInTheDocument())
|
||||
expect(hubCount).toHaveBeenCalledWith('T', 'Advanced Blocking', 'a')
|
||||
expect(downloaded(fetched)).toEqual([])
|
||||
expect(within(tile('Block List Domains')).getByText(/1 list not counted/)).toBeInTheDocument()
|
||||
await userEvent.hover(within(tile('Block List Domains')).getByText(/1 list not counted/))
|
||||
})
|
||||
|
||||
it('with "All servers" chosen behind a hub there is no panel and the page downloads no list (the hub counts them)', async () => {
|
||||
serveAdvancedBlocking()
|
||||
vi.spyOn(appLists, 'getAppListCounts').mockResolvedValue({ kind: 'ok', data: { response: { lists: [], total: 0 } } } as never)
|
||||
const fetched = fetchLists()
|
||||
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as never)
|
||||
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue(OK)
|
||||
render(
|
||||
<HubMode.Provider value>
|
||||
<SlotProvider>{(ref) => <><div ref={ref} /><Overview token="T" permissions={undefined} nodes={[{ name: 'a', type: 'Primary' }]} clusterInitialised /></>}</SlotProvider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
await screen.findByText('5,000')
|
||||
expect(screen.queryByRole('heading', { name: 'Advanced Blocking', level: 2 })).not.toBeInTheDocument()
|
||||
expect(downloaded(fetched)).toEqual([])
|
||||
})
|
||||
|
||||
it('shows the Dashboard tiles and one server selector, in the chrome slot', async () => {
|
||||
serveAdvancedBlocking(AB_CONFIG, false)
|
||||
const { container } = mount()
|
||||
await screen.findByText('5,000')
|
||||
expect(container.querySelectorAll('[data-slot=frame-panel]').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByRole('group', { name: 'Server' })).not.toBeInTheDocument()
|
||||
for (const label of ['Total Queries', 'Blocked', 'Block List Domains', 'Your Rules']) expect(tile(label)).toBeInTheDocument()
|
||||
expect(container.querySelector('polyline')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -33,39 +33,12 @@ measures the two ambers as indistinguishable. The sentence and its icon say it.
|
||||
*/
|
||||
.partial { display: flex; align-items: center; gap: var(--s-3); padding: var(--s-5) var(--s-9); color: var(--mute); }
|
||||
|
||||
/*
|
||||
A figure's trend, beside its text and as tall as it (`.kpi` in Blocking.module.css):
|
||||
the text keeps its width, so its three lines stay level with the other cards', and the
|
||||
trend takes what is left, up to half the card. Size containment drops the drawing's own
|
||||
100:32 shape, so a wide screen cannot make the trend, and with it the card, taller.
|
||||
*/
|
||||
.spark {
|
||||
display: block;
|
||||
flex: 1 1 0;
|
||||
align-self: stretch;
|
||||
min-width: var(--s-12);
|
||||
max-width: 50%;
|
||||
height: auto;
|
||||
contain: size;
|
||||
/* The KPI strip's cells: the Dashboard's tile (Dashboard.module.css), four to a row while they fit. */
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
padding: 0;
|
||||
}
|
||||
/*
|
||||
`--bp-stack`. Two cards a row leave no room beside the text, so the trend drops to the
|
||||
card's foot and runs to its edges, as the drawing has it: the body's padding
|
||||
(`--s-9`, ui/Panel) is given back on three sides and the panel's rounded corners clip
|
||||
it. The two cards with a trend share the first row, so no card in a row has a band
|
||||
the other lacks.
|
||||
*/
|
||||
@media (max-width: 560px) {
|
||||
.trended { flex-wrap: wrap; }
|
||||
.spark {
|
||||
flex-basis: calc(100% + 2 * var(--s-9));
|
||||
max-width: none;
|
||||
height: var(--s-12);
|
||||
margin: 0 calc(-1 * var(--s-9)) calc(-1 * var(--s-9));
|
||||
}
|
||||
}
|
||||
.sparkTotal { color: var(--ch-total); }
|
||||
.sparkBlocked { color: var(--ch-block); }
|
||||
|
||||
/* The ring, with the share written in its hole. */
|
||||
.ring { position: relative; }
|
||||
@@ -82,3 +55,8 @@ floating in it, so it keeps its own height instead, top-aligned. The check panel
|
||||
answer region is empty (`:empty`) until then.
|
||||
*/
|
||||
.checkRow:has([aria-live]:not(:empty)) > :first-child { align-self: start; }
|
||||
|
||||
/* The Advanced Blocking panel: a group's name with its state, and the networks that map to it. */
|
||||
.groupCell { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-3); }
|
||||
.groupName { font-weight: 600; }
|
||||
.nets { font-family: var(--font-mono); font-size: var(--t-2); overflow-wrap: break-word; }
|
||||
@@ -33,13 +33,11 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
/** The value of a figure, found by its label: a bare number can also be a hit count,
|
||||
and `Blocked` is also the ring's word. A figure is value, sub-line, label. */
|
||||
and `Blocked` is also the ring's word. A tile is label, then a line holding the figure. */
|
||||
function figure(label: string) {
|
||||
const own = screen
|
||||
.getAllByText(label, { selector: 'span' })
|
||||
.filter((el) => el.parentElement!.children[2] === el)
|
||||
const own = screen.getAllByText(label).filter((el) => /_k_/.test(el.className))
|
||||
expect(own).toHaveLength(1)
|
||||
return own[0].parentElement!.firstElementChild!.textContent
|
||||
return own[0].nextElementSibling!.firstElementChild!.textContent
|
||||
}
|
||||
|
||||
async function shows(label: string, value: string) {
|
||||
@@ -55,13 +53,10 @@ describe('Overview against the harness', () => {
|
||||
render(<Overview token="T" permissions={undefined} />)
|
||||
await shows('Total Queries', s.totalQueries.toLocaleString())
|
||||
expect(figure('Blocked')).toBe(s.totalBlocked.toLocaleString())
|
||||
expect(screen.getByText(`${percentage(s.totalBlocked, s.totalQueries)} of total`)).toBeInTheDocument()
|
||||
expect(screen.getAllByText(percentage(s.totalBlocked, s.totalQueries)).length).toBeGreaterThan(0)
|
||||
expect(figure('Block List Domains')).toBe(s.blockListZones.toLocaleString())
|
||||
const rules = screen.getByText('Your Rules', { selector: 'span' }).parentElement!
|
||||
expect([...rules.children[0].children].map((c) => c.textContent)).toEqual([
|
||||
s.blockedZones.toLocaleString(),
|
||||
s.allowedZones.toLocaleString(),
|
||||
])
|
||||
expect(figure('Your Rules')).toBe(s.blockedZones.toLocaleString())
|
||||
expect(screen.getByText(`blocked · ${s.allowedZones.toLocaleString()} allowed`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('a day of traffic: the bars split Total into Allowed and Blocked, minute by minute', async () => {
|
||||
|
||||
@@ -8,11 +8,12 @@ import * as zonelists from '../../api/zonelists'
|
||||
import { SlotProvider } from '../../app/ChromeSlot'
|
||||
import { Overview, blockingChart } from './Overview'
|
||||
|
||||
/** Your Rules: each number with the word under it, in the card's two columns. */
|
||||
/** Your Rules: the blocked count as the tile's figure, the allowed one in its context line. */
|
||||
function rulePairs() {
|
||||
const text = screen.getByText('Your Rules', { selector: 'span' }).parentElement!
|
||||
const [numbers, words] = [...text.children].slice(0, 2).map((row) => [...row.children].map((c) => c.textContent))
|
||||
return numbers.map((n, i) => [n, words[i]])
|
||||
const label = screen.getAllByText('Your Rules').find((el) => /_k_/.test(el.className))!
|
||||
const line = label.nextElementSibling!
|
||||
const allowed = /^blocked · (.*) allowed$/.exec(line.children[1].textContent ?? '')![1]
|
||||
return [[line.children[0].textContent, 'blocked'], [allowed, 'allowed']]
|
||||
}
|
||||
|
||||
/* Every `data` the charts were handed, by chart: Chart.tsx rebuilds its canvas on a
|
||||
@@ -114,7 +115,7 @@ describe('Overview', () => {
|
||||
expect(await screen.findByText('20,354')).toBeInTheDocument()
|
||||
expect(screen.getByText('5,310')).toBeInTheDocument()
|
||||
/* The ring writes the same share in its hole: the figure is looked for in its card. */
|
||||
expect(within(screen.getByText('5,310').parentElement!).getByText('26.09% of total')).toBeInTheDocument()
|
||||
expect(within(screen.getByText('5,310').parentElement!).getByText('26.09%')).toBeInTheDocument()
|
||||
expect(screen.getByText('74,779')).toBeInTheDocument()
|
||||
expect(rulePairs()).toEqual([['1', 'blocked'], ['1', 'allowed']])
|
||||
})
|
||||
@@ -301,7 +302,7 @@ describe('Overview', () => {
|
||||
const flat = { labels: ['a'], datasets: [{ label: 'Total', data: [0] }, { label: 'Blocked', data: [0] }] }
|
||||
serve().mockResolvedValue({ kind: 'ok', data: { ...OK.data, stats: zero, mainChartData: flat } })
|
||||
render(<Overview token="T" permissions={undefined} />)
|
||||
expect(await screen.findByText('0% of total')).toBeInTheDocument()
|
||||
expect(await screen.findByText('0%')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('No queries for this period.').length).toBe(2)
|
||||
expect(screen.queryByRole('img', { name: 'Allowed and blocked queries over time' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { KpiTile, StatCard } from '../dashboard/Figures'
|
||||
import { FIGURES, TOTAL, num2 } from '../dashboard/metrics'
|
||||
import dash from '../dashboard/Dashboard.module.css'
|
||||
import {
|
||||
getDashboardStats, RANGES, RANGE_LABEL,
|
||||
type ChartData, type DashboardStats, type Range,
|
||||
@@ -16,6 +20,9 @@ import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||||
import { StaleData } from '../StaleData'
|
||||
import { AddDomainBar } from './AddDomainBar'
|
||||
import { StatusPanel } from './StatusPanel'
|
||||
import { AdvancedBlockingPanel } from './AdvancedBlockingPanel'
|
||||
import { useAdvancedBlocking } from './advanced-blocking-state'
|
||||
import { useAppListCount, useAllServersListCount, type AppCount } from './app-list-count'
|
||||
import { TopTable } from './TopTable'
|
||||
import { RecentBlocked } from './RecentBlocked'
|
||||
import { LAST_HOUR_REFRESH_MS, useLiveView } from '../dashboard/useLive'
|
||||
@@ -71,6 +78,8 @@ export function blockingChart(main: ChartData): ChartData {
|
||||
}
|
||||
}
|
||||
|
||||
const BLOCKED = FIGURES.find((m) => m.k === 'totalBlocked')!
|
||||
|
||||
const BAR_LEGEND = ['Allowed', 'Blocked'] as const
|
||||
|
||||
/*
|
||||
@@ -105,67 +114,21 @@ function Bars({ chart, resetOn }: { chart: ChartData; resetOn: unknown }) {
|
||||
)
|
||||
}
|
||||
|
||||
/* A figure's trend, beside it: decorative, so hidden from assistive technology (the
|
||||
figure says the number). */
|
||||
function Spark({ data, tone }: { data: number[]; tone: string }) {
|
||||
if (data.length < 2) return null
|
||||
const max = Math.max(...data, 1)
|
||||
const step = 100 / (data.length - 1)
|
||||
const points = data.map((v, i) => `${(i * step).toFixed(2)},${(30 - (v / max) * 28).toFixed(2)}`).join(' ')
|
||||
/*
|
||||
The line under "Block List Domains" when the Advanced Blocking app has block lists: how the
|
||||
figure splits, or that the app's part is still being counted or could not be. With lists
|
||||
that could not be counted the figure is "at least" (a trailing +) and the line says how many.
|
||||
*/
|
||||
function AppMeta({ built, count }: { built: number; count: AppCount | null }) {
|
||||
if (count == null) return null
|
||||
if (count.phase === 'loading') return <span className={styles.appMeta}>Counting Advanced Blocking lists…</span>
|
||||
if (count.phase === 'failed') return <span className={styles.appMeta}>Advanced Blocking lists could not be counted</span>
|
||||
const missed = count.lists.filter((l) => l.count == null)
|
||||
return (
|
||||
<svg className={`${styles.spark} ${tone}`} viewBox="0 0 100 32" preserveAspectRatio="none" aria-hidden="true">
|
||||
<polyline points={`0,32 ${points} 100,32`} fill="currentColor" fillOpacity="0.18" stroke="none" />
|
||||
<polyline points={points} fill="none" stroke="currentColor" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Kpi({
|
||||
value,
|
||||
sub,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
/** Two figures side by side (`Your Rules`) come as pairs, each number over its word. */
|
||||
value: string | [string, string]
|
||||
sub?: string | [string, string]
|
||||
label: string
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const paired = Array.isArray(value) && Array.isArray(sub)
|
||||
return (
|
||||
<Panel className={shared.centred}>
|
||||
<Body>
|
||||
<div className={`${shared.kpi}${children != null ? ` ${styles.trended}` : ''}`}>
|
||||
<div className={`${shared.kpiText}${paired ? ` ${shared.kpiPaired}` : ''}`}>
|
||||
{paired ? (
|
||||
/* Both lines are laid on one two-column grid, so each number stands
|
||||
over its own word: written as one string, the 7 of "2 · 7" sat over
|
||||
the middle of "blocked · allowed". */
|
||||
<>
|
||||
<span className={`${shared.kpiValue} ${shared.kpiRow}`}>
|
||||
<span>{value[0]}</span>
|
||||
<span>{value[1]}</span>
|
||||
</span>
|
||||
<span className={`${shared.kpiSub} ${shared.kpiRow}`}>
|
||||
<span>{sub[0]}</span>
|
||||
<span>{sub[1]}</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={shared.kpiValue}>{value}</span>
|
||||
{/* A figure without a sub-line keeps an empty one, a full line high, so the four
|
||||
labels sit on one line as the drawing has them. */}
|
||||
<span className={shared.kpiSub}>{sub ?? '\u00a0'}</span>
|
||||
</>
|
||||
)}
|
||||
<span className={shared.kpiLabel}>{label}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</Body>
|
||||
</Panel>
|
||||
<span className={styles.appMeta} title={missed.map((l) => `${l.url}: ${l.error ?? 'not counted'}`).join(', ') || undefined}>
|
||||
{num2(built)} built-in, {num2(count.total)}{count.uncounted > 0 ? '+' : ''} Advanced Blocking
|
||||
{count.uncounted > 0 && ` (${count.uncounted} ${count.uncounted === 1 ? 'list' : 'lists'} not counted)`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -183,6 +146,7 @@ function Gap({ failure, loading, announce = true }: { failure: boolean; loading:
|
||||
|
||||
export function Overview({
|
||||
tabs,
|
||||
actions,
|
||||
token,
|
||||
permissions: rolePermissions,
|
||||
nodes = [],
|
||||
@@ -190,6 +154,7 @@ export function Overview({
|
||||
serverDomain,
|
||||
}: {
|
||||
tabs?: ReactNode
|
||||
actions?: ReactNode
|
||||
token: string | null
|
||||
permissions: Permissions
|
||||
nodes?: ClusterNode[]
|
||||
@@ -213,6 +178,12 @@ export function Overview({
|
||||
const allServers =
|
||||
hub && node === AGGREGATE && missing(rolePermissions, 'Blocked.canModify', 'Allowed.canModify') == null
|
||||
const barPermissions = allServers ? rolePermissions : permissions
|
||||
/* The Advanced Blocking app of the server in view: its panel, and its lists' domains in the figure. */
|
||||
const ab = useAdvancedBlocking(token, rolePermissions, hub ? node : '')
|
||||
const everywhere = hub && node === AGGREGATE
|
||||
const single = useAppListCount(token, hub ? node : '', ab.state)
|
||||
const summed = useAllServersListCount(token, everywhere ? nodes.map((n) => n.name) : [])
|
||||
const appCount = everywhere ? summed : single
|
||||
const [data, setData] = useState<DashboardStats | null>(null)
|
||||
/** The first read of this period and node failed: there is nothing to show. */
|
||||
const [failure, setFailure] = useState(false)
|
||||
@@ -347,21 +318,21 @@ export function Overview({
|
||||
onChange={setNode}
|
||||
/>
|
||||
</InChrome>
|
||||
<HubWriteHint write={write} allServers={allServers} />
|
||||
|
||||
<SectionHeader
|
||||
title="Blocking"
|
||||
subtitle={OVERVIEW_SUBTITLE}
|
||||
tabs={tabs}
|
||||
actions={
|
||||
{/* As the Dashboard's head: the title with its actions, the period in its own row
|
||||
under it, then the tabs. */}
|
||||
<div className={dash.head}>
|
||||
<SectionHeader title="Blocking" subtitle={OVERVIEW_SUBTITLE} actions={actions} />
|
||||
<div className={dash.period}>
|
||||
<Segmented
|
||||
label="Period"
|
||||
options={PERIODS.map((r) => ({ id: r, label: RANGE_LABEL[r] }))}
|
||||
active={range}
|
||||
onChoose={setRange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{tabs}
|
||||
<HubWriteHint write={write} allServers={allServers} />
|
||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||
|
||||
<div className={shared.stack}>
|
||||
@@ -370,28 +341,36 @@ export function Overview({
|
||||
{statsNeed != null ? (
|
||||
<Locked title="Statistics" need={statsNeed} />
|
||||
) : (
|
||||
<div className={shared.kpis}>
|
||||
<Kpi value={s ? s.totalQueries.toLocaleString() : '—'} label="Total Queries">
|
||||
{view && <Spark data={series(view.mainChartData, 'Total')} tone={styles.sparkTotal} />}
|
||||
</Kpi>
|
||||
<Kpi
|
||||
value={s ? s.totalBlocked.toLocaleString() : '—'}
|
||||
sub={s ? `${percentage(s.totalBlocked, s.totalQueries)} of total` : undefined}
|
||||
label="Blocked"
|
||||
>
|
||||
{view && <Spark data={series(view.mainChartData, 'Blocked')} tone={styles.sparkBlocked} />}
|
||||
</Kpi>
|
||||
<Kpi value={s ? s.blockListZones.toLocaleString() : '—'} label="Block List Domains" />
|
||||
<Kpi
|
||||
value={s ? [s.blockedZones.toLocaleString(), s.allowedZones.toLocaleString()] : '—'}
|
||||
sub={s ? ['blocked', 'allowed'] : undefined}
|
||||
label="Your Rules"
|
||||
/>
|
||||
</div>
|
||||
/* The Dashboard's KPI strip: a Frame of tiles, no trends (the chart below has the series). */
|
||||
<Frame>
|
||||
<FramePanel className={styles.tiles}>
|
||||
<StatCard m={TOTAL} s={s} total={s?.totalQueries ?? 0} changes={null} against="" />
|
||||
<StatCard m={BLOCKED} s={s} total={s?.totalQueries ?? 0} changes={null} against="" />
|
||||
<KpiTile
|
||||
label="Block List Domains"
|
||||
icon="logs"
|
||||
colour="var(--ch-block)"
|
||||
figure={s ? `${num2(s.blockListZones + (appCount?.phase === 'done' ? appCount.total : 0))}${appCount?.phase === 'done' && appCount.uncounted > 0 ? '+' : ''}` : '—'}
|
||||
noData={!s}
|
||||
wrap
|
||||
>
|
||||
{s ? <AppMeta built={s.blockListZones} count={appCount} /> : ' '}
|
||||
</KpiTile>
|
||||
<KpiTile
|
||||
label="Your Rules"
|
||||
icon="edit"
|
||||
colour="var(--ch-ok)"
|
||||
figure={s ? num2(s.blockedZones) : '—'}
|
||||
noData={!s}
|
||||
>
|
||||
{s ? <span className={dash.p}>blocked · {num2(s.allowedZones)} allowed</span> : ' '}
|
||||
</KpiTile>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)}
|
||||
|
||||
<div className={`${shared.row2} ${styles.checkRow}`}>
|
||||
<StatusPanel token={token} permissions={barPermissions} onNotice={setNotice} />
|
||||
<StatusPanel token={token} permissions={barPermissions} onNotice={setNotice} servers={nodes.map((n) => n.name)} />
|
||||
<AddDomainBar
|
||||
token={token}
|
||||
permissions={barPermissions}
|
||||
@@ -400,6 +379,8 @@ export function Overview({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdvancedBlockingPanel ab={ab} />
|
||||
|
||||
{/* Without Dashboard.View every panel fed by the stats keeps its place and its
|
||||
title, locked: the screen
|
||||
does not change shape with who looks. */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as apps from '../../api/apps'
|
||||
import * as blocking from '../../api/blocking'
|
||||
import * as zonelists from '../../api/zonelists'
|
||||
import { Rules } from './Rules'
|
||||
@@ -366,6 +367,8 @@ describe('Rules', () => {
|
||||
to read again, as the blocking state does (StatusPanel). */
|
||||
it('a failed first read leaves Failure and Retry in the table slot', async () => {
|
||||
const read = vi.spyOn(blocking, 'readRuleExport').mockResolvedValue({ kind: 'error', message: 'Access was denied.' })
|
||||
// No app to list: the Advanced Blocking panel has its own Retry, and this test is about the table's.
|
||||
vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [] } } } as never)
|
||||
draw()
|
||||
expect(await screen.findByText('Could not read the rules.')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
@@ -24,6 +24,7 @@ import { StaleData } from '../StaleData'
|
||||
import { Lists, ImportDomains, type Confirmation } from '../lists/Lists'
|
||||
import { LockedBody, LockedItem } from './Locked'
|
||||
import { AddDomainBar } from './AddDomainBar'
|
||||
import { AdvancedBlockingEntries } from './AdvancedBlockingEntries'
|
||||
import { missing, type Need, type Permissions } from './permissions'
|
||||
import {
|
||||
joinDeleted, modifyNeed, notRestoredNotice, restoreRules, RESTORED_MS, undoMessage, undoNeed, UNDO_MS,
|
||||
@@ -62,6 +63,9 @@ Without `Modify` on the rule's list the session could not put it back, so that
|
||||
Delete still asks first, with upstream's sentence. Everywhere else the
|
||||
confirmation stays: Flush, the tree (upstream's own screen) and the other sections.
|
||||
|
||||
Under the table, when the Advanced Blocking app is installed, its own rules by group (blocked and
|
||||
allowed domains, blocked and allowed regex): `AdvancedBlockingEntries.tsx`.
|
||||
|
||||
`Tree` mounts the tree that exists today, so nothing upstream has is lost. It
|
||||
reports through this page's notifier, and every change (from the table, the foot,
|
||||
the add bar or the tree itself) reads the table again from the primary node and
|
||||
@@ -79,12 +83,14 @@ const LABEL: Record<DomainList, string> = { blocked: 'Blocked', allowed: 'Allowe
|
||||
|
||||
export function Rules({
|
||||
tabs,
|
||||
actions,
|
||||
token,
|
||||
permissions,
|
||||
nodes = [],
|
||||
clusterInitialised = false,
|
||||
}: {
|
||||
tabs?: ReactNode
|
||||
actions?: ReactNode
|
||||
token: string | null
|
||||
permissions: Permissions
|
||||
nodes?: { name: string; type: string }[]
|
||||
@@ -379,7 +385,7 @@ export function Rules({
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Blocking" subtitle={RULES_SUBTITLE} tabs={tabs} />
|
||||
<SectionHeader title="Blocking" subtitle={RULES_SUBTITLE} tabs={tabs} actions={actions} />
|
||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||
|
||||
<div className={shared.stack}>
|
||||
@@ -586,6 +592,8 @@ export function Rules({
|
||||
</Menu>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<AdvancedBlockingEntries kind="rules" token={token} permissions={permissions} node={hubNode} onNotice={setNotice} />
|
||||
</div>
|
||||
|
||||
{/* The action's promise goes back to `Confirm`, which keeps the dialog busy
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as settings from '../../api/settings'
|
||||
import type { DnsSettings } from '../../api/settings'
|
||||
import { StatusPanel } from './StatusPanel'
|
||||
import { blockingMark } from './blocking-state'
|
||||
import { AB_CONFIG, serveAdvancedBlocking } from './advanced-blocking.fixture'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const serve = (s: Partial<DnsSettings>) => vi.spyOn(settings, 'getSettings').mockResolvedValue(s as DnsSettings)
|
||||
const mount = (permissions: Record<string, unknown> | undefined = undefined) =>
|
||||
render(<StatusPanel token="T" permissions={permissions as never} onNotice={() => {}} />)
|
||||
|
||||
describe('StatusPanel with the Advanced Blocking app', () => {
|
||||
it('built-in off but the app blocking: says so, Active, and still offers Enable', async () => {
|
||||
serve({ enableBlocking: false })
|
||||
serveAdvancedBlocking()
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking by Advanced Blocking')).toBeInTheDocument()
|
||||
expect(screen.getByText('Active').className).toMatch(/ok/)
|
||||
expect(screen.getByText(/The built-in blocking is off/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Nothing is being blocked.')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Blocking options' })).toHaveTextContent('Enable')
|
||||
})
|
||||
|
||||
it('built-in paused and the app blocking: the app wins, and the pause is mentioned', async () => {
|
||||
serve({ enableBlocking: false, temporaryDisableBlockingTill: '2099-01-01T21:15:00Z' })
|
||||
serveAdvancedBlocking()
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking by Advanced Blocking')).toBeInTheDocument()
|
||||
expect(screen.getByText(/The built-in blocking is paused \(until /)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('the app installed but its switch off: as before, blocking is disabled', async () => {
|
||||
serve({ enableBlocking: false })
|
||||
serveAdvancedBlocking({ ...AB_CONFIG, enableBlocking: false })
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking is disabled')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nothing is being blocked.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('the app installed with every group off: as before', async () => {
|
||||
serve({ enableBlocking: false })
|
||||
serveAdvancedBlocking({ ...AB_CONFIG, groups: [{ name: 'g', enableBlocking: false }] })
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking is disabled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('the app not installed: as before', async () => {
|
||||
serve({ enableBlocking: false })
|
||||
serveAdvancedBlocking(AB_CONFIG, false)
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking is disabled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('paused with the app not blocking keeps the paused wording', async () => {
|
||||
serve({ enableBlocking: false, temporaryDisableBlockingTill: '2099-01-01T21:15:00Z' })
|
||||
serveAdvancedBlocking(AB_CONFIG, false)
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking is paused')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('built-in on: the app is not even asked', async () => {
|
||||
serve({ enableBlocking: true })
|
||||
const { get } = serveAdvancedBlocking()
|
||||
mount()
|
||||
expect(await screen.findByText('Blocking is enabled')).toBeInTheDocument()
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('without Apps.canView the app is not asked and the block says what the settings say', async () => {
|
||||
serve({ enableBlocking: false })
|
||||
const { get } = serveAdvancedBlocking()
|
||||
mount({ Apps: { canView: false, canModify: false, canDelete: false } })
|
||||
expect(await screen.findByText('Blocking is disabled')).toBeInTheDocument()
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the sidebar mark', () => {
|
||||
it('has none while the app blocks in the built-in blocking place', () => {
|
||||
expect(blockingMark({ kind: 'app' }, undefined)).toBeNull()
|
||||
expect(blockingMark({ kind: 'app', till: '2099-01-01T00:00:00Z' }, undefined)).toBeNull()
|
||||
expect(blockingMark({ kind: 'off' }, undefined)?.text).toBe('Off')
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,9 @@ import type { Notice } from '../../lib/notice'
|
||||
import { Locked } from './Locked'
|
||||
import { missing, switchNeed, type Permissions } from './permissions'
|
||||
import { AllServersActions } from './AllServersActions'
|
||||
import { untilText, useBlockingState } from './blocking-state'
|
||||
import { appServersLine, summariseServers, untilText, useAllServersBlocking, useBlockingState } from './blocking-state'
|
||||
import { AGGREGATE } from '../../ui/ClusterNodeSelect'
|
||||
import { APP_NAME } from './advanced-blocking'
|
||||
import shared from './Blocking.module.css'
|
||||
import styles from './Overview.module.css'
|
||||
|
||||
@@ -32,6 +34,9 @@ Three things the block owes the rules rather than the menu:
|
||||
- A pause ends on the server by itself, so the block reads again when it should
|
||||
have ended: otherwise it would keep saying "paused" for as long as the screen is
|
||||
open (`blocking-state.ts`, AFTER_PAUSE_MS).
|
||||
- With the built-in blocking off and the Advanced Blocking app blocking in its place
|
||||
(`kind: 'app'`), the block says so, with the `Active` pill, and still offers the
|
||||
built-in blocking's own `Enable`.
|
||||
- The amber of "paused" says "yours to change". Without `Settings.canModify` it is
|
||||
not, and the design rule is that the padlock wins: the mark goes neutral.
|
||||
*/
|
||||
@@ -40,7 +45,10 @@ export function StatusPanel({
|
||||
token,
|
||||
permissions,
|
||||
onNotice,
|
||||
servers = [],
|
||||
}: {
|
||||
/** Behind a hub: every server, for the "All servers" wording. */
|
||||
servers?: readonly string[]
|
||||
token: string | null
|
||||
permissions: Permissions
|
||||
onNotice: (n: Notice) => void
|
||||
@@ -56,6 +64,11 @@ export function StatusPanel({
|
||||
servers" chosen it opens the flow, and the list actions for several servers sit beside it. */
|
||||
const hub = useContext(HubMode)
|
||||
const all = useContext(HubAll)
|
||||
/* "All servers": each server's own blocking, the app's included (`blocking-state.ts`). */
|
||||
const everywhere = hub && node === AGGREGATE
|
||||
const rows = useAllServersBlocking(token, permissions, servers, everywhere && viewNeed == null)
|
||||
const summary = everywhere ? summariseServers(rows) : null
|
||||
const appLine = everywhere ? appServersLine(rows) : null
|
||||
|
||||
if (viewNeed != null) return <Locked title="Blocking" need={viewNeed} />
|
||||
|
||||
@@ -78,7 +91,10 @@ export function StatusPanel({
|
||||
|
||||
const verb = state?.kind === 'on' ? 'Disable' : 'Enable'
|
||||
/* The padlock wins: a pause the user cannot lift is not drawn in amber. */
|
||||
const tone = state?.kind === 'paused' && modifyNeed != null ? 'off' : state?.kind
|
||||
const tone =
|
||||
summary != null
|
||||
? summary.active ? 'on' : 'off'
|
||||
: state?.kind === 'paused' && modifyNeed != null ? 'off' : state?.kind === 'app' ? 'on' : state?.kind
|
||||
|
||||
/* Centred: the panel shares its row with the taller add bar and stretches to it. */
|
||||
return (
|
||||
@@ -90,20 +106,24 @@ export function StatusPanel({
|
||||
) : (
|
||||
<div className={styles.status}>
|
||||
<span className={`${styles.mark} ${styles[tone ?? 'off']}`} data-tone={tone}>
|
||||
<Icon name={state.kind === 'on' ? 'blocked' : 'power'} size={18} />
|
||||
<Icon name={summary?.active ?? (state.kind === 'on' || state.kind === 'app') ? 'blocked' : 'power'} size={18} />
|
||||
</span>
|
||||
<div className={styles.statusText}>
|
||||
<div className={styles.statusTitle}>
|
||||
{state.kind === 'on'
|
||||
{summary != null
|
||||
? summary.title
|
||||
: state.kind === 'on'
|
||||
? 'Blocking is enabled'
|
||||
: state.kind === 'paused'
|
||||
? 'Blocking is paused'
|
||||
: 'Blocking is disabled'}
|
||||
: state.kind === 'app'
|
||||
? `Blocking by ${APP_NAME}`
|
||||
: state.kind === 'paused'
|
||||
? 'Blocking is paused'
|
||||
: 'Blocking is disabled'}
|
||||
{/* The state as a pill, as the drawing has it. Paused, the amber says
|
||||
"yours to lift": without Settings.canModify it is neutral, because
|
||||
the padlock wins. */}
|
||||
{state.kind === 'on' && <Tag tone="ok">Active</Tag>}
|
||||
{state.kind === 'paused' &&
|
||||
{(summary?.active ?? (state.kind === 'on' || state.kind === 'app')) && <Tag tone="ok">Active</Tag>}
|
||||
{summary == null && state.kind === 'paused' &&
|
||||
(modifyNeed == null ? (
|
||||
<Tag tone="acc">{untilText(state.till)}</Tag>
|
||||
) : (
|
||||
@@ -111,11 +131,17 @@ export function StatusPanel({
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.statusSub}>
|
||||
{state.kind === 'paused'
|
||||
? 'Resumes automatically.'
|
||||
: state.kind === 'on'
|
||||
? 'Queries matching a block list or your rules are answered as blocked.'
|
||||
: 'Nothing is being blocked.'}
|
||||
{summary != null
|
||||
? summary.sub
|
||||
: state.kind === 'app'
|
||||
? state.till != null
|
||||
? `The built-in blocking is paused (${untilText(state.till).toLowerCase()}); queries are blocked by the app's groups.`
|
||||
: "The built-in blocking is off; queries are blocked by the app's groups."
|
||||
: state.kind === 'paused'
|
||||
? 'Resumes automatically.'
|
||||
: state.kind === 'on'
|
||||
? 'Queries matching a block list or your rules are answered as blocked.'
|
||||
: 'Nothing is being blocked.'}
|
||||
</div>
|
||||
</div>
|
||||
{modifyNeed != null ? (
|
||||
@@ -125,6 +151,7 @@ export function StatusPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state != null && appLine != null && <p className="m-0 text-xs text-muted-foreground">{appLine}</p>}
|
||||
{state != null && all !== null && modifyNeed == null && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AllServersActions token={token} />
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { getAppConfig, listApps, setAppConfig, type InstalledApp } from '../../api/apps'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||||
import { findApp, parseConfig, serialize, type Json } from './advanced-blocking'
|
||||
import { missing, type Permissions } from './permissions'
|
||||
|
||||
/*
|
||||
Whether this server has the Advanced Blocking app and what its config says. OURS. Four
|
||||
places read it (the sidebar's mark, the status block, the app's panel and the Lists and
|
||||
Rules tabs), so it lives here once.
|
||||
|
||||
It reads `apps/list` and then `apps/config/get`, on the node the console is connected to
|
||||
(behind a hub: the server it names; the hub refuses a config read without one, so with
|
||||
"All servers" chosen nothing is read and the state is `none`). Without `Apps.canView`
|
||||
nothing is read either: the server would refuse, and the answer is `none`, as for an app
|
||||
that is not installed. Reads are numbered, so an older answer cannot overwrite a newer one.
|
||||
|
||||
A write made from this console (`saveAppConfig`) tells the other readers on the page
|
||||
(`appConfigChanged`), as the settings do (`lib/settings-signal.ts`).
|
||||
*/
|
||||
|
||||
export type AppState =
|
||||
| { kind: 'loading' }
|
||||
/** Not installed, not readable, or no single server to ask. */
|
||||
| { kind: 'none' }
|
||||
| { kind: 'failed'; message: string }
|
||||
| { kind: 'ready'; app: InstalledApp; root: Json; text: string | null }
|
||||
|
||||
type Listener = () => void
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
export function onAppConfigChanged(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function appConfigChanged(): void {
|
||||
for (const l of [...listeners]) l()
|
||||
}
|
||||
|
||||
/** One read: the installed app and its config, or why there is none. */
|
||||
export async function readAppState(token: string | null, node: string): Promise<AppState> {
|
||||
const listed = await listApps(token, node)
|
||||
if (listed.kind !== 'ok') return { kind: 'failed', message: noticeFromFailure(listed).text }
|
||||
const app = findApp(listed.data.response?.apps)
|
||||
if (app == null) return { kind: 'none' }
|
||||
const got = await getAppConfig(token, app.name, node)
|
||||
if (got.kind !== 'ok') return { kind: 'failed', message: noticeFromFailure(got).text }
|
||||
const text = got.data.response?.config ?? null
|
||||
const parsed = parseConfig(text)
|
||||
if (!parsed.ok) return { kind: 'failed', message: 'The Advanced Blocking config is not valid JSON.' }
|
||||
return { kind: 'ready', app, root: parsed.root, text }
|
||||
}
|
||||
|
||||
export function useAdvancedBlocking(
|
||||
token: string | null,
|
||||
permissions: Permissions,
|
||||
/** Behind a hub: the server to ask. Empty elsewhere. */
|
||||
node = '',
|
||||
/** False reads nothing (the app is not wanted, e.g. the built-in blocking is already on). */
|
||||
enabled = true,
|
||||
): { state: AppState; reload: () => void; retry: () => void } {
|
||||
const hub = useContext(HubMode)
|
||||
const viewNeed = missing(permissions, 'Apps.canView')
|
||||
const single = !hub || (node !== '' && node !== 'cluster')
|
||||
const active = enabled && viewNeed == null && single
|
||||
const [state, setState] = useState<AppState>({ kind: 'loading' })
|
||||
const asked = useRef(0)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!active) return
|
||||
const mine = ++asked.current
|
||||
void readAppState(token, node).then((s) => {
|
||||
if (mine === asked.current) setState(s)
|
||||
})
|
||||
}, [active, token, node])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
asked.current++
|
||||
return
|
||||
}
|
||||
setState({ kind: 'loading' })
|
||||
reload()
|
||||
}, [active, reload])
|
||||
|
||||
useEffect(() => (active ? onAppConfigChanged(reload) : undefined), [active, reload])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setState({ kind: 'loading' })
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
return { state: active ? state : { kind: 'none' }, reload, retry }
|
||||
}
|
||||
|
||||
/*
|
||||
Writes the whole config back and tells the page. The caller has changed only the field it
|
||||
meant to; `serialize` keeps every other key. Resolves to a notice when it failed.
|
||||
*/
|
||||
export async function saveAppConfig(
|
||||
token: string | null,
|
||||
name: string,
|
||||
root: Json,
|
||||
node = '',
|
||||
): Promise<{ ok: true } | { ok: false; notice: Notice }> {
|
||||
const r = await setAppConfig(token, name, serialize(root), node)
|
||||
if (r.kind !== 'ok') return { ok: false, notice: noticeFromFailure(r) }
|
||||
appConfigChanged()
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/*
|
||||
One change to the app's config, read-modify-write. The config is read again just before
|
||||
(so an edit made elsewhere since the page loaded is not overwritten with an old copy),
|
||||
`change` returns the new root or `null` when what it meant to change is gone (a group that
|
||||
was renamed meanwhile), and the whole text goes back with `apps/config/set`.
|
||||
*/
|
||||
export async function changeAppConfig(
|
||||
token: string | null,
|
||||
name: string,
|
||||
node: string,
|
||||
change: (root: Json) => Json | null,
|
||||
): Promise<{ ok: true } | { ok: false; notice: Notice }> {
|
||||
const got = await getAppConfig(token, name, node)
|
||||
if (got.kind !== 'ok') return { ok: false, notice: noticeFromFailure(got) }
|
||||
const parsed = parseConfig(got.data.response?.config ?? null)
|
||||
if (!parsed.ok) return { ok: false, notice: { type: 'danger', title: 'Error!', text: 'The Advanced Blocking config is not valid JSON.' } }
|
||||
const next = change(parsed.root)
|
||||
if (next == null) return { ok: false, notice: { type: 'warning', title: 'Not saved', text: 'That group no longer exists in the Advanced Blocking config.' } }
|
||||
return saveAppConfig(token, name, next, node)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { vi } from 'vitest'
|
||||
import * as apps from '../../api/apps'
|
||||
import type { Json } from './advanced-blocking'
|
||||
|
||||
/* A server that has the Advanced Blocking app, with this config. Shared by the tests of the Blocking section. */
|
||||
export const AB_CONFIG: Json = {
|
||||
enableBlocking: true,
|
||||
blockingAnswerTtl: 30,
|
||||
futureKey: { keep: ['me'] },
|
||||
localEndPointGroupMap: { '127.0.0.1': 'bypass' },
|
||||
networkGroupMap: { '192.168.10.0/24': 'family', '0.0.0.0/0': 'everyone' },
|
||||
groups: [
|
||||
{
|
||||
name: 'everyone',
|
||||
enableBlocking: true,
|
||||
blocked: ['ads.example.com'],
|
||||
allowed: ['good.example.org'],
|
||||
blockListUrls: ['https://lists.example/hosts', 'https://lists.example/more'],
|
||||
allowListUrls: ['https://lists.example/allow'],
|
||||
blockedRegex: ['^ads\\.'],
|
||||
adblockListUrls: ['https://lists.example/easy'],
|
||||
},
|
||||
{ name: 'family', enableBlocking: false, blocked: ['games.example.com'] },
|
||||
],
|
||||
}
|
||||
|
||||
export function serveAdvancedBlocking(config: Json | null = AB_CONFIG, installed = true) {
|
||||
const list = installed
|
||||
? [{ name: 'Advanced Blocking', description: '', version: '10', dnsApps: [{ classPath: 'AdvancedBlocking.App' }] }]
|
||||
: [{ name: 'Split Horizon', description: '', version: '1', dnsApps: [{ classPath: 'SplitHorizon.App' }] }]
|
||||
vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: list } } } as never)
|
||||
const get = vi.spyOn(apps, 'getAppConfig').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { response: { config: config == null ? null : JSON.stringify(config) } },
|
||||
} as never)
|
||||
const set = vi.spyOn(apps, 'setAppConfig').mockResolvedValue({ kind: 'ok', data: { status: 'ok' } } as never)
|
||||
return { get, set }
|
||||
}
|
||||
|
||||
/** The config the last `setAppConfig` was given, parsed. */
|
||||
export function written(set: ReturnType<typeof serveAdvancedBlocking>['set']): Json {
|
||||
return JSON.parse(set.mock.calls.at(-1)![2] as string) as Json
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { InstalledApp } from '../../api/apps'
|
||||
import {
|
||||
addEntry, blocksNow, countableUrls, editEntry, findApp, parseConfig, removeEntry, serialize, summarize, type Json,
|
||||
} from './advanced-blocking'
|
||||
|
||||
const CONFIG: Json = {
|
||||
enableBlocking: true,
|
||||
blockingAnswerTtl: 30,
|
||||
futureKey: { keep: ['me'] },
|
||||
localEndPointGroupMap: { '127.0.0.1': 'bypass' },
|
||||
networkGroupMap: { '10.0.0.0/8': 'family', '0.0.0.0/0': 'everyone' },
|
||||
groups: [
|
||||
{
|
||||
name: 'everyone',
|
||||
enableBlocking: true,
|
||||
blockAsNxDomain: true,
|
||||
blocked: ['a.example'],
|
||||
blockListUrls: ['https://x.example/hosts', { url: 'https://x.example/obj', note: 'kept' }],
|
||||
adblockListUrls: ['https://x.example/easy'],
|
||||
regexBlockListUrls: ['https://x.example/re'],
|
||||
blockedRegex: ['^ads'],
|
||||
},
|
||||
{ name: 'family', enableBlocking: false, blockListUrls: ['https://x.example/hosts'] },
|
||||
],
|
||||
}
|
||||
|
||||
const app = (name: string, classPath = 'Other.App'): InstalledApp => ({
|
||||
name, description: null, version: '1', dnsApps: [{ classPath } as InstalledApp['dnsApps'][number]],
|
||||
})
|
||||
|
||||
describe('findApp', () => {
|
||||
it('finds the app by its name or by its class', () => {
|
||||
expect(findApp([app('Split Horizon'), app('Advanced Blocking')])?.name).toBe('Advanced Blocking')
|
||||
expect(findApp([app('Renamed', 'AdvancedBlocking.App')])?.name).toBe('Renamed')
|
||||
expect(findApp([app('Split Horizon')])).toBeUndefined()
|
||||
expect(findApp(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseConfig', () => {
|
||||
it('reads an object, treats an emptied config as empty, and refuses anything else', () => {
|
||||
expect(parseConfig('{"enableBlocking":true}')).toEqual({ ok: true, root: { enableBlocking: true } })
|
||||
expect(parseConfig(null)).toEqual({ ok: true, root: {} })
|
||||
expect(parseConfig(' ')).toEqual({ ok: true, root: {} })
|
||||
expect(parseConfig('[1]')).toEqual({ ok: false })
|
||||
expect(parseConfig('{nope')).toEqual({ ok: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('blocksNow', () => {
|
||||
it('needs the app on and at least one group on', () => {
|
||||
expect(blocksNow(CONFIG)).toBe(true)
|
||||
expect(blocksNow({ ...CONFIG, enableBlocking: false })).toBe(false)
|
||||
expect(blocksNow({ ...CONFIG, groups: [{ name: 'g', enableBlocking: false }] })).toBe(false)
|
||||
expect(blocksNow({ enableBlocking: true })).toBe(false)
|
||||
expect(blocksNow({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarize', () => {
|
||||
it('counts what each group holds and names the networks that map to it', () => {
|
||||
const s = summarize(CONFIG)
|
||||
expect(s.enabled).toBe(true)
|
||||
expect(s.blocking).toBe(true)
|
||||
expect(s.groups.map((g) => [g.name, g.enabled])).toEqual([['everyone', true], ['family', false]])
|
||||
const g = s.groups[0]
|
||||
expect(g.counts.blockListUrls).toBe(2)
|
||||
expect(g.counts.adblockListUrls).toBe(1)
|
||||
expect(g.counts.regexBlockListUrls).toBe(1)
|
||||
expect(g.counts.allowListUrls).toBe(0)
|
||||
expect(g.counts.blocked).toBe(1)
|
||||
expect(g.counts.blockedRegex).toBe(1)
|
||||
expect(g.networks).toEqual(['0.0.0.0/0'])
|
||||
expect(s.groups[1].networks).toEqual(['10.0.0.0/8'])
|
||||
})
|
||||
|
||||
it('tells a local end point from a network', () => {
|
||||
const s = summarize({ ...CONFIG, groups: [{ name: 'bypass', enableBlocking: true }] })
|
||||
expect(s.groups[0].endpoints).toEqual(['127.0.0.1'])
|
||||
expect(s.groups[0].networks).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countableUrls', () => {
|
||||
it('lists block and adblock URLs once, strings and objects, http(s) only', () => {
|
||||
expect(countableUrls({ groups: [...(CONFIG.groups as unknown[]), { name: 'x', blockListUrls: ['file:///l'] }] })).toEqual([
|
||||
'https://x.example/hosts', 'https://x.example/obj', 'https://x.example/easy',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('changing a group', () => {
|
||||
it('adds in the form the list already has, and touches nothing else', () => {
|
||||
const next = addEntry(CONFIG, 'everyone', 'blockListUrls', 'https://x.example/new')
|
||||
const list = (next.groups as Json[])[0].blockListUrls
|
||||
expect(list).toEqual(['https://x.example/hosts', { url: 'https://x.example/obj', note: 'kept' }, { url: 'https://x.example/new' }])
|
||||
expect(next.futureKey).toEqual({ keep: ['me'] })
|
||||
expect((next.groups as Json[])[1]).toEqual((CONFIG.groups as Json[])[1])
|
||||
expect(Object.keys(next)).toEqual(Object.keys(CONFIG))
|
||||
expect(Object.keys((next.groups as Json[])[0])).toEqual(Object.keys((CONFIG.groups as Json[])[0]))
|
||||
// The original is not changed.
|
||||
expect((CONFIG.groups as Json[])[0].blockListUrls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('adds a plain string to a list of strings, and starts a list the group did not have at its end', () => {
|
||||
const next = addEntry(CONFIG, 'family', 'allowedRegex', '^x$')
|
||||
expect((next.groups as Json[])[1]).toEqual({ name: 'family', enableBlocking: false, blockListUrls: ['https://x.example/hosts'], allowedRegex: ['^x$'] })
|
||||
expect(Object.keys((next.groups as Json[])[1]).at(-1)).toBe('allowedRegex')
|
||||
expect(((addEntry(CONFIG, 'family', 'blockListUrls', 'https://y.example') as Json).groups as Json[])[1].blockListUrls).toEqual(['https://x.example/hosts', 'https://y.example'])
|
||||
})
|
||||
|
||||
it('removes one entry and edits one keeping the other keys of an object entry', () => {
|
||||
const removed = removeEntry(CONFIG, 'everyone', 'blockListUrls', 0)
|
||||
expect((removed.groups as Json[])[0].blockListUrls).toEqual([{ url: 'https://x.example/obj', note: 'kept' }])
|
||||
const edited = editEntry(CONFIG, 'everyone', 'blockListUrls', 1, 'https://x.example/moved')
|
||||
expect((edited.groups as Json[])[0].blockListUrls).toEqual(['https://x.example/hosts', { url: 'https://x.example/moved', note: 'kept' }])
|
||||
})
|
||||
|
||||
it('leaves the config alone for a group that is not there', () => {
|
||||
expect(addEntry(CONFIG, 'gone', 'blocked', 'a.example')).toBe(CONFIG)
|
||||
})
|
||||
|
||||
it('writes two-space JSON that reads back the same', () => {
|
||||
expect(JSON.parse(serialize(CONFIG))).toEqual(CONFIG)
|
||||
expect(serialize({ a: 1 })).toBe('{\n "a": 1\n}')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { InstalledApp } from '../../api/apps'
|
||||
|
||||
/*
|
||||
The Advanced Blocking DNS app, as the Blocking section reads and writes it. OURS: the
|
||||
section only knew the built-in blocking, and a server that blocks with this app (built-in
|
||||
switched off) looked unprotected. The app's config is a JSON text that
|
||||
`apps/config/get` returns and `apps/config/set` takes whole, so this file is the
|
||||
pure part: finding the app, reading the config, summarising it, and changing ONE field of
|
||||
ONE group while every other key stays where it was.
|
||||
|
||||
The config (AdvancedBlocking.App, `BlockingApp` config):
|
||||
|
||||
{ enableBlocking, blockingAnswerTtl, blockListUrlUpdateIntervalHours,
|
||||
localEndPointGroupMap: { "127.0.0.1": "bypass" },
|
||||
networkGroupMap: { "0.0.0.0/0": "everyone" },
|
||||
groups: [ { name, enableBlocking, allowTxtBlockingReport, blockAsNxDomain,
|
||||
blockingAddresses, allowed, blocked, allowListUrls, blockListUrls,
|
||||
allowedRegex, blockedRegex, regexAllowListUrls, regexBlockListUrls,
|
||||
adblockListUrls } ] }
|
||||
|
||||
Any field may be missing, and a list URL may be a plain string or (newer versions) an
|
||||
object with a `url`. What is read in one form is written back in that form, and keys this
|
||||
file does not know are never touched.
|
||||
*/
|
||||
|
||||
export const APP_NAME = 'Advanced Blocking'
|
||||
const CLASS_PATH = 'AdvancedBlocking.App'
|
||||
|
||||
export type Json = Record<string, unknown>
|
||||
|
||||
/** The five fields of a group that hold list URLs, with the words the console uses for them. */
|
||||
export const URL_FIELDS = [
|
||||
{ field: 'blockListUrls', label: 'Block list', kind: 'block' },
|
||||
{ field: 'allowListUrls', label: 'Allow list', kind: 'allow' },
|
||||
{ field: 'regexBlockListUrls', label: 'Regex block list', kind: 'block' },
|
||||
{ field: 'regexAllowListUrls', label: 'Regex allow list', kind: 'allow' },
|
||||
{ field: 'adblockListUrls', label: 'Adblock list', kind: 'block' },
|
||||
] as const
|
||||
|
||||
/** The four fields of a group that hold rules typed in by hand. */
|
||||
export const RULE_FIELDS = [
|
||||
{ field: 'blocked', label: 'Blocked domain', kind: 'block', regex: false },
|
||||
{ field: 'allowed', label: 'Allowed domain', kind: 'allow', regex: false },
|
||||
{ field: 'blockedRegex', label: 'Blocked regex', kind: 'block', regex: true },
|
||||
{ field: 'allowedRegex', label: 'Allowed regex', kind: 'allow', regex: true },
|
||||
] as const
|
||||
|
||||
export type UrlField = (typeof URL_FIELDS)[number]['field']
|
||||
export type RuleField = (typeof RULE_FIELDS)[number]['field']
|
||||
export type GroupField = UrlField | RuleField
|
||||
|
||||
const isObject = (v: unknown): v is Json => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/** The installed app, if this server has it (by name, or by the class it carries). */
|
||||
export function findApp(apps: readonly InstalledApp[] | undefined): InstalledApp | undefined {
|
||||
return (apps ?? []).find(
|
||||
(a) => a.name === APP_NAME || (a.dnsApps ?? []).some((d) => d.classPath === CLASS_PATH),
|
||||
)
|
||||
}
|
||||
|
||||
export type Parsed = { ok: true; root: Json } | { ok: false }
|
||||
|
||||
/** `config: null` (an emptied config) is a config with nothing in it; text that is not an object is unreadable. */
|
||||
export function parseConfig(text: string | null | undefined): Parsed {
|
||||
if (text == null || text.trim() === '') return { ok: true, root: {} }
|
||||
try {
|
||||
const value: unknown = JSON.parse(text)
|
||||
return isObject(value) ? { ok: true, root: value } : { ok: false }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function serialize(root: Json): string {
|
||||
return JSON.stringify(root, null, 2)
|
||||
}
|
||||
|
||||
export function groupsOf(root: Json): Json[] {
|
||||
return Array.isArray(root.groups) ? root.groups.filter(isObject) : []
|
||||
}
|
||||
|
||||
export const groupName = (g: Json): string => (typeof g.name === 'string' ? g.name : '')
|
||||
|
||||
/** A list entry's URL: the string itself, or the `url` of an object. */
|
||||
export function entryText(entry: unknown): string {
|
||||
if (typeof entry === 'string') return entry
|
||||
if (isObject(entry) && typeof entry.url === 'string') return entry.url
|
||||
return ''
|
||||
}
|
||||
|
||||
export function entriesOf(group: Json, field: GroupField): unknown[] {
|
||||
const v = group[field]
|
||||
return Array.isArray(v) ? v : []
|
||||
}
|
||||
|
||||
/** Whether the app blocks anything: its own switch is on and at least one group's is. */
|
||||
export function blocksNow(root: Json): boolean {
|
||||
return root.enableBlocking === true && groupsOf(root).some((g) => g.enableBlocking === true)
|
||||
}
|
||||
|
||||
export interface GroupSummary {
|
||||
name: string
|
||||
enabled: boolean
|
||||
/** Entries per field, for every field of URL_FIELDS and RULE_FIELDS. */
|
||||
counts: Record<GroupField, number>
|
||||
/** The networks (and local end points) that map to this group. */
|
||||
networks: string[]
|
||||
endpoints: string[]
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
enabled: boolean
|
||||
blocking: boolean
|
||||
groups: GroupSummary[]
|
||||
}
|
||||
|
||||
/** The keys of a map whose value is `name`. */
|
||||
function mapped(map: unknown, name: string): string[] {
|
||||
if (!isObject(map)) return []
|
||||
return Object.entries(map)
|
||||
.filter(([, v]) => v === name)
|
||||
.map(([k]) => k)
|
||||
}
|
||||
|
||||
export function summarize(root: Json): Summary {
|
||||
const groups = groupsOf(root).map((g): GroupSummary => {
|
||||
const name = groupName(g)
|
||||
const counts = {} as Record<GroupField, number>
|
||||
for (const { field } of URL_FIELDS) counts[field] = entriesOf(g, field).length
|
||||
for (const { field } of RULE_FIELDS) counts[field] = entriesOf(g, field).length
|
||||
return {
|
||||
name,
|
||||
enabled: g.enableBlocking === true,
|
||||
counts,
|
||||
networks: mapped(root.networkGroupMap, name),
|
||||
endpoints: mapped(root.localEndPointGroupMap, name),
|
||||
}
|
||||
})
|
||||
return { enabled: root.enableBlocking === true, blocking: blocksNow(root), groups }
|
||||
}
|
||||
|
||||
/*
|
||||
The block list URLs the app downloads that count towards "Block List Domains": the plain
|
||||
block lists, the regex ones are patterns and not domains and are left out, the adblock
|
||||
ones are domains. Each once, in the order they appear.
|
||||
*/
|
||||
export function countableUrls(root: Json): string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const g of groupsOf(root)) {
|
||||
for (const field of ['blockListUrls', 'adblockListUrls'] as const) {
|
||||
for (const e of entriesOf(g, field)) {
|
||||
const url = entryText(e).trim()
|
||||
if (/^https?:\/\//i.test(url)) seen.add(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
/*
|
||||
Changing a group. Every function returns a NEW root: the groups array and the one group
|
||||
are copied, every other key keeps its value and its place. A field the group did not have
|
||||
is added at its end; one it had is replaced where it stands.
|
||||
*/
|
||||
export function withGroupField(root: Json, name: string, field: GroupField, values: unknown[]): Json {
|
||||
const groups = Array.isArray(root.groups) ? root.groups : []
|
||||
const at = groups.findIndex((g) => isObject(g) && groupName(g) === name)
|
||||
if (at < 0) return root
|
||||
const next = groups.map((g, i) => (i === at ? { ...(g as Json), [field]: values } : g))
|
||||
return { ...root, groups: next }
|
||||
}
|
||||
|
||||
/** A new entry in the form the list already has: an object with a `url` when its entries are objects. */
|
||||
export function newEntry(existing: readonly unknown[], text: string): unknown {
|
||||
return existing.some(isObject) ? { url: text } : text
|
||||
}
|
||||
|
||||
/** The entry with another text, keeping every other key of an object entry. */
|
||||
export function replaceText(entry: unknown, text: string): unknown {
|
||||
return isObject(entry) ? { ...entry, url: text } : text
|
||||
}
|
||||
|
||||
export function addEntry(root: Json, name: string, field: GroupField, text: string): Json {
|
||||
const group = groupsOf(root).find((g) => groupName(g) === name)
|
||||
if (group == null) return root
|
||||
const list = entriesOf(group, field)
|
||||
return withGroupField(root, name, field, [...list, newEntry(list, text)])
|
||||
}
|
||||
|
||||
export function removeEntry(root: Json, name: string, field: GroupField, index: number): Json {
|
||||
const group = groupsOf(root).find((g) => groupName(g) === name)
|
||||
if (group == null) return root
|
||||
return withGroupField(root, name, field, entriesOf(group, field).filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
export function editEntry(root: Json, name: string, field: GroupField, index: number, text: string): Json {
|
||||
const group = groupsOf(root).find((g) => groupName(g) === name)
|
||||
if (group == null) return root
|
||||
return withGroupField(root, name, field, entriesOf(group, field).map((e, i) => (i === index ? replaceText(e, text) : e)))
|
||||
}
|
||||
|
||||
export const isUrl = (text: string): boolean => /^(https?|file):\/\/\S+$/i.test(text)
|
||||
@@ -0,0 +1,28 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BROWSER_MAX_BYTES, countInBrowser, forgetListCounts } from './app-list-count'
|
||||
|
||||
beforeEach(() => forgetListCounts())
|
||||
|
||||
describe('countInBrowser', () => {
|
||||
it('counts a list and remembers it for the session', async () => {
|
||||
const fetcher = vi.fn(async () => new Response('0.0.0.0 a.example\n0.0.0.0 b.example\n'))
|
||||
expect(await countInBrowser('https://l.example/a', fetcher as never)).toEqual({ url: 'https://l.example/a', count: 2 })
|
||||
await countInBrowser('https://l.example/a', fetcher as never)
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports a list the browser may not read as not counted, never as zero', async () => {
|
||||
const blocked = vi.fn(async () => {
|
||||
throw new TypeError('Failed to fetch')
|
||||
})
|
||||
const r = await countInBrowser('https://l.example/cors', blocked as never)
|
||||
expect(r.count).toBeNull()
|
||||
expect(r.error).toMatch(/CORS/)
|
||||
})
|
||||
|
||||
it('reports an error status and an oversize list', async () => {
|
||||
expect((await countInBrowser('https://l.example/404', (async () => new Response('', { status: 404 })) as never)).error).toBe('status 404')
|
||||
const big = (async () => new Response('a.example', { headers: { 'content-length': String(BROWSER_MAX_BYTES + 1) } })) as never
|
||||
expect((await countInBrowser('https://l.example/big', big)).error).toBe('too large')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { getAppListCounts, type ListCount } from '../../api/app-lists'
|
||||
import { countDomains } from '../../lib/list-count'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { APP_NAME, countableUrls } from './advanced-blocking'
|
||||
import type { AppState } from './advanced-blocking-state'
|
||||
|
||||
/*
|
||||
The domains in the Advanced Blocking app's block lists, for the "Block List Domains"
|
||||
figure. OURS. The app has no API for this count, so the lists are downloaded and counted:
|
||||
|
||||
- behind a hub, by the hub (`hub/blocking/app-lists/count`), which caches them for hours;
|
||||
- without one, by this page with \`fetch\`. A list whose server does not allow it (CORS), is
|
||||
too big or does not answer is reported as not counted, never as zero. The answers are kept
|
||||
in memory for the session.
|
||||
|
||||
It never holds the page up: the figure shows the built-in count at once and this arrives
|
||||
later. Lists shared by several groups are counted once; a domain in two lists counts in both.
|
||||
*/
|
||||
|
||||
export type AppCount =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'failed' }
|
||||
| { phase: 'done'; total: number; lists: ListCount[]; uncounted: number }
|
||||
|
||||
/** What a download may take, so a huge or slow file cannot hold the page. */
|
||||
export const BROWSER_TIMEOUT_MS = 20_000
|
||||
export const BROWSER_MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
const session = new Map<string, ListCount>()
|
||||
|
||||
/** For the tests. */
|
||||
export function forgetListCounts(): void {
|
||||
session.clear()
|
||||
}
|
||||
|
||||
export async function countInBrowser(url: string, fetcher: typeof fetch = fetch): Promise<ListCount> {
|
||||
const hit = session.get(url)
|
||||
if (hit != null) return hit
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), BROWSER_TIMEOUT_MS)
|
||||
let result: ListCount
|
||||
try {
|
||||
const res = await fetcher(url, { signal: controller.signal, credentials: 'omit', referrerPolicy: 'no-referrer' })
|
||||
if (!res.ok) throw new Error(`status ${res.status}`)
|
||||
const declared = Number(res.headers.get('content-length') ?? 0)
|
||||
if (declared > BROWSER_MAX_BYTES) throw new Error('too large')
|
||||
const text = await res.text()
|
||||
if (text.length > BROWSER_MAX_BYTES) throw new Error('too large')
|
||||
result = { url, count: countDomains(text) }
|
||||
} catch (e) {
|
||||
const aborted = e instanceof Error && e.name === 'AbortError'
|
||||
result = {
|
||||
url,
|
||||
count: null,
|
||||
error: aborted ? 'timed out' : e instanceof TypeError ? 'blocked by the list server (CORS) or unreachable' : e instanceof Error ? e.message : 'failed',
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
session.set(url, result)
|
||||
return result
|
||||
}
|
||||
|
||||
export function useAppListCount(token: string | null, node: string, state: AppState): AppCount | null {
|
||||
const hub = useContext(HubMode)
|
||||
const urls = state.kind === 'ready' ? countableUrls(state.root) : []
|
||||
const name = state.kind === 'ready' ? state.app.name : APP_NAME
|
||||
const key = JSON.stringify(urls)
|
||||
const [count, setCount] = useState<AppCount | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const wanted = JSON.parse(key) as string[]
|
||||
if (wanted.length === 0) {
|
||||
setCount(null)
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
setCount({ phase: 'loading' })
|
||||
const finish = (lists: ListCount[]) => {
|
||||
if (!live) return
|
||||
// Only the lists asked for, so a stale answer from the hub cannot add a list the config dropped.
|
||||
const asked = lists.filter((l) => wanted.includes(l.url))
|
||||
const total = asked.reduce((n, l) => n + (l.count ?? 0), 0)
|
||||
setCount({ phase: 'done', total, lists: asked, uncounted: wanted.length - asked.filter((l) => l.count != null).length })
|
||||
}
|
||||
if (hub) {
|
||||
void getAppListCounts(token, name, node).then((r) => {
|
||||
if (!live) return
|
||||
if (r.kind !== 'ok') setCount({ phase: 'failed' })
|
||||
else finish(r.data.response.lists ?? [])
|
||||
})
|
||||
} else {
|
||||
void Promise.all(wanted.map((u) => countInBrowser(u))).then(finish)
|
||||
}
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [key, hub, token, node, name])
|
||||
|
||||
return state.kind === 'ready' ? count : null
|
||||
}
|
||||
|
||||
/*
|
||||
"All servers" behind a hub: each server's app lists are counted by the hub and the figures are
|
||||
added up. A list shared by two servers is counted once per server, as the built-in figure is added
|
||||
across servers. A server without the app (or with no block lists) answers an empty list and adds
|
||||
nothing; when no server has any list there is nothing to say (`null`). `servers` empty means this
|
||||
is not the all-servers view and nothing is read.
|
||||
*/
|
||||
export function useAllServersListCount(token: string | null, servers: readonly string[]): AppCount | null {
|
||||
const key = JSON.stringify(servers)
|
||||
const [count, setCount] = useState<AppCount | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const names = JSON.parse(key) as string[]
|
||||
if (names.length === 0) {
|
||||
setCount(null)
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
setCount({ phase: 'loading' })
|
||||
void Promise.all(names.map((n) => getAppListCounts(token, APP_NAME, n))).then((answers) => {
|
||||
if (!live) return
|
||||
const lists: ListCount[] = []
|
||||
let failed = 0
|
||||
for (const r of answers) {
|
||||
if (r.kind !== 'ok') failed++
|
||||
else lists.push(...(r.data.response.lists ?? []))
|
||||
}
|
||||
if (failed === answers.length) setCount({ phase: 'failed' })
|
||||
else if (lists.length === 0 && failed === 0) setCount(null)
|
||||
else {
|
||||
const counted = lists.filter((l) => l.count != null)
|
||||
setCount({
|
||||
phase: 'done',
|
||||
total: counted.reduce((n, l) => n + (l.count ?? 0), 0),
|
||||
lists,
|
||||
/* A server that did not answer may have lists; the figure is "at least" then. */
|
||||
uncounted: lists.length - counted.length + failed,
|
||||
})
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [key, token])
|
||||
|
||||
return count
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import * as settings from '../../api/settings'
|
||||
import * as client from '../../api/client'
|
||||
import type { DnsSettings } from '../../api/settings'
|
||||
import { settingsChanged } from '../../lib/settings-signal'
|
||||
import { blockingMark, stateOf, useBlockingState } from './blocking-state'
|
||||
import { VISIBLE_REREAD_MS, blockingMark, stateOf, useAllServersBlocking, useBlockingState } from './blocking-state'
|
||||
import type { Permissions } from './permissions'
|
||||
|
||||
afterEach(() => {
|
||||
@@ -96,6 +96,47 @@ describe('blocking state', () => {
|
||||
expect(screen.getByText('off')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('a tab shown again reads only if the last read is a minute old', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: true } as DnsSettings)
|
||||
render(<Probe />)
|
||||
expect(await screen.findByText('on')).toBeInTheDocument()
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(VISIBLE_REREAD_MS - 5_000))
|
||||
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(10_000))
|
||||
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
|
||||
expect(read).toHaveBeenCalledTimes(2)
|
||||
|
||||
// A write of our own reads at once, whatever the age of the last read, and restarts the minute.
|
||||
act(() => settingsChanged())
|
||||
expect(read).toHaveBeenCalledTimes(3)
|
||||
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
|
||||
expect(read).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('the servers of All servers are not asked again by every return to the tab', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: true } as DnsSettings)
|
||||
function All() {
|
||||
const rows = useAllServersBlocking('T', { Settings: { canView: true, canModify: false, canDelete: false } }, ['a', 'b'], true)
|
||||
return <output>{rows == null ? 'none' : `rows ${rows.length}`}</output>
|
||||
}
|
||||
render(<All />)
|
||||
expect(await screen.findByText('rows 2')).toBeInTheDocument()
|
||||
expect(read).toHaveBeenCalledTimes(2)
|
||||
|
||||
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
|
||||
expect(read).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(VISIBLE_REREAD_MS + 1_000))
|
||||
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
|
||||
expect(read).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('without Settings.canView, or disabled, it reads nothing, even on a write', async () => {
|
||||
const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
|
||||
render(<><Probe permissions={S(false, false)} /><Probe enabled={false} /></>)
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getSettings } from '../../api/settings'
|
||||
import { minuteStamp } from '../../lib/dates'
|
||||
import { onSettingsChanged } from '../../lib/settings-signal'
|
||||
import { missing, switchNeed, type Permissions } from './permissions'
|
||||
import { blocksNow } from './advanced-blocking'
|
||||
import { onAppConfigChanged, readAppState, useAdvancedBlocking, type AppState } from './advanced-blocking-state'
|
||||
|
||||
/*
|
||||
Whether blocking is on, read where the Overview's status block reads it
|
||||
@@ -20,19 +22,31 @@ When it reads:
|
||||
- when a pause should have ended. The server's own timer is armed a hair BEFORE it
|
||||
computes `till` (BlockListZoneManager.cs:993-995), so reading a second after
|
||||
`till` finds blocking back on; the second also absorbs a small clock skew;
|
||||
- when the 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.
|
||||
- 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, if the last read is more than a
|
||||
minute old: the state may have been changed from elsewhere meanwhile, and a hidden tab is
|
||||
not kept current.
|
||||
Reads are numbered, so an older answer arriving last cannot overwrite a newer one.
|
||||
*/
|
||||
|
||||
/** How long after `till` to read again. See above. */
|
||||
export const AFTER_PAUSE_MS = 1000
|
||||
/** A tab shown again re-reads only if the last read is older than this: flicking between tabs is not a reason to ask every server again. */
|
||||
export const VISIBLE_REREAD_MS = 60_000
|
||||
/** `setTimeout`'s ceiling (2^31 - 1 ms, about 24.8 days). Past it the browser fires
|
||||
AT ONCE, which with a far-off `till` would read in a loop; capped, it reads,
|
||||
finds the pause still on and waits again. */
|
||||
export const MAX_TIMEOUT_MS = 2_147_483_647
|
||||
|
||||
export type BlockingState = { kind: 'on' } | { kind: 'paused'; till: string } | { kind: 'off' }
|
||||
/*
|
||||
`app` is OURS: the built-in blocking is off (or paused) but the Advanced Blocking app is
|
||||
installed, switched on and has a group that blocks, so queries ARE being blocked. `till`
|
||||
is the end of the built-in pause when that is what the built-in blocking is doing. The app
|
||||
is only asked about when the built-in blocking is not on, and an app that is missing,
|
||||
unreadable or not blocking leaves the state as the settings said it.
|
||||
*/
|
||||
export type BlockingState = { kind: 'on' } | { kind: 'paused'; till: string } | { kind: 'off' } | { kind: 'app'; till?: string }
|
||||
|
||||
export function stateOf(enable: boolean, till: string | null | undefined, now = Date.now()): BlockingState {
|
||||
if (enable) return { kind: 'on' }
|
||||
@@ -66,12 +80,14 @@ export function useBlockingState(
|
||||
node = '',
|
||||
): { state: BlockingRead; reload: () => void; retry: () => void } {
|
||||
const viewNeed = enabled ? missing(permissions, 'Settings.canView') : 'Settings.canView'
|
||||
const [state, setState] = useState<BlockingRead>(null)
|
||||
const [base, setState] = useState<BlockingRead>(null)
|
||||
const asked = useRef(0)
|
||||
const lastRead = useRef(0)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (viewNeed != null) return
|
||||
const mine = ++asked.current
|
||||
lastRead.current = Date.now()
|
||||
void getSettings(token, node).then((s) => {
|
||||
if (mine !== asked.current) return
|
||||
setState(s == null ? 'failed' : stateOf(s.enableBlocking, s.temporaryDisableBlockingTill))
|
||||
@@ -88,14 +104,14 @@ export function useBlockingState(
|
||||
|
||||
useEffect(() => {
|
||||
const shown = () => {
|
||||
if (document.visibilityState === 'visible') reload()
|
||||
if (document.visibilityState === 'visible' && Date.now() - lastRead.current >= VISIBLE_REREAD_MS) reload()
|
||||
}
|
||||
document.addEventListener('visibilitychange', shown)
|
||||
return () => document.removeEventListener('visibilitychange', shown)
|
||||
}, [reload])
|
||||
|
||||
/* Without the permission nothing is known, whatever an earlier read said. */
|
||||
const known = viewNeed != null ? null : state
|
||||
const known = viewNeed != null ? null : base
|
||||
const till = known != null && known !== 'failed' && known.kind === 'paused' ? known.till : null
|
||||
useEffect(() => {
|
||||
if (till == null) return
|
||||
@@ -109,7 +125,13 @@ export function useBlockingState(
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
return { state: known, reload, retry }
|
||||
/* The app is asked only when the built-in blocking is off or paused. */
|
||||
const builtInOff = known != null && known !== 'failed' && known.kind !== 'on' && known.kind !== 'app'
|
||||
const app = useAdvancedBlocking(token, permissions, node, builtInOff)
|
||||
const viaApp = builtInOff && app.state.kind === 'ready' && blocksNow(app.state.root)
|
||||
const state: BlockingRead = viaApp ? { kind: 'app', ...(till != null ? { till } : {}) } : known
|
||||
|
||||
return { state, reload, retry }
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -129,7 +151,7 @@ export interface BlockingMark {
|
||||
}
|
||||
|
||||
export function blockingMark(state: BlockingRead, permissions: Permissions, now = new Date(), hub = false): BlockingMark | null {
|
||||
if (state == null || state === 'failed' || state.kind === 'on') return null
|
||||
if (state == null || state === 'failed' || state.kind === 'on' || state.kind === 'app') return null
|
||||
if (state.kind === 'off') return { text: 'Off', label: 'Blocking, disabled', tone: 'neutral' }
|
||||
const canLift = switchNeed(permissions, hub) == null
|
||||
return {
|
||||
@@ -140,7 +162,168 @@ export function blockingMark(state: BlockingRead, permissions: Permissions, now
|
||||
}
|
||||
|
||||
/** The mark for the sidebar's Blocking entry; `visible` false when there is no entry. */
|
||||
export function useBlockingMark(token: string | null, permissions: Permissions, visible: boolean, hub = false): BlockingMark | null {
|
||||
const { state } = useBlockingState(token, permissions, visible)
|
||||
export function useBlockingMark(token: string | null, permissions: Permissions, visible: boolean, hub = false, node = ''): BlockingMark | null {
|
||||
const { state } = useBlockingState(token, permissions, visible, node)
|
||||
return blockingMark(state, permissions, new Date(), hub)
|
||||
}
|
||||
|
||||
/*
|
||||
Behind a hub, all servers together. OURS. "Is blocking on" is a question about each server: its
|
||||
built-in blocking, or the Advanced Blocking app blocking in its place. With "All servers" chosen,
|
||||
and for the sidebar's mark (which has no single server to ask), every server is asked for both
|
||||
(`settings/get` and the app's config, each with its node: the hub refuses a config read without
|
||||
one) and the answers are put together by `summariseServers` and `hubMark`.
|
||||
|
||||
`method` is how a server blocks: `builtin`, `app`, `both`, or not at all (`nothing`; `paused` when
|
||||
the built-in blocking is paused and the app does not make up for it). A server whose settings could
|
||||
not be read is `unknown` and is left out of both summaries rather than guessed at.
|
||||
*/
|
||||
export type Method = 'builtin' | 'app' | 'both' | 'paused' | 'nothing' | 'unknown'
|
||||
|
||||
export interface ServerBlocking {
|
||||
server: string
|
||||
method: Method
|
||||
/** The app is installed here (blocking or not). */
|
||||
appInstalled: boolean
|
||||
appBlocking: boolean
|
||||
}
|
||||
|
||||
export async function readServerBlocking(
|
||||
token: string | null,
|
||||
server: string,
|
||||
canSettings: boolean,
|
||||
canApps: boolean,
|
||||
): Promise<ServerBlocking> {
|
||||
const [settings, app] = await Promise.all([
|
||||
canSettings ? getSettings(token, server) : Promise.resolve(null),
|
||||
canApps ? readAppState(token, server) : Promise.resolve<AppState>({ kind: 'none' }),
|
||||
])
|
||||
const appInstalled = app.kind === 'ready'
|
||||
const appBlocking = app.kind === 'ready' && blocksNow(app.root)
|
||||
const built = settings == null ? null : stateOf(settings.enableBlocking, settings.temporaryDisableBlockingTill)
|
||||
let method: Method
|
||||
if (built == null) method = appBlocking ? 'app' : 'unknown'
|
||||
else if (built.kind === 'on') method = appBlocking ? 'both' : 'builtin'
|
||||
else if (appBlocking) method = 'app'
|
||||
else method = built.kind === 'paused' ? 'paused' : 'nothing'
|
||||
return { server, method, appInstalled, appBlocking }
|
||||
}
|
||||
|
||||
export function useAllServersBlocking(
|
||||
token: string | null,
|
||||
permissions: Permissions,
|
||||
servers: readonly string[],
|
||||
enabled: boolean,
|
||||
): ServerBlocking[] | null {
|
||||
const canSettings = missing(permissions, 'Settings.canView') == null
|
||||
const canApps = missing(permissions, 'Apps.canView') == null
|
||||
const key = JSON.stringify(servers)
|
||||
const [rows, setRows] = useState<ServerBlocking[] | null>(null)
|
||||
const asked = useRef(0)
|
||||
const lastRead = useRef(0)
|
||||
const active = enabled && canSettings && servers.length > 0
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!active) return
|
||||
const mine = ++asked.current
|
||||
lastRead.current = Date.now()
|
||||
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' && Date.now() - lastRead.current >= VISIBLE_REREAD_MS) reload()
|
||||
}
|
||||
document.addEventListener('visibilitychange', shown)
|
||||
return () => document.removeEventListener('visibilitychange', shown)
|
||||
}, [active, reload])
|
||||
|
||||
return active ? rows : null
|
||||
}
|
||||
|
||||
const names = (list: readonly string[]) => list.join(', ')
|
||||
|
||||
/* "Built-in on a, b; Advanced Blocking on c" for the servers that block. */
|
||||
function methodsText(rows: readonly ServerBlocking[]): string {
|
||||
const on = (m: Method) => rows.filter((r) => r.method === m).map((r) => r.server)
|
||||
const parts: string[] = []
|
||||
if (on('builtin').length > 0) parts.push(`Built-in on ${names(on('builtin'))}`)
|
||||
if (on('app').length > 0) parts.push(`Advanced Blocking on ${names(on('app'))}`)
|
||||
if (on('both').length > 0) parts.push(`Built-in and Advanced Blocking on ${names(on('both'))}`)
|
||||
return `${parts.join('; ')}.`
|
||||
}
|
||||
|
||||
export interface ServersSummary {
|
||||
title: string
|
||||
sub: string
|
||||
/** Every server blocks, one way or another. */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/*
|
||||
What the status block says for all servers, or `null` when the single-server wording (the first
|
||||
server's settings) is already right: every server blocks with the built-in blocking, none blocks at
|
||||
all, or something could not be read. Otherwise: every server blocks and some by the app, or some
|
||||
server blocks by nothing and the block names it.
|
||||
*/
|
||||
export function summariseServers(rows: readonly ServerBlocking[] | null): ServersSummary | null {
|
||||
if (rows == null || rows.length === 0 || rows.some((r) => r.method === 'unknown')) return null
|
||||
const gaps = rows.filter((r) => r.method === 'nothing' || r.method === 'paused')
|
||||
const blocking = rows.filter((r) => !gaps.includes(r))
|
||||
if (gaps.length === rows.length) return null
|
||||
if (gaps.length > 0) {
|
||||
return {
|
||||
title: `Blocking is off on ${names(gaps.map((r) => r.server))}`,
|
||||
sub: methodsText(blocking),
|
||||
active: false,
|
||||
}
|
||||
}
|
||||
if (rows.every((r) => r.method === 'builtin')) return null
|
||||
if (rows.every((r) => r.method === 'app')) {
|
||||
return { title: 'Blocking by Advanced Blocking', sub: 'The built-in blocking is off on every server.', active: true }
|
||||
}
|
||||
return { title: 'Blocking is enabled', sub: methodsText(rows), active: true }
|
||||
}
|
||||
|
||||
/* The sidebar's mark behind a hub: only when some server blocks by nothing, and it says which. */
|
||||
export function hubMark(rows: readonly ServerBlocking[] | null, permissions: Permissions): BlockingMark | null {
|
||||
if (rows == null) return null
|
||||
const off = rows.filter((r) => r.method === 'nothing').map((r) => r.server)
|
||||
if (off.length > 0) return { text: 'Off', label: `Blocking, disabled on ${names(off)}`, tone: 'neutral' }
|
||||
const paused = rows.filter((r) => r.method === 'paused').map((r) => r.server)
|
||||
if (paused.length === 0) return null
|
||||
return {
|
||||
text: 'Paused',
|
||||
label: `Blocking, paused on ${names(paused)}`,
|
||||
tone: switchNeed(permissions, true) == null ? 'acc' : 'neutral',
|
||||
}
|
||||
}
|
||||
|
||||
export function useHubBlockingMark(
|
||||
token: string | null,
|
||||
permissions: Permissions,
|
||||
visible: boolean,
|
||||
servers: readonly string[],
|
||||
): BlockingMark | null {
|
||||
return hubMark(useAllServersBlocking(token, permissions, servers, visible), permissions)
|
||||
}
|
||||
|
||||
/* The line under the status for all servers: where the app is installed. `null` when nowhere. */
|
||||
export function appServersLine(rows: readonly ServerBlocking[] | null): string | null {
|
||||
const have = (rows ?? []).filter((r) => r.appInstalled)
|
||||
if (have.length === 0) return null
|
||||
const idle = have.filter((r) => !r.appBlocking).map((r) => r.server)
|
||||
return `Advanced Blocking: on ${names(have.map((r) => r.server))}${idle.length > 0 ? ` (not blocking on ${names(idle)})` : ''}. Choose a server for details.`
|
||||
}
|
||||
@@ -88,6 +88,8 @@ parent's size.
|
||||
/* The share of the total, beside its figure. */
|
||||
.p { font: 400 var(--t-2)/1.3 var(--font-mono); color: var(--mute); white-space: nowrap; }
|
||||
.ctx { min-width:0; white-space: nowrap; }
|
||||
/* A context line that is a sentence (Blocking's split of Block List Domains): its own row, wrapping. */
|
||||
.ctxWrap { flex: 1 1 100%; white-space: normal; }
|
||||
/* The change: muted like the share, its size in ink, and never red or green
|
||||
(Figures.tsx). */
|
||||
.change { font: 400 var(--t-2)/1.3 var(--font-mono); font-variant-numeric: tabular-nums; color: var(--mute); }
|
||||
|
||||
@@ -20,7 +20,7 @@ import { TopStats } from './TopStats'
|
||||
import { TopRowMenu } from './TopRowMenu'
|
||||
import { useDomainAction } from '../../lib/allow-block'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
import { Loading } from '../../ui/Empty'
|
||||
import { LoadingRegion } from '../../ui/Empty'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import styles from './Dashboard.module.css'
|
||||
@@ -443,7 +443,7 @@ export function Dashboard({
|
||||
: undefined}
|
||||
>
|
||||
<Body>
|
||||
{loading && <Loading compact />}
|
||||
{loading && <LoadingRegion height="h-72" />}
|
||||
{!loading && view && hasData(view.mainChartData) && (
|
||||
<Line data={view.mainChartData} resetOn={data?.mainChartData} onPick={pickPoint} height={300} />
|
||||
)}
|
||||
@@ -495,7 +495,9 @@ export function Dashboard({
|
||||
rows={view?.topBlockedDomains ?? []}
|
||||
onMore={() => setTop('TopBlockedDomains')}
|
||||
failure={failure}
|
||||
beforeMore={<BlockingMenu token={token} onNotice={setNotice} node={hub ? node : ''} allowed={canBlock} />}
|
||||
/* With "All servers" the menu would only be a disabled button squeezing the title onto two
|
||||
lines: it is drawn once a server is chosen. */
|
||||
beforeMore={hub && node === AGGREGATE ? undefined : <BlockingMenu token={token} onNotice={setNotice} node={hub ? node : ''} allowed={canBlock} />}
|
||||
menu={(f) => <TopRowMenu kind="TopBlockedDomains" name={f.name} node={node} action={action} />}
|
||||
/>
|
||||
<Breakdown
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Stats } from '../../api/dashboard'
|
||||
import { num2, percentage, type Metric } from './metrics'
|
||||
import { formatChange, type Changes } from './compare'
|
||||
@@ -68,6 +69,57 @@ const GLYPH: Record<string, IconName> = {
|
||||
totalCached: 'cache', totalBlocked: 'blocked', totalDropped: 'power',
|
||||
}
|
||||
|
||||
/*
|
||||
The structure of a KPI cell, written once: the icon tile in the figure's colour, its
|
||||
name, its figure and the context line beside it. `StatCard` fills it for the
|
||||
Dashboard's figures; the Blocking Overview fills it for the two figures that are not a
|
||||
`Metric` (Block List Domains, Your Rules).
|
||||
*/
|
||||
export function KpiTile({
|
||||
label,
|
||||
icon,
|
||||
colour,
|
||||
figure,
|
||||
noData = false,
|
||||
wrap = false,
|
||||
children,
|
||||
onOpen,
|
||||
}: {
|
||||
label: string
|
||||
icon: IconName
|
||||
/** A colour token's value, e.g. `var(--ch-block)`. */
|
||||
colour: string
|
||||
figure: string
|
||||
/** The figure is a dash, not a value. */
|
||||
noData?: boolean
|
||||
/** The context line is a sentence: it takes its own row under the figure and may wrap. */
|
||||
wrap?: boolean
|
||||
/** The context line: a share, a change, a sentence. */
|
||||
children?: ReactNode
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.tile} style={{ ['--tc' as string]: colour }}>
|
||||
<span className={styles.ico} aria-hidden="true"><Icon name={icon} size={18} /></span>
|
||||
<div className={styles.text}>
|
||||
<div className={styles.k}>{label}</div>
|
||||
{/* With no data it is NOT a zero: it is a dash, and dimmed. A zero is a
|
||||
true figure, and here it would be a lie. */}
|
||||
<div className={styles.line}>
|
||||
<Figure className={`${styles.v}${noData ? ` ${styles.noData}` : ''}`} text={figure} />
|
||||
<div className={wrap ? `${styles.ctx} ${styles.ctxWrap}` : styles.ctx}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Upstream's card is a link to Query Logs (v15.6.2). Here a button laid over
|
||||
the whole card, so the card's own reading order stays as it is; its name
|
||||
is the card's label and its title upstream's menu text. */}
|
||||
{onOpen && (
|
||||
<button type="button" className={styles.open} aria-label={label} title="Show Query Logs" onClick={onOpen} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
m,
|
||||
s,
|
||||
@@ -90,29 +142,19 @@ export function StatCard({
|
||||
const share = m.pct && s ? percentage(s[m.k], total) : null
|
||||
const ratio = s ? changes?.[m.k] : undefined
|
||||
return (
|
||||
<div className={styles.tile} style={{ ['--tc' as string]: colour }}>
|
||||
<span className={styles.ico} aria-hidden="true"><Icon name={GLYPH[m.k] ?? 'dashboard'} size={18} /></span>
|
||||
<div className={styles.text}>
|
||||
<div className={styles.k}>{m.label}</div>
|
||||
{/* With no data it is NOT a zero: it is a dash, and dimmed. A zero is a
|
||||
true figure, and here it would be a lie. */}
|
||||
<div className={styles.line}>
|
||||
<Figure className={`${styles.v}${s ? '' : ` ${styles.noData}`}`} text={s ? num2(s[m.k]) : '—'} />
|
||||
{/* The hint beside the figure: its share of the total and its change. */}
|
||||
<div className={styles.ctx}>
|
||||
{share != null && <span className={styles.p}>{share}</span>}
|
||||
{share != null && ratio != null && ' '}
|
||||
<Change ratio={ratio} against={against} long={m.k === 'totalQueries'} />
|
||||
{share == null && ratio == null && ' '}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Upstream's card is a link to Query Logs (v15.6.2). Here a button laid over
|
||||
the whole card, so the card's own reading order stays as it is; its name
|
||||
is the card's label and its title upstream's menu text. */}
|
||||
{onOpen && (
|
||||
<button type="button" className={styles.open} aria-label={m.label} title="Show Query Logs" onClick={onOpen} />
|
||||
)}
|
||||
</div>
|
||||
<KpiTile
|
||||
label={m.label}
|
||||
icon={GLYPH[m.k] ?? 'dashboard'}
|
||||
colour={colour}
|
||||
figure={s ? num2(s[m.k]) : '—'}
|
||||
noData={!s}
|
||||
onOpen={onOpen}
|
||||
>
|
||||
{/* The hint beside the figure: its share of the total and its change. */}
|
||||
{share != null && <span className={styles.p}>{share}</span>}
|
||||
{share != null && ratio != null && ' '}
|
||||
<Change ratio={ratio} against={against} long={m.k === 'totalQueries'} />
|
||||
{share == null && ratio == null && ' '}
|
||||
</KpiTile>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { getHours } from '../../api/dashboard'
|
||||
import { Body, Panel } from '../../ui/Panel'
|
||||
import { Failure, Loading } from '../../ui/Empty'
|
||||
import { Failure, LoadingRegion } from '../../ui/Empty'
|
||||
import { heatRows, hourRequests, level, stitchHours, weekSpan, type HeatCell } from './heatmap'
|
||||
import { spanFilters } from './jump'
|
||||
import type { QueryLogsFilters } from '../../app/handoff'
|
||||
@@ -102,7 +102,7 @@ export function Heatmap({
|
||||
return (
|
||||
<Panel title="Queries per Hour, Last 7 Days">
|
||||
<Body>
|
||||
{state.kind === 'loading' && <Loading compact />}
|
||||
{state.kind === 'loading' && <LoadingRegion height="h-40" />}
|
||||
{state.kind === 'failed' && <Failure>Could not load this data.</Failure>}
|
||||
{/* A quiet week is drawn, not replaced by the empty box: zero is a true
|
||||
figure, and every cell still says so. */}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function Top({
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={title}
|
||||
title={<span className="whitespace-nowrap">{title}</span>}
|
||||
className={styles.top}
|
||||
actions={
|
||||
<div className={styles.panelActions}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Dhcp } from './Dhcp'
|
||||
import * as api from '../../api/dhcp'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { HubWritable } from '../../lib/hub-write'
|
||||
import { WithChrome } from '../../test/chrome'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
@@ -20,7 +21,9 @@ function hubDhcp(writable: string[], canModify = true) {
|
||||
return render(
|
||||
<HubMode.Provider value>
|
||||
<HubWritable.Provider value={writable}>
|
||||
<Dhcp token="t" sub="Scopes" nodes={NODES} clusterInitialised canModify={canModify} canDelete={canModify} />
|
||||
<WithChrome>
|
||||
<Dhcp token="t" sub="Scopes" nodes={NODES} clusterInitialised canModify={canModify} canDelete={canModify} />
|
||||
</WithChrome>
|
||||
</HubWritable.Provider>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
|
||||
@@ -88,13 +88,13 @@ describe('DHCP › Leases', () => {
|
||||
the table's own footer (`dhcp.js:74`) and the rest of the console uses its
|
||||
"there is nothing here" row.
|
||||
*/
|
||||
it('with no leases it says so INSIDE the table, and the footer keeps counting', async () => {
|
||||
it('with no leases it says so, with a way out', async () => {
|
||||
vi.spyOn(api, 'listLeases').mockResolvedValue({ kind: 'ok', data: [] })
|
||||
render(<Dhcp token="t" sub="Leases" />)
|
||||
|
||||
const message = await screen.findByText('No Lease Found')
|
||||
expect(message.closest('table')).not.toBeNull()
|
||||
expect(screen.getByText('Total Leases: 0')).toBeInTheDocument()
|
||||
/* An empty state with a way out, and the figures still known: zero is a true count here. */
|
||||
expect(await screen.findByText('No Lease Found')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Reload leases' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('each row offers only the conversion its type calls for', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { SubTabs } from '../../ui/SubTabs'
|
||||
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
|
||||
import { ServerSelect } from '../../ui/ServerSelect'
|
||||
import { Leases } from './Leases'
|
||||
import { Scopes } from './Scopes'
|
||||
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
@@ -84,7 +84,8 @@ export function Dhcp({
|
||||
{/* Upstream mounts one here (`optDhcpClusterNode` / `optLogsClusterNode`).
|
||||
It draws nothing without a cluster, so it costs a single-server install
|
||||
nothing. */}
|
||||
<ClusterNodeSelect
|
||||
<ServerSelect
|
||||
hint={<HubWriteHint write={write} />}
|
||||
nodes={nodes}
|
||||
initialised={clusterInitialised}
|
||||
value={node}
|
||||
@@ -92,7 +93,6 @@ export function Dhcp({
|
||||
disabled={nodeLocked}
|
||||
label="Cluster Node"
|
||||
/>
|
||||
<HubWriteHint write={write} />
|
||||
{active === 'Scopes' ? (
|
||||
<Scopes
|
||||
tabs={tabs}
|
||||
@@ -103,7 +103,7 @@ export function Dhcp({
|
||||
onLockNode={setNodeLocked}
|
||||
/>
|
||||
) : (
|
||||
<Leases tabs={tabs} token={token} node={node} canModify={canModify} canDelete={canDelete} />
|
||||
<Leases tabs={tabs} token={token} node={node} canModify={canModify} canDelete={canDelete} onOpenScopes={() => onSubChange?.('Scopes')} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
+48
-22
@@ -14,7 +14,7 @@ import { Dialog } from '../../ui/Dialog'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
import { minuteStamp } from './dates'
|
||||
import type { Notice } from './notices'
|
||||
import { Failure, Loading } from '../../ui/Empty'
|
||||
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import tbl from '../../ui/Table.module.css'
|
||||
import { Panel } from '../../ui/Panel'
|
||||
@@ -45,6 +45,8 @@ and the type tag are kept.
|
||||
*/
|
||||
|
||||
export interface LeasesProps {
|
||||
/** The empty state's way to the other sub-tab. */
|
||||
onOpenScopes?: () => void
|
||||
/** The section sub-navigation, drawn under the header. */
|
||||
tabs?: ReactNode
|
||||
token: string | null
|
||||
@@ -66,7 +68,7 @@ const KEYS: Keys<DhcpLease> = {
|
||||
expires: (l) => minuteStamp(l.leaseExpires),
|
||||
}
|
||||
|
||||
export function Leases({ tabs, token, node = '', canModify = true, canDelete = true }: LeasesProps) {
|
||||
export function Leases({ tabs, token, node = '', canModify = true, canDelete = true, onOpenScopes }: LeasesProps) {
|
||||
const [leases, setLeases] = useState<DhcpLease[] | null>(null)
|
||||
/* When the list was read: what "expiring in 10 min" counts from. Read with the list, not in render. */
|
||||
const [readAt, setReadAt] = useState(0)
|
||||
@@ -174,8 +176,6 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
|
||||
})
|
||||
}
|
||||
|
||||
if (leases == null) return <Loading />
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<SectionHeader
|
||||
@@ -187,26 +187,52 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
|
||||
|
||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||
|
||||
{leases != null && !failure && (
|
||||
<KpiStrip
|
||||
label="Lease totals"
|
||||
items={[
|
||||
{ label: 'Leases', value: leases.length, icon: 'dhcp' },
|
||||
{ label: 'Reserved', value: leases.filter((l) => l.type === 'Reserved').length, icon: 'lock' },
|
||||
{ label: 'Scopes', value: new Set(leases.map((l) => l.scope)).size, icon: 'cache' },
|
||||
{
|
||||
label: 'Expiring in 10 min',
|
||||
value: leases.filter((l) => l.type !== 'Reserved' && Date.parse(l.leaseExpires) - readAt < 600000 && Date.parse(l.leaseExpires) > readAt).length,
|
||||
icon: 'power',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{/* Waiting or failed, the figures are not known: the dash, never a zero. */}
|
||||
<KpiStrip
|
||||
label="Lease totals"
|
||||
items={
|
||||
leases == null || failure
|
||||
? [
|
||||
{ label: 'Leases', value: '\u2014', icon: 'dhcp' },
|
||||
{ label: 'Reserved', value: '\u2014', icon: 'lock' },
|
||||
{ label: 'Scopes', value: '\u2014', icon: 'cache' },
|
||||
{ label: 'Expiring in 10 min', value: '\u2014', icon: 'power' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Leases', value: leases.length, icon: 'dhcp' },
|
||||
{ label: 'Reserved', value: leases.filter((l) => l.type === 'Reserved').length, icon: 'lock' },
|
||||
{ label: 'Scopes', value: new Set(leases.map((l) => l.scope)).size, icon: 'cache' },
|
||||
{
|
||||
label: 'Expiring in 10 min',
|
||||
value: leases.filter((l) => l.type !== 'Reserved' && Date.parse(l.leaseExpires) - readAt < 600000 && Date.parse(l.leaseExpires) > readAt).length,
|
||||
icon: 'power',
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
|
||||
{failure ? (
|
||||
{leases == null ? (
|
||||
<LoadingRegion height="h-48" />
|
||||
) : failure ? (
|
||||
/* Solid, not dashed, and with no count: how many there are is not known.
|
||||
The server's message goes in the notice at the top. */
|
||||
<Failure>Could not load the list.</Failure>
|
||||
<FailureRetry onRetry={() => void load()}>Could not load the list.</FailureRetry>
|
||||
) : leases.length === 0 ? (
|
||||
<Panel title="Leases" description="Scope, hardware address, IP address, host name, type and expiry">
|
||||
<Empty
|
||||
title="No Lease Found"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="primary" onClick={() => void load()}>
|
||||
Reload leases
|
||||
</Button>
|
||||
{onOpenScopes != null && <Button onClick={onOpenScopes}>Open Scopes</Button>}
|
||||
</>
|
||||
}
|
||||
>
|
||||
No client holds a lease from this server yet.
|
||||
</Empty>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
<Panel title="Leases" description="Scope, hardware address, IP address, host name, type and expiry">
|
||||
@@ -301,7 +327,7 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={styles.total}>
|
||||
<div className={`${styles.total} px-4 py-3`}>
|
||||
{/* The footer is the count and nothing else. When there are no rows,
|
||||
the one that says so is the table itself, with its centred row, like
|
||||
the rest of the console and like upstream (`dhcp.js:74`); here it was
|
||||
|
||||
+24
-16
@@ -17,7 +17,7 @@ import { SectionHeader } from '../../ui/SectionHeader'
|
||||
import { errorNotice, type Notice } from './notices'
|
||||
import { formFromScope, blankForm, type ScopeForm as Form } from './model'
|
||||
import { ScopeForm } from './ScopeForm'
|
||||
import { Failure, Loading } from '../../ui/Empty'
|
||||
import { FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import tbl from '../../ui/Table.module.css'
|
||||
import styles from './Dhcp.module.css'
|
||||
@@ -200,16 +200,6 @@ export function Scopes({
|
||||
<div className={styles.wrap}>
|
||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||
|
||||
{scopes != null && !failure && (
|
||||
<KpiStrip
|
||||
label="Scope totals"
|
||||
items={[
|
||||
{ label: 'Scopes', value: scopes.length, icon: 'cache' },
|
||||
{ label: 'Enabled', value: scopes.filter((s) => s.enabled).length, icon: 'check', tone: 'ok' },
|
||||
{ label: 'Disabled', value: scopes.filter((s) => !s.enabled).length, icon: 'power' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<ScopeForm
|
||||
key={editing.title + editing.form.oldName}
|
||||
title={editing.title}
|
||||
@@ -226,8 +216,6 @@ export function Scopes({
|
||||
)
|
||||
}
|
||||
|
||||
if (scopes == null) return <Loading />
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<SectionHeader
|
||||
@@ -249,10 +237,30 @@ export function Scopes({
|
||||
|
||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||
|
||||
{failure ? (
|
||||
{/* Waiting or failed, the figures are not known: the dash, never a zero. */}
|
||||
<KpiStrip
|
||||
label="Scope totals"
|
||||
items={
|
||||
scopes == null || failure
|
||||
? [
|
||||
{ label: 'Scopes', value: '\u2014', icon: 'cache' },
|
||||
{ label: 'Enabled', value: '\u2014', icon: 'check' },
|
||||
{ label: 'Disabled', value: '\u2014', icon: 'power' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Scopes', value: scopes.length, icon: 'cache' },
|
||||
{ label: 'Enabled', value: scopes.filter((s) => s.enabled).length, icon: 'check', tone: 'ok' },
|
||||
{ label: 'Disabled', value: scopes.filter((s) => !s.enabled).length, icon: 'power' },
|
||||
]
|
||||
}
|
||||
/>
|
||||
|
||||
{scopes == null ? (
|
||||
<LoadingRegion height="h-48" />
|
||||
) : failure ? (
|
||||
/* Solid, not dashed, and with no count: how many there are is not known.
|
||||
The server's message goes in the notice at the top. */
|
||||
<Failure>Could not load the list.</Failure>
|
||||
<FailureRetry onRetry={() => void load()}>Could not load the list.</FailureRetry>
|
||||
) : (
|
||||
<>
|
||||
<Panel title="Scopes" description="Address pools and their options">
|
||||
@@ -354,7 +362,7 @@ export function Scopes({
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={styles.total}>
|
||||
<div className={`${styles.total} px-4 py-3`}>
|
||||
{/* The footer is the count and nothing else. When there are no rows,
|
||||
the one that says so is the table itself, with its centred row, like
|
||||
the rest of the console and like upstream (`dhcp.js:74`); here it was
|
||||
|
||||
@@ -3,6 +3,8 @@ The query bar. `align-items: flex-end` is what lines the buttons up with the
|
||||
bottom edge of the fields; before it was achieved with a `marginBottom: 1` put by
|
||||
hand on each button, which is the symptom of a row that does not align.
|
||||
*/
|
||||
.wrap { composes: screen from '../../ui/SectionHeader.module.css'; }
|
||||
|
||||
.flt {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -22,7 +24,7 @@ hand on each button, which is the symptom of a row that does not align.
|
||||
it carries the field's height and not an invented margin. */
|
||||
/* `.chk` went in the redesign: a third name for the settings checkbox, and the only
|
||||
place in the console where it measured 34 px high. It uses `ui/Check`. */
|
||||
.panel { margin-bottom: var(--s-9); }
|
||||
.panel { margin-bottom: 0; }
|
||||
|
||||
.meta { composes: annotationQuiet from '../../ui/text.module.css'; }
|
||||
/*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
|
||||
import { ServerSelect } from '../../ui/ServerSelect'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { DnsClientRequest } from '../../app/handoff'
|
||||
import { PROTOCOLS, TYPES, prepareServer, resolve } from '../../api/dnsclient'
|
||||
@@ -12,7 +12,7 @@ import { Field, Input, LabeledInput, LabeledSelect } from '../../ui/Field'
|
||||
import { Menu } from '../../ui/Menu'
|
||||
import { loadQuickList, protocolForServer, serverListOptions, type QuickServer } from '../../lib/quick-lists'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
import { Empty } from '../../ui/Empty'
|
||||
import { Empty, FailureRetry } from '../../ui/Empty'
|
||||
import { Raw } from '../../ui/Raw'
|
||||
import { Details } from '../../ui/Details'
|
||||
import styles from './DnsClient.module.css'
|
||||
@@ -92,6 +92,8 @@ export function DnsClient({
|
||||
const [raw, setRaw] = useState<unknown[]>([])
|
||||
const [alert, setAlert] = useState<AlertState | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
/* Why the last query failed, drawn where its response would be: no answer is not an empty answer. */
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
|
||||
async function fire(runImport: boolean) {
|
||||
// The order is upstream's: extract first, check afterwards.
|
||||
@@ -124,8 +126,10 @@ export function DnsClient({
|
||||
setOutput(null)
|
||||
setRaw([])
|
||||
setAlert(noticeFromFailure(outcome))
|
||||
setFailure(noticeFromFailure(outcome).text)
|
||||
return
|
||||
}
|
||||
setFailure(null)
|
||||
|
||||
const r = outcome.data.response
|
||||
setOutput(JSON.stringify(r.result, null, 2))
|
||||
@@ -154,17 +158,17 @@ export function DnsClient({
|
||||
}, [request])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.wrap}>
|
||||
<SectionHeader title="DNS Client" subtitle="Resolve a name through this server or another resolver" />
|
||||
|
||||
<ClusterNodeSelect
|
||||
<ServerSelect
|
||||
hint={<HubWriteHint write={write} />}
|
||||
nodes={nodes}
|
||||
initialised={clusterInitialised}
|
||||
value={clusterNode}
|
||||
onChange={setClusterNode}
|
||||
label="Cluster Node"
|
||||
/>
|
||||
<HubWriteHint write={write} />
|
||||
|
||||
<Notifier notice={alert} onClose={() => setAlert(null)} />
|
||||
|
||||
@@ -219,7 +223,8 @@ export function DnsClient({
|
||||
</LabeledSelect>
|
||||
</div>
|
||||
<div className={styles.short}>
|
||||
<LabeledSelect label="DNS-over-" value={protocol} onChange={(e) => setProtocol(e.target.value)}>
|
||||
{/* Upstream's label is "DNS-over-" (the protocol's suffix); on its own it was cut off. */}
|
||||
<LabeledSelect label="Protocol" value={protocol} onChange={(e) => setProtocol(e.target.value)}>
|
||||
{PROTOCOLS.map((t) => <option key={t}>{t}</option>)}
|
||||
</LabeledSelect>
|
||||
</div>
|
||||
@@ -230,17 +235,23 @@ export function DnsClient({
|
||||
THIRD name for the same gesture, and the only one in the console
|
||||
that came out at 34 px high. */}
|
||||
<Check label="Enable DNSSEC Validation" checked={dnssec} onChange={setDnssec} />
|
||||
<Button variant="primary" disabled={busy} onClick={() => void fire(false)}>
|
||||
Resolve
|
||||
</Button>
|
||||
<PermissionButton permission={canImport ? undefined : 'DnsClient.canModify'} disabled={busy || !write.allowed} onClick={() => void fire(true)}>
|
||||
Import
|
||||
</PermissionButton>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" disabled={busy} onClick={() => void fire(false)}>
|
||||
Resolve
|
||||
</Button>
|
||||
<PermissionButton permission={canImport ? undefined : 'DnsClient.canModify'} disabled={busy || !write.allowed} onClick={() => void fire(true)}>
|
||||
Import
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{output === null ? (
|
||||
<Empty>Run a query to see the response.</Empty>
|
||||
failure !== null ? (
|
||||
<FailureRetry onRetry={() => void fire(false)}>The query failed. {failure}</FailureRetry>
|
||||
) : (
|
||||
<Empty>Run a query to see the response.</Empty>
|
||||
)
|
||||
) : (
|
||||
<Panel
|
||||
title="Response"
|
||||
@@ -265,6 +276,6 @@ export function DnsClient({
|
||||
</Body>
|
||||
</Panel>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { HubWritable } from '../../lib/hub-write'
|
||||
import { HubServerIds } from '../../lib/hub-all'
|
||||
import * as hubRun from '../../api/hub-run'
|
||||
import { WithChrome } from '../../test/chrome'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
@@ -22,7 +23,9 @@ function hubCache(writable: string[], canDelete = true) {
|
||||
<HubMode.Provider value>
|
||||
<HubWritable.Provider value={writable}>
|
||||
<HubServerIds.Provider value={['mdns', 'ndns']}>
|
||||
<Cache token="t" nodes={NODES} clusterInitialised canDelete={canDelete} />
|
||||
<WithChrome>
|
||||
<Cache token="t" nodes={NODES} clusterInitialised canDelete={canDelete} />
|
||||
</WithChrome>
|
||||
</HubServerIds.Provider>
|
||||
</HubWritable.Provider>
|
||||
</HubMode.Provider>,
|
||||
@@ -62,7 +65,8 @@ describe('Cache behind a hub, on several servers', () => {
|
||||
.mockResolvedValueOnce(done({ server: 'mdns', ok: true, message: 'Cache flushed.' }, { server: 'ndns', ok: false, message: 'ndns did not answer' }))
|
||||
.mockResolvedValueOnce(done({ server: 'ndns', ok: true, message: 'Cache flushed.' }))
|
||||
hubCache(['mdns', 'ndns'])
|
||||
await user.click(await screen.findByRole('button', { name: 'Flush on all servers' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'All servers' }))
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Flush on all servers' }))
|
||||
const picker = await screen.findByRole('dialog')
|
||||
await user.click(within(picker).getByRole('button', { name: 'Flush cache' }))
|
||||
expect(run).toHaveBeenNthCalledWith(1, 't', 'cache.flush', {}, ['mdns', 'ndns'])
|
||||
@@ -76,7 +80,8 @@ describe('Cache behind a hub, on several servers', () => {
|
||||
const user = userEvent.setup()
|
||||
const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(done({ server: 'mdns', ok: true }))
|
||||
hubCache(['mdns'])
|
||||
await user.click(await screen.findByRole('button', { name: 'Delete a domain on all servers' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'All servers' }))
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Delete a domain on all servers' }))
|
||||
const ask = await screen.findByRole('dialog')
|
||||
await user.type(within(ask).getByLabelText('Domain'), 'bad domain')
|
||||
await user.click(within(ask).getByRole('button', { name: 'Choose servers' }))
|
||||
@@ -93,6 +98,6 @@ describe('Cache behind a hub, on several servers', () => {
|
||||
it('is not offered to a role that may not delete from the cache', async () => {
|
||||
hubCache(['mdns', 'ndns'], false)
|
||||
await screen.findByRole('button', { name: 'Flush Cache' })
|
||||
expect(screen.queryByRole('button', { name: 'Flush on all servers' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'All servers' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useContext, useState } from 'react'
|
||||
import { useAllServersFlow } from '../../lib/hub-all'
|
||||
import { AskDialog } from '../../ui/AskDialog'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { Menu } from '../../ui/Menu'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
|
||||
/*
|
||||
@@ -24,20 +24,36 @@ export function CacheAllServers({ token }: { token: string | null }) {
|
||||
if (!hub) return null
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() =>
|
||||
start({
|
||||
action: 'cache.flush',
|
||||
title: 'Flush the cache on several servers',
|
||||
intro: 'The whole cache of each chosen server is emptied.',
|
||||
confirmLabel: 'Flush cache',
|
||||
})
|
||||
}
|
||||
>
|
||||
Flush on all servers
|
||||
</Button>
|
||||
<Button onClick={() => setAsking(true)}>Delete a domain on all servers</Button>
|
||||
{/* One menu, not two more buttons next to the chosen server's own Flush Cache. */}
|
||||
<Menu label="All servers" text="All servers">
|
||||
{(close) => (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
close()
|
||||
start({
|
||||
action: 'cache.flush',
|
||||
title: 'Flush the cache on several servers',
|
||||
intro: 'The whole cache of each chosen server is emptied.',
|
||||
confirmLabel: 'Flush cache',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Flush on all servers
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
close()
|
||||
setAsking(true)
|
||||
}}
|
||||
>
|
||||
Delete a domain on all servers
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
{asking && (
|
||||
<AskDialog
|
||||
title="Delete a domain from the cache on all servers"
|
||||
|
||||
@@ -63,7 +63,9 @@ describe('stale data in the lists', () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' } as never)
|
||||
render(<Cache token="t" />)
|
||||
|
||||
expect(await screen.findByText(/boom/)).toBeInTheDocument()
|
||||
/* The notice carries the server's message and the region repeats it beside its Retry. */
|
||||
expect((await screen.findAllByText(/boom/)).length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Could not refresh/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { KpiStrip } from '../../ui/KpiStrip'
|
||||
import { ClusterNodeSelect, HubMode, primaryNodeName } from '../../ui/ClusterNodeSelect'
|
||||
import { HubMode, primaryNodeName } from '../../ui/ClusterNodeSelect'
|
||||
import { ServerSelect } from '../../ui/ServerSelect'
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
@@ -21,7 +22,7 @@ import { Confirm } from '../../ui/Confirm'
|
||||
import { Dialog } from '../../ui/Dialog'
|
||||
import { Field, Input, LabeledTextarea } from '../../ui/Field'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
import { Empty, Loading } from '../../ui/Empty'
|
||||
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||
import { StaleData } from '../StaleData'
|
||||
import { Tree } from './Tree'
|
||||
import { CacheAllServers } from './CacheAllServers'
|
||||
@@ -265,6 +266,8 @@ export function Lists({
|
||||
/* A first read that failed and was reported to the host: there is no notice here
|
||||
to tell "failed" from "still loading", so it is said apart. */
|
||||
const [failedOutside, setFailedOutside] = useState(false)
|
||||
/* Why the first read failed, drawn in the regions: a failed read has no records, which is not an empty node. */
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
/*
|
||||
Stale data. The same gap Zones had, and the same design rule (old data never
|
||||
looks like new data): the previous list
|
||||
@@ -310,6 +313,7 @@ export function Lists({
|
||||
const outcome = await listNode(list, token, domain, up ? 'up' : undefined, node)
|
||||
if (outcome.kind === 'ok') {
|
||||
setNode(outcome.data)
|
||||
setFailure(null)
|
||||
setStale(false)
|
||||
setLastGood(new Date().toISOString())
|
||||
hadData.current = true
|
||||
@@ -328,6 +332,7 @@ export function Lists({
|
||||
*/
|
||||
if (!hadData.current) {
|
||||
if (host.current != null) setFailedOutside(true)
|
||||
setFailure(noticeFromFailure(outcome).text)
|
||||
report(noticeFromFailure(outcome))
|
||||
} else setStale(true)
|
||||
},
|
||||
@@ -366,7 +371,16 @@ export function Lists({
|
||||
/* The first load, still in flight: `node` is null and nothing failed. A load
|
||||
that FAILED leaves `node` null too, but it sets the notice, and then this is
|
||||
not loading, it is a failure with nothing behind it. */
|
||||
const loading = node == null && notice == null && !failedOutside
|
||||
const failed = node == null && (notice != null || failedOutside || failure != null)
|
||||
const loading = node == null && !failed
|
||||
|
||||
/* Retry asks for the root again, with every trace of the failed read gone. */
|
||||
const retry = () => {
|
||||
setNotice(null)
|
||||
setFailedOutside(false)
|
||||
setFailure(null)
|
||||
void load('')
|
||||
}
|
||||
|
||||
const domain = node?.domain ?? ''
|
||||
const nodeTitle = domain === '' ? '<ROOT>' : (node?.domainIdn ?? domain)
|
||||
@@ -468,7 +482,8 @@ export function Lists({
|
||||
Changing it reloads the open domain from that node, and the list, Flush
|
||||
and Delete all go to it (other-zones.js:20-61, 106). */}
|
||||
{isCache && !embedded && (
|
||||
<ClusterNodeSelect
|
||||
<ServerSelect
|
||||
hint={<HubWriteHint write={cacheWrite} />}
|
||||
nodes={nodes}
|
||||
initialised={clusterInitialised}
|
||||
value={clusterNode}
|
||||
@@ -481,7 +496,6 @@ export function Lists({
|
||||
label="Cluster Node"
|
||||
/>
|
||||
)}
|
||||
{isCache && !embedded && <HubWriteHint write={cacheWrite} />}
|
||||
|
||||
{/* On Cache the identity band's second line moves up under the title: the
|
||||
screen is one panel now, and a band naming the panel under a header
|
||||
@@ -562,7 +576,9 @@ export function Lists({
|
||||
but it sets the notice, and that is what the reader has to see.
|
||||
*/}
|
||||
{loading ? (
|
||||
<div className={styles.body}><Loading /></div>
|
||||
<div className={styles.body}><LoadingRegion height="h-48" /></div>
|
||||
) : failed ? (
|
||||
<div className={styles.body}><Empty compact>The domain tree could not be read.</Empty></div>
|
||||
) : (
|
||||
<>
|
||||
{/* The count lives in the count bar, like the records one: in the
|
||||
@@ -630,7 +646,7 @@ export function Lists({
|
||||
shows, which is the defect the tree had fixed on 2026-09-07 and this
|
||||
side still had.
|
||||
*/}
|
||||
{!loading && (
|
||||
{!loading && !failed && (
|
||||
<div className={styles.count}>
|
||||
<span>
|
||||
{records.length} records at <span className={styles.mono}>{nodeTitle}</span>
|
||||
@@ -666,7 +682,11 @@ export function Lists({
|
||||
{loading ? (
|
||||
/* The tree's placeholder above already announced this wait: two
|
||||
`role="status"` for one request read it out twice. */
|
||||
<div className={styles.empty}><Loading announce={false} /></div>
|
||||
<div className={styles.empty}><LoadingRegion announce={false} height="h-48" /></div>
|
||||
) : failed ? (
|
||||
<div className={styles.empty}>
|
||||
<FailureRetry onRetry={retry}>Could not read the records. {failure}</FailureRetry>
|
||||
</div>
|
||||
) : records.length > 0 ? (
|
||||
<ResourceRecords records={records} withDnssec={isCache} node={domain} />
|
||||
) : (
|
||||
|
||||
@@ -59,7 +59,7 @@ the file list keeps its own height), its body, and `Raw`'s `fill` box at the end
|
||||
|
||||
.pbList { padding: var(--s-5); }
|
||||
|
||||
.logfiles { display: flex; flex-direction: column; gap: var(--s-1); }
|
||||
.logfiles { display: flex; flex-direction: column; gap: var(--s-1); max-height: 60dvh; overflow-y: auto; }
|
||||
.logfile { composes: row from '../../ui/list.module.css'; justify-content: space-between; }
|
||||
.logfile .name { font-family: var(--font-mono); }
|
||||
.sz { composes: annotationQuiet from '../../ui/text.module.css'; }
|
||||
|
||||
@@ -70,6 +70,11 @@ function withApps(list: InstalledApp[]) {
|
||||
.mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: { apps: list } } } as never)
|
||||
}
|
||||
|
||||
/* The two destructive verbs share the header's "Delete all" menu. */
|
||||
async function openDelete() {
|
||||
await userEvent.setup().click(await screen.findByRole('button', { name: 'Delete all' }))
|
||||
}
|
||||
|
||||
describe('Logs › View Logs', () => {
|
||||
it('it lists the files with their size and offers to delete them all', async () => {
|
||||
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
|
||||
@@ -77,8 +82,9 @@ describe('Logs › View Logs', () => {
|
||||
|
||||
expect(await screen.findByText('2026-08-26')).toBeInTheDocument()
|
||||
expect(screen.getByText('[20.48 KB]')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete All Logs' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeInTheDocument()
|
||||
await openDelete()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete All Logs' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete All Stats' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('with no files it says \"No Log File Was Found\" and \"Delete All Logs\" disappears, but NOT \"Delete All Stats\"', async () => {
|
||||
@@ -86,8 +92,9 @@ describe('Logs › View Logs', () => {
|
||||
render(<Logs token="t" sub="View Logs" />)
|
||||
|
||||
expect(await screen.findByText('No Log File Was Found')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Delete All Logs' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeInTheDocument()
|
||||
await openDelete()
|
||||
expect(screen.queryByRole('menuitem', { name: 'Delete All Logs' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete All Stats' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opening a file asks for only the first 2 MB and draws it', async () => {
|
||||
@@ -172,7 +179,8 @@ describe('Logs › View Logs', () => {
|
||||
render(<Logs token="t" sub="View Logs" />)
|
||||
await screen.findByText('2026-08-26')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete All Logs' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Delete all' }))
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Delete All Logs' }))
|
||||
expect(
|
||||
screen.getByText('Are you sure you want to permanently delete all log files?'),
|
||||
).toBeInTheDocument()
|
||||
@@ -192,7 +200,8 @@ describe('Logs › View Logs', () => {
|
||||
render(<Logs token="t" sub="View Logs" />)
|
||||
await screen.findByText('2026-08-26')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete All Stats' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Delete all' }))
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Delete All Stats' }))
|
||||
expect(
|
||||
screen.getByText('Are you sure you want to permanently delete all stats files?'),
|
||||
).toBeInTheDocument()
|
||||
@@ -219,12 +228,14 @@ describe('Logs › View Logs', () => {
|
||||
const { unmount } = render(
|
||||
<Logs token="t" sub="View Logs" canDeleteLogs={false} canDeleteStats />,
|
||||
)
|
||||
await openDelete()
|
||||
expect(await screen.findByRole('button', { name: /Delete All Logs/ })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeEnabled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete All Stats' })).toBeEnabled()
|
||||
unmount()
|
||||
|
||||
render(<Logs token="t" sub="View Logs" canDeleteLogs canDeleteStats={false} />)
|
||||
expect(await screen.findByRole('button', { name: 'Delete All Logs' })).toBeEnabled()
|
||||
await openDelete()
|
||||
expect(await screen.findByRole('menuitem', { name: 'Delete All Logs' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: /Delete All Stats/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -237,6 +248,7 @@ describe('Logs › View Logs', () => {
|
||||
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
|
||||
render(<Logs token="t" sub="View Logs" canDeleteLogs={false} canDeleteStats={false} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Delete all' }))
|
||||
const stats = await screen.findByRole('button', { name: /Delete All Stats/ })
|
||||
await user.hover(stats.parentElement!)
|
||||
expect(await screen.findByText('Requires Dashboard: Delete')).toBeInTheDocument()
|
||||
@@ -254,7 +266,8 @@ describe('Logs › View Logs', () => {
|
||||
render(<Logs token="t" sub="View Logs" canDeleteLogs canDeleteStats />)
|
||||
|
||||
expect(await screen.findByText('No Log File Was Found')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Delete All Logs/ })).not.toBeInTheDocument()
|
||||
await openDelete()
|
||||
expect(screen.queryByRole('menuitem', { name: /Delete All Logs/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { useAllServersFlow } from '../../lib/hub-all'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
import { SubTabs } from '../../ui/SubTabs'
|
||||
import { AGGREGATE, ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { AGGREGATE, HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { ServerSelect } from '../../ui/ServerSelect'
|
||||
import { QueryLogs } from './QueryLogs'
|
||||
import { ViewLogs } from './ViewLogs'
|
||||
import type { QueryLogsRequest } from '../../app/handoff'
|
||||
@@ -96,7 +97,8 @@ export function Logs({
|
||||
{/* Upstream mounts one here (`optDhcpClusterNode` / `optLogsClusterNode`).
|
||||
It draws nothing without a cluster, so it costs a single-server install
|
||||
nothing. */}
|
||||
<ClusterNodeSelect
|
||||
<ServerSelect
|
||||
hint={active === 'Query Logs' && <HubWriteHint write={write} allServers={allServers} />}
|
||||
nodes={nodes}
|
||||
initialised={clusterInitialised}
|
||||
value={node}
|
||||
@@ -104,7 +106,6 @@ export function Logs({
|
||||
aggregate={aggregateShown}
|
||||
label="Cluster Node"
|
||||
/>
|
||||
{active === 'Query Logs' && <HubWriteHint write={write} allServers={allServers} />}
|
||||
{active === 'Query Logs' ? (
|
||||
<HubNode.Provider value={hub ? node : ''}>
|
||||
<QueryLogs
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Button } from '../../ui/Button'
|
||||
import { Input, Select } from '../../ui/Field'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
import { toIso, dateTime } from './dates'
|
||||
import { Loading } from '../../ui/Empty'
|
||||
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||
import { Check } from '../../ui/Check'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { DataTable } from '../../ui/DataTable'
|
||||
@@ -331,6 +331,10 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
|
||||
const [apps, setApps] = useState<{ name: string; classPaths: string[] }[] | null>(null)
|
||||
const [f, setF] = useState<Filters>(() => defaultFilters('', ''))
|
||||
const [page, setPage] = useState<QueryLogPage | null>(null)
|
||||
/* Why the last query (or the read of the logging apps) failed: a failure has no rows, which is not an empty log. */
|
||||
const [queryFailure, setQueryFailure] = useState<string | null>(null)
|
||||
const [appsFailed, setAppsFailed] = useState(false)
|
||||
const [appsTry, setAppsTry] = useState(0)
|
||||
const [notice, setNotice] = useState<Notice | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [live, setLive] = useState(false)
|
||||
@@ -444,10 +448,12 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
|
||||
if (outcome.kind !== 'ok') {
|
||||
if (!live) {
|
||||
setNotice(noticeFromFailure(outcome))
|
||||
setQueryFailure(noticeFromFailure(outcome).text)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setQueryFailure(null)
|
||||
setPage(outcome.data.response)
|
||||
},
|
||||
[token, params, reveal],
|
||||
@@ -490,9 +496,11 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
|
||||
run, so the screen sat dead without saying why.
|
||||
*/
|
||||
setApps([])
|
||||
setAppsFailed(true)
|
||||
setNotice(noticeFromFailure(outcome))
|
||||
return
|
||||
}
|
||||
setAppsFailed(false)
|
||||
const list = appsWithQueryLogs(outcome.data.response.apps ?? [])
|
||||
setApps(list)
|
||||
const first = list[0]
|
||||
@@ -516,7 +524,7 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [token])
|
||||
}, [token, appsTry])
|
||||
|
||||
/* logs.js:610. While "Live Update" is checked, it repeats every 2 s. */
|
||||
useEffect(() => {
|
||||
@@ -599,7 +607,14 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
|
||||
}
|
||||
}
|
||||
|
||||
if (apps == null) return <Loading />
|
||||
if (apps == null) {
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<SectionHeader title="Logs" subtitle="Server log files and the query log of the installed logging app" tabs={tabs} />
|
||||
<LoadingRegion height="h-48" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* What the folded panel lists (FOLD_QUERY). */
|
||||
const active = activeFilters(f, defaultFilters(apps[0]?.name ?? '', apps[0]?.classPaths[0] ?? ''))
|
||||
@@ -917,6 +932,27 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{page == null && (
|
||||
busy ? (
|
||||
<LoadingRegion height="h-48" />
|
||||
) : appsFailed ? (
|
||||
<FailureRetry onRetry={() => { setApps(null); setAppsTry((n) => n + 1) }}>Could not read the logging apps.</FailureRetry>
|
||||
) : queryFailure !== null ? (
|
||||
<FailureRetry onRetry={() => void query(f.pageNumber, false)}>Could not read the query log. {queryFailure}</FailureRetry>
|
||||
) : (
|
||||
<Empty
|
||||
title="No query yet"
|
||||
actions={
|
||||
<Button variant="primary" disabled={live} onClick={() => void query(f.pageNumber, false)}>
|
||||
Run query
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Choose the filters and run a query to read the log.
|
||||
</Empty>
|
||||
)
|
||||
)}
|
||||
|
||||
{page != null && (
|
||||
<Panel title="Query Logs" description="Logging app, dates, client, domain and response type">
|
||||
<div className={styles.count}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
deleteAllLogs,
|
||||
deleteLog,
|
||||
@@ -15,7 +15,10 @@ import { LogText, stampWidth } from './LogText'
|
||||
import { PermissionButton } from '../../ui/PermissionButton'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
import {Empty, Loading} from '../../ui/Empty'
|
||||
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||
import { Menu } from '../../ui/Menu'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { LockedItem } from '../blocking/Locked'
|
||||
import styles from './Logs.module.css'
|
||||
import { Body, Panel } from '../../ui/Panel'
|
||||
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||||
@@ -95,7 +98,10 @@ export function ViewLogs({
|
||||
canDeleteLogs = true,
|
||||
canDeleteStats = true,
|
||||
}: ViewLogsProps) {
|
||||
const hub = useContext(HubMode)
|
||||
const [files, setFiles] = useState<LogFile[] | null>(null)
|
||||
/* The list could not be read: that is not a server without log files. */
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [open, setOpen] = useState<string | null>(null)
|
||||
const [body, setBody] = useState<string | null>(null)
|
||||
const [loadingBody, setLoadingBody] = useState(false)
|
||||
@@ -108,9 +114,11 @@ export function ViewLogs({
|
||||
const r = await listLogFiles(token, node)
|
||||
if (r.kind === 'ok') {
|
||||
setFiles(r.data)
|
||||
setFailed(false)
|
||||
return r.data
|
||||
}
|
||||
setFiles([])
|
||||
setFailed(true)
|
||||
setNotice(noticeFromFailure(r))
|
||||
return null
|
||||
}, [token, node])
|
||||
@@ -211,8 +219,6 @@ export function ViewLogs({
|
||||
})
|
||||
}
|
||||
|
||||
if (files == null) return <Loading />
|
||||
|
||||
const CONFIRM_TEXT: Record<Confirmation, { title: string; text: string; label: string }> = {
|
||||
log: {
|
||||
title: 'Delete Log',
|
||||
@@ -253,24 +259,31 @@ export function ViewLogs({
|
||||
controls, among the nine compared with upstream, that "does not ask for
|
||||
the permission of the screen whose name it carries".
|
||||
*/}
|
||||
{files.length > 0 && (
|
||||
<PermissionButton
|
||||
variant="danger"
|
||||
disabled={busy}
|
||||
permission={canDeleteLogs ? undefined : 'Logs.canDelete'}
|
||||
onClick={() => setConfirm('allLogs')}
|
||||
>
|
||||
Delete All Logs
|
||||
</PermissionButton>
|
||||
)}
|
||||
<PermissionButton
|
||||
variant="danger"
|
||||
disabled={busy}
|
||||
permission={canDeleteStats ? undefined : 'Dashboard.canDelete'}
|
||||
onClick={() => setConfirm('allStats')}
|
||||
>
|
||||
Delete All Stats
|
||||
</PermissionButton></>}
|
||||
{/* Behind a hub both are refused, so neither is offered. Without one they share a menu:
|
||||
two destructive buttons stacked the header into two rows. */}
|
||||
{!hub && (
|
||||
<Menu label="Delete all" text="Delete all">
|
||||
{(close) => (
|
||||
<>
|
||||
{files != null && files.length > 0 &&
|
||||
(canDeleteLogs ? (
|
||||
<button type="button" data-variant="danger" disabled={busy} onClick={() => { close(); setConfirm('allLogs') }}>
|
||||
Delete All Logs
|
||||
</button>
|
||||
) : (
|
||||
<LockedItem need="Logs.canDelete" placement="left">Delete All Logs</LockedItem>
|
||||
))}
|
||||
{canDeleteStats ? (
|
||||
<button type="button" data-variant="danger" disabled={busy} onClick={() => { close(); setConfirm('allStats') }}>
|
||||
Delete All Stats
|
||||
</button>
|
||||
) : (
|
||||
<LockedItem need="Dashboard.canDelete" placement="left">Delete All Stats</LockedItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
)}</>}
|
||||
/>
|
||||
|
||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||
@@ -278,11 +291,19 @@ export function ViewLogs({
|
||||
<div className={styles.split}>
|
||||
<Panel title="Log Files" description="Server log files by date">
|
||||
<Body className={styles.pbList}>
|
||||
{files.length === 0 ? (
|
||||
<Empty>
|
||||
{notice?.type === 'danger'
|
||||
? 'Unable to load the log files.'
|
||||
: 'No Log File Was Found'}
|
||||
{files == null ? (
|
||||
<LoadingRegion height="h-40" />
|
||||
) : failed ? (
|
||||
<FailureRetry onRetry={() => { setFiles(null); void load() }}>Unable to load the log files.</FailureRetry>
|
||||
) : files.length === 0 ? (
|
||||
<Empty
|
||||
actions={
|
||||
<Button variant="primary" onClick={() => { setFiles(null); void load() }}>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
No Log File Was Found
|
||||
</Empty>
|
||||
) : (
|
||||
<div className={styles.logfiles}>
|
||||
@@ -303,7 +324,13 @@ export function ViewLogs({
|
||||
</Body>
|
||||
</Panel>
|
||||
|
||||
{open != null && (
|
||||
{open == null ? (
|
||||
<Panel title="Log viewer" description="The content of the file you choose" className={styles.reader}>
|
||||
<Body>
|
||||
<Empty title="Choose a log file">Pick a file from the list to read it here.</Empty>
|
||||
</Body>
|
||||
</Panel>
|
||||
) : (
|
||||
<Panel
|
||||
className={styles.reader}
|
||||
title={<span className={styles.mono}>{open}</span>}
|
||||
@@ -326,7 +353,7 @@ export function ViewLogs({
|
||||
>
|
||||
<Body className={styles.readerBody}>
|
||||
{loadingBody ? (
|
||||
<Loading />
|
||||
<LoadingRegion height="h-48" />
|
||||
) : (
|
||||
/*
|
||||
The server's error is drawn HERE and not as a notice, because the
|
||||
|
||||
@@ -41,6 +41,23 @@ describe('Settings: loading', () => {
|
||||
expect(await screen.findByText('Unable to load the DNS Server settings.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the header (and with it the server selector) while the read is slow', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockImplementation(() => new Promise<never>(() => {}) as never)
|
||||
render(<Settings token="tok" />)
|
||||
expect(await screen.findByRole('heading', { name: 'Settings' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading')
|
||||
})
|
||||
|
||||
it('and when the read fails, the header stays and Retry reads again', async () => {
|
||||
const request = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' })
|
||||
render(<Settings token="tok" />)
|
||||
expect(await screen.findByText('Unable to load the DNS Server settings.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument()
|
||||
const asked = request.mock.calls.length
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
await waitFor(() => expect(request.mock.calls.length).toBeGreaterThan(asked))
|
||||
})
|
||||
|
||||
it('the active sub-tab arrives by prop: the sub-navigation belongs to the Shell', async () => {
|
||||
server()
|
||||
await mount({ sub: 'Logging' })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { ServerSelect } from '../../ui/ServerSelect'
|
||||
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { nodeArg, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
@@ -35,7 +36,7 @@ import { Blocking } from './panes/Blocking'
|
||||
import { ProxyForwarders } from './panes/ProxyForwarders'
|
||||
import { Logging } from './panes/Logging'
|
||||
import { BackupDialog, Confirm, RestoreDialog } from './dialogs'
|
||||
import { Failure, Loading } from '../../ui/Empty'
|
||||
import { FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||
import styles from './Settings.module.css'
|
||||
import panelFormStyles from '../../ui/PanelForm.module.css'
|
||||
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||||
@@ -298,9 +299,35 @@ export function Settings({
|
||||
}, [indexed, active, loading])
|
||||
const here = useSectionInView(sections.map((x) => x.id))
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (form == null || settings == null) {
|
||||
return <Failure>Unable to load the DNS Server settings.</Failure>
|
||||
/* The selector is drawn in every state: a slow or silent server must not take away the way
|
||||
to choose the other one. */
|
||||
const serverSelect = (
|
||||
<ServerSelect
|
||||
hint={<HubWriteHint write={write} />}
|
||||
nodes={nodes}
|
||||
initialised={clusterInitialised}
|
||||
aggregate={!hub}
|
||||
value={node}
|
||||
onChange={setNode}
|
||||
label="Cluster Node"
|
||||
/>
|
||||
)
|
||||
if (loading || form == null || settings == null) {
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<SectionHeader
|
||||
title="Settings"
|
||||
subtitle="Server configuration"
|
||||
tabs={<SubTabs label="Settings sections" section="settings" tabs={SUB_TABS} active={active} onChoose={(t) => onSubChange?.(t as SubTab)} />}
|
||||
actions={serverSelect}
|
||||
/>
|
||||
{loading ? (
|
||||
<LoadingRegion height="h-96">Loading…</LoadingRegion>
|
||||
) : (
|
||||
<FailureRetry onRetry={() => void load()}>Unable to load the DNS Server settings.</FailureRetry>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const en = enabled(form)
|
||||
@@ -501,14 +528,7 @@ export function Settings({
|
||||
actions={
|
||||
<>
|
||||
{hub && canModify && <CompareServersButton token={token} scope="settings" />}
|
||||
<ClusterNodeSelect
|
||||
nodes={nodes}
|
||||
initialised={clusterInitialised}
|
||||
aggregate={!hub}
|
||||
value={node}
|
||||
onChange={setNode}
|
||||
label="Cluster Node"
|
||||
/>
|
||||
{serverSelect}
|
||||
<PermissionButton
|
||||
permission={canBackup ? undefined : 'Settings.canDelete'}
|
||||
onClick={() => {
|
||||
@@ -549,7 +569,7 @@ export function Settings({
|
||||
}
|
||||
/>
|
||||
|
||||
<HubWriteHint write={write} />
|
||||
{!hub && <HubWriteHint write={write} />}
|
||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||
|
||||
{/*
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '../../api/zones'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { Tick } from '../../ui/Tick'
|
||||
import { Loading } from '../../ui/Empty'
|
||||
import { FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||
import { StaleData } from '../StaleData'
|
||||
import { Field, Input, Select } from '../../ui/Field'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
@@ -129,6 +129,8 @@ export function ZoneList({
|
||||
fixed for on this screen. Measured on 2026-09-07 with the state matrix.
|
||||
*/
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
/* Why the first read failed, drawn in the region. A failed read has no zones: it is not an empty list. */
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
const [pageNumber, setPageNumber] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [totalZones, setTotalZones] = useState(0)
|
||||
@@ -191,7 +193,10 @@ export function ZoneList({
|
||||
*/
|
||||
const hadData = lastGood != null
|
||||
setStale(hadData)
|
||||
if (!hadData) onNotice(noticeFromFailure(r))
|
||||
if (!hadData) {
|
||||
onNotice(noticeFromFailure(r))
|
||||
setFailure(noticeFromFailure(r).text)
|
||||
}
|
||||
/* A first load that failed is no longer waiting: it has an answer, and it
|
||||
is a bad one. Leaving `loaded` false here would leave the screen saying
|
||||
"loading" over a failure that has already been reported. */
|
||||
@@ -201,6 +206,7 @@ export function ZoneList({
|
||||
|
||||
const data = r.data
|
||||
setStale(false)
|
||||
setFailure(null)
|
||||
setLastGood(new Date().toISOString())
|
||||
setZones(data.zones)
|
||||
setLoaded(true)
|
||||
@@ -395,6 +401,8 @@ export function ZoneList({
|
||||
const shown = view === 'all' ? zones : zones.filter(VIEWS[view])
|
||||
const { rows: visibleZones, sort, toggle } = useSort(KEYS, shown)
|
||||
const kpi = summary(all)
|
||||
/* No answer yet, or none that came: the figures are unknown, which is not zero. */
|
||||
const noData = !loaded || failure !== null
|
||||
|
||||
const allChecked = zones.length > 0 && checkedOnes.length === zones.length
|
||||
|
||||
@@ -439,10 +447,10 @@ export function ZoneList({
|
||||
<KpiStrip
|
||||
label="Zone totals"
|
||||
items={[
|
||||
{ label: 'Zones', value: all.length || totalZones, icon: 'zones' },
|
||||
{ label: 'Enabled', value: kpi.enabled, icon: 'check', tone: 'ok' },
|
||||
{ label: 'Signed with DNSSEC', value: kpi.signed, icon: 'lock' },
|
||||
{ label: 'Secondary pending sync', value: kpi.pending, icon: 'convert', tone: kpi.pending > 0 ? 'warn' : undefined },
|
||||
{ label: 'Zones', value: noData ? '\u2014' : all.length || totalZones, icon: 'zones' },
|
||||
{ label: 'Enabled', value: noData ? '\u2014' : kpi.enabled, icon: 'check', tone: noData ? undefined : 'ok' },
|
||||
{ label: 'Signed with DNSSEC', value: noData ? '\u2014' : kpi.signed, icon: 'lock' },
|
||||
{ label: 'Secondary pending sync', value: noData ? '\u2014' : kpi.pending, icon: 'convert', tone: !noData && kpi.pending > 0 ? 'warn' : undefined },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -549,7 +557,9 @@ export function ZoneList({
|
||||
{stale && <StaleData since={lastGood} onRetry={() => void load(pageNumber)} />}
|
||||
|
||||
{!loaded ? (
|
||||
<Loading />
|
||||
<LoadingRegion height="h-64" />
|
||||
) : failure !== null ? (
|
||||
<FailureRetry onRetry={() => void load(pageNumber)}>Could not load the zones. {failure}</FailureRetry>
|
||||
) : (
|
||||
<>
|
||||
{/*
|
||||
@@ -801,7 +811,7 @@ function zoneCells(p: RowProps) {
|
||||
</>
|
||||
),
|
||||
status: (<Tag tone={stateTone}>{state}</Tag>),
|
||||
serial: z.soaSerial ?? ' ',
|
||||
serial: z.soaSerial ?? '\u2014',
|
||||
expiry: date(z.expiry),
|
||||
modified: date(z.lastModified),
|
||||
actions: (
|
||||
|
||||
@@ -45,4 +45,17 @@ describe('a zone list that has not answered yet', () => {
|
||||
render(<ZoneList {...props} />)
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('a failed read is an error with Retry, figures that are not known and no empty state', async () => {
|
||||
const request = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'mdns did not answer: timed out after 8000 ms' } as never)
|
||||
render(<ZoneList {...props} />)
|
||||
expect(await screen.findByText(/Could not load the zones\. mdns did not answer/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('No Zone Found')).not.toBeInTheDocument()
|
||||
const kpis = screen.getByRole('group', { name: 'Zone totals' })
|
||||
expect(kpis).toHaveTextContent(String.fromCharCode(0x2014))
|
||||
expect(kpis).not.toHaveTextContent('0')
|
||||
request.mockResolvedValue({ kind: 'ok', data: { response: { zones: [], pageNumber: 1, totalPages: 1, totalZones: 0 } } } as never)
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByText('No Zone Found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -157,7 +157,8 @@ describe('who reports the failure', () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' } as never)
|
||||
render(<Zones token="t" canModify canDelete />)
|
||||
|
||||
expect(await screen.findByText(/boom/)).toBeInTheDocument()
|
||||
/* The notice and the region both carry it: the region stays when the notice is dismissed. */
|
||||
expect((await screen.findAllByText(/boom/)).length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText(/Could not refresh/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
|
||||
import { ServerSelect } from '../../ui/ServerSelect'
|
||||
import { useContext, useState } from 'react'
|
||||
import { AGGREGATE, HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { ZonesMatrix } from './ZonesMatrix'
|
||||
@@ -154,7 +154,8 @@ export function Zones({
|
||||
same state here, since it is the same question. The list's reloads
|
||||
the list (index.html:436); the records view's is always disabled
|
||||
(index.html:548). */}
|
||||
<ClusterNodeSelect
|
||||
<ServerSelect
|
||||
hint={!matrixMode && <HubWriteHint write={write} />}
|
||||
nodes={nodes}
|
||||
initialised={clusterInitialised}
|
||||
aggregate={hub}
|
||||
@@ -166,7 +167,6 @@ export function Zones({
|
||||
disabled={open != null}
|
||||
label="Cluster Node"
|
||||
/>
|
||||
{!matrixMode && <HubWriteHint write={write} />}
|
||||
|
||||
{matrixMode ? (
|
||||
<ZonesMatrix
|
||||
|
||||
@@ -290,7 +290,8 @@ describe('SessionProvider', () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue(session())
|
||||
window.history.replaceState(null, '', '/settings/')
|
||||
mount()
|
||||
await screen.findByRole('navigation')
|
||||
// The sidebar and, while Settings has not read yet, its own sub-tab bar.
|
||||
await screen.findAllByRole('navigation')
|
||||
|
||||
// Being "in Settings" and nothing more is half a page: the address is completed.
|
||||
await waitFor(() => expect(window.location.pathname).toBe('/settings/general/'))
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { SlotProvider } from '../app/ChromeSlot'
|
||||
|
||||
/* The shell's chrome slot, for a test of a screen that puts its server selector there. */
|
||||
export function WithChrome({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SlotProvider>
|
||||
{(ref) => (
|
||||
<>
|
||||
<div ref={ref} />
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</SlotProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Button } from './Button'
|
||||
import styles from './Empty.module.css'
|
||||
|
||||
/*
|
||||
@@ -105,3 +107,43 @@ nothing passes would be inventing API ahead of its use.
|
||||
export function Failure({ children }: { children: ReactNode }) {
|
||||
return <div className={styles.failure}>{children}</div>
|
||||
}
|
||||
|
||||
/*
|
||||
OURS. `Loading` for a data REGION, drawn as the region's own layout: a bar for the filter
|
||||
or header line and a block for the body (the picture `ZonesMatrix` already used). The wait
|
||||
is still announced once, through `role="status"` and the words for a reader, so a region
|
||||
that says "Loading the apps of every server…" keeps saying it.
|
||||
*/
|
||||
export function LoadingRegion({
|
||||
children = 'Loading…',
|
||||
height = 'h-40',
|
||||
announce = true,
|
||||
}: {
|
||||
/** What is loading, for a reader: "Loading the apps of every server…". */
|
||||
children?: ReactNode
|
||||
/** The body's height, as the region's real content would take. */
|
||||
height?: string
|
||||
announce?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div role={announce ? 'status' : undefined} className="flex flex-col gap-3">
|
||||
<span className="sr-only">{children}</span>
|
||||
<Skeleton className="h-8 w-1/2" />
|
||||
<Skeleton className={`${height} w-full`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* `Failure` with the one action it asked for: the same read, again. */
|
||||
export function FailureRetry({ children, onRetry }: { children: ReactNode; onRetry: () => void }) {
|
||||
return (
|
||||
<Failure>
|
||||
<span className="flex flex-col items-center gap-3">
|
||||
<span>{children}</span>
|
||||
<Button variant="primary" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</span>
|
||||
</Failure>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { useContext } from 'react'
|
||||
import { InChrome } from '../app/ChromeSlot'
|
||||
import { ClusterNodeSelect, HubMode } from './ClusterNodeSelect'
|
||||
|
||||
/*
|
||||
OURS. The server selector of a section, drawn the same way on every page.
|
||||
|
||||
Behind a hub it goes up into the chrome slot, exactly as the Dashboard's does (compact
|
||||
"Server" field, the same gap to the title on every page), with the line that says why the
|
||||
controls are read only beside it. Without a hub nothing changes: upstream's node selector
|
||||
stays where the section put it.
|
||||
*/
|
||||
export function ServerSelect({ hint, ...props }: ComponentProps<typeof ClusterNodeSelect> & { hint?: ReactNode }) {
|
||||
const hub = useContext(HubMode)
|
||||
if (!hub) return <ClusterNodeSelect {...props} />
|
||||
return (
|
||||
<InChrome>
|
||||
<ClusterNodeSelect {...props} />
|
||||
{hint}
|
||||
</InChrome>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user