Hub stage 5: manage each server from one console

Zones, Cache, Blocking, DNS Client, Settings, DHCP and Logs can now be
changed on one chosen server through the hub, with that server's admin
token. Operators manage cache and blocking; admins also zones, settings
and DHCP. Under "All servers", or on a server without an admin token,
the controls are disabled with a hint.

The hub refuses settings/set changes to a server's web service fields
and anything about users, SSO or LDAP; backup, restore, log and stats
deletion and admin/* stay refused. Non-admins get settings with secrets
blanked. Write params travel in the POST body, downloads pass through
with a single-use token, and the audit log names no values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 22:06:37 +07:00
1 parent 57e492cceb
commit 8bb1e01936
45 files changed
+1851 -190

No files matched your search

+7 -6
View File
@@ -2,6 +2,7 @@ import { urlApi } from '../app/base'
import { envelopeOutcome, type ApiOutcome } from './client'
import { queryLogs, type QueryLogEntry } from './logs'
import { addDomain, deleteDomain, type DomainList } from './zonelists'
import { nodeArg } from '../lib/hub-write'
/*
The Blocking section's own reads. Every endpoint here already exists in the server;
@@ -144,15 +145,15 @@ export async function recentBlocked(
}
/* `allowDomain` (other-zones.js:637): out of Blocked first, then into Allowed. */
export async function allowDomain(token: string | null, domain: string): Promise<ApiOutcome> {
const removed = await deleteDomain('blocked', token, domain)
export async function allowDomain(token: string | null, domain: string, node = ''): Promise<ApiOutcome> {
const removed = await deleteDomain('blocked', token, domain, ...nodeArg(node))
if (removed.kind !== 'ok') return removed
return addDomain('allowed', token, domain)
return addDomain('allowed', token, domain, ...nodeArg(node))
}
/* `blockDomain` (other-zones.js:686): out of Allowed first, then into Blocked. */
export async function blockDomain(token: string | null, domain: string): Promise<ApiOutcome> {
const removed = await deleteDomain('allowed', token, domain)
export async function blockDomain(token: string | null, domain: string, node = ''): Promise<ApiOutcome> {
const removed = await deleteDomain('allowed', token, domain, ...nodeArg(node))
if (removed.kind !== 'ok') return removed
return addDomain('blocked', token, domain)
return addDomain('blocked', token, domain, ...nodeArg(node))
}
+4 -3
View File
@@ -277,8 +277,8 @@ export async function setSettings(
}
/** `settings/forceUpdateBlockLists`. No parameters: forces the download now. */
export async function forceUpdateBlockLists(token: string | null): Promise<boolean> {
const outcome = await apiRequest('settings/forceUpdateBlockLists', { token })
export async function forceUpdateBlockLists(token: string | null, node = ''): Promise<boolean> {
const outcome = await apiRequest('settings/forceUpdateBlockLists', { token, ...(node ? { node } : {}) })
return outcome.kind === 'ok'
}
@@ -286,10 +286,11 @@ export async function forceUpdateBlockLists(token: string | null): Promise<boole
export async function temporaryDisableBlocking(
token: string | null,
minutes: string,
node = '',
): Promise<string | null> {
const outcome = await apiRequest<{ response: { temporaryDisableBlockingTill: string } }>(
'settings/temporaryDisableBlocking',
{ token, body: { minutes } },
{ token, body: { minutes }, ...(node ? { node } : {}) },
)
if (outcome.kind === 'ok') settingsChanged()
return outcome.kind === 'ok' ? (outcome.data.response?.temporaryDisableBlockingTill ?? null) : null
+11 -7
View File
@@ -172,8 +172,9 @@ export function addDomain(
list: DomainList,
token: string | null,
domain: string,
node = '',
): Promise<ApiOutcome> {
return apiRequest(`${list}/add`, { token, body: { domain } })
return apiRequest(`${list}/add`, { token, body: { domain }, ...(node ? { node } : {}) })
}
/** `deleteAllowedZone` / `deleteBlockedZone`. */
@@ -181,13 +182,14 @@ export function deleteDomain(
list: DomainList,
token: string | null,
domain: string,
node = '',
): Promise<ApiOutcome> {
return apiRequest(`${list}/delete`, { token, body: { domain } })
return apiRequest(`${list}/delete`, { token, body: { domain }, ...(node ? { node } : {}) })
}
/** `flushAllowedZone` / `flushBlockedZone`. No `node`, unlike the rest. */
export function flushList(list: DomainList, token: string | null): Promise<ApiOutcome> {
return apiRequest(`${list}/flush`, { token })
/** `flushAllowedZone` / `flushBlockedZone`. No `node`, unlike the rest, except behind a hub (a change is made on one server). */
export function flushList(list: DomainList, token: string | null, node = ''): Promise<ApiOutcome> {
return apiRequest(`${list}/flush`, { token, ...(node ? { node } : {}) })
}
/*
@@ -200,9 +202,10 @@ export function importDomains(
list: DomainList,
token: string | null,
zones: string,
node = '',
): Promise<ApiOutcome> {
const field = list === 'allowed' ? 'allowedZones' : 'blockedZones'
return apiRequest(`${list}/import`, { token, method: 'POST', body: { [field]: zones } })
return apiRequest(`${list}/import`, { token, method: 'POST', body: { [field]: zones }, ...(node ? { node } : {}) })
}
/*
@@ -214,6 +217,7 @@ token.
export function exportDomains(
list: DomainList,
token: string | null,
node = '',
): Promise<{ ok: boolean; url?: string }> {
return openDownload(token, `${list}/export`)
return openDownload(token, `${list}/export`, node ? { node } : {})
}
+14 -3
View File
@@ -32,6 +32,7 @@ import { Menu } from '../ui/Menu'
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 { FooterLinks } from '../ui/FooterLinks'
import { Versions } from './Versions'
import { BlockingMark } from './BlockingMark'
@@ -82,6 +83,9 @@ function keep(key: string, value: unknown) {
}
}
/* A stable empty list for a session without the field (outside a hub). */
const NO_SERVERS: string[] = []
const ICONS: Record<string, IconName> = {
dashboard: 'dashboard', zones: 'zones', cache: 'cache', blocking: 'blocked',
apps: 'apps', dnsclient: 'dnsclient', settings: 'settings',
@@ -135,6 +139,8 @@ export function Shell({
}) {
const permissions = session.info?.permissions
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
const hubWarnings = useHubWarnings()
/* Behind a hub: the count comes with the session, the list is read now and then. */
const hubDriftAtStart = session.info?.hubDrift
@@ -422,6 +428,7 @@ export function Shell({
<HandoffContext.Provider value={handoff}>
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
<HubMode.Provider value={hub}>
<HubWritable.Provider value={hubWritable}>
<SlotProvider>
{(slot) => (
<div className={styles.shell} data-rail={rail}>
@@ -602,7 +609,7 @@ export function Shell({
<span key={i} data-last={i === crumbs.length - 1}>{c}</span>
))}
</div>
{hub && <DriftStatus token={session.token} canWrite={permissions?.Apps?.canModify === true} writable={session.info?.hubWritable ?? []} />}
{hub && <DriftStatus token={session.token} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} />}
<Popover>
<PopoverTrigger className={styles.status} data-attention={blockingNow != null || hubWarnings.length > 0} aria-label="Server status">
<i className={styles.pdot} data-tone={hubWarnings.length > 0 ? 'warn' : (blockingNow?.tone ?? 'ok')} />
@@ -657,20 +664,22 @@ export function Shell({
token={session.token}
nodes={session.info?.clusterNodes ?? []}
clusterInitialised={session.info?.clusterInitialized === true}
canBlock={hub ? permissions?.Settings?.canModify !== false : true}
/>
) : current?.id === 'dnsclient' ? (
<DnsClient
token={session.token}
nodes={session.info?.clusterNodes ?? []}
clusterInitialised={session.info?.clusterInitialized === true}
canImport={permissions?.DnsClient?.canModify !== false}
request={pending != null && 'dnsclient' in pending ? pending.dnsclient : undefined}
/>
) : current?.id === 'about' ? (
<About info={info} />
) : current?.id === 'apps' ? (
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={session.info?.hubWritable ?? []} />
<Apps token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canWrite={permissions?.Apps?.canModify === true} writable={hubWritable} />
) : current?.id === 'cache' ? (
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} />
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} canDelete={hub ? permissions?.Cache?.canDelete !== false : undefined} />
) : current?.id === 'blocking' ? (
<Blocking
token={session.token}
@@ -716,6 +725,7 @@ export function Shell({
clusterInitialised={session.info?.clusterInitialized === true}
sub={currentSub ?? 'View Logs'}
onSubChange={setSub}
canBlock={hub ? permissions?.Blocked?.canModify !== false && permissions?.Allowed?.canModify !== false : true}
canDeleteLogs={permissions?.Logs?.canDelete !== false}
canDeleteStats={permissions?.Dashboard?.canDelete !== false}
request={pending != null && 'logs' in pending ? pending.logs : undefined}
@@ -837,6 +847,7 @@ export function Shell({
</div>
)}
</SlotProvider>
</HubWritable.Provider>
</HubMode.Provider>
</ThisServerName.Provider>
</HandoffContext.Provider>
+8 -3
View File
@@ -1,4 +1,5 @@
import { useRef, useState } from 'react'
import { useContext, useRef, useState } from 'react'
import { HubNode, nodeArg } from './hub-write'
import { allowDomain, blockDomain, BLOCKED_TYPES } from '../api/blocking'
import { noticeFromFailure, type Notice } from './notice'
@@ -32,7 +33,11 @@ export function verbFor(responseType: string): DomainVerb {
return BLOCKED.includes(responseType.toLowerCase()) ? 'Allow Domain' : 'Block Domain'
}
export function useDomainAction(token: string | null, onNotice: (n: Notice) => void) {
/* `node` is the server the change is made on, for the hub; by default the one the section around it has chosen
(`HubNode`, empty outside a hub: the server the console talks to). */
export function useDomainAction(token: string | null, onNotice: (n: Notice) => void, node?: string) {
const section = useContext(HubNode)
const target = node ?? section
const [busy, setBusy] = useState<ReadonlySet<string>>(new Set())
/* Read by `run` so two clicks inside one render cannot both pass the guard. */
const inFlight = useRef(new Set<string>())
@@ -41,7 +46,7 @@ export function useDomainAction(token: string | null, onNotice: (n: Notice) => v
if (inFlight.current.has(key)) return false
inFlight.current.add(key)
setBusy(new Set(inFlight.current))
const outcome = await (verb === 'Allow Domain' ? allowDomain(token, domain) : blockDomain(token, domain))
const outcome = await (verb === 'Allow Domain' ? allowDomain(token, domain, ...nodeArg(target)) : blockDomain(token, domain, ...nodeArg(target)))
inFlight.current.delete(key)
setBusy(new Set(inFlight.current))
if (outcome.kind !== 'ok') {
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { CHOOSE_A_SERVER, NO_ADMIN_TOKEN, hubDefaultNode, hubWriteState, nodeArg } from './hub-write'
const NODES = [
{ name: 'mdns', type: 'Primary' },
{ name: 'ndns', type: 'Secondary' },
]
describe('hubWriteState', () => {
const base = { hub: true, writable: ['mdns'], allowed: true }
it('allows a change on a chosen server the hub can write to', () => {
expect(hubWriteState({ ...base, node: 'mdns' })).toEqual({ allowed: true, reason: null })
})
it('says "Choose a server" for the aggregate and for no node', () => {
for (const node of ['', 'cluster']) {
expect(hubWriteState({ ...base, node })).toEqual({ allowed: false, reason: CHOOSE_A_SERVER })
}
})
it('says there is no admin token for a server the hub cannot write to', () => {
expect(hubWriteState({ ...base, node: 'ndns' })).toEqual({ allowed: false, reason: NO_ADMIN_TOKEN })
expect(NO_ADMIN_TOKEN).toBe('No admin token configured on the hub')
})
it('gives no reason when the role is what says no (the control carries its own tooltip)', () => {
expect(hubWriteState({ ...base, node: 'mdns', allowed: false })).toEqual({ allowed: false, reason: null })
expect(hubWriteState({ ...base, node: '', allowed: false })).toEqual({ allowed: false, reason: null })
})
it('is the permission and nothing more outside a hub', () => {
expect(hubWriteState({ hub: false, node: '', writable: [], allowed: true })).toEqual({ allowed: true, reason: null })
expect(hubWriteState({ hub: false, node: 'x', writable: [], allowed: false })).toEqual({ allowed: false, reason: null })
})
})
describe('hubDefaultNode', () => {
it('names the first server behind a hub and nothing elsewhere', () => {
expect(hubDefaultNode(true, NODES)).toBe('mdns')
expect(hubDefaultNode(false, NODES)).toBe('')
expect(hubDefaultNode(true, [])).toBe('')
})
})
describe('nodeArg', () => {
it('adds the argument only when a server is chosen', () => {
expect(nodeArg('')).toEqual([])
expect(nodeArg('mdns')).toEqual(['mdns'])
})
})
+75
View File
@@ -0,0 +1,75 @@
import { createContext, useContext } from 'react'
import { HubMode, type ClusterNode } from '../ui/ClusterNodeSelect'
/*
Behind a hub a change is made on ONE server at a time (`node=<id>`), and only on a server the
hub holds an admin token for. These are the pieces every screen that changes something shares,
so the rule is written once.
· `HubWritable`: the servers the hub can write to (`hubWritable` of the session), given by the shell.
· `hubWriteState`: whether a change is allowed on `node`, and if not, the sentence that says why.
· `useHubWrite`: that, read from the contexts, for a screen's own node.
· `useHubDefaultNode`: the server a screen without an "All servers" choice starts on.
· `HubNode`: the server a section's controls act on, for the controls that are not handed it.
Outside a hub none of this applies: `allowed` is the permission and there is no reason.
*/
export const HubWritable = createContext<readonly string[]>([])
/* The server the controls of a section act on (`''` outside a hub, or when none is chosen). */
export const HubNode = createContext('')
export const CHOOSE_A_SERVER = 'Choose a server'
export const NO_ADMIN_TOKEN = 'No admin token configured on the hub'
export interface HubWrite {
/** The role may change it, a server is chosen and the hub can write to it. */
allowed: boolean
/** Why not, when it is the node and not the role; `null` when allowed or when the role says no. */
reason: string | null
}
export function hubWriteState({
hub,
node,
writable,
allowed,
}: {
hub: boolean
node: string
writable: readonly string[]
/** What the role allows (`canModify`). */
allowed: boolean
}): HubWrite {
if (!hub) return { allowed, reason: null }
if (!allowed) return { allowed: false, reason: null }
if (node === '' || node === 'cluster') return { allowed: false, reason: CHOOSE_A_SERVER }
if (!writable.includes(node)) return { allowed: false, reason: NO_ADMIN_TOKEN }
return { allowed: true, reason: null }
}
/* The write state of a screen on its own `node`. `allowed` is the role's permission, true when it has none. */
export function useHubWrite(node: string, allowed = true): HubWrite {
const hub = useContext(HubMode)
const writable = useContext(HubWritable)
return hubWriteState({ hub, node, writable, allowed })
}
/*
Where a screen without an "All servers" choice starts. Outside a hub that is the empty string (this
server, which the real console takes as its own node). Behind a hub the empty string is the hub, not
a server, so the first server is named: the selector shows it and the requests carry it.
*/
export function hubDefaultNode(hub: boolean, nodes: readonly ClusterNode[]): string {
return hub && nodes.length > 0 ? nodes[0].name : ''
}
export function useHubDefaultNode(nodes: readonly ClusterNode[]): string {
return hubDefaultNode(useContext(HubMode), nodes)
}
/*
The optional `node` argument of an API function, spread into its call: nothing when no server is
chosen, so a console used without a hub makes exactly the calls it always made.
*/
export const nodeArg = (node: string): [] | [string] => (node === '' ? [] : [node])
+6 -3
View File
@@ -1,4 +1,5 @@
import { useId, useRef, useState } from 'react'
import { useContext, useId, useRef, useState } from 'react'
import { HubNode, nodeArg } from '../../lib/hub-write'
import { addDomain, type DomainList } from '../../api/zonelists'
import { Panel, Body } from '../../ui/Panel'
import { Field, Input } from '../../ui/Field'
@@ -51,6 +52,8 @@ export function AddDomainBar({
onNotice: (n: Notice) => void
onChanged: (list: DomainList, domain: string) => void
}) {
/* Behind a hub: the server the section has chosen (empty elsewhere, which sends no `node`). */
const node = useContext(HubNode)
const [field, setField] = useState('')
const [busy, setBusy] = useState(false)
const entry = useRef<HTMLInputElement>(null)
@@ -82,7 +85,7 @@ export function AddDomainBar({
checkDomain(token, domain, {
settings: missing(permissions, 'Settings.canView'),
allowed: missing(permissions, 'Allowed.canView'),
}),
}, ...nodeArg(node)),
/* The names the Lists tab gives its rows: Technitium's catalogue, then ours. */
loadQuickList('quick-block-lists').then((c) => [...c, ...moreLists(c)]),
])
@@ -120,7 +123,7 @@ export function AddDomainBar({
}
setBusy(true)
const outcome = await addDomain(list, token, domain)
const outcome = await addDomain(list, token, domain, ...nodeArg(node))
setBusy(false)
if (outcome.kind !== 'ok') {
onNotice(noticeFromFailure(outcome))
+6 -3
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { useCallback, useContext, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { HubNode, nodeArg } from '../../lib/hub-write'
import { readSettings, setSettings, forceUpdateBlockLists } from '../../api/settings'
import { getDashboardStats } from '../../api/dashboard'
import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
@@ -209,7 +210,9 @@ export function BlockLists({
/** Told whether there are unsaved changes, for the tab's mark; false on leaving. */
onDirty?: (dirty: boolean) => void
}) {
const node = clusterInitialised ? 'cluster' : ''
/* Behind a hub the lists are the chosen server's own (no cluster); elsewhere the cluster's. */
const hubNode = useContext(HubNode)
const node = hubNode !== '' ? hubNode : clusterInitialised ? 'cluster' : ''
const viewNeed = missing(permissions, 'Settings.canView')
const modifyNeed = missing(permissions, 'Settings.canModify')
const statsNeed = missing(permissions, 'Dashboard.canView')
@@ -461,7 +464,7 @@ export function BlockLists({
// known if the read fails.
const before = await readSettings(token, node)
const from = before.kind !== 'ok' ? serverNext.current : (before.data.blockListNextUpdatedOn ?? null)
const ok = await forceUpdateBlockLists(token)
const ok = await forceUpdateBlockLists(token, ...nodeArg(hubNode))
setBusy(false)
if (!ok) return
// main.js:2356. The label becomes "Updating Now" without reloading the settings.
+41 -13
View File
@@ -1,9 +1,12 @@
import { useEffect, useState } from 'react'
import { useContext, useEffect, useState } from 'react'
import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import { getSettings } from '../../api/settings'
import { getDashboardStats } from '../../api/dashboard'
import { BlockingMenu } from '../dashboard/BlockingMenu'
import { Tag } from '../../ui/Tag'
import { missing } from './permissions'
import { missing, withoutWrites } from './permissions'
import { untilText, useBlockingState } from './blocking-state'
import { SubTabs } from '../../ui/SubTabs'
import { ruleSearch } from './rules-model'
@@ -36,12 +39,12 @@ function dropRule(): void {
/* What the tabs count: the rules (blocked and allowed zones) and the lists. Each is read
once and only when the session may read it; a failed read leaves the tab without one. */
function useCounts(token: string | null, permissions: Permissions) {
function useCounts(token: string | null, permissions: Permissions, node = '') {
const [counts, setCounts] = useState<{ Rules?: number; Lists?: number }>({})
useEffect(() => {
let alive = true
if (missing(permissions, 'Dashboard.canView') == null) {
void getDashboardStats(token).then((o) => {
void getDashboardStats(token, undefined, undefined, node).then((o) => {
if (alive && o.kind === 'ok') {
const s = o.data.stats
setCounts((c) => ({ ...c, Rules: s.blockedZones + s.allowedZones }))
@@ -49,7 +52,7 @@ function useCounts(token: string | null, permissions: Permissions) {
})
}
if (missing(permissions, 'Settings.canView') == null) {
void getSettings(token).then((s) => {
void getSettings(token, node).then((s) => {
if (alive && s != null) setCounts((c) => ({ ...c, Lists: s.blockListUrls?.length ?? 0 }))
})
}
@@ -58,14 +61,14 @@ function useCounts(token: string | null, permissions: Permissions) {
}
// The permission object is read once: the counts are a hint, not a live figure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token])
}, [token, node])
return counts
}
/* The state strip of the canvas, above the tabs. Overview carries the full status block
itself, so the strip is for the other two. */
function Banner({ token, permissions }: { token: string | null; permissions: Permissions }) {
const { state } = useBlockingState(token, permissions)
function Banner({ token, permissions, node }: { token: string | null; permissions: Permissions; node: string }) {
const { state } = useBlockingState(token, permissions, true, node)
if (state == null || state === 'failed') return null
const canModify = missing(permissions, 'Settings.canModify') == null
return (
@@ -82,7 +85,7 @@ function Banner({ token, permissions }: { token: string | null; permissions: Per
</span>
{canModify && (
<span className={shared.bannerAct}>
<BlockingMenu token={token} text={state.kind === 'on' ? 'Temporarily Disable Blocking' : 'Enable Blocking'} onNotice={() => {}} />
<BlockingMenu token={token} text={state.kind === 'on' ? 'Temporarily Disable Blocking' : 'Enable Blocking'} onNotice={() => {}} node={node} />
</span>
)}
</div>
@@ -131,11 +134,30 @@ export function Blocking({
const [listsDirty, setListsDirty] = useState(false)
const counts = useCounts(token, permissions)
/*
Behind a hub the Rules and Lists tabs act on ONE server, chosen here (the first at the start;
Overview keeps its own selector, which also offers "All servers" for the figures). Where the
hub cannot write (no admin token) the writes are switched off and the line under the selector
says why. Outside a hub none of this exists: `hubNode` is empty and `permissions` are the role's.
*/
const hub = useContext(HubMode)
const defaultNode = useHubDefaultNode(nodes)
const [chosen, setChosen] = useState(defaultNode)
const hubNode = hub ? chosen : ''
const write = useHubWrite(hubNode)
const effective = write.allowed ? permissions : withoutWrites(permissions)
const counts = useCounts(token, permissions, hubNode)
const tabs = (
<>
{active !== 'Overview' && <Banner token={token} permissions={permissions} />}
{hub && active !== 'Overview' && (
<>
<ClusterNodeSelect nodes={nodes} initialised={clusterInitialised} value={chosen} onChange={setChosen} />
<HubWriteHint write={write} />
</>
)}
{active !== 'Overview' && <Banner token={token} permissions={effective} node={hubNode} />}
<SubTabs
label="Blocking sections"
section="blocking"
@@ -149,17 +171,23 @@ export function Blocking({
)
if (active === 'Rules') {
return <Rules tabs={tabs} token={token} permissions={permissions} nodes={nodes} clusterInitialised={clusterInitialised} />
return (
<HubNode.Provider value={hubNode}>
<Rules tabs={tabs} token={token} permissions={effective} nodes={nodes} clusterInitialised={clusterInitialised} />
</HubNode.Provider>
)
}
if (active === 'Lists') {
return (
<HubNode.Provider value={hubNode}>
<BlockLists
tabs={tabs}
token={token}
permissions={permissions}
permissions={effective}
clusterInitialised={clusterInitialised}
onDirty={setListsDirty}
/>
</HubNode.Provider>
)
}
return (
+15 -7
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
getDashboardStats, RANGES, RANGE_LABEL,
type ChartData, type DashboardStats, type Range,
@@ -6,7 +6,7 @@ import {
import { percentage, hasData, Legend, useHidden } from '../dashboard/Dashboard'
import { Chart } from '../dashboard/Chart'
import { InChrome } from '../../app/ChromeSlot'
import { ClusterNodeSelect, AGGREGATE, type ClusterNode } from '../../ui/ClusterNodeSelect'
import { ClusterNodeSelect, AGGREGATE, HubMode, type ClusterNode } from '../../ui/ClusterNodeSelect'
import { SectionHeader } from '../../ui/SectionHeader'
import { Segmented } from '../../ui/Segmented'
import { Panel, Body } from '../../ui/Panel'
@@ -20,7 +20,9 @@ import { TopTable } from './TopTable'
import { RecentBlocked } from './RecentBlocked'
import { LAST_HOUR_REFRESH_MS, useLiveView } from '../dashboard/useLive'
import { Locked } from './Locked'
import { missing, type Permissions } from './permissions'
import { missing, withoutWrites, type Permissions } from './permissions'
import { HubNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import shared from './Blocking.module.css'
import styles from './Overview.module.css'
@@ -181,7 +183,7 @@ function Gap({ failure, loading, announce = true }: { failure: boolean; loading:
export function Overview({
tabs,
token,
permissions,
permissions: rolePermissions,
nodes = [],
clusterInitialised = false,
serverDomain,
@@ -193,9 +195,14 @@ export function Overview({
clusterInitialised?: boolean
serverDomain?: string
}) {
const statsNeed = missing(permissions, 'Dashboard.canView')
const statsNeed = missing(rolePermissions, 'Dashboard.canView')
const [range, setRange] = useState<Period>('LastHour')
const [node, setNode] = useState<string>(() => localStorage.getItem('dashboardClusterNode') || AGGREGATE)
/* Behind a hub, a change is allowed only on a chosen server the hub can write to: with "All servers"
the controls are switched off like a missing permission, and the line under the selector says why. */
const hub = useContext(HubMode)
const write = useHubWrite(node)
const permissions = write.allowed ? rolePermissions : withoutWrites(rolePermissions)
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)
@@ -318,7 +325,7 @@ export function Overview({
const loading = data == null && !failure
return (
<>
<HubNode.Provider value={hub ? node : ''}>
<InChrome>
<ClusterNodeSelect
nodes={nodes}
@@ -328,6 +335,7 @@ export function Overview({
onChange={setNode}
/>
</InChrome>
<HubWriteHint write={write} />
<SectionHeader
title="Blocking"
@@ -465,6 +473,6 @@ export function Overview({
refreshOn={s?.totalBlocked}
/>
</div>
</>
</HubNode.Provider>
)
}
+14 -9
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { HubNode, nodeArg } from '../../lib/hub-write'
import { readRuleExport } from '../../api/blocking'
import { addDomain, deleteDomain, exportDomains, flushList, type DomainList } from '../../api/zonelists'
import { SectionHeader } from '../../ui/SectionHeader'
@@ -89,6 +90,8 @@ export function Rules({
nodes?: { name: string; type: string }[]
clusterInitialised?: boolean
}) {
/* Behind a hub: the server the section has chosen (empty elsewhere). */
const hubNode = useContext(HubNode)
const viewBlocked = missing(permissions, 'Blocked.canView') == null
const viewAllowed = missing(permissions, 'Allowed.canView') == null
/* Neither list can be read: there is nothing to count, so nothing is counted;
@@ -162,7 +165,8 @@ export function Rules({
change, the PRIMARY, where the change was made (other-zones.js:269, 434): a
secondary still answers the old list until the cluster syncs. */
const load = useCallback(async (fromPrimary = false) => {
const node = fromPrimary ? primary.current : ''
/* Behind a hub every read and change is on the server the section has chosen. */
const node = hubNode !== '' ? hubNode : fromPrimary ? primary.current : ''
const mine = ++asked.current
const read = (list: DomainList, may: boolean) => (may ? readRuleExport(list, token, node) : null)
const [b, a] = await Promise.all([read('blocked', viewBlocked), read('allowed', viewAllowed)])
@@ -181,7 +185,7 @@ export function Rules({
setFailed(false)
setStale(false)
setLastGood(new Date().toISOString())
}, [token, viewBlocked, viewAllowed])
}, [token, viewBlocked, viewAllowed, hubNode])
useEffect(() => {
void load()
@@ -252,7 +256,7 @@ export function Rules({
label: 'Delete',
action: () =>
mutate(
() => deleteDomain(rule.list, token, rule.domain),
() => deleteDomain(rule.list, token, rule.domain, ...nodeArg(hubNode)),
isAllowed
? { type: 'success', title: 'Deleted!', text: `Domain '${rule.domain}' was deleted from Allowed Zone successfully.` }
: { type: 'success', title: 'Deleted!', text: `Blocked zone '${rule.domain}' was deleted successfully.` },
@@ -268,7 +272,7 @@ export function Rules({
return
}
setBusy(true)
const outcome = await deleteDomain(rule.list, token, rule.domain)
const outcome = await deleteDomain(rule.list, token, rule.domain, ...nodeArg(hubNode))
if (!mounted.current) return
setBusy(false)
if (outcome.kind !== 'ok') {
@@ -285,7 +289,7 @@ export function Rules({
const deleted = undo.rules
setUndo({ rules: deleted, phase: 'restoring' })
setBusy(true)
const result = await restoreRules(deleted, (r) => addDomain(r.list, token, r.domain))
const result = await restoreRules(deleted, (r) => addDomain(r.list, token, r.domain, ...nodeArg(hubNode)))
if (!mounted.current) return
setBusy(false)
changed()
@@ -309,7 +313,7 @@ export function Rules({
label: 'Flush',
action: () =>
mutate(
() => flushList(list, token),
() => flushList(list, token, ...nodeArg(hubNode)),
isAllowed
? { type: 'success', title: 'Flushed!', text: 'Allowed zone was flushed successfully.' }
: { type: 'success', title: 'Flushed!', text: 'Blocked zone was flushed successfully.' },
@@ -319,7 +323,7 @@ export function Rules({
async function runExport(list: DomainList) {
setBusy(true)
const r = await exportDomains(list, token)
const r = await exportDomains(list, token, ...nodeArg(hubNode))
setBusy(false)
if (!r.ok) return
setNotice({
@@ -454,7 +458,7 @@ export function Rules({
are framed boxes of their own, and flush they doubled the panel's edge. */
<div className={styles.tree}>
<Lists
key={`${treeList}:${generation}`}
key={`${treeList}:${generation}:${hubNode}`}
list={treeList}
token={token}
nodes={nodes}
@@ -617,6 +621,7 @@ export function Rules({
list={importing}
open
token={token}
node={hubNode}
onClose={() => setImporting(null)}
onDone={(n) => {
setNotice(n)
+6 -2
View File
@@ -1,3 +1,5 @@
import { useContext } from 'react'
import { HubNode } from '../../lib/hub-write'
import { BlockingMenu } from '../dashboard/BlockingMenu'
import { Panel, Body } from '../../ui/Panel'
import { PermissionButton } from '../../ui/PermissionButton'
@@ -44,7 +46,9 @@ export function StatusPanel({
/* The reads (when, from where, and the one when a pause ends) are
`useBlockingState`'s, shared with the sidebar's mark. A change made from the
menu below reaches it as every settings write does (`lib/settings-signal.ts`). */
const { state, retry } = useBlockingState(token, permissions)
/* Behind a hub: the server the section has chosen. */
const node = useContext(HubNode)
const { state, retry } = useBlockingState(token, permissions, true, node)
if (viewNeed != null) return <Locked title="Blocking" need={viewNeed} />
@@ -109,7 +113,7 @@ export function StatusPanel({
{modifyNeed != null ? (
<PermissionButton permission={modifyNeed}>{verb}</PermissionButton>
) : (
<BlockingMenu token={token} text={verb} onNotice={onNotice} />
<BlockingMenu token={token} text={verb} onNotice={onNotice} node={node} />
)}
</div>
)}
+4 -2
View File
@@ -62,6 +62,8 @@ export function useBlockingState(
/** False reads nothing, as a missing `Settings.canView` does: the sidebar without
a Blocking entry to mark. */
enabled = true,
/** Behind a hub: the server read (a hub has no settings of its own). Empty elsewhere. */
node = '',
): { state: BlockingRead; reload: () => void; retry: () => void } {
const viewNeed = enabled ? missing(permissions, 'Settings.canView') : 'Settings.canView'
const [state, setState] = useState<BlockingRead>(null)
@@ -70,11 +72,11 @@ export function useBlockingState(
const reload = useCallback(() => {
if (viewNeed != null) return
const mine = ++asked.current
void getSettings(token).then((s) => {
void getSettings(token, node).then((s) => {
if (mine !== asked.current) return
setState(s == null ? 'failed' : stateOf(s.enableBlocking, s.temporaryDisableBlockingTill))
})
}, [token, viewNeed])
}, [token, viewNeed, node])
useEffect(() => {
// A read already travelling must not land after the permission went.
+6 -3
View File
@@ -2,6 +2,7 @@ import type { ApiOutcome } from '../../api/client'
import { readRuleExport } from '../../api/blocking'
import { resolve } from '../../api/dnsclient'
import { readSettings } from '../../api/settings'
import { nodeArg } from '../../lib/hub-write'
import type { QuickEntry } from '../../lib/quick-lists'
import { listName } from './list-lines'
@@ -321,11 +322,13 @@ export async function checkDomain(
token: string | null,
domain: string,
needs: { settings?: string; allowed?: string },
/** Behind a hub: the server asked (a hub has no server of its own to ask). */
node = '',
): Promise<ApiOutcome<CheckInputs>> {
const [answer, settings, allowed] = await Promise.all([
resolve(token, { server: 'this-server', domain, type: 'A', protocol: 'UDP', dnssec: false, eDnsClientSubnet: '' }),
needs.settings != null ? { need: needs.settings } : readSettings(token),
needs.allowed != null ? { need: needs.allowed } : readRuleExport('allowed', token),
resolve(token, { server: 'this-server', domain, type: 'A', protocol: 'UDP', dnssec: false, eDnsClientSubnet: '', ...(node === '' ? {} : { node }) }),
needs.settings != null ? { need: needs.settings } : readSettings(token, ...nodeArg(node)),
needs.allowed != null ? { need: needs.allowed } : readRuleExport('allowed', token, ...nodeArg(node)),
])
if (answer.kind !== 'ok') return answer
return {
+14
View File
@@ -19,6 +19,20 @@ export function missing(perms: Permissions, ...needs: Need[]): Need | undefined
return undefined
}
/*
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
controls then render as they do for a role without the permission.
*/
export function withoutWrites(perms: Permissions): Permissions {
if (perms == null) return perms
const out: Record<string, Permission> = { ...perms }
for (const section of ['Allowed', 'Blocked', 'Settings']) {
out[section] = { canView: perms[section]?.canView ?? true, canModify: false, canDelete: false }
}
return out
}
/** The same sentence `PermissionButton` draws: `Requires Logs: View`. */
export function requiresText(need: string): string {
const [section, action] = need.split('.')
+22 -3
View File
@@ -2,6 +2,9 @@ import { 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 type { AlertType } from '../../ui/Alert'
import { noticeFromFailure } from '../../lib/notice'
@@ -53,8 +56,14 @@ export function BlockingMenu({
onNotice,
text = 'Blocking',
onChanged,
node = '',
allowed = true,
}: {
token: string | null
/** Behind a hub: the server the change is made on (empty or "cluster": none chosen, so it is switched off). */
node?: string
/** Behind a hub: what the role may do. */
allowed?: boolean
onNotice: (a: { type: AlertType; title: string; text: string }) => void
/** The trigger text. The Dashboard keeps upstream's "Blocking". */
text?: string
@@ -67,7 +76,7 @@ export function BlockingMenu({
async function loadState() {
setActive(null)
const s = await getSettings(token)
const s = await getSettings(token, ...nodeArg(node))
if (s != null) setActive(s.enableBlocking)
}
@@ -78,7 +87,7 @@ export function BlockingMenu({
label: turnOn ? 'Enable' : 'Disable',
variant: turnOn ? 'primary' : 'danger',
perform: async () => {
const r = await setSettings(token, { enableBlocking: String(turnOn) })
const r = await setSettings(token, { enableBlocking: String(turnOn), ...(node ? { node } : {}) })
// The same text as the other thirty-six: it was the only one that said
// a bare "Session expired." for this very condition.
if (r.kind !== 'ok') throw new Error(noticeFromFailure(r).text)
@@ -99,7 +108,7 @@ export function BlockingMenu({
label: 'Disable',
variant: 'danger',
perform: async () => {
const till = await temporaryDisableBlocking(token, String(minutes))
const till = await temporaryDisableBlocking(token, String(minutes), ...nodeArg(node))
if (till == null) throw new Error('The request failed.')
setActive(false)
onNotice({
@@ -126,6 +135,16 @@ 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 (!write.allowed) {
return (
<Tooltip text={write.reason ?? 'Requires Settings: Modify'}>
<Button disabled>{text}</Button>
</Tooltip>
)
}
return (
<>
<Menu label="Blocking options" text={text} onOpen={() => void loadState()}>
+12 -5
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useContext, useEffect, useMemo, useRef, useState } from 'react'
import { HubNode } from '../../lib/hub-write'
import {
RANGE_LABEL, RANGES, getDashboardStats,
type DashboardStats, type Range, type Stats, type TopKind,
@@ -13,7 +14,7 @@ import { Heatmap } from './Heatmap.tsx'
import {
bucketWindow, periodFilters, protocolFilter, queryTypeFilter, responseTypeFilter,
} from './jump'
import { ClusterNodeSelect, AGGREGATE, type ClusterNode } from '../../ui/ClusterNodeSelect'
import { ClusterNodeSelect, AGGREGATE, HubMode, type ClusterNode } from '../../ui/ClusterNodeSelect'
import { InChrome } from '../../app/ChromeSlot'
import { TopStats } from './TopStats'
import { TopRowMenu } from './TopRowMenu'
@@ -52,11 +53,15 @@ export function Dashboard({
token,
nodes = [],
clusterInitialised = false,
canBlock = true,
}: {
token: string | null
nodes?: ClusterNode[]
clusterInitialised?: boolean
/** Behind a hub: whether the role may switch blocking or allow and block a domain. */
canBlock?: boolean
}) {
const hub = useContext(HubMode)
/*
Which node this screen is reading. Upstream offers the aggregate here and
remembers the choice per screen (`cluster.js`, key `dashboardClusterNode`).
@@ -96,7 +101,7 @@ export function Dashboard({
const [notice, setNotice] = useState<{ type: AlertType; title: string; text: string } | null>(null)
/* Allowing or blocking from a top list reports on the page (main.js:2825 and
2853 pass no alert placeholder). */
const action = useDomainAction(token, setNotice)
const action = useDomainAction(token, setNotice, hub ? node : '')
/*
The custom range. `start`/`end` are what is typed into the two fields;
`requested` is the last thing "Show" was pressed with, which is what triggers the
@@ -490,7 +495,7 @@ export function Dashboard({
rows={view?.topBlockedDomains ?? []}
onMore={() => setTop('TopBlockedDomains')}
failure={failure}
beforeMore={<BlockingMenu token={token} onNotice={setNotice} />}
beforeMore={<BlockingMenu token={token} onNotice={setNotice} node={hub ? node : ''} allowed={canBlock} />}
menu={(f) => <TopRowMenu kind="TopBlockedDomains" name={f.name} node={node} action={action} />}
/>
<Breakdown
@@ -505,7 +510,9 @@ export function Dashboard({
/>
</div>
<TopStats type={top} range={range} token={token} node={node} onClose={() => setTop(null)} />
<HubNode.Provider value={hub ? node : ''}>
<TopStats type={top} range={range} token={token} node={node} onClose={() => setTop(null)} />
</HubNode.Provider>
</>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Dhcp } from './Dhcp'
import * as api from '../../api/dhcp'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { HubWritable } from '../../lib/hub-write'
afterEach(() => vi.restoreAllMocks())
/* Behind a hub the screen starts on the first server, and changes only where the hub can write. */
const NODES = [
{ name: 'mdns', type: 'Primary' },
{ name: 'ndns', type: 'Secondary' },
]
function hubDhcp(writable: string[], canModify = true) {
vi.spyOn(api, 'listScopes').mockResolvedValue({ kind: 'ok', data: [] })
return render(
<HubMode.Provider value>
<HubWritable.Provider value={writable}>
<Dhcp token="t" sub="Scopes" nodes={NODES} clusterInitialised canModify={canModify} canDelete={canModify} />
</HubWritable.Provider>
</HubMode.Provider>,
)
}
describe('DHCP behind a hub', () => {
it('starts on the first server, named in the request and shown in the selector', async () => {
hubDhcp(['mdns', 'ndns'])
await screen.findByText('No Scope Found')
expect(api.listScopes).toHaveBeenCalledWith('t', 'mdns')
expect(screen.getByRole('combobox', { name: 'Server' })).toHaveTextContent('mdns')
expect(screen.queryByRole('option', { name: 'All servers' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Scope' })).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('switches changes off on a server the hub holds no admin token for, and says why', async () => {
const user = userEvent.setup()
hubDhcp(['mdns'])
await screen.findByText('No Scope Found')
await user.click(screen.getByRole('combobox', { name: 'Server' }))
await user.click(await screen.findByRole('option', { name: 'ndns' }))
expect(await screen.findByText(/No admin token configured on the hub/)).toBeInTheDocument()
expect(api.listScopes).toHaveBeenLastCalledWith('t', 'ndns')
expect(screen.queryByRole('button', { name: 'Add Scope' })).not.toBeInTheDocument()
})
it('says nothing about the server when the role is what forbids the change', async () => {
hubDhcp(['mdns'], false)
await screen.findByText('No Scope Found')
expect(screen.queryByRole('button', { name: 'Add Scope' })).not.toBeInTheDocument()
expect(screen.queryByText(/admin token|Choose a server/)).not.toBeInTheDocument()
})
})
describe('DHCP outside a hub', () => {
it('keeps the empty node and no hint', async () => {
vi.spyOn(api, 'listScopes').mockResolvedValue({ kind: 'ok', data: [] })
render(<Dhcp token="t" sub="Scopes" />)
await screen.findByText('No Scope Found')
expect(api.listScopes).toHaveBeenCalledWith('t', '')
expect(screen.getByRole('button', { name: 'Add Scope' })).toBeInTheDocument()
})
})
+11 -3
View File
@@ -3,6 +3,8 @@ import { SubTabs } from '../../ui/SubTabs'
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { Leases } from './Leases'
import { Scopes } from './Scopes'
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
/*
DHCP. Two sub-tabs, with upstream's literal labels (index.html:2496-2497):
@@ -48,12 +50,17 @@ export function Dhcp({
clusterInitialised = false,
token,
sub,
canModify = true,
canDelete = true,
canModify: mayModify = true,
canDelete: mayDelete = true,
}: DhcpProps) {
/* Which node this screen reads. Upstream mounts a selector here
and the parameter was already travelling empty. */
const [node, setNode] = useState<string>(() => '')
const defaultNode = useHubDefaultNode(nodes)
const [node, setNode] = useState<string>(defaultNode)
/* Behind a hub, a change is allowed only on a chosen server the hub can write to. */
const write = useHubWrite(node, mayModify)
const canModify = write.allowed
const canDelete = useHubWrite(node, mayDelete).allowed
const [nodeLocked, setNodeLocked] = useState(false)
const requested = (sub ?? 'Leases') as SubTab
const active: SubTab = SUB_TABS.includes(requested) ? requested : 'Leases'
@@ -85,6 +92,7 @@ export function Dhcp({
disabled={nodeLocked}
label="Cluster Node"
/>
<HubWriteHint write={write} />
{active === 'Scopes' ? (
<Scopes
tabs={tabs}
+13 -3
View File
@@ -4,6 +4,9 @@ import type { DnsClientRequest } from '../../app/handoff'
import { PROTOCOLS, TYPES, prepareServer, resolve } from '../../api/dnsclient'
import { type AlertType } from '../../ui/Alert'
import { Button } from '../../ui/Button'
import { PermissionButton } from '../../ui/PermissionButton'
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import { Check } from '../../ui/Check'
import { Field, Input, LabeledInput, LabeledSelect } from '../../ui/Field'
import { Menu } from '../../ui/Menu'
@@ -32,18 +35,24 @@ export function DnsClient({
token,
nodes = [],
clusterInitialised = false,
canImport = true,
request,
}: {
token: string | null
/** The cluster nodes, for the node selector. */
nodes?: { name: string; type: string }[]
clusterInitialised?: boolean
/** `DnsClient.canModify`: Import writes the answer into a zone of the server. */
canImport?: boolean
/** A query another screen asked for: fill the form with it and run it. */
request?: DnsClientRequest
}) {
/* Upstream mounts one here (`optDnsClientClusterNode`), no aggregate, no
persistence. The jump picks a node only when it was given one. */
const [clusterNode, setClusterNode] = useState<string>(() => request?.node ?? '')
const defaultNode = useHubDefaultNode(nodes)
const [clusterNode, setClusterNode] = useState<string>(() => (request?.node != null && request.node !== 'cluster' ? request.node : defaultNode))
/* Behind a hub, Import is allowed only on a chosen server the hub can write to. */
const write = useHubWrite(clusterNode, canImport)
const [server, setServer] = useState('This Server {this-server}')
@@ -155,6 +164,7 @@ export function DnsClient({
onChange={setClusterNode}
label="Cluster Node"
/>
<HubWriteHint write={write} />
<Notifier notice={alert} onClose={() => setAlert(null)} />
@@ -223,9 +233,9 @@ export function DnsClient({
<Button variant="primary" disabled={busy} onClick={() => void fire(false)}>
Resolve
</Button>
<Button disabled={busy} onClick={() => void fire(true)}>
<PermissionButton permission={canImport ? undefined : 'DnsClient.canModify'} disabled={busy || !write.allowed} onClick={() => void fire(true)}>
Import
</Button>
</PermissionButton>
</div>
</Panel>
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Cache } from './Lists'
import * as api from '../../api/zonelists'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { HubWritable } from '../../lib/hub-write'
afterEach(() => vi.restoreAllMocks())
const NODES = [
{ name: 'mdns', type: 'Primary' },
{ name: 'ndns', type: 'Secondary' },
]
function hubCache(writable: string[], canDelete = true) {
vi.spyOn(api, 'listNode').mockResolvedValue({ kind: 'ok', data: { domain: '', zones: ['com'], records: [] } })
vi.spyOn(api, 'flushCache').mockResolvedValue({ kind: 'ok', data: {} })
return render(
<HubMode.Provider value>
<HubWritable.Provider value={writable}>
<Cache token="t" nodes={NODES} clusterInitialised canDelete={canDelete} />
</HubWritable.Provider>
</HubMode.Provider>,
)
}
describe('Cache behind a hub', () => {
it('reads the first server and flushes it', async () => {
const user = userEvent.setup()
hubCache(['mdns', 'ndns'])
const flush = await screen.findByRole('button', { name: 'Flush Cache' })
expect(api.listNode).toHaveBeenCalledWith('cache', 't', '', undefined, 'mdns')
expect(flush).toBeEnabled()
await user.click(flush)
await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Flush Cache' }))
expect(api.flushCache).toHaveBeenCalledWith('t', 'mdns')
})
it('switches Flush Cache off on a server without an admin token, with the reason', async () => {
const user = userEvent.setup()
hubCache(['mdns'])
await screen.findByRole('button', { name: 'Flush Cache' })
await user.click(screen.getByRole('combobox', { name: 'Server' }))
await user.click(await screen.findByRole('option', { name: 'ndns' }))
expect(await screen.findByText(/No admin token configured on the hub/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Flush Cache' })).toBeDisabled()
})
})
+29 -11
View File
@@ -1,6 +1,8 @@
import { KpiStrip } from '../../ui/KpiStrip'
import { ClusterNodeSelect, primaryNodeName } from '../../ui/ClusterNodeSelect'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import {
deleteDomain,
deleteCacheNode,
@@ -87,12 +89,15 @@ export function ImportDomains({
list,
open,
token,
node = '',
onClose,
onDone,
}: {
list: DomainList
open: boolean
token: string | null
/** Behind a hub: the server the lists are imported into. */
node?: string
onClose: () => void
onDone: (n: Notice) => void
}) {
@@ -133,7 +138,7 @@ export function ImportDomains({
}
setBusy(true)
const outcome = await importDomains(list, token, zones)
const outcome = await importDomains(list, token, zones, node)
setBusy(false)
if (outcome.kind !== 'ok') {
@@ -231,10 +236,19 @@ export function Lists({
* read its own view of the list again. */
onChanged?: () => void
}) {
const [clusterNode, setClusterNode] = useState<string>('')
/* Behind a hub the empty node is the hub, not a server: the cache starts on the first one. */
const defaultNode = useHubDefaultNode(nodes)
const [clusterNode, setClusterNode] = useState<string>(defaultNode)
/* Read by `load` through a ref, so choosing a node does not rebuild it (and
re-run the mount effect that reads the root). */
const cacheNode = useRef('')
const cacheNode = useRef(defaultNode)
/* Inside Blocking behind a hub: the server its Rules tab has chosen. The lists are that server's. */
const sectionNode = useContext(HubNode)
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 cacheWrite = useHubWrite(clusterNode, canDelete)
const listWrite = useHubWrite(listServer, canDelete)
const [node, setNode] = useState<ListNode | null>(null)
const [field, setField] = useState('')
@@ -280,7 +294,8 @@ export function Lists({
const primary = useRef('')
useEffect(() => {
primary.current = primaryNodeName(nodes, clusterInitialised)
}, [nodes, clusterInitialised])
sectionRef.current = embedded ? sectionNode : ''
}, [nodes, clusterInitialised, embedded, sectionNode])
/* Bumped whenever what the cache tree has read is out of date. */
const [treeEpoch, setTreeEpoch] = useState(0)
@@ -289,7 +304,7 @@ export function Lists({
async (domain: string, up?: boolean, fromPrimary = false) => {
/* other-zones.js:269 and 434: after a change, Allowed and Blocked are read
back from the PRIMARY node, where the change was made. Cache never is. */
const node = list === 'cache' ? cacheNode.current : fromPrimary ? primary.current : ''
const node = list === 'cache' ? cacheNode.current : sectionRef.current !== '' ? sectionRef.current : fromPrimary ? primary.current : ''
const outcome = await listNode(list, token, domain, up ? 'up' : undefined, node)
if (outcome.kind === 'ok') {
setNode(outcome.data)
@@ -424,7 +439,7 @@ export function Lists({
label: 'Delete',
action: () =>
mutate(
() => deleteDomain(domainList, token, nodeTitle),
() => deleteDomain(domainList, token, nodeTitle, listServer),
isAllowed
? {
type: 'success',
@@ -464,6 +479,7 @@ 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
@@ -474,7 +490,7 @@ export function Lists({
subtitle={isCache ? 'Records cached by the recursive resolver' : undefined}
actions={
isCache ? (
<Button variant="danger" disabled={busy} onClick={askFlushCache}>
<Button variant="danger" disabled={busy || !cacheWrite.allowed} onClick={askFlushCache}>
Flush Cache
</Button>
) : undefined
@@ -630,7 +646,7 @@ export function Lists({
<PermissionButton
size="sm"
variant="danger"
disabled={busy}
disabled={busy || !(isCache ? cacheWrite : listWrite).allowed}
permission={canDelete ? undefined : `${TITLE[list]}.canDelete`}
onClick={isCache ? askDeleteCacheNode : askDeleteDomain}
>
@@ -676,10 +692,12 @@ export function Lists({
)
}
export function Cache({ token, nodes, clusterInitialised }: {
export function Cache({ token, nodes, clusterInitialised, canDelete }: {
token: string | null
nodes?: { name: string; type: string }[]
clusterInitialised?: boolean
/** `Cache.canDelete`: Flush and Delete. */
canDelete?: boolean
}) {
return <Lists list="cache" token={token} nodes={nodes} clusterInitialised={clusterInitialised} />
return <Lists list="cache" token={token} nodes={nodes} clusterInitialised={clusterInitialised} canDelete={canDelete} />
}
+22 -4
View File
@@ -1,6 +1,8 @@
import { useState } from 'react'
import { useContext, useState } from 'react'
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import { SubTabs } from '../../ui/SubTabs'
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { AGGREGATE, ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
import { QueryLogs } from './QueryLogs'
import { ViewLogs } from './ViewLogs'
import type { QueryLogsRequest } from '../../app/handoff'
@@ -36,6 +38,8 @@ export interface LogsProps {
canDeleteLogs?: boolean
/** `Dashboard.canDelete`: deleting all the statistics. */
canDeleteStats?: boolean
/** `Allowed.canModify` and `Blocked.canModify`: the Allow and Block actions of a Query Logs row. */
canBlock?: boolean
/** The cluster node. Empty means "this server". */
node?: string
/** `showQueryLogs` from another screen: Query Logs filtered and run on arrival. */
@@ -50,14 +54,24 @@ export function Logs({
sub,
canDeleteLogs = true,
canDeleteStats = true,
canBlock = true,
request,
}: LogsProps) {
/* Which node this screen reads. Upstream mounts a selector here
and the parameter was already travelling empty. `showQueryLogs` picks one
only when it was given one (logs.js:634-635). */
const [node, setNode] = useState<string>(() => request?.node ?? '')
const hub = useContext(HubMode)
const defaultNode = useHubDefaultNode(nodes)
/* Behind a hub the empty node is the hub, not a server. Query Logs merges every server's rows
("All servers", where it starts); a log file belongs to one server, so View Logs always shows
one (the first unless chosen) and never labels one server's file as all of them. */
const [picked, setNode] = useState<string>(() => request?.node ?? (hub ? AGGREGATE : ''))
const requested = (sub ?? 'View Logs') as SubTab
const active: SubTab = SUB_TABS.includes(requested) ? requested : 'View Logs'
const aggregateShown = hub && active === 'Query 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)
/*
The bar is built once here and handed to whichever sub-screen is drawn: the same
@@ -83,10 +97,14 @@ export function Logs({
initialised={clusterInitialised}
value={node}
onChange={setNode}
aggregate={aggregateShown}
label="Cluster Node"
/>
{active === 'Query Logs' && <HubWriteHint write={write} />}
{active === 'Query Logs' ? (
<QueryLogs tabs={tabs} token={token} node={node} request={request} />
<HubNode.Provider value={hub ? node : ''}>
<QueryLogs tabs={tabs} token={token} node={node} request={request} canBlock={write.allowed} />
</HubNode.Provider>
) : (
<ViewLogs tabs={tabs}
token={token}
+4 -2
View File
@@ -319,11 +319,13 @@ export interface QueryLogsProps {
tabs?: ReactNode
token: string | null
node?: string
/** Whether a row may be allowed or blocked (behind a hub: a server is chosen and writable). */
canBlock?: boolean
/** `showQueryLogs` from another screen: these filters, queried on arrival. */
request?: QueryLogsRequest
}
export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
export function QueryLogs({ tabs, token, node = '', request, canBlock = true }: QueryLogsProps) {
const [apps, setApps] = useState<{ name: string; classPaths: string[] }[] | null>(null)
const [f, setF] = useState<Filters>(() => defaultFilters('', ''))
const [page, setPage] = useState<QueryLogPage | null>(null)
@@ -1041,7 +1043,7 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
</button>
<button
type="button"
disabled={action.isBusy(key)}
disabled={action.isBusy(key) || !canBlock}
onClick={() => {
close()
void action.run(key, verb, domain)
+21 -12
View File
@@ -1,5 +1,7 @@
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { nodeArg, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import { PermissionButton } from '../../ui/PermissionButton'
import { SectionHeader } from '../../ui/SectionHeader'
import { SubTabs } from '../../ui/SubTabs'
@@ -19,7 +21,7 @@ import {
type DnsSettings,
type SettingsEnvelope,
} from '../../api/settings'
import { buildBody, formFromSettings, enabled, selectedNode, type SettingsForm } from './model'
import { buildBody, formFromSettings, enabled, hubSelectedNode, selectedNode, type SettingsForm } from './model'
import { pendingByPane, pendingText } from './pending'
import { detectReverseProxy, REDIRECT_DELAY_MS, webConsoleRedirection } from './redirect'
import { General } from './panes/General'
@@ -193,13 +195,19 @@ export function Settings({
See `selectedNode`. Upstream stores the held value on every load
(main.js:905), so a standalone server remembers `""`.
*/
const hub = useContext(HubMode)
const [remembered, setNode] = useState<string | null>(() =>
localStorage.getItem('settingsClusterNode'),
)
const node = selectedNode(remembered, clusterInitialised, nodes)
/* Behind a hub there is no "All servers" here: every setting belongs to one server, so the
remembered one if it still exists, else the first. A save sends the whole form to it. */
const node = hub ? hubSelectedNode(remembered, nodes) : selectedNode(remembered, clusterInitialised, nodes)
useEffect(() => {
localStorage.setItem('settingsClusterNode', node)
}, [node])
/* Behind a hub, a change is allowed only on a chosen server the hub can write to. */
const write = useHubWrite(node, canModify)
const flushWrite = useHubWrite(node, canFlushCache)
/*
Two pieces of upstream's global state this screen reads and writes.
@@ -317,7 +325,7 @@ export function Settings({
async function save() {
if (form == null) return
const result = buildBody(form, node)
const result = buildBody(form, node, hub)
if (result.error) {
const { title, text, tab } = result.error
@@ -379,7 +387,7 @@ export function Settings({
const minutes = form.temporaryDisableBlockingMinutes
setConfirm(null)
setBusy(true)
const till = await temporaryDisableBlocking(token, minutes)
const till = await temporaryDisableBlocking(token, minutes, ...nodeArg(hub ? node : ''))
setBusy(false)
if (till == null) return
@@ -398,7 +406,7 @@ export function Settings({
async function updateLists() {
setConfirm(null)
setBusy(true)
const ok = await forceUpdateBlockLists(token)
const ok = await forceUpdateBlockLists(token, ...nodeArg(hub ? node : ''))
setBusy(false)
if (!ok) return
// main.js:2356. The label becomes "Updating Now" without reloading the settings.
@@ -462,7 +470,7 @@ export function Settings({
const props = { f: form, set, en }
/* What the save would change, pane by pane (`pending.ts`). */
const pending = loadedForm == null ? [] : pendingByPane(loadedForm, form, node, SUB_TABS)
const pending = loadedForm == null ? [] : pendingByPane(loadedForm, form, node, SUB_TABS, hub)
const pendingWords = pendingText(pending)
return (
@@ -494,7 +502,7 @@ export function Settings({
<ClusterNodeSelect
nodes={nodes}
initialised={clusterInitialised}
aggregate
aggregate={!hub}
value={node}
onChange={setNode}
label="Cluster Node"
@@ -521,7 +529,7 @@ export function Settings({
</PermissionButton>
<PermissionButton
variant="primary"
disabled={busy}
disabled={busy || !write.allowed}
permission={canModify ? undefined : 'Settings.canModify'}
onClick={() => void save()}
>
@@ -529,7 +537,7 @@ export function Settings({
</PermissionButton>
<PermissionButton
variant="danger"
disabled={busy}
disabled={busy || !flushWrite.allowed}
permission={canFlushCache ? undefined : 'Cache.canDelete'}
onClick={() => setConfirm('flush')}
>
@@ -539,6 +547,7 @@ export function Settings({
}
/>
<HubWriteHint write={write} />
<Notifier notice={notice} onClose={() => setNotice(null)} />
{/*
@@ -566,7 +575,7 @@ export function Settings({
onTemporaryDisable: askDisableBlocking,
onUpdateNow: () => setConfirm('update'),
hasSavedBlockLists: settings.blockListUrls != null,
busy,
busy: busy || !write.allowed,
}}
/>
)}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { hubSelectedNode, nodeScope } from './model'
import { withoutWrites } from '../blocking/permissions'
/* Behind a hub every setting belongs to the one server chosen. */
describe('hubSelectedNode', () => {
const nodes = [{ name: 'mdns' }, { name: 'ndns' }]
it('keeps the remembered server and never offers the aggregate', () => {
expect(hubSelectedNode('ndns', nodes)).toBe('ndns')
expect(hubSelectedNode('cluster', nodes)).toBe('mdns')
expect(hubSelectedNode(null, nodes)).toBe('mdns')
expect(hubSelectedNode('gone', nodes)).toBe('mdns')
})
})
describe('nodeScope with the whole form', () => {
it('sends both blocks to a named server, where a cluster would send only its node blocks', () => {
expect(nodeScope('mdns')).toEqual({ cluster: false, node: true })
expect(nodeScope('mdns', true)).toEqual({ cluster: true, node: true })
})
})
describe('withoutWrites', () => {
it('switches off modify and delete on the blocking sections and leaves the rest', () => {
const perms = {
Allowed: { canView: true, canModify: true, canDelete: true },
Blocked: { canView: true, canModify: true, canDelete: true },
Settings: { canView: true, canModify: true, canDelete: false },
Dashboard: { canView: true, canModify: false, canDelete: false },
}
const off = withoutWrites(perms)!
expect(off.Allowed).toEqual({ canView: true, canModify: false, canDelete: false })
expect(off.Blocked.canModify).toBe(false)
expect(off.Settings).toEqual({ canView: true, canModify: false, canDelete: false })
expect(off.Dashboard).toEqual(perms.Dashboard)
expect(withoutWrites(undefined)).toBeUndefined()
})
})
+13 -3
View File
@@ -500,6 +500,13 @@ export function selectedNode(
return nodes.length > 0 ? nodes[0].name : wanted
}
/* Behind a hub: the remembered server if it is still listed, else the first. No aggregate, so the
remembered "cluster" of a console used without a hub is not honoured. */
export function hubSelectedNode(remembered: string | null, nodes: readonly { name: string }[]): string {
if (remembered != null && nodes.some((n) => n.name === remembered)) return remembered
return nodes.length > 0 ? nodes[0].name : ''
}
/*
Which blocks a save carries depends on the node chosen in the selector
(main.js:1639-1644):
@@ -513,17 +520,20 @@ The skipped blocks are skipped whole, VALIDATIONS INCLUDED: with a node chosen,
an empty Resolver Retries does not stop the save, because the Recursion block
never runs. The comparison is upstream's literal `"cluster"`.
*/
export function nodeScope(node: string): { cluster: boolean; node: boolean } {
export function nodeScope(node: string, whole = false): { cluster: boolean; node: boolean } {
/* Behind a hub there is no cluster: every setting belongs to the one server chosen, so a save
sends both blocks (`whole`), with that server as `node`. */
if (whole) return { cluster: true, node: true }
const includeClusterParameters = node === '' || node === 'cluster'
const includeNodeParameters = node === '' || !includeClusterParameters
return { cluster: includeClusterParameters, node: includeNodeParameters }
}
export function buildBody(f: SettingsForm, node = ''): BodyResult {
export function buildBody(f: SettingsForm, node = '', whole = false): BodyResult {
// `node` is always the first parameter, even when empty (main.js:1644).
const body: Record<string, string> = { node }
const sanitised: Partial<SettingsForm> = {}
const include = nodeScope(node)
const include = nodeScope(node, whole)
const missing = (text: string, tab: string, field: string): BodyResult => ({
error: { title: 'Missing!', text, tab, field },
+4 -3
View File
@@ -249,8 +249,8 @@ export const FIELDS: Record<Key, Field> = {
} as Record<Key, Field>
/** Whether a save with this form, to this node, would send the field. */
export function sends(field: Field, f: SettingsForm, node: string): boolean {
return nodeScope(node)[field.scope] && (field.when == null || field.when(f))
export function sends(field: Field, f: SettingsForm, node: string, whole = false): boolean {
return nodeScope(node, whole)[field.scope] && (field.when == null || field.when(f))
}
/** The panes with something a save would change, in their tab order, and how many fields each. */
@@ -259,10 +259,11 @@ export function pendingByPane(
form: SettingsForm,
node: string,
panes: readonly SubTab[],
whole = false,
): { pane: SubTab; fields: number }[] {
const count = new Map<SubTab, number>()
for (const field of Object.values(FIELDS)) {
if (!sends(field, form, node)) continue
if (!sends(field, form, node, whole)) continue
if (field.sent(form) === field.sent(saved)) continue
count.set(field.pane, (count.get(field.pane) ?? 0) + 1)
}
+17 -6
View File
@@ -1,5 +1,8 @@
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { useState } from 'react'
import { useContext, useState } from 'react'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import type { ResourceRecord, ZoneDetails } from '../../api/records'
import { Confirm } from '../../ui/Confirm'
import { ZoneList } from './ZoneList'
@@ -70,8 +73,8 @@ export function Zones({
token,
nodes = [],
clusterInitialised = false,
canModify,
canDelete,
canModify: mayModify,
canDelete: mayDelete,
useSoaSerialDateScheme = false,
dnssecValidation = false,
recordTtls = DEFAULT_TTLS,
@@ -79,7 +82,14 @@ export function Zones({
}: ZonesProps) {
/* Upstream mounts a selector here (`optZonesClusterNode`) with no aggregate
and no persistence; the parameter was already travelling. */
const [node, setNode] = useState<string>('')
const hub = useContext(HubMode)
/* Behind a hub the empty node is the hub, not a server: the first one is chosen, and a change
is allowed only on a server the hub can write to. */
const defaultNode = useHubDefaultNode(nodes)
const [node, setNode] = useState<string>(defaultNode)
const write = useHubWrite(node, mayModify)
const canModify = write.allowed
const canDelete = useHubWrite(node, mayDelete).allowed
const [open, setOpen] = useState<string | null>(null)
const [notice, setNotice] = useState<Notice | null>(null)
const [confirmation, setConfirmation] = useState<Confirmation | null>(null)
@@ -152,6 +162,7 @@ export function Zones({
disabled={open != null}
label="Cluster Node"
/>
<HubWriteHint write={write} />
{open == null ? (
<ZoneList
@@ -170,7 +181,7 @@ export function Zones({
onImport={(z) => openModal('import', z)}
onConvert={(z, t) => openModal('convert', z, t)}
onClone={(z) => openModal('clone', z)}
onPermissions={(z) => openModal('permissions', z)}
onPermissions={(z) => (hub ? setNotice({ type: 'info', title: 'Note!', text: "Zone permissions are changed in that server's own console." }) : openModal('permissions', z))}
onOptions={(z) => openModal('options', z)}
refresh={listRefresh}
/>
@@ -205,7 +216,7 @@ export function Zones({
onImport={(z) => openModal('import', z)}
onConvert={(z, t) => openModal('convert', z, t)}
onClone={(z) => openModal('clone', z)}
onPermissions={(z) => openModal('permissions', z)}
onPermissions={(z) => (hub ? setNotice({ type: 'info', title: 'Note!', text: "Zone permissions are changed in that server's own console." }) : openModal('permissions', z))}
onOptions={(z) => openModal('options', z)}
onSign={(z) => openModal('sign', z)}
onUnsign={(z) => openModal('unsign', z)}
+16
View File
@@ -0,0 +1,16 @@
import { CHOOSE_A_SERVER, type HubWrite } from '../lib/hub-write'
/*
The line under a server selector that says why the controls are switched off behind a hub:
no server is chosen ("All servers"), or the hub holds no admin token for the one chosen.
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 }) {
if (write.reason == null) return null
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.`}
</p>
)
}