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 <noreply@anthropic.com>
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

+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