Hub stage 6: blocking and cache actions on several servers

One endpoint, hub/servers/run (operate), runs a fixed set of actions on
the chosen servers in parallel with each server's admin token: add or
remove a domain in Blocked or Allowed, disable blocking for a time or
enable it, update block lists, replace the block list URLs, flush the
cache or delete a domain from it. Each server gets its own result and
the console offers Retry failed.

In "All servers", Blocking, Cache and Query Logs rows open this flow
instead of being disabled. The hub validates domains, minutes and the
server's list grammar (allow lists with !, switched-off lines with #)
and logs no URL list. Operators can now use the blocking switch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 22:50:09 +07:00
1 parent 8bb1e01936
commit 34de910e04
31 files changed
+1560 -40

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
import { apiRequest } from './client'
import type { RunOutcome } from './apps'
/*
The same change on several servers, outside Apps (hub/servers-run.ts). The hub checks the
parameters and sends the one matching call to each target with that server's admin token; the
answer is a result per server.
*/
export type ServerAction =
| 'blocked.add'
| 'blocked.delete'
| 'allowed.add'
| 'allowed.delete'
| 'blocking.disable'
| 'blocking.enable'
| 'blocking.updateLists'
| 'blocking.setListUrls'
| 'cache.flush'
| 'cache.delete'
/** What an action needs besides the servers: a `domain`, `minutes`, or `urls` (one per line). */
export type ServerParams = Partial<Record<'domain' | 'minutes' | 'urls', string>>
export function runOnServers(token: string | null, action: ServerAction, params: ServerParams, targets: string[]): Promise<RunOutcome> {
return apiRequest('hub/servers/run', { token, method: 'POST', body: { action, ...params, targets: targets.join(',') } })
}
+7 -2
View File
@@ -33,6 +33,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { HubMode, ThisServerName } from '../ui/ClusterNodeSelect'
import { HubWritable } from '../lib/hub-write'
import { HubServerIds } from '../lib/hub-all'
import { FooterLinks } from '../ui/FooterLinks'
import { Versions } from './Versions'
import { BlockingMark } from './BlockingMark'
@@ -141,6 +142,8 @@ export function Shell({
const hub = session.info?.hub === true
/* Behind a hub: the servers a change can be made on (the screens read it through `HubWritable`). */
const hubWritable = session.info?.hubWritable ?? NO_SERVERS
/* And every server it fronts, for the picker of a change made on several at once. */
const hubServers = useMemo(() => (session.info?.clusterNodes ?? []).map((n) => n.name), [session.info?.clusterNodes])
const hubWarnings = useHubWarnings()
/* Behind a hub: the count comes with the session, the list is read now and then. */
const hubDriftAtStart = session.info?.hubDrift
@@ -276,7 +279,7 @@ 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'))
const blockingNow = useBlockingMark(session.token, permissions, sections.some((s) => s.id === 'blocking'), hub)
/*
The jumps other screens' row menus make (`app/handoff.ts`). The section switch
@@ -429,6 +432,7 @@ export function Shell({
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
<HubMode.Provider value={hub}>
<HubWritable.Provider value={hubWritable}>
<HubServerIds.Provider value={hubServers}>
<SlotProvider>
{(slot) => (
<div className={styles.shell} data-rail={rail}>
@@ -664,7 +668,7 @@ export function Shell({
token={session.token}
nodes={session.info?.clusterNodes ?? []}
clusterInitialised={session.info?.clusterInitialized === true}
canBlock={hub ? permissions?.Settings?.canModify !== false : true}
canBlock={hub ? permissions?.Blocked?.canModify !== false && permissions?.Allowed?.canModify !== false : true}
/>
) : current?.id === 'dnsclient' ? (
<DnsClient
@@ -847,6 +851,7 @@ export function Shell({
</div>
)}
</SlotProvider>
</HubServerIds.Provider>
</HubWritable.Provider>
</HubMode.Provider>
</ThisServerName.Provider>
+64
View File
@@ -0,0 +1,64 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
import { runOnServers, type ServerAction, type ServerParams } from '../api/hub-run'
import { RunFlow, pickerServers, type RunRequest } from '../screens/apps/RunFlow'
import { HubWritable } from './hub-write'
/*
Behind a hub, a change that belongs on several servers at once (Blocked and Allowed, the blocking
switch, the block lists, the cache) goes through the same three steps as the apps: pick the
servers, wait, read what happened on each, with "Retry failed". This is the glue:
· `HubServerIds`: every server the hub fronts, given by the shell. The picker lists all of them
and disables the ones the hub holds no admin token for, with the reason.
· `useAllServersFlow`: `start(spec)` opens the flow, `flow` is the element to render.
· `HubAll`: what a section provides to the controls inside it when "All servers" is chosen and
the role may make the change. A control that finds `null` there behaves as it always did.
*/
export const HubServerIds = createContext<readonly string[]>([])
export interface AllSpec {
action: ServerAction
params?: ServerParams
title: string
intro?: ReactNode
confirmLabel?: string
/** The servers ticked at the start; every writable one when absent. */
preselected?: string[]
/** After the flow closes, told whether any server changed. */
onDone?: (changed: boolean) => void
}
export type StartAll = (spec: AllSpec) => void
export const HubAll = createContext<StartAll | null>(null)
export function useAllServersFlow(token: string | null): { start: StartAll; flow: ReactNode } {
const ids = useContext(HubServerIds)
const writable = useContext(HubWritable)
const [open, setOpen] = useState<{ spec: AllSpec; request: RunRequest } | null>(null)
const start: StartAll = (spec) => {
const request: RunRequest = {
title: spec.title,
intro: spec.intro,
confirmLabel: spec.confirmLabel ?? 'Run',
servers: pickerServers(ids.map((id) => ({ id, writable: writable.includes(id) }))),
preselected: spec.preselected ?? ids.filter((id) => writable.includes(id)),
run: (targets) => runOnServers(token, spec.action, spec.params ?? {}, targets),
}
setOpen({ spec, request })
}
const flow =
open === null ? null : (
<RunFlow
request={open.request}
onClose={(changed) => {
const done = open.spec.onDone
setOpen(null)
done?.(changed)
}}
/>
)
return { start, flow }
}
+74
View File
@@ -1,10 +1,13 @@
import { useContext, useId, useRef, useState } from 'react'
import { HubNode, nodeArg } from '../../lib/hub-write'
import { HubAll } from '../../lib/hub-all'
import { addDomain, type DomainList } from '../../api/zonelists'
import { Panel, Body } from '../../ui/Panel'
import { Field, Input } from '../../ui/Field'
import { Loading } from '../../ui/Empty'
import { PermissionButton } from '../../ui/PermissionButton'
import { Menu } from '../../ui/Menu'
import { LockedItem } from './Locked'
import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
import { noticeFromFailure, type Notice } from '../../lib/notice'
import { checkDomain, verdictOf, type Verdict } from './check-domain'
@@ -54,6 +57,8 @@ export function AddDomainBar({
}) {
/* Behind a hub: the server the section has chosen (empty elsewhere, which sends no `node`). */
const node = useContext(HubNode)
/* With "All servers" chosen the verbs open the flow that makes the change on the servers picked next. */
const all = useContext(HubAll)
const [field, setField] = useState('')
const [busy, setBusy] = useState(false)
const entry = useRef<HTMLInputElement>(null)
@@ -122,6 +127,24 @@ export function AddDomainBar({
return
}
if (all !== null) {
const verb = list === 'allowed' ? 'Allowed' : 'Blocked'
all({
action: `${list}.add`,
params: { domain: domain.trim() },
title: `Add '${domain.trim()}' to ${verb} on several servers`,
confirmLabel: `Add to ${verb}`,
onDone: (changed) => {
if (changed) {
setField('')
forget()
onChanged(list, domain)
}
},
})
return
}
setBusy(true)
const outcome = await addDomain(list, token, domain, ...nodeArg(node))
setBusy(false)
@@ -140,6 +163,30 @@ export function AddDomainBar({
onChanged(list, domain)
}
/* Removing a rule from Blocked or Allowed: only offered for several servers at once, since on one server the Rules tab does it. */
function remove(list: DomainList) {
const domain = field.trim()
if (domain === '') {
onNotice({ type: 'warning', title: 'Missing!', text: 'Please enter a domain name to remove.' })
entry.current?.focus()
return
}
const verb = list === 'allowed' ? 'Allowed' : 'Blocked'
all?.({
action: `${list}.delete`,
params: { domain },
title: `Remove '${domain}' from ${verb} on several servers`,
confirmLabel: `Remove from ${verb}`,
onDone: (changed) => {
if (changed) {
setField('')
forget()
onChanged(list, domain)
}
},
})
}
return (
<Panel title="Check, block or allow a domain">
<Body>
@@ -192,6 +239,33 @@ export function AddDomainBar({
>
Check
</PermissionButton>
{/* One menu, not two more buttons: the row has no room for five at desktop width. */}
{all !== null && (
<Menu label="Remove" text="Remove">
{(close) =>
(['blocked', 'allowed'] as const).map((list) => {
const verb = list === 'allowed' ? 'Allowed' : 'Blocked'
const need = missing(permissions, list === 'allowed' ? 'Allowed.canModify' : 'Blocked.canModify')
return need != null ? (
<LockedItem key={list} need={need} placement="left">
From {verb}
</LockedItem>
) : (
<button
key={list}
type="button"
onClick={() => {
close()
remove(list)
}}
>
From {verb}
</button>
)
})
}
</Menu>
)}
</form>
{/* One live region for the whole answer: the wait and the verdict that
replaces it are announced once each. */}
@@ -0,0 +1,207 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as dashboard from '../../api/dashboard'
import * as settings from '../../api/settings'
import * as apps from '../../api/apps'
import * as hubRun from '../../api/hub-run'
import { Overview } from './Overview'
import { BlockLists } from './BlockLists'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { HubNode, HubWritable } from '../../lib/hub-write'
import { HubServerIds } from '../../lib/hub-all'
import type { Permissions } from './permissions'
import { checkUrls } from './AllServersActions'
vi.mock('../dashboard/Chart', () => ({ Chart: ({ aria }: { aria: string }) => <div role="img" aria-label={aria} /> }))
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
})
const NODES = [
{ name: 'mdns', type: 'Primary' },
{ name: 'ndns', type: 'Secondary' },
]
/* An operator: blocking and cache yes, settings no (what the hub session says). */
const OPERATOR: Permissions = {
Dashboard: { canView: true, canModify: false, canDelete: false },
Settings: { canView: true, canModify: false, canDelete: false },
Blocked: { canView: true, canModify: true, canDelete: true },
Allowed: { canView: true, canModify: true, canDelete: true },
DnsClient: { canView: true, canModify: false, canDelete: false },
}
const STATS = {
totalQueries: 100, totalNoError: 0, totalServerFailure: 0, totalNxDomain: 0, totalRefused: 0,
totalAuthoritative: 0, totalRecursive: 0, totalCached: 0, totalBlocked: 10, totalDropped: 0,
totalClients: 1, zones: 0, cachedEntries: 0, allowedZones: 0, blockedZones: 0, allowListZones: 0, blockListZones: 0,
}
const MAIN = { labels: ['a'], datasets: [{ label: 'Total', data: [1] }, { label: 'Blocked', data: [1] }] }
function serve() {
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: true } as never)
vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [] } } } as never)
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({
kind: 'ok',
data: { stats: STATS, mainChartData: MAIN, queryResponseChartData: MAIN, queryTypeChartData: MAIN, protocolTypeChartData: MAIN, topClients: [], topDomains: [], topBlockedDomains: [] },
} as never)
}
const results = (...r: { server: string; ok: boolean; message?: string }[]) => ({ kind: 'ok', data: { response: { results: r } } }) as never
function hubOverview(writable = ['mdns', 'ndns'], permissions: Permissions = OPERATOR) {
serve()
return render(
<HubMode.Provider value>
<HubWritable.Provider value={writable}>
<HubServerIds.Provider value={['mdns', 'ndns']}>
<Overview token="t" permissions={permissions} nodes={NODES} clusterInitialised />
</HubServerIds.Provider>
</HubWritable.Provider>
</HubMode.Provider>,
)
}
describe('Blocking overview behind a hub, in All servers', () => {
it('says the change goes to the servers chosen next, and keeps Block and Allow enabled', async () => {
hubOverview()
expect(await screen.findByText(/goes to the servers you choose in the next step/)).toBeInTheDocument()
expect(screen.queryByText(/Choose a server to make changes/)).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Block' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Allow' })).toBeEnabled()
})
it('Block opens the picker, runs on the ticked servers and retries only the failed', async () => {
const run = vi
.spyOn(hubRun, 'runOnServers')
.mockResolvedValueOnce(results({ server: 'mdns', ok: true, message: 'Added to Blocked.' }, { server: 'ndns', ok: false, message: 'ndns did not answer' }))
.mockResolvedValueOnce(results({ server: 'ndns', ok: true, message: 'Added to Blocked.' }))
hubOverview()
await userEvent.type(await screen.findByLabelText('Domain'), 'ads.example')
await userEvent.click(screen.getByRole('button', { name: 'Block' }))
const picker = await screen.findByRole('dialog')
expect(within(picker).getByRole('checkbox', { name: 'mdns' })).toBeChecked()
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeChecked()
await userEvent.click(within(picker).getByRole('button', { name: 'Add to Blocked' }))
expect(run).toHaveBeenNthCalledWith(1, 't', 'blocked.add', { domain: 'ads.example' }, ['mdns', 'ndns'])
const list = await screen.findByRole('list', { name: 'Results' })
expect(within(list).getByText('ndns did not answer')).toBeInTheDocument()
expect(within(list).getAllByText('Failed')).toHaveLength(1)
await userEvent.click(screen.getByRole('button', { name: 'Retry failed' }))
expect(run).toHaveBeenNthCalledWith(2, 't', 'blocked.add', { domain: 'ads.example' }, ['ndns'])
expect(await within(await screen.findByRole('list', { name: 'Results' })).findAllByText('Done')).toHaveLength(2)
})
it('disables a server the hub holds no admin token for, with the reason', async () => {
hubOverview(['mdns'])
await userEvent.type(await screen.findByLabelText('Domain'), 'ads.example')
await userEvent.click(screen.getByRole('button', { name: 'Allow' }))
const picker = await screen.findByRole('dialog')
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
expect(within(picker).getByText('No admin token configured on the hub')).toBeInTheDocument()
expect(within(picker).getByRole('checkbox', { name: 'mdns' })).toBeChecked()
})
it('removes a domain from Blocked on several servers', async () => {
const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(results({ server: 'mdns', ok: true }, { server: 'ndns', ok: true }))
hubOverview()
await userEvent.type(await screen.findByLabelText('Domain'), 'ads.example')
await userEvent.click(screen.getByRole('button', { name: 'Remove' }))
await userEvent.click(await screen.findByRole('menuitem', { name: 'From Blocked' }))
await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Remove from Blocked' }))
expect(run).toHaveBeenCalledWith('t', 'blocked.delete', { domain: 'ads.example' }, ['mdns', 'ndns'])
})
it('lets an operator use the blocking switch, and runs a temporary disable on all servers', async () => {
const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(results({ server: 'mdns', ok: true }, { server: 'ndns', ok: true }))
hubOverview()
const trigger = await screen.findByRole('button', { name: 'Blocking options' })
expect(trigger).toBeEnabled()
await userEvent.click(trigger)
await userEvent.click(await screen.findByRole('menuitem', { name: 'Disable Blocking For 15 Minutes' }))
await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Disable blocking' }))
expect(run).toHaveBeenCalledWith('t', 'blocking.disable', { minutes: '15' }, ['mdns', 'ndns'])
})
it('updates the lists on all servers', async () => {
const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(results({ server: 'mdns', ok: true }, { server: 'ndns', ok: true }))
hubOverview()
await userEvent.click(await screen.findByRole('button', { name: 'Update lists on all servers' }))
await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Update lists' }))
expect(run).toHaveBeenCalledWith('t', 'blocking.updateLists', {}, ['mdns', 'ndns'])
})
it('edits the list URLs on all servers, refusing an address that is not http or https', async () => {
const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(results({ server: 'mdns', ok: true }, { server: 'ndns', ok: true }))
hubOverview()
await userEvent.click(await screen.findByRole('button', { name: 'Edit list URLs on all servers' }))
const ask = await screen.findByRole('dialog')
const field = within(ask).getByLabelText('Block list URLs')
await userEvent.type(field, 'ftp://bad.example/x')
await userEvent.click(within(ask).getByRole('button', { name: 'Choose servers' }))
expect(await within(ask).findByRole('alert')).toHaveTextContent(/Not a valid http, https or file address/)
expect(run).not.toHaveBeenCalled()
await userEvent.clear(field)
await userEvent.type(field, 'https://a.example/hosts.txt')
await userEvent.click(within(ask).getByRole('button', { name: 'Choose servers' }))
await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Replace URLs' }))
expect(run).toHaveBeenCalledWith('t', 'blocking.setListUrls', { urls: 'https://a.example/hosts.txt' }, ['mdns', 'ndns'])
})
it('offers nothing of this to a role that may not change blocking', async () => {
hubOverview(['mdns', 'ndns'], {
...OPERATOR,
Blocked: { canView: true, canModify: false, canDelete: false },
Allowed: { canView: true, canModify: false, canDelete: false },
})
await screen.findByLabelText('Domain')
expect(screen.queryByRole('button', { name: 'Update lists on all servers' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Block' })).toBeDisabled()
})
})
describe('the blocking switch outside a hub', () => {
it('still asks for Settings: Modify, and offers nothing for several servers', async () => {
serve()
render(<Overview token="t" permissions={OPERATOR} />)
expect(await screen.findByRole('button', { name: 'Disable' })).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Update lists on all servers' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument()
})
})
describe('block lists behind a hub', () => {
it('lets an operator update now and offers the actions for all servers, with the URLs filled in', async () => {
vi.spyOn(settings, 'readSettings').mockResolvedValue({ kind: 'ok', data: { blockListUrls: ['https://a.example/x', '# note'], blockListNextUpdatedOn: null } } as never)
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'ok', data: { stats: STATS } } as never)
render(
<HubMode.Provider value>
<HubWritable.Provider value={['mdns']}>
<HubServerIds.Provider value={['mdns', 'ndns']}>
<HubNode.Provider value="mdns">
<BlockLists token="t" permissions={OPERATOR} allServers />
</HubNode.Provider>
</HubServerIds.Provider>
</HubWritable.Provider>
</HubMode.Provider>,
)
expect(await screen.findByRole('button', { name: 'Update lists on all servers' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Update Now' })).toBeEnabled()
await userEvent.click(screen.getByRole('button', { name: 'Edit list URLs on all servers' }))
expect(await screen.findByLabelText('Block list URLs')).toHaveValue('https://a.example/x\n# note')
})
})
describe('the list dialog check', () => {
it('takes allow lines, switched-off lines and headings, and refuses a bad scheme after the bang or a comma', () => {
expect(checkUrls('# Privacy\nhttps://a.example/x\n!https://b.example/allow\n#https://c.example/off\nfile:///x.txt')).toBeNull()
expect(checkUrls('!ftp://b.example/allow')).toMatch(/Not a valid/)
expect(checkUrls('# a, b')).toMatch(/comma/)
expect(checkUrls('https://a.example/x,y')).toMatch(/comma/)
expect(checkUrls(' \n ')).toMatch(/at least one/)
})
})
@@ -0,0 +1,93 @@
import { useContext, useState } from 'react'
import { useAllServersFlow } from '../../lib/hub-all'
import { Button } from '../../ui/Button'
import { AskDialog } from '../../ui/AskDialog'
import { HubMode } from '../../ui/ClusterNodeSelect'
/*
Behind a hub: the block list actions that go to several servers. "Update lists" asks every chosen
server to download its lists now; "Edit list URLs" REPLACES the list of URLs on every chosen
server with the one typed here (one per line), so the dialog says so. The
caller shows this only to a role that may change blocking. Drawn nowhere outside a hub.
*/
/*
What the dialog starts with: the lines of the chosen server's list as it holds them, `!` allow
lists, `#` switched-off lists and headings included, so saving it back changes nothing.
*/
export function plainUrls(lines: readonly string[] | null | undefined): string {
return (lines ?? []).map((l) => l.trim()).filter((l) => l !== '').join('\n')
}
/* The hub's rules (hub/servers-run.ts `cleanUrls`), so the dialog and the hub give the same answer. */
export function checkUrls(text: string): string | null {
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '')
if (lines.length === 0) return 'Give at least one block list URL.'
if (lines.length > 300) return 'At most 300 lines.'
for (const line of lines) {
if (line.includes(',')) return `A line cannot contain a comma: ${line.slice(0, 60)}`
if (line.startsWith('#')) {
if (line.length > 200) return 'A comment line is too long.'
continue
}
const body = line.startsWith('!') ? line.slice(1).trim() : line
let ok = line.length <= 2000 && !/\s/.test(body)
if (ok) {
try {
ok = ['http:', 'https:', 'file:'].includes(new URL(body).protocol)
} catch {
ok = false
}
}
if (!ok) return `Not a valid http, https or file address: ${line.slice(0, 60)}`
}
return null
}
export function AllServersActions({ token, initialUrls }: { token: string | null; initialUrls?: readonly string[] | null }) {
const hub = useContext(HubMode)
const { start, flow } = useAllServersFlow(token)
const [editing, setEditing] = useState(false)
if (!hub) return null
return (
<>
<Button
onClick={() =>
start({
action: 'blocking.updateLists',
title: 'Update block lists on several servers',
intro: 'Each chosen server downloads its block lists now.',
confirmLabel: 'Update lists',
})
}
>
Update lists on all servers
</Button>
<Button onClick={() => setEditing(true)}>Edit list URLs on all servers</Button>
{editing && (
<AskDialog
title="Edit list URLs on all servers"
label="Block list URLs"
intro="One address per line: a list, `!` before an allow list, `#` before a switched-off list or a heading. This replaces the whole list of block list URLs on every server you choose next."
initial={plainUrls(initialUrls)}
multiline
placeholder="https://example.com/hosts.txt"
confirmLabel="Choose servers"
check={checkUrls}
onClose={() => setEditing(false)}
onSubmit={(text) => {
setEditing(false)
start({
action: 'blocking.setListUrls',
params: { urls: text },
title: 'Replace block list URLs on several servers',
intro: 'The list of URLs on each chosen server is replaced by the one you typed.',
confirmLabel: 'Replace URLs',
})
}}
/>
)}
{flow}
</>
)
}
+17 -2
View File
@@ -1,5 +1,7 @@
import { useCallback, useContext, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { HubNode, nodeArg } from '../../lib/hub-write'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { AllServersActions } from './AllServersActions'
import { readSettings, setSettings, forceUpdateBlockLists } from '../../api/settings'
import { getDashboardStats } from '../../api/dashboard'
import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
@@ -25,7 +27,7 @@ import {
} from './list-lines'
import { Locked } from './Locked'
import { hostsOf, moreLists } from './extra-lists'
import { missing, type Permissions } from './permissions'
import { missing, switchNeed, type Permissions } from './permissions'
import shared from './Blocking.module.css'
import styles from './BlockLists.module.css'
import { UrlText } from '../../ui/UrlText'
@@ -202,11 +204,14 @@ export function BlockLists({
permissions,
clusterInitialised = false,
onDirty,
allServers = false,
}: {
tabs?: ReactNode
token: string | null
permissions: Permissions
clusterInitialised?: boolean
/** Behind a hub: the role may change blocking on several servers at once, so the actions for that are offered. */
allServers?: boolean
/** Told whether there are unsaved changes, for the tab's mark; false on leaving. */
onDirty?: (dirty: boolean) => void
}) {
@@ -215,6 +220,9 @@ export function BlockLists({
const node = hubNode !== '' ? hubNode : clusterInitialised ? 'cluster' : ''
const viewNeed = missing(permissions, 'Settings.canView')
const modifyNeed = missing(permissions, 'Settings.canModify')
/* Behind a hub the list update is a blocking action (an operator's), unlike saving the URLs, which is a settings change. */
const hub = useContext(HubMode)
const updateNeed = switchNeed(permissions, hub)
const statsNeed = missing(permissions, 'Dashboard.canView')
const invalidId = useId()
@@ -624,6 +632,7 @@ export function BlockLists({
</>
)}
<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>
@@ -633,13 +642,19 @@ export function BlockLists({
</span>
</div>
<PermissionButton
permission={modifyNeed}
permission={updateNeed}
disabled={busy || !hasSaved}
onClick={() => setAskUpdate(true)}
>
Update Now
</PermissionButton>
</div>
{hub && allServers && (
<div className="flex flex-wrap items-center gap-2">
<AllServersActions token={token} initialUrls={saved?.map((l) => l.raw)} />
</div>
)}
</div>
</Body></Panel>
</div>
+4 -2
View File
@@ -6,7 +6,7 @@ import { getSettings } from '../../api/settings'
import { getDashboardStats } from '../../api/dashboard'
import { BlockingMenu } from '../dashboard/BlockingMenu'
import { Tag } from '../../ui/Tag'
import { missing, withoutWrites } from './permissions'
import { missing, switchNeed, withoutWrites } from './permissions'
import { untilText, useBlockingState } from './blocking-state'
import { SubTabs } from '../../ui/SubTabs'
import { ruleSearch } from './rules-model'
@@ -69,8 +69,9 @@ function useCounts(token: string | null, permissions: Permissions, node = '') {
itself, so the strip is for the other two. */
function Banner({ token, permissions, node }: { token: string | null; permissions: Permissions; node: string }) {
const { state } = useBlockingState(token, permissions, true, node)
const hub = useContext(HubMode)
if (state == null || state === 'failed') return null
const canModify = missing(permissions, 'Settings.canModify') == null
const canModify = switchNeed(permissions, hub) == null
return (
<div className={shared.banner} role="status">
{state.kind === 'on' && <Tag tone="ok">Enabled</Tag>}
@@ -186,6 +187,7 @@ export function Blocking({
permissions={effective}
clusterInitialised={clusterInitialised}
onDirty={setListsDirty}
allServers={hub && missing(permissions, 'Blocked.canModify', 'Allowed.canModify') == null}
/>
</HubNode.Provider>
)
+16 -3
View File
@@ -22,6 +22,7 @@ import { LAST_HOUR_REFRESH_MS, useLiveView } from '../dashboard/useLive'
import { Locked } from './Locked'
import { missing, withoutWrites, type Permissions } from './permissions'
import { HubNode, useHubWrite } from '../../lib/hub-write'
import { HubAll, useAllServersFlow } from '../../lib/hub-all'
import { HubWriteHint } from '../../ui/HubWriteHint'
import shared from './Blocking.module.css'
import styles from './Overview.module.css'
@@ -203,6 +204,15 @@ export function Overview({
const hub = useContext(HubMode)
const write = useHubWrite(node)
const permissions = write.allowed ? rolePermissions : withoutWrites(rolePermissions)
/*
With "All servers" chosen a role that may change blocking still can: the controls of the status
block and the add bar open a flow that picks the servers (`HubAll`). The tables and the recent
list below keep the switched-off permissions, since a row acts on one server.
*/
const all = useAllServersFlow(token)
const allServers =
hub && node === AGGREGATE && missing(rolePermissions, 'Blocked.canModify', 'Allowed.canModify') == null
const barPermissions = allServers ? rolePermissions : permissions
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)
@@ -326,6 +336,8 @@ export function Overview({
return (
<HubNode.Provider value={hub ? node : ''}>
<HubAll.Provider value={allServers ? all.start : null}>
{all.flow}
<InChrome>
<ClusterNodeSelect
nodes={nodes}
@@ -335,7 +347,7 @@ export function Overview({
onChange={setNode}
/>
</InChrome>
<HubWriteHint write={write} />
<HubWriteHint write={write} allServers={allServers} />
<SectionHeader
title="Blocking"
@@ -379,10 +391,10 @@ export function Overview({
)}
<div className={`${shared.row2} ${styles.checkRow}`}>
<StatusPanel token={token} permissions={permissions} onNotice={setNotice} />
<StatusPanel token={token} permissions={barPermissions} onNotice={setNotice} />
<AddDomainBar
token={token}
permissions={permissions}
permissions={barPermissions}
onNotice={setNotice}
onChanged={() => void load()}
/>
@@ -473,6 +485,7 @@ export function Overview({
refreshOn={s?.totalBlocked}
/>
</div>
</HubAll.Provider>
</HubNode.Provider>
)
}
+16 -2
View File
@@ -1,5 +1,7 @@
import { useContext } from 'react'
import { HubNode } from '../../lib/hub-write'
import { HubAll } from '../../lib/hub-all'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { BlockingMenu } from '../dashboard/BlockingMenu'
import { Panel, Body } from '../../ui/Panel'
import { PermissionButton } from '../../ui/PermissionButton'
@@ -9,7 +11,8 @@ import { Icon } from '../../ui/Icon'
import { Tag } from '../../ui/Tag'
import type { Notice } from '../../lib/notice'
import { Locked } from './Locked'
import { missing, type Permissions } from './permissions'
import { missing, switchNeed, type Permissions } from './permissions'
import { AllServersActions } from './AllServersActions'
import { untilText, useBlockingState } from './blocking-state'
import shared from './Blocking.module.css'
import styles from './Overview.module.css'
@@ -49,10 +52,14 @@ export function StatusPanel({
/* Behind a hub: the server the section has chosen. */
const node = useContext(HubNode)
const { state, retry } = useBlockingState(token, permissions, true, node)
/* Behind a hub the switch is a blocking change (Blocked and Allowed), not a settings one. With "All
servers" chosen it opens the flow, and the list actions for several servers sit beside it. */
const hub = useContext(HubMode)
const all = useContext(HubAll)
if (viewNeed != null) return <Locked title="Blocking" need={viewNeed} />
const modifyNeed = missing(permissions, 'Settings.canModify')
const modifyNeed = switchNeed(permissions, hub)
if (state === 'failed') {
return (
@@ -77,6 +84,7 @@ export function StatusPanel({
return (
<Panel className={shared.centred}>
<Body>
<div className="flex flex-col gap-4">
{state == null ? (
<Loading compact />
) : (
@@ -117,6 +125,12 @@ export function StatusPanel({
)}
</div>
)}
{state != null && all !== null && modifyNeed == null && (
<div className="flex flex-wrap items-center gap-2">
<AllServersActions token={token} />
</div>
)}
</div>
</Body>
</Panel>
)
+5 -5
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { getSettings } from '../../api/settings'
import { minuteStamp } from '../../lib/dates'
import { onSettingsChanged } from '../../lib/settings-signal'
import { missing, type Permissions } from './permissions'
import { missing, switchNeed, type Permissions } from './permissions'
/*
Whether blocking is on, read where the Overview's status block reads it
@@ -128,10 +128,10 @@ export interface BlockingMark {
tone: 'acc' | 'neutral'
}
export function blockingMark(state: BlockingRead, permissions: Permissions, now = new Date()): BlockingMark | null {
export function blockingMark(state: BlockingRead, permissions: Permissions, now = new Date(), hub = false): BlockingMark | null {
if (state == null || state === 'failed' || state.kind === 'on') return null
if (state.kind === 'off') return { text: 'Off', label: 'Blocking, disabled', tone: 'neutral' }
const canLift = missing(permissions, 'Settings.canModify') == null
const canLift = switchNeed(permissions, hub) == null
return {
text: 'Paused',
label: `Blocking, paused ${untilText(state.till, now).replace(/^Until/, 'until')}`,
@@ -140,7 +140,7 @@ 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): BlockingMark | null {
export function useBlockingMark(token: string | null, permissions: Permissions, visible: boolean, hub = false): BlockingMark | null {
const { state } = useBlockingState(token, permissions, visible)
return blockingMark(state, permissions)
return blockingMark(state, permissions, new Date(), hub)
}
+10
View File
@@ -19,6 +19,16 @@ export function missing(perms: Permissions, ...needs: Need[]): Need | undefined
return undefined
}
/*
What the blocking switch (the temporary disable, enable, and the block list update) asks for.
Upstream asks `Settings: Modify` of the call. Behind a hub those calls are blocking, which an
operator may do (`Blocked` and `Allowed` Modify), while `Settings: Modify` is an admin's: the
switch follows the blocking permission there, and is unchanged anywhere else.
*/
export function switchNeed(perms: Permissions, hub: boolean): Need | undefined {
return hub ? missing(perms, 'Blocked.canModify', 'Allowed.canModify') : missing(perms, 'Settings.canModify')
}
/*
Behind a hub, when the chosen node is not one a change can be made on ("All servers", or a server
the hub holds no admin token for): the same permissions with every write switched off. The
+40 -1
View File
@@ -1,10 +1,11 @@
import { useState } from 'react'
import { useContext, useState } from 'react'
import { getSettings, setSettings, temporaryDisableBlocking } from '../../api/settings'
import { Confirm } from '../../ui/Confirm'
import { Menu } from '../../ui/Menu'
import { Button } from '../../ui/Button'
import { Tooltip } from '../../ui/Tooltip'
import { nodeArg, useHubWrite } from '../../lib/hub-write'
import { HubAll } from '../../lib/hub-all'
import type { AlertType } from '../../ui/Alert'
import { noticeFromFailure } from '../../lib/notice'
@@ -73,6 +74,8 @@ export function BlockingMenu({
const [active, setActive] = useState<boolean | null>(null)
const [pending, setPending] = useState<Pending | null>(null)
const [busy, setBusy] = useState(false)
/* Behind a hub with "All servers" chosen: the choice opens the flow that makes it on the servers picked next. */
const all = useContext(HubAll)
async function loadState() {
setActive(null)
@@ -137,6 +140,42 @@ export function BlockingMenu({
/* Behind a hub, only on a chosen server the hub can write to; the tooltip says why not. */
const write = useHubWrite(node, allowed)
if (all !== null) {
return (
<Menu label="Blocking options" text={text}>
{(close) => (
<>
<button
role="menuitem"
onClick={() => {
close()
all({ action: 'blocking.enable', title: 'Enable blocking on several servers', confirmLabel: 'Enable blocking' })
}}
>
Enable Blocking
</button>
{DURATIONS.map((p) => (
<button
key={p.minutes}
role="menuitem"
onClick={() => {
close()
all({
action: 'blocking.disable',
params: { minutes: String(p.minutes) },
title: `Disable blocking for ${p.minutes} minute(s) on several servers`,
confirmLabel: 'Disable blocking',
})
}}
>
{p.text}
</button>
))}
</>
)}
</Menu>
)
}
if (!write.allowed) {
return (
<Tooltip text={write.reason ?? 'Requires Settings: Modify'}>
+50 -1
View File
@@ -5,6 +5,8 @@ import { Cache } from './Lists'
import * as api from '../../api/zonelists'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { HubWritable } from '../../lib/hub-write'
import { HubServerIds } from '../../lib/hub-all'
import * as hubRun from '../../api/hub-run'
afterEach(() => vi.restoreAllMocks())
@@ -19,7 +21,9 @@ function hubCache(writable: string[], canDelete = true) {
return render(
<HubMode.Provider value>
<HubWritable.Provider value={writable}>
<Cache token="t" nodes={NODES} clusterInitialised canDelete={canDelete} />
<HubServerIds.Provider value={['mdns', 'ndns']}>
<Cache token="t" nodes={NODES} clusterInitialised canDelete={canDelete} />
</HubServerIds.Provider>
</HubWritable.Provider>
</HubMode.Provider>,
)
@@ -47,3 +51,48 @@ describe('Cache behind a hub', () => {
expect(screen.getByRole('button', { name: 'Flush Cache' })).toBeDisabled()
})
})
describe('Cache behind a hub, on several servers', () => {
const done = (...r: { server: string; ok: boolean; message?: string }[]) => ({ kind: 'ok', data: { response: { results: r } } }) as never
it('flushes the cache on the servers picked, and retries only the one that failed', async () => {
const user = userEvent.setup()
const run = vi
.spyOn(hubRun, 'runOnServers')
.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' }))
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'])
const list = await screen.findByRole('list', { name: 'Results' })
expect(within(list).getByText('ndns did not answer')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Retry failed' }))
expect(run).toHaveBeenNthCalledWith(2, 't', 'cache.flush', {}, ['ndns'])
})
it('deletes one domain on the servers picked, after asking for it', async () => {
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' }))
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' }))
expect(await within(ask).findByRole('alert')).toHaveTextContent('not a valid domain')
await user.clear(within(ask).getByLabelText('Domain'))
await user.type(within(ask).getByLabelText('Domain'), 'x.example')
await user.click(within(ask).getByRole('button', { name: 'Choose servers' }))
const picker = await screen.findByRole('dialog')
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
await user.click(within(picker).getByRole('button', { name: 'Delete from cache' }))
expect(run).toHaveBeenCalledWith('t', 'cache.delete', { domain: 'x.example' }, ['mdns'])
})
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()
})
})
+65
View File
@@ -0,0 +1,65 @@
import { useContext, useState } from 'react'
import { useAllServersFlow } from '../../lib/hub-all'
import { AskDialog } from '../../ui/AskDialog'
import { Button } from '../../ui/Button'
import { HubMode } from '../../ui/ClusterNodeSelect'
/*
Behind a hub: the cache actions that go to several servers. "Flush" empties the whole cache of
every chosen server; "Delete a domain" removes one domain, with its records, from each. Drawn
nowhere outside a hub, and by the caller only to a role that may delete from the cache.
*/
const NAME = /^(?:\*\.)?(?:[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\.)*[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\.?$/i
export const checkDomain = (text: string): string | null => {
const domain = text.trim()
if (domain === '') return 'Please enter a domain name.'
return domain.length <= 254 && NAME.test(domain) ? null : 'That is not a valid domain name.'
}
export function CacheAllServers({ token }: { token: string | null }) {
const hub = useContext(HubMode)
const { start, flow } = useAllServersFlow(token)
const [asking, setAsking] = useState(false)
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>
{asking && (
<AskDialog
title="Delete a domain from the cache on all servers"
label="Domain"
intro="The domain and its cached records are removed from the cache of every server you choose next."
placeholder="example.com"
confirmLabel="Choose servers"
check={checkDomain}
onClose={() => setAsking(false)}
onSubmit={(text) => {
setAsking(false)
const domain = text.trim()
start({
action: 'cache.delete',
params: { domain },
title: `Delete '${domain}' from the cache on several servers`,
confirmLabel: 'Delete from cache',
})
}}
/>
)}
{flow}
</>
)
}
+9 -4
View File
@@ -1,5 +1,5 @@
import { KpiStrip } from '../../ui/KpiStrip'
import { ClusterNodeSelect, primaryNodeName } from '../../ui/ClusterNodeSelect'
import { ClusterNodeSelect, HubMode, primaryNodeName } from '../../ui/ClusterNodeSelect'
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
@@ -24,6 +24,7 @@ import { SectionHeader } from '../../ui/SectionHeader'
import { Empty, Loading } from '../../ui/Empty'
import { StaleData } from '../StaleData'
import { Tree } from './Tree'
import { CacheAllServers } from './CacheAllServers'
import { Path } from './Path'
import { CacheTree } from './CacheTree'
import { Frame, FrameHeader, FramePanel } from '@/components/reui/frame'
@@ -247,6 +248,7 @@ export function Lists({
const sectionRef = useRef(sectionNode)
const listServer = embedded ? sectionNode : ''
/* A change is allowed only on a chosen server the hub can write to (outside a hub: the permission). */
const hub = useContext(HubMode)
const cacheWrite = useHubWrite(clusterNode, canDelete)
const listWrite = useHubWrite(listServer, canDelete)
@@ -490,9 +492,12 @@ export function Lists({
subtitle={isCache ? 'Records cached by the recursive resolver' : undefined}
actions={
isCache ? (
<Button variant="danger" disabled={busy || !cacheWrite.allowed} onClick={askFlushCache}>
Flush Cache
</Button>
<>
<Button variant="danger" disabled={busy || !cacheWrite.allowed} onClick={askFlushCache}>
Flush Cache
</Button>
{hub && canDelete && <CacheAllServers token={token} />}
</>
) : undefined
}
/>
+25 -2
View File
@@ -1,5 +1,6 @@
import { useContext, useState } from 'react'
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'
@@ -72,6 +73,9 @@ export function Logs({
const node = hub && !aggregateShown && (picked === '' || picked === AGGREGATE) ? defaultNode : picked
/* The Allow and Block actions of a row: one server at a time. */
const write = useHubWrite(node, canBlock)
/* With "All servers" chosen a row's Allow and Block open the flow, with the row's own server ticked. */
const all = useAllServersFlow(token)
const allServers = aggregateShown && node === AGGREGATE && canBlock
/*
The bar is built once here and handed to whichever sub-screen is drawn: the same
@@ -100,10 +104,29 @@ export function Logs({
aggregate={aggregateShown}
label="Cluster Node"
/>
{active === 'Query Logs' && <HubWriteHint write={write} />}
{active === 'Query Logs' && <HubWriteHint write={write} allServers={allServers} />}
{active === 'Query Logs' ? (
<HubNode.Provider value={hub ? node : ''}>
<QueryLogs tabs={tabs} token={token} node={node} request={request} canBlock={write.allowed} />
<QueryLogs
tabs={tabs}
token={token}
node={node}
request={request}
canBlock={write.allowed || allServers}
onAll={
allServers
? (verb, domain, server) =>
all.start({
action: verb === 'Allow Domain' ? 'allowed.add' : 'blocked.add',
params: { domain },
title: `${verb === 'Allow Domain' ? 'Allow' : 'Block'} '${domain}' on several servers`,
confirmLabel: verb === 'Allow Domain' ? 'Allow' : 'Block',
preselected: server != null && server !== '' ? [server] : undefined,
})
: undefined
}
/>
{all.flow}
</HubNode.Provider>
) : (
<ViewLogs tabs={tabs}
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Logs } from './Logs'
import * as logs from '../../api/logs'
import * as apps from '../../api/apps'
import * as hubRun from '../../api/hub-run'
import type { QueryLogEntry } from '../../api/logs'
import { HandoffContext } from '../../app/handoff'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { HubWritable } from '../../lib/hub-write'
import { HubServerIds } from '../../lib/hub-all'
/* Behind a hub, in "All servers", a row's Allow and Block open the flow with the row's own server ticked. */
afterEach(() => vi.restoreAllMocks())
const NODES = [
{ name: 'mdns', type: 'Primary' },
{ name: 'ndns', type: 'Secondary' },
]
function entry(rowNumber: number, node: string, responseType = 'Recursive'): QueryLogEntry {
return {
rowNumber,
timestamp: '2026-08-26T05:32:14Z',
clientIpAddress: '10.0.0.7',
protocol: 'Udp',
responseType,
rcode: 'NoError',
qname: `r${rowNumber}.example`,
qtype: 'A',
qclass: 'IN',
answer: null,
node,
}
}
async function show(entries: QueryLogEntry[], canBlock = true) {
vi.spyOn(apps, 'listApps').mockResolvedValue({
kind: 'ok',
data: {
status: 'ok',
response: {
apps: [
{
name: 'Query Logs (Sqlite)',
description: '',
version: '8.0',
dnsApps: [{ classPath: 'QueryLogsSqlite.App', description: '', recordDataTemplate: null, isAppRecordRequestHandler: false, isRequestController: false, isAuthoritativeRequestHandler: false, isRequestBlockingHandler: false, isQueryLogger: true, isQueryLogs: true, isPostProcessor: false }],
},
],
},
},
} as never)
vi.spyOn(logs, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: { pageNumber: 1, totalPages: 1, totalEntries: entries.length, entries } },
} as never)
render(
<HubMode.Provider value>
<HubWritable.Provider value={['mdns', 'ndns']}>
<HubServerIds.Provider value={['mdns', 'ndns']}>
<HandoffContext.Provider value={{ queryDnsServer: vi.fn(), showQueryLogs: vi.fn() }}>
<Logs token="t" sub="Query Logs" nodes={NODES} clusterInitialised canBlock={canBlock} />
</HandoffContext.Provider>
</HubServerIds.Provider>
</HubWritable.Provider>
</HubMode.Provider>,
)
await userEvent.click(await screen.findByRole('button', { name: 'Query' }))
await screen.findByText('r1.example')
}
describe('Query Logs behind a hub, in All servers', () => {
it('opens the flow from a row with that row\'s server ticked, and runs on it only', async () => {
const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Added to Blocked.' }] } } } as never)
await show([entry(1, 'mdns'), entry(2, 'ndns')])
await userEvent.click(screen.getByRole('button', { name: 'Actions for row 2' }))
await userEvent.click(await screen.findByText('Block Domain'))
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByRole('checkbox', { name: 'ndns' })).toBeChecked()
expect(within(dialog).getByRole('checkbox', { name: 'mdns' })).not.toBeChecked()
await userEvent.click(within(dialog).getByRole('button', { name: 'Block' }))
expect(run).toHaveBeenCalledWith('t', 'blocked.add', { domain: 'r2.example' }, ['ndns'])
expect(await screen.findByText('Added to Blocked.')).toBeInTheDocument()
})
it('uses Allow for a blocked row', async () => {
const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'mdns', ok: true }] } } } as never)
await show([entry(1, 'mdns', 'Blocked')])
await userEvent.click(screen.getByRole('button', { name: 'Actions for row 1' }))
await userEvent.click(await screen.findByText('Allow Domain'))
await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Allow' }))
expect(run).toHaveBeenCalledWith('t', 'allowed.add', { domain: 'r1.example' }, ['mdns'])
})
it('keeps the actions off for a role that may not change blocking', async () => {
await show([entry(1, 'mdns')], false)
await userEvent.click(screen.getByRole('button', { name: 'Actions for row 1' }))
expect(await screen.findByText('Block Domain')).toBeDisabled()
})
})
+7 -4
View File
@@ -31,7 +31,7 @@ import { Notifier } from '../../ui/Notifier'
import { Menu } from '../../ui/Menu'
import tbl from '../../ui/Table.module.css'
import { useHandoff, type QueryLogsRequest } from '../../app/handoff'
import { useDomainAction, verbFor } from '../../lib/allow-block'
import { useDomainAction, verbFor, type DomainVerb } from '../../lib/allow-block'
import { useMedia } from '../../lib/use-media'
import { Icon } from '../../ui/Icon'
@@ -321,11 +321,13 @@ export interface QueryLogsProps {
node?: string
/** Whether a row may be allowed or blocked (behind a hub: a server is chosen and writable). */
canBlock?: boolean
/** Behind a hub with "All servers" chosen: a row's Allow or Block goes through this, with the row's server. */
onAll?: (verb: DomainVerb, domain: string, server: string | undefined) => void
/** `showQueryLogs` from another screen: these filters, queried on arrival. */
request?: QueryLogsRequest
}
export function QueryLogs({ tabs, token, node = '', request, canBlock = true }: QueryLogsProps) {
export function QueryLogs({ tabs, token, node = '', request, canBlock = true, onAll }: QueryLogsProps) {
const [apps, setApps] = useState<{ name: string; classPaths: string[] }[] | null>(null)
const [f, setF] = useState<Filters>(() => defaultFilters('', ''))
const [page, setPage] = useState<QueryLogPage | null>(null)
@@ -1043,10 +1045,11 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true }:
</button>
<button
type="button"
disabled={action.isBusy(key) || !canBlock}
disabled={(onAll === undefined && action.isBusy(key)) || !canBlock}
onClick={() => {
close()
void action.run(key, verb, domain)
if (onAll !== undefined) onAll(verb, domain, e.node)
else void action.run(key, verb, domain)
}}
>
{verb}
+92
View File
@@ -0,0 +1,92 @@
import { useState, type ReactNode } from 'react'
import { Button } from './Button'
import { Dialog } from './Dialog'
import { Field, Input, Textarea } from './Field'
/*
One question in a dialog: a field (a line, or several with `multiline`) and a button. For the
step before a change on several servers that needs a value, a domain or a list of URLs, which
the next step (the server picker) does not ask for. `check` returns the sentence that says what
is wrong with the text, or `null` when it is fine; it is shown under the field and the answer
is not passed on.
*/
export function AskDialog({
title,
label,
intro,
initial = '',
placeholder,
multiline = false,
confirmLabel,
check,
onSubmit,
onClose,
}: {
title: string
label: string
intro?: ReactNode
initial?: string
placeholder?: string
multiline?: boolean
confirmLabel: string
check?: (text: string) => string | null
onSubmit: (text: string) => void
onClose: () => void
}) {
const [text, setText] = useState(initial)
const [problem, setProblem] = useState<string | null>(null)
function submit() {
const wrong = check?.(text) ?? null
setProblem(wrong)
if (wrong === null) onSubmit(text)
}
return (
<Dialog
open
onOpenChange={onClose}
title={title}
close="Cancel"
actions={
<Button variant="primary" onClick={submit}>
{confirmLabel}
</Button>
}
>
{intro != null && <p className="m-0 text-sm text-muted-foreground">{intro}</p>}
<Field label={label}>
{(id) =>
multiline ? (
<Textarea
id={id}
mono
rows={8}
placeholder={placeholder}
value={text}
aria-invalid={problem !== null || undefined}
onChange={(e) => setText(e.target.value)}
/>
) : (
<Input
id={id}
mono
placeholder={placeholder}
value={text}
aria-invalid={problem !== null || undefined}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submit()
}}
/>
)
}
</Field>
{problem !== null && (
<p className="m-0 text-xs text-destructive" role="alert">
{problem}
</p>
)}
</Dialog>
)
}
+8 -1
View File
@@ -6,8 +6,15 @@ no server is chosen ("All servers"), or the hub holds no admin token for the one
Nothing is drawn when the change is allowed, and nothing when the role is what says no (the
controls then carry their own "Requires ..." tooltip).
*/
export function HubWriteHint({ write }: { write: HubWrite }) {
export function HubWriteHint({ write, allServers = false }: { write: HubWrite; allServers?: boolean }) {
if (write.reason == null) return null
if (allServers && write.reason === CHOOSE_A_SERVER) {
return (
<p className="m-0 text-xs text-muted-foreground" role="status">
A change made here goes to the servers you choose in the next step.
</p>
)
}
return (
<p className="m-0 text-xs text-muted-foreground" role="status">
{write.reason === CHOOSE_A_SERVER ? 'Choose a server to make changes. Changes are made on one server at a time.' : `${write.reason}, so this server is read only.`}