Hub stage 7: compare and align zones, settings and blocking

Zones gets "All servers" in hub mode: one table of every zone on every
server with its type and serial, marking zones that are on one server
only or whose serials or types differ. A zone can be copied to another
server (export, create, import; Overwrite must be ticked for a zone
that is already there) or made a Secondary there (the source's zone
transfer and notify lists gain the target, then the target zone is
created).

Settings and Blocking can be compared across servers against a baseline
kept in the hub. Host fields stay local, secrets are compared inside
the hub only and never shown or logged, and an apply sends only the
changed shared fields after saving each server's settings to history.
Blocked and Allowed lists are compared as sets; extras are removed only
when asked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-01 01:20:05 +07:00
1 parent 34de910e04
commit 392b644faa
25 files changed
+4865 -22

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
import { apiRequest, type ApiOutcome } from './client'
import type { ConfigDiff, HistoryEntry, RunOutcome } from './apps'
/*
Settings and blocking across the servers, behind a hub (hub/settings-config.ts). Every endpoint
needs the hub's admin role: settings carry secrets, so even the comparison is for admins. No answer
ever carries a secret; a secret shows as `<secret>` and a row says only that it differs.
*/
export const SECRET_PLACEHOLDER = '<secret>'
export interface SettingsCell {
value: unknown
differs: boolean
secretChanged?: boolean
}
export interface SettingsRow {
name: string
group: 'blocking' | 'general'
local: boolean
secret: boolean
expected?: unknown
cells: Record<string, SettingsCell>
}
export interface SettingsServer {
id: string
ok: boolean
error?: string
writable: boolean
status: 'same' | 'differs' | 'local-only' | 'unknown'
differing: number
localDiffering: number
paused: boolean
}
export interface SettingsBaselineView {
settings: Record<string, unknown>
importedFrom?: string
updatedAt: string
updatedBy: string
}
export interface SettingsCompare {
baseline: SettingsBaselineView | null
withBaseline: boolean
localFields: { builtIn: string[]; builtInPatterns: string[]; extra: string[] }
blockingFields: string[]
servers: SettingsServer[]
fields: SettingsRow[]
}
export const getSettingsCompare = (token: string | null): Promise<ApiOutcome<{ response: SettingsCompare }>> => apiRequest('hub/settings/compare', { token })
export const importSettingsBaseline = (token: string | null, server: string, group?: 'blocking'): Promise<ApiOutcome<{ response: { baseline: SettingsBaselineView } }>> =>
apiRequest('hub/settings/baseline/import', { token, method: 'POST', body: { server, ...(group === undefined ? {} : { group }) } })
export const saveSettingsBaseline = (token: string | null, text: string): Promise<ApiOutcome<{ response: { baseline: SettingsBaselineView } }>> =>
apiRequest('hub/settings/baseline/save', { token, method: 'POST', body: { text } })
export const saveSettingsOverrides = (token: string | null, paths: string[]): Promise<ApiOutcome<{ response: { overrides: { paths: string[] } } }>> =>
apiRequest('hub/settings/overrides/save', { token, method: 'POST', body: { paths: paths.join(',') } })
export interface SettingsPreview {
server: string
ok: boolean
error?: string
writable: boolean
currentHash: string
changes: ConfigDiff
secretFields: string[]
unchanged: boolean
warnings: string[]
}
export const previewSettings = (token: string | null, targets: string[], group?: 'blocking'): Promise<ApiOutcome<{ response: { targets: SettingsPreview[] } }>> =>
apiRequest('hub/settings/preview', { token, method: 'POST', body: { targets: targets.join(','), ...(group === undefined ? {} : { group }) } })
export const applySettings = (token: string | null, targets: string[], expect: Record<string, string>, group?: 'blocking'): Promise<RunOutcome> =>
apiRequest('hub/settings/apply', { token, method: 'POST', body: { targets: targets.join(','), expect: JSON.stringify(expect), ...(group === undefined ? {} : { group }) } })
export const getSettingsHistory = (token: string | null): Promise<ApiOutcome<{ response: { entries: HistoryEntry[] } }>> => apiRequest('hub/settings/history/list', { token })
export const getSettingsHistoryEntry = (
token: string | null,
id: string,
): Promise<ApiOutcome<{ response: { entry: HistoryEntry; settings?: Record<string, unknown>; lists?: { blocked: number; allowed: number } } }>> =>
apiRequest('hub/settings/history/get', { token, body: { id } })
export interface ListCounts {
count: number
missing: number
extra: number
missingSample: string[]
extraSample: string[]
}
export interface ListsServer {
id: string
ok: boolean
error?: string
writable: boolean
blocked: ListCounts
allowed: ListCounts
status: 'same' | 'differs' | 'unknown'
tooMany?: string
}
export interface ListsCompare {
baseline: { blocked: number; allowed: number; importedFrom?: string; updatedAt: string; updatedBy: string } | null
withBaseline: boolean
servers: ListsServer[]
}
export const getListsCompare = (token: string | null): Promise<ApiOutcome<{ response: ListsCompare }>> => apiRequest('hub/blocking/lists/compare', { token })
export const importListsBaseline = (token: string | null, server: string): Promise<ApiOutcome<{ response: { imported: boolean } }>> =>
apiRequest('hub/blocking/lists/baseline/import', { token, method: 'POST', body: { server } })
export const previewLists = (token: string | null, targets: string[], removeExtras: boolean): Promise<ApiOutcome<{ response: { targets: ListsServer[] } }>> =>
apiRequest('hub/blocking/lists/preview', { token, method: 'POST', body: { targets: targets.join(','), removeExtras: String(removeExtras) } })
export const applyLists = (token: string | null, targets: string[], removeExtras: boolean): Promise<RunOutcome> =>
apiRequest('hub/blocking/lists/apply', { token, method: 'POST', body: { targets: targets.join(','), removeExtras: String(removeExtras) } })
+80
View File
@@ -0,0 +1,80 @@
import { apiRequest, type ApiOutcome } from './client'
import type { RunOutcome } from './apps'
/*
Zones across the servers, behind a hub (hub/zones-sync.ts): the merged list, copying a zone to
other servers, and making a zone a Secondary of another server's Primary.
*/
export interface ZoneCell {
state: 'present' | 'absent' | 'unknown'
type?: string
serial?: number | null
disabled?: boolean
dnssec?: string
lastModified?: string
}
export type RowStatus = 'same' | 'serial-differs' | 'type-differs' | 'only-on' | 'missing-on' | 'unknown'
export interface MatrixZone {
name: string
cells: Record<string, ZoneCell>
status: RowStatus
onlyOn?: string
missingOn?: string[]
note?: 'primary-secondary' | 'secondary-behind'
}
export interface ZonesMatrixData {
servers: { id: string; ok: boolean; writable: boolean; host: string; error?: string }[]
zones: MatrixZone[]
}
export const getZonesMatrix = (token: string | null): Promise<ApiOutcome<{ response: ZonesMatrixData }>> => apiRequest('hub/zones/matrix', { token })
export interface CopyTargetPreview {
server: string
ok: boolean
error?: string
writable: boolean
exists: boolean
type?: string
serial?: number | null
needsOverwrite: boolean
}
export interface CopyPreview {
zone: string
source: { server: string; type: string; serial: number | null; records: number; bytes: number }
warnings: string[]
targets: CopyTargetPreview[]
}
export const previewZoneCopy = (token: string | null, source: string, zone: string, targets: string[]): Promise<ApiOutcome<{ response: CopyPreview }>> =>
apiRequest('hub/zones/copy/preview', { token, method: 'POST', body: { source, zone, targets: targets.join(',') } })
export const copyZone = (token: string | null, source: string, zone: string, targets: string[], overwrite: string[]): Promise<RunOutcome> =>
apiRequest('hub/zones/copy', { token, method: 'POST', body: { source, zone, targets: targets.join(','), overwrite: overwrite.join(',') } })
export interface SecondaryInput {
source: string
target: string
zone: string
primaryAddress: string
targetAddress: string
tsigKeyName: string
}
export interface SecondaryPreview {
zone: string
source: string
target: string
warnings: string[]
steps: { server: string; role: 'source' | 'target'; summary: string; changes: boolean }[]
}
export const previewSecondary = (token: string | null, input: SecondaryInput): Promise<ApiOutcome<{ response: SecondaryPreview }>> =>
apiRequest('hub/zones/secondary/preview', { token, method: 'POST', body: { ...input } })
export const makeSecondary = (token: string | null, input: SecondaryInput): Promise<RunOutcome> =>
apiRequest('hub/zones/secondary', { token, method: 'POST', body: { ...input } })
+7 -4
View File
@@ -30,7 +30,7 @@ export interface RunRequest {
A review step between the picker and the change (the config sync uses it): it reads what
would happen on the picked servers and draws it. `context` is handed back to `run`.
*/
review?: (targets: string[]) => Promise<{ kind: 'ok'; body: ReactNode; context: unknown; confirmLabel?: string } | { kind: 'error'; message: string }>
review?: (targets: string[], setReady: (ready: boolean) => void) => Promise<{ kind: 'ok'; body: ReactNode; context: unknown; confirmLabel?: string } | { kind: 'error'; message: string }>
run: (targets: string[], context?: unknown) => Promise<RunOutcome>
}
@@ -57,12 +57,15 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
const [lastTargets, setLastTargets] = useState<string[]>([])
const [reviewed, setReviewed] = useState<{ body: ReactNode; context: unknown; confirmLabel?: string } | null>(null)
const [reviewFailure, setReviewFailure] = useState<string | null>(null)
/* A review can hold the confirm button back until the user has answered it (an Overwrite tick). */
const [ready, setReady] = useState(true)
async function next(targets: string[]) {
if (request.review === undefined) return go(targets)
setStage('reviewing')
setReviewFailure(null)
const outcome = await request.review(targets)
setReady(true)
const outcome = await request.review(targets, setReady)
if (outcome.kind === 'ok') setReviewed(outcome)
else {
setReviewed(null)
@@ -103,7 +106,7 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
</Button>
}
>
{request.intro != null && <p className="m-0 text-sm text-muted-foreground">{request.intro}</p>}
{request.intro != null && <div className="m-0 text-sm text-muted-foreground">{request.intro}</div>}
<div className="flex flex-col gap-3" role="group" aria-label="Servers">
{request.servers.map((s) => (
<Check
@@ -140,7 +143,7 @@ export function RunFlow({ request, onClose }: { request: RunRequest; onClose: (c
<>
<Button onClick={() => setStage('pick')}>Back</Button>
{reviewed !== null && (
<Button variant="primary" onClick={() => void go(lastTargets)}>
<Button variant="primary" disabled={!ready} onClick={() => void go(lastTargets)}>
{reviewed.confirmLabel ?? request.confirmLabel}
</Button>
)}
+6
View File
@@ -1,5 +1,6 @@
import { useContext, useEffect, useState } from 'react'
import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
import { CompareServersButton } from '../settings/SettingsCompare'
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import { getSettings } from '../../api/settings'
@@ -152,6 +153,11 @@ export function Blocking({
const tabs = (
<>
{hub && permissions?.Settings?.canModify === true && (
<div className="flex flex-wrap items-center gap-2">
<CompareServersButton token={token} scope="blocking" />
</div>
)}
{hub && active !== 'Overview' && (
<>
<ClusterNodeSelect nodes={nodes} initialised={clusterInitialised} value={chosen} onChange={setChosen} />
+2
View File
@@ -4,6 +4,7 @@ import { nodeArg, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import { PermissionButton } from '../../ui/PermissionButton'
import { SectionHeader } from '../../ui/SectionHeader'
import { CompareServersButton } from './SettingsCompare'
import { SubTabs } from '../../ui/SubTabs'
import { IndexedForm } from '../../ui/IndexedForm'
import { useSectionInView } from '../../ui/useSectionInView'
@@ -499,6 +500,7 @@ export function Settings({
}
actions={
<>
{hub && canModify && <CompareServersButton token={token} scope="settings" />}
<ClusterNodeSelect
nodes={nodes}
initialised={clusterInitialised}
@@ -0,0 +1,162 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SettingsCompare, CompareServersButton } from './SettingsCompare'
import * as api from '../../api/hub-settings'
import type { ListsCompare, SettingsCompare as Compare } from '../../api/hub-settings'
afterEach(() => vi.restoreAllMocks())
const base = {
withBaseline: true,
localFields: { builtIn: ['dnsServerDomain'], builtInPatterns: ['webService*'], extra: [] },
blockingFields: ['blockingType'],
}
const DATA: Compare = {
...base,
baseline: { settings: { defaultRecordTtl: 3600 }, importedFrom: 'mdns', updatedAt: '2026-10-01T00:00:00Z', updatedBy: 'admin' },
servers: [
{ id: 'mdns', ok: true, writable: true, status: 'same', differing: 0, localDiffering: 1, paused: false },
{ id: 'ndns', ok: true, writable: true, status: 'differs', differing: 3, localDiffering: 1, paused: false },
],
fields: [
{ name: 'defaultRecordTtl', group: 'general', local: false, secret: false, expected: 3600, cells: { mdns: { value: 3600, differs: false }, ndns: { value: 7200, differs: true } } },
{ name: 'blockingType', group: 'blocking', local: false, secret: false, expected: 'NxDomain', cells: { mdns: { value: 'NxDomain', differs: false }, ndns: { value: 'AnyAddress', differs: true } } },
{ name: 'tsigKeys', group: 'general', local: false, secret: true, expected: [], cells: { mdns: { value: [], differs: false }, ndns: { value: [], differs: true, secretChanged: true } } },
{ name: 'dnsServerDomain', group: 'general', local: true, secret: false, cells: { mdns: { value: 'a.example', differs: true }, ndns: { value: 'b.example', differs: true } } },
],
}
const LISTS: ListsCompare = {
baseline: { blocked: 2, allowed: 1, importedFrom: 'mdns', updatedAt: '2026-10-01T00:00:00Z', updatedBy: 'admin' },
withBaseline: true,
servers: [
{ id: 'mdns', ok: true, writable: true, status: 'same', blocked: { count: 2, missing: 0, extra: 0, missingSample: [], extraSample: [] }, allowed: { count: 1, missing: 0, extra: 0, missingSample: [], extraSample: [] } },
{ id: 'ndns', ok: true, writable: true, status: 'differs', blocked: { count: 2, missing: 1, extra: 1, missingSample: ['ads.example'], extraSample: ['only-b.example'] }, allowed: { count: 1, missing: 0, extra: 0, missingSample: [], extraSample: [] } },
],
}
function mock(data: Compare = DATA) {
vi.spyOn(api, 'getSettingsCompare').mockResolvedValue({ kind: 'ok', data: { response: data } } as never)
vi.spyOn(api, 'getListsCompare').mockResolvedValue({ kind: 'ok', data: { response: LISTS } } as never)
}
describe('SettingsCompare', () => {
it('shows the status of each server and the fields that differ, with a secret only as "secret changed"', async () => {
mock()
render(<SettingsCompare token="t" scope="settings" onClose={() => {}} />)
expect(await screen.findByText('defaultRecordTtl')).toBeInTheDocument()
expect(screen.getByText('Same')).toBeInTheDocument()
expect(screen.getByText('Differs · 3')).toBeInTheDocument()
expect(screen.getByText('Secret changed')).toBeInTheDocument()
expect(screen.getByText('7200')).toBeInTheDocument()
expect(screen.getAllByText('Local').length).toBeGreaterThan(0)
})
it('shows only the blocking fields and the lists in the blocking scope', async () => {
mock()
render(<SettingsCompare token="t" scope="blocking" onClose={() => {}} />)
expect(await screen.findByText('blockingType')).toBeInTheDocument()
expect(screen.queryByText('defaultRecordTtl')).not.toBeInTheDocument()
expect(screen.getByText('Missing: ads.example')).toBeInTheDocument()
expect(screen.getByText('Extra: only-b.example')).toBeInTheDocument()
})
it('offers to accept a server as the baseline when there is none', async () => {
mock({ ...DATA, baseline: null, withBaseline: false })
const accept = vi.spyOn(api, 'importSettingsBaseline').mockResolvedValue({ kind: 'ok', data: { response: { baseline: {} } } } as never)
render(<SettingsCompare token="t" scope="settings" onClose={() => {}} />)
await userEvent.click((await screen.findAllByRole('button', { name: 'Accept ndns as baseline' }))[0])
expect(accept).toHaveBeenCalledWith('t', 'ndns', undefined)
expect(screen.getByRole('button', { name: 'Apply to servers…' })).toBeDisabled()
})
it('applies after a preview: the preview hashes reach the apply, a secret is not shown', async () => {
mock()
vi.spyOn(api, 'previewSettings').mockResolvedValue({
kind: 'ok',
data: {
response: {
targets: [
{
server: 'ndns',
ok: true,
writable: true,
currentHash: 'h1',
unchanged: false,
warnings: [],
secretFields: ['tsigKeys'],
changes: { kind: 'json', changes: [{ path: 'defaultRecordTtl', type: 'changed', a: 7200, b: 3600 }] },
},
],
},
},
} as never)
const apply = vi.spyOn(api, 'applySettings').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Applied and verified (1 field).' }] } } } as never)
render(<SettingsCompare token="t" scope="settings" onClose={() => {}} />)
await userEvent.click(await screen.findByRole('button', { name: 'Apply to servers…' }))
await userEvent.click(await screen.findByRole('button', { name: 'Preview' }))
expect(await screen.findByText(/the secret values are sent but never shown/)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Apply' }))
expect(await screen.findByText('Applied and verified (1 field).')).toBeInTheDocument()
expect(apply).toHaveBeenCalledWith('t', ['ndns'], { ndns: 'h1' }, undefined)
})
it('applies the lists, removing extras only when ticked', async () => {
mock()
vi.spyOn(api, 'previewLists').mockResolvedValue({ kind: 'ok', data: { response: { targets: [LISTS.servers[1]] } } } as never)
const apply = vi.spyOn(api, 'applyLists').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Added 1, removed 1.' }] } } } as never)
render(<SettingsCompare token="t" scope="blocking" onClose={() => {}} />)
await userEvent.click(await screen.findByRole('button', { name: 'Apply lists…' }))
await userEvent.click(await screen.findByRole('button', { name: 'Preview' }))
await userEvent.click(await screen.findByRole('checkbox', { name: 'Also remove extras' }))
await userEvent.click(screen.getByRole('button', { name: 'Apply' }))
await screen.findByText('Added 1, removed 1.')
expect(apply).toHaveBeenCalledWith('t', ['ndns'], true)
})
it('does not remove extras when the box is left alone', async () => {
mock()
vi.spyOn(api, 'previewLists').mockResolvedValue({ kind: 'ok', data: { response: { targets: [LISTS.servers[1]] } } } as never)
const apply = vi.spyOn(api, 'applyLists').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'done' }] } } } as never)
render(<SettingsCompare token="t" scope="blocking" onClose={() => {}} />)
await userEvent.click(await screen.findByRole('button', { name: 'Apply lists…' }))
await userEvent.click(await screen.findByRole('button', { name: 'Preview' }))
await userEvent.click(await screen.findByRole('button', { name: 'Apply' }))
await screen.findByText('done')
expect(apply).toHaveBeenCalledWith('t', ['ndns'], false)
})
it('lists the built-in local fields and saves extra ones', async () => {
mock()
const save = vi.spyOn(api, 'saveSettingsOverrides').mockResolvedValue({ kind: 'ok', data: { response: { overrides: { paths: [] } } } } as never)
render(<SettingsCompare token="t" scope="settings" onClose={() => {}} />)
await userEvent.click(await screen.findByRole('tab', { name: 'Local fields' }))
expect(within(screen.getByRole('list', { name: 'Built-in local fields' })).getByText('dnsServerDomain')).toBeInTheDocument()
await userEvent.type(screen.getByLabelText('New local field'), 'forwarderTimeout')
await userEvent.click(screen.getByRole('button', { name: 'Add field' }))
await userEvent.click(screen.getByRole('button', { name: 'Save local fields' }))
expect(save).toHaveBeenCalledWith('t', ['forwarderTimeout'])
})
it('shows an error with Retry, and history with an empty state', async () => {
const spy = vi.spyOn(api, 'getSettingsCompare').mockResolvedValueOnce({ kind: 'error', message: 'boom' } as never).mockResolvedValue({ kind: 'ok', data: { response: DATA } } as never)
vi.spyOn(api, 'getSettingsHistory').mockResolvedValue({ kind: 'ok', data: { response: { entries: [] } } } as never)
render(<SettingsCompare token="t" scope="settings" onClose={() => {}} />)
expect(await screen.findByText(/boom/)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
await screen.findByText('defaultRecordTtl')
expect(spy).toHaveBeenCalledTimes(2)
await userEvent.click(screen.getByRole('tab', { name: 'History' }))
expect(await screen.findByText('No history yet')).toBeInTheDocument()
})
it('opens from its button', async () => {
mock()
render(<CompareServersButton token="t" scope="settings" />)
await userEvent.click(screen.getByRole('button', { name: 'Compare servers' }))
expect(await screen.findByText('defaultRecordTtl')).toBeInTheDocument()
})
})
+580
View File
@@ -0,0 +1,580 @@
import { useCallback, useEffect, useState } from 'react'
import {
applyLists,
applySettings,
getListsCompare,
getSettingsCompare,
getSettingsHistory,
getSettingsHistoryEntry,
importListsBaseline,
importSettingsBaseline,
previewLists,
previewSettings,
saveSettingsBaseline,
saveSettingsOverrides,
type ListsCompare,
type ListsServer,
type SettingsCompare as Compare,
type SettingsRow,
} from '../../api/hub-settings'
import type { HistoryEntry } from '../../api/apps'
import { Alert } from '../../ui/Alert'
import { Button } from '../../ui/Button'
import { Check } from '../../ui/Check'
import { DataTable, type Col } from '../../ui/DataTable'
import { Dialog } from '../../ui/Dialog'
import { Empty, Failure } from '../../ui/Empty'
import { Input, LabeledTextarea } from '../../ui/Field'
import { head } from '../../ui/Table'
import { Tag } from '../../ui/Tag'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { DiffView, show } from '../apps/ConfigDiffView'
import { RunFlow, pickerServers, type PickerServer, type RunRequest } from '../apps/RunFlow'
/*
Behind a hub, for an admin: the settings of every server side by side, a baseline kept in the hub,
the fields that stay local to each server, and what an apply would change. The same dialog serves
Settings (every field) and Blocking (the blocking fields, plus the custom Blocked and Allowed lists
compared as sets). A secret is never shown: a row says "secret changed".
*/
export type Scope = 'settings' | 'blocking'
type Tab = 'compare' | 'baseline' | 'local' | 'history'
const when = (iso: string): string => (iso === '' ? '' : new Date(iso).toLocaleString())
const sessionEnded = (kind: string, message?: string): string => (kind === 'error' ? (message ?? 'Failed.') : 'The session ended.')
function StatusTag({ status, differing }: { status: string; differing: number }) {
if (status === 'same') return <Tag tone="ok">Same</Tag>
if (status === 'differs') return <Tag tone="warn">{differing > 0 ? `Differs · ${differing}` : 'Differs'}</Tag>
if (status === 'local-only') return <Tag tone="info">Only local fields differ</Tag>
return <Tag tone="dan">Did not answer</Tag>
}
/* ---- Requests for the picker, the review and the results ---- */
function settingsApplyRequest(token: string | null, servers: PickerServer[], preselected: string[], scope: Scope): RunRequest {
const group = scope === 'blocking' ? 'blocking' : undefined
return {
title: scope === 'blocking' ? 'Apply the blocking settings' : 'Apply the settings baseline',
intro: 'Only the shared fields that differ are sent. Local fields and the web service settings are never touched, and each server\'s settings are saved to the hub\'s history first.',
confirmLabel: 'Preview',
servers,
preselected,
review: async (targets) => {
const outcome = await previewSettings(token, targets, group)
if (outcome.kind !== 'ok') return { kind: 'error', message: sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined) }
const previews = outcome.data.response.targets
return {
kind: 'ok',
confirmLabel: 'Apply',
context: Object.fromEntries(previews.filter((p) => p.ok).map((p) => [p.server, p.currentHash])),
body: (
<div className="flex flex-col gap-6">
{previews.map((p) => (
<section key={p.server} aria-label={`Preview ${p.server}`} className="flex flex-col gap-3">
<h3 className="m-0 flex items-center gap-2 text-sm font-semibold">
{p.server}
{!p.ok ? <Tag tone="dan">Cannot apply</Tag> : p.unchanged ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">Will change</Tag>}
</h3>
{p.error !== undefined && <Alert type="danger" title="Error">{p.error}</Alert>}
{p.warnings.map((w) => (
<Alert key={w} type="warning" title="Warning">
{w}
</Alert>
))}
{p.secretFields.length > 0 && (
<Alert type="info" title="Secret changed">
{p.secretFields.join(', ')}: the secret values are sent but never shown.
</Alert>
)}
{p.ok && !p.unchanged && <DiffView diff={p.changes} from="Now" to="After apply" />}
</section>
))}
</div>
),
}
},
run: (targets, context) => applySettings(token, targets, (context ?? {}) as Record<string, string>, group),
}
}
const sample = (items: string[], total: number): string => (items.length === 0 ? 'none' : `${items.slice(0, 5).join(', ')}${total > 5 ? ` and ${total - 5} more` : ''}`)
function ListLine({ label, c }: { label: string; c: ListsServer['blocked'] }) {
return (
<li className="text-sm">
<span className="font-medium">{label}</span> <span className="text-muted-foreground">{c.count} domains, missing {c.missing}, extra {c.extra}</span>
{c.missing > 0 && <div className="text-xs text-muted-foreground">Missing: {sample(c.missingSample, c.missing)}</div>}
{c.extra > 0 && <div className="text-xs text-muted-foreground">Extra: {sample(c.extraSample, c.extra)}</div>}
</li>
)
}
function ListsReview({ targets, opts }: { targets: ListsServer[]; opts: { remove: boolean } }) {
const [remove, setRemove] = useState(opts.remove)
const extras = targets.reduce((n, t) => n + t.blocked.extra + t.allowed.extra, 0)
return (
<div className="flex flex-col gap-4">
{targets.map((t) => (
<section key={t.id} aria-label={`Preview ${t.id}`} className="flex flex-col gap-2">
<h3 className="m-0 flex items-center gap-2 text-sm font-semibold">
{t.id}
{!t.ok ? <Tag tone="dan">Cannot apply</Tag> : t.status === 'same' ? <Tag tone="ok">Already in sync</Tag> : <Tag tone="warn">Will change</Tag>}
</h3>
{t.error !== undefined && <Alert type="danger" title="Error">{t.error}</Alert>}
{t.ok && (
<ul className="m-0 flex list-none flex-col gap-1 p-0">
<ListLine label="Blocked" c={t.blocked} />
<ListLine label="Allowed" c={t.allowed} />
</ul>
)}
</section>
))}
<Check
label="Also remove extras"
help={`Deletes the ${extras} domain(s) a server has that the baseline does not. The server's lists are saved to the hub's history first.`}
checked={remove}
onChange={(on) => {
opts.remove = on
setRemove(on)
}}
/>
</div>
)
}
function listsApplyRequest(token: string | null, servers: PickerServer[], preselected: string[]): RunRequest {
const opts = { remove: false }
return {
title: 'Apply the Blocked and Allowed lists',
intro: 'Missing domains are added. Extra domains stay unless you tick "Also remove extras" in the next step.',
confirmLabel: 'Preview',
servers,
preselected,
review: async (targets) => {
opts.remove = false
const outcome = await previewLists(token, targets, false)
if (outcome.kind !== 'ok') return { kind: 'error', message: sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined) }
return { kind: 'ok', confirmLabel: 'Apply', context: undefined, body: <ListsReview targets={outcome.data.response.targets} opts={opts} /> }
},
run: (targets) => applyLists(token, targets, opts.remove),
}
}
/* ---- The dialog ---- */
export function SettingsCompare({ token, scope, onClose }: { token: string | null; scope: Scope; onClose: () => void }) {
const [data, setData] = useState<Compare | null>(null)
const [lists, setLists] = useState<ListsCompare | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [tab, setTab] = useState<Tab>('compare')
const [request, setRequest] = useState<RunRequest | null>(null)
const [busy, setBusy] = useState(false)
const [problem, setProblem] = useState<string | null>(null)
const [saved, setSaved] = useState<string | null>(null)
const [baselineText, setBaselineText] = useState('')
const [extra, setExtra] = useState<string[]>([])
const [newField, setNewField] = useState('')
const [loads, setLoads] = useState(0)
const blocking = scope === 'blocking'
const load = useCallback(async () => {
const [s, l] = await Promise.all([getSettingsCompare(token), blocking ? getListsCompare(token) : Promise.resolve(null)])
if (s.kind !== 'ok') {
setFailure(sessionEnded(s.kind, s.kind === 'error' ? s.message : undefined))
return
}
if (l !== null && l.kind !== 'ok') {
setFailure(sessionEnded(l.kind, l.kind === 'error' ? l.message : undefined))
return
}
setData(s.data.response)
setLists(l === null || l.kind !== 'ok' ? null : l.data.response)
setLoads((n) => n + 1)
setFailure(null)
setBaselineText(s.data.response.baseline === null ? '' : JSON.stringify(s.data.response.baseline.settings, null, 2))
setExtra(s.data.response.localFields.extra)
}, [token, blocking])
useEffect(() => {
void load()
}, [load])
async function run(call: () => Promise<{ kind: string; message?: string }>, done: string) {
setBusy(true)
setProblem(null)
setSaved(null)
const outcome = await call()
setBusy(false)
if (outcome.kind !== 'ok') {
setProblem(sessionEnded(outcome.kind, outcome.message))
return
}
setSaved(done)
await load()
}
const servers = data?.servers ?? []
const pickable = (extraReasons: Record<string, string> = {}) => pickerServers(servers.map((s) => ({ id: s.id, writable: s.writable, ok: s.ok })), extraReasons)
const rows: SettingsRow[] = (data?.fields ?? []).filter((r) => !blocking || r.group === 'blocking')
const hasBaseline = data?.baseline != null
const liveServers = servers.filter((s) => s.ok)
const differsIds = servers.filter((s) => s.status === 'differs').map((s) => s.id)
const columns: Col<SettingsRow>[] = [
{
id: 'name',
...head({ children: 'Field' }),
cell: (r) => (
<span className="flex flex-wrap items-center gap-2 font-mono text-xs">
{r.name}
{r.local && <Tag tone="info">Local</Tag>}
{r.secret && <Tag tone="neutral">Secret</Tag>}
</span>
),
},
...liveServers.map(
(s): Col<SettingsRow> => ({
id: `s-${s.id}`,
...head({ children: s.id }),
cell: (r) => {
const c = r.cells[s.id]
if (c === undefined) return <span className="text-xs text-muted-foreground">Not reported</span>
if (c.secretChanged === true) return <Tag tone="warn">Secret changed</Tag>
return <span className={`font-mono text-xs ${c.differs ? 'font-semibold' : 'text-muted-foreground'}`}>{show(c.value)}</span>
},
}),
),
...(hasBaseline
? [
{
id: 'expected',
...head({ children: 'Baseline' }),
cell: (r: SettingsRow) => (r.local ? <span className="text-xs text-muted-foreground">Local</span> : <span className="font-mono text-xs text-muted-foreground">{show(r.expected)}</span>),
} satisfies Col<SettingsRow>,
]
: []),
]
const retry = (
<Button variant="primary" onClick={() => void load()}>
Retry
</Button>
)
return (
<>
<Dialog
open
onOpenChange={(o) => {
if (!o) onClose()
}}
size="wide"
title={blocking ? 'Compare blocking across servers' : 'Compare settings across servers'}
actions={
<>
{blocking && (
<Button disabled={busy || lists?.baseline == null} onClick={() => setRequest(listsApplyRequest(token, pickable(), lists?.servers.filter((s) => s.status === 'differs').map((s) => s.id) ?? []))}>
Apply lists…
</Button>
)}
<Button variant="primary" disabled={!hasBaseline || busy} onClick={() => setRequest(settingsApplyRequest(token, pickable(), differsIds, scope))}>
{blocking ? 'Apply blocking settings…' : 'Apply to servers…'}
</Button>
</>
}
>
{failure !== null && data === null ? (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>Could not read the settings. {failure}</span>
{retry}
</span>
</Failure>
) : data === null ? (
<div className="flex flex-col gap-3" role="status" aria-label="Loading the settings">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : (
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="flex-col">
<TabsList variant="line">
<TabsTrigger value="compare">Compare</TabsTrigger>
<TabsTrigger value="baseline">Baseline</TabsTrigger>
<TabsTrigger value="local">Local fields</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>
{problem !== null && <Alert type="danger" title="Error">{problem}</Alert>}
{saved !== null && <Alert type="success" title="Saved." onDismiss={() => setSaved(null)}>{saved}</Alert>}
<TabsContent value="compare" className="flex flex-col gap-4 pt-3">
<div className="flex flex-wrap items-center gap-4">
{servers.map((s) => (
<span key={s.id} className="flex items-center gap-2 text-sm">
<span className="font-medium">{s.id}</span>
<StatusTag status={s.status} differing={s.differing} />
{s.paused && <Tag tone="warn">Blocking paused</Tag>}
</span>
))}
</div>
{servers.filter((s) => s.error !== undefined).map((s) => (
<Alert key={s.id} type="warning" title={s.id}>
{s.error}
</Alert>
))}
{!hasBaseline && (
<Empty
title="No settings baseline yet"
actions={
<span className="flex flex-wrap justify-center gap-2">
{liveServers.map((s) => (
<Button key={s.id} variant="primary" disabled={busy} onClick={() => void run(() => importSettingsBaseline(token, s.id, blocking ? 'blocking' : undefined), `Baseline taken from ${s.id}.`)}>
Accept {s.id} as baseline
</Button>
))}
</span>
}
>
The baseline is the settings every server should share. Accept one from a server that is right; the fields that belong to a host stay local to it. Until then the servers are compared with the first one.
</Empty>
)}
{rows.length === 0 ? (
<Empty compact>No differences in {blocking ? 'the blocking settings' : 'the settings'}.</Empty>
) : (
<DataTable data={rows} columns={columns} getRowId={(r) => r.name} />
)}
{hasBaseline && (
<div className="flex flex-wrap items-center gap-2">
{liveServers.map((s) => (
<Button key={s.id} disabled={busy} onClick={() => void run(() => importSettingsBaseline(token, s.id, blocking ? 'blocking' : undefined), `Baseline taken from ${s.id}.`)}>
Accept {s.id} as baseline
</Button>
))}
</div>
)}
{blocking && lists !== null && (
<section aria-label="Blocked and Allowed lists" className="flex flex-col gap-3">
<h3 className="m-0 text-sm font-semibold">Blocked and Allowed lists</h3>
<p className="m-0 text-xs text-muted-foreground">
{lists.baseline === null
? 'No lists baseline yet; the servers are compared with the first one.'
: `Baseline: ${lists.baseline.blocked} blocked, ${lists.baseline.allowed} allowed${lists.baseline.importedFrom === undefined ? '' : `, from ${lists.baseline.importedFrom}`}.`}
</p>
{lists.servers.map((s) => (
<div key={s.id} className="flex flex-col gap-1">
<span className="flex items-center gap-2 text-sm font-medium">
{s.id}
{s.status === 'unknown' ? <Tag tone="dan">Did not answer</Tag> : s.status === 'same' ? <Tag tone="ok">Same</Tag> : <Tag tone="warn">Differs</Tag>}
</span>
{s.error !== undefined && <span className="text-xs text-muted-foreground">{s.error}</span>}
{s.ok && (
<ul className="m-0 flex list-none flex-col gap-1 p-0">
<ListLine label="Blocked" c={s.blocked} />
<ListLine label="Allowed" c={s.allowed} />
</ul>
)}
</div>
))}
<div className="flex flex-wrap items-center gap-2">
{lists.servers.filter((s) => s.ok).map((s) => (
<Button key={s.id} disabled={busy} onClick={() => void run(() => importListsBaseline(token, s.id), `Lists taken from ${s.id}.`)}>
Accept {s.id}&apos;s lists as baseline
</Button>
))}
</div>
</section>
)}
</TabsContent>
<TabsContent value="baseline" className="flex flex-col gap-3 pt-3">
<p className="m-0 text-xs text-muted-foreground">
{data.baseline === null
? 'No baseline saved.'
: `${data.baseline.importedFrom !== undefined ? `Taken from ${data.baseline.importedFrom}. ` : ''}Updated by ${data.baseline.updatedBy} at ${when(data.baseline.updatedAt)}. Secrets show as ${'<secret>'}; leave them as they are to keep them.`}
</p>
<LabeledTextarea label="Baseline settings (JSON)" mono rows={16} spellCheck={false} value={baselineText} onChange={(e) => setBaselineText(e.target.value)} />
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={busy || baselineText.trim() === ''} onClick={() => void run(() => saveSettingsBaseline(token, baselineText), 'Baseline saved.')}>
Save baseline
</Button>
{liveServers.map((s) => (
<Button key={s.id} disabled={busy} onClick={() => void run(() => importSettingsBaseline(token, s.id), `Baseline taken from ${s.id}.`)}>
Import all from {s.id}
</Button>
))}
</div>
</TabsContent>
<TabsContent value="local" className="flex flex-col gap-4 pt-3">
<p className="m-0 text-xs text-muted-foreground">
Local fields are never sent by an apply: each server keeps its own. Built in: the host identity and addresses, certificates, paths and sockets of the host, and every <code>webService*</code> setting. Read-only and runtime values (version, uptime, cluster, the temporary blocking switch) are never compared.
</p>
<ul className="m-0 flex list-none flex-wrap gap-2 p-0" aria-label="Built-in local fields">
{data.localFields.builtIn.map((f) => (
<li key={f}>
<Tag tone="info">{f}</Tag>
</li>
))}
{data.localFields.builtInPatterns.map((f) => (
<li key={f}>
<Tag tone="info">{f}</Tag>
</li>
))}
</ul>
<ul className="m-0 flex list-none flex-col gap-2 p-0" aria-label="Extra local fields">
{extra.length === 0 && <li className="text-sm text-muted-foreground">No extra local fields.</li>}
{extra.map((f) => (
<li key={f} className="flex items-center gap-2 font-mono text-xs">
{f}
<Button size="sm" onClick={() => setExtra((old) => old.filter((x) => x !== f))}>
Remove {f}
</Button>
</li>
))}
</ul>
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-xs">
<span>New local field</span>
<Input mono aria-label="New local field" placeholder="forwarderTimeout" value={newField} onChange={(e) => setNewField(e.target.value)} />
</label>
<Button
disabled={newField.trim() === '' || extra.includes(newField.trim())}
onClick={() => {
setExtra((old) => [...old, newField.trim()])
setNewField('')
}}
>
Add field
</Button>
<Button variant="primary" disabled={busy} onClick={() => void run(() => saveSettingsOverrides(token, extra), 'Local fields saved.')}>
Save local fields
</Button>
</div>
{data.fields.some((f) => f.local) && (
<div className="flex flex-col gap-2">
<h3 className="m-0 text-sm font-semibold">Local values that differ</h3>
<ul className="m-0 flex list-none flex-col gap-1 p-0" aria-label="Local values">
{data.fields.filter((f) => f.local).map((f) => (
<li key={f.name} className="font-mono text-xs">
{f.name}: {Object.entries(f.cells).map(([id, c]) => `${id} = ${show(c.value)}`).join(', ')}
</li>
))}
</ul>
</div>
)}
</TabsContent>
<TabsContent value="history" className="pt-3">
<HistoryTab token={token} active={tab === 'history'} version={String(loads)} />
</TabsContent>
</Tabs>
)}
</Dialog>
{request !== null && (
<RunFlow
request={request}
onClose={(changed) => {
setRequest(null)
if (changed) void load()
}}
/>
)}
</>
)
}
const KIND: Record<string, string> = {
'before-apply': 'Before an apply',
'before-save': 'Old baseline',
'before-lists': 'Before removing list entries',
}
/* What the hub kept before it changed a server or replaced the baseline. View only: there is no rollback here. */
function HistoryTab({ token, active, version }: { token: string | null; active: boolean; version: string }) {
const [entries, setEntries] = useState<HistoryEntry[] | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [picked, setPicked] = useState<string | null>(null)
const [text, setText] = useState('')
const load = useCallback(async () => {
const o = await getSettingsHistory(token)
if (o.kind === 'ok') {
setEntries(o.data.response.entries)
setFailure(null)
} else setFailure(sessionEnded(o.kind, o.kind === 'error' ? o.message : undefined))
}, [token])
useEffect(() => {
if (active) void load()
}, [active, load, version])
useEffect(() => {
if (picked === null) return
void getSettingsHistoryEntry(token, picked).then((o) => {
if (o.kind !== 'ok') return setText('')
const r = o.data.response
setText(r.settings !== undefined ? JSON.stringify(r.settings, null, 2) : `${r.lists?.blocked ?? 0} blocked and ${r.lists?.allowed ?? 0} allowed domains.`)
})
}, [token, picked])
if (failure !== null && entries === null) {
return (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>Could not read the history. {failure}</span>
<Button variant="primary" onClick={() => void load()}>
Retry
</Button>
</span>
</Failure>
)
}
if (entries === null) return <Skeleton className="h-24 w-full" />
if (entries.length === 0) {
return (
<Empty title="No history yet">
The hub keeps a server&apos;s settings before an apply, its lists before extras are removed, and the baseline before it is replaced. They appear here to read; they are put back by hand.
</Empty>
)
}
return (
<div className="flex flex-col gap-4">
<ul className="m-0 flex max-h-56 list-none flex-col gap-1 overflow-auto p-0" aria-label="History">
{entries.map((e) => (
<li key={e.id}>
<button
type="button"
aria-pressed={e.id === picked}
aria-label={`${new Date(e.at).toLocaleString()} ${e.server} ${KIND[e.kind] ?? e.kind}`}
className="flex w-full cursor-pointer flex-wrap items-center gap-3 border-0 border-b border-border bg-transparent px-3 py-2 text-left text-sm transition-colors hover:bg-muted aria-pressed:bg-muted"
onClick={() => {
setPicked(e.id)
setText('')
}}
>
<span className="tabular-nums">{new Date(e.at).toLocaleString()}</span>
<Tag tone={e.server === 'baseline' ? 'info' : 'neutral'}>{e.server === 'baseline' ? 'Baseline' : e.server}</Tag>
<span className="text-muted-foreground">{KIND[e.kind] ?? e.kind}</span>
</button>
</li>
))}
</ul>
{picked !== null && <LabeledTextarea label="Saved version" mono rows={12} readOnly spellCheck={false} value={text} onChange={() => {}} />}
</div>
)
}
/* The button that opens the dialog, for the Settings and Blocking headers. Drawn by the caller only for an admin behind a hub. */
export function CompareServersButton({ token, scope }: { token: string | null; scope: Scope }) {
const [open, setOpen] = useState(false)
return (
<>
<Button onClick={() => setOpen(true)}>Compare servers</Button>
{open && <SettingsCompare token={token} scope={scope} onClose={() => setOpen(false)} />}
</>
)
}
+18 -3
View File
@@ -1,6 +1,7 @@
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { useContext, useState } from 'react'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { AGGREGATE, HubMode } from '../../ui/ClusterNodeSelect'
import { ZonesMatrix } from './ZonesMatrix'
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import type { ResourceRecord, ZoneDetails } from '../../api/records'
@@ -90,6 +91,8 @@ export function Zones({
const write = useHubWrite(node, mayModify)
const canModify = write.allowed
const canDelete = useHubWrite(node, mayDelete).allowed
/* Behind a hub, "All servers" shows the merged table instead of one server's list. */
const matrixMode = hub && node === AGGREGATE
const [open, setOpen] = useState<string | null>(null)
const [notice, setNotice] = useState<Notice | null>(null)
const [confirmation, setConfirmation] = useState<Confirmation | null>(null)
@@ -154,6 +157,7 @@ export function Zones({
<ClusterNodeSelect
nodes={nodes}
initialised={clusterInitialised}
aggregate={hub}
value={node}
onChange={(n) => {
setNode(n)
@@ -162,9 +166,20 @@ export function Zones({
disabled={open != null}
label="Cluster Node"
/>
<HubWriteHint write={write} />
{!matrixMode && <HubWriteHint write={write} />}
{open == null ? (
{matrixMode ? (
<ZonesMatrix
token={token}
canWrite={mayModify}
refresh={listRefresh}
onOpen={(server, zone) => {
setNode(server)
setNotice(null)
setOpen(zone)
}}
/>
) : open == null ? (
<ZoneList
token={token}
node={node}
+205
View File
@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react'
import { copyZone, makeSecondary, previewSecondary, previewZoneCopy, type CopyPreview, type CopyTargetPreview } from '../../api/hub-zones'
import { getTsigKeyNames } from '../../api/settings'
import { Alert } from '../../ui/Alert'
import { Check } from '../../ui/Check'
import { LabeledInput, LabeledSelect } from '../../ui/Field'
import { Tag } from '../../ui/Tag'
import type { PickerServer, RunRequest } from '../apps/RunFlow'
/*
The two changes to zones across servers that go through the picker, the review and the results:
copy a zone to other servers, and make it a Secondary of this server's Primary. The review step
reads what would happen first; nothing is written before its confirm button.
The review and the fields live inside the dialog, so what they hold reaches `run` through a small
mutable object the request closes over (`overwrite`, `fields`); RunFlow keeps only what the review
hands it as `context`.
*/
const sessionEnded = (kind: string, message?: string): string => (kind === 'error' ? (message ?? 'Failed.') : 'The session ended.')
function TargetReview({ t, zone, ticked, onTick }: { t: CopyTargetPreview; zone: string; ticked: boolean; onTick: (on: boolean) => void }) {
return (
<section aria-label={`Preview ${t.server}`} className="flex flex-col gap-3">
<h3 className="m-0 flex items-center gap-2 text-sm font-semibold">
{t.server}
{!t.ok ? <Tag tone="dan">Cannot copy</Tag> : t.exists ? <Tag tone="warn">Zone exists</Tag> : <Tag tone="ok">Will be created</Tag>}
</h3>
{t.error !== undefined && (
<Alert type="danger" title="Error">
{t.error}
</Alert>
)}
{t.ok && t.exists && (
<>
<p className="m-0 text-sm text-muted-foreground">
{zone} is a {t.type} zone here with serial {t.serial ?? 'none'}. Copying replaces all of its records with the source's.
</p>
<Check label={`Overwrite ${zone} on ${t.server}`} checked={ticked} onChange={onTick} />
</>
)}
</section>
)
}
function CopyReview({ preview, overwrite, setReady }: { preview: CopyPreview; overwrite: Set<string>; setReady: (ready: boolean) => void }) {
const [, rerender] = useState(0)
/* Copy stays off until every server that already has the zone is ticked for Overwrite. */
const answered = () => preview.targets.every((t) => !t.ok || !t.exists || overwrite.has(t.server))
return (
<div className="flex flex-col gap-6">
<p className="m-0 text-sm text-muted-foreground">
{preview.zone} on {preview.source.server}: {preview.source.type}, serial {preview.source.serial ?? 'none'}, about {preview.source.records} records.
</p>
{preview.warnings.map((w) => (
<Alert key={w} type="warning" title="Note">
{w}
</Alert>
))}
{preview.targets.map((t) => (
<TargetReview
key={t.server}
t={t}
zone={preview.zone}
ticked={overwrite.has(t.server)}
onTick={(on) => {
if (on) overwrite.add(t.server)
else overwrite.delete(t.server)
setReady(answered())
rerender((n) => n + 1)
}}
/>
))}
</div>
)
}
/* The zone of `source`, copied to the servers picked. The ones that already have it need Overwrite ticked. */
export function copyRequest(token: string | null, source: string, zone: string, servers: PickerServer[], preselected: string[]): RunRequest {
const overwrite = new Set<string>()
return {
title: `Copy ${zone} from ${source}`,
intro: 'The zone file is read from the source and imported on each server you choose. You see what is there first.',
confirmLabel: 'Preview',
servers,
preselected,
review: async (targets, setReady) => {
overwrite.clear()
const outcome = await previewZoneCopy(token, source, zone, targets)
if (outcome.kind !== 'ok') return { kind: 'error', message: sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined) }
const preview = outcome.data.response
setReady(preview.targets.every((t) => !t.ok || !t.exists))
return { kind: 'ok', confirmLabel: 'Copy', context: undefined, body: <CopyReview preview={preview} overwrite={overwrite} setReady={setReady} /> }
},
run: (targets) => copyZone(token, source, zone, targets, [...overwrite].filter((id) => targets.includes(id))),
}
}
interface Fields {
primaryAddress: string
targetAddress: string
tsigKeyName: string
}
function SecondaryFields({ token, source, target, fields }: { token: string | null; source: string; target: string; fields: Fields }) {
const [primary, setPrimary] = useState(fields.primaryAddress)
const [address, setAddress] = useState(fields.targetAddress)
const [key, setKey] = useState(fields.tsigKeyName)
const [keys, setKeys] = useState<string[] | null>(null)
useEffect(() => {
// A key has to exist on both servers: the choices are the ones both have.
void Promise.all([getTsigKeyNames(token, source), getTsigKeyNames(token, target)]).then(([a, b]) => setKeys(a.filter((k) => b.includes(k))))
}, [token, source, target])
return (
<div className="flex flex-col gap-3">
<p className="m-0 text-sm text-muted-foreground">
{source} keeps the Primary zone. {target} gets a Secondary zone that fetches it by zone transfer (AXFR), so records are never copied by the hub.
</p>
<LabeledInput
label={`Address of ${source} as ${target} reaches it`}
mono
value={primary}
onChange={(e) => {
setPrimary(e.target.value)
fields.primaryAddress = e.target.value
}}
/>
<LabeledInput
label={`IP address of ${target} as ${source} sees it`}
mono
placeholder="203.0.113.10"
value={address}
onChange={(e) => {
setAddress(e.target.value)
fields.targetAddress = e.target.value
}}
/>
<p className="m-0 text-xs text-muted-foreground">It goes into the zone transfer and notify lists of {source}. The hub cannot know it for you.</p>
<LabeledSelect
label="TSIG key (optional)"
value={key}
onChange={(e) => {
setKey(e.target.value)
fields.tsigKeyName = e.target.value
}}
>
<option value="">None</option>
{(keys ?? []).map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</LabeledSelect>
</div>
)
}
/* `source` keeps the Primary zone; `target` becomes its Secondary. `hostOfSource` fills the first field. */
export function secondaryRequest(token: string | null, source: string, target: string, zone: string, hostOfSource: string, servers: PickerServer[]): RunRequest {
const fields: Fields = { primaryAddress: hostOfSource, targetAddress: '', tsigKeyName: '' }
const input = () => ({ source, target, zone, ...fields })
return {
title: `Make ${zone} a Secondary on ${target}`,
intro: <SecondaryFields token={token} source={source} target={target} fields={fields} />,
confirmLabel: 'Preview',
servers,
preselected: [target],
review: async () => {
const outcome = await previewSecondary(token, input())
if (outcome.kind !== 'ok') return { kind: 'error', message: sessionEnded(outcome.kind, outcome.kind === 'error' ? outcome.message : undefined) }
const p = outcome.data.response
return {
kind: 'ok',
confirmLabel: 'Make secondary',
context: undefined,
body: (
<div className="flex flex-col gap-4">
{p.warnings.map((w) => (
<Alert key={w} type="warning" title="Note">
{w}
</Alert>
))}
<ol className="m-0 flex list-decimal flex-col gap-3 pl-5 text-sm" aria-label="Steps">
{p.steps.map((s) => (
<li key={s.role}>
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{s.role === 'source' ? 'Source' : 'Target'}</span>
<Tag tone={s.changes ? 'warn' : 'ok'}>{s.changes ? 'Will change' : 'Already set'}</Tag>
</span>
<span className="text-muted-foreground">{s.summary}</span>
</li>
))}
</ol>
<p className="m-0 text-xs text-muted-foreground">
The source is changed first. If creating the zone on {target} then fails, the change on {source} stays, and running this again is safe.
</p>
</div>
),
}
},
// Both steps always run (they are safe to repeat), so a retry of one server runs the pair again.
run: () => makeSecondary(token, input()),
}
}
+172
View File
@@ -0,0 +1,172 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ZonesMatrix } from './ZonesMatrix'
import * as api from '../../api/hub-zones'
import * as settings from '../../api/settings'
import type { ZonesMatrixData } from '../../api/hub-zones'
afterEach(() => vi.restoreAllMocks())
const present = (type: string, serial: number | null) => ({ state: 'present' as const, type, serial })
const DATA: ZonesMatrixData = {
servers: [
{ id: 'mdns', ok: true, writable: true, host: 'mdns.example' },
{ id: 'ndns', ok: true, writable: true, host: 'ndns.example' },
],
zones: [
{ name: 'alike.test', status: 'same', cells: { mdns: present('Primary', 5), ndns: present('Primary', 5) } },
{ name: 'lan.test', status: 'serial-differs', cells: { mdns: present('Primary', 3), ndns: present('Primary', 4) } },
{ name: 'mine.test', status: 'only-on', onlyOn: 'mdns', cells: { mdns: present('Primary', 1), ndns: { state: 'absent' } } },
{ name: 'pair.test', status: 'same', note: 'secondary-behind', cells: { mdns: present('Primary', 10), ndns: present('Secondary', 8) } },
],
}
function mockMatrix(data: ZonesMatrixData = DATA) {
return vi.spyOn(api, 'getZonesMatrix').mockResolvedValue({ kind: 'ok', data: { response: data } } as never)
}
const row = (name: string) => screen.getByText(name).closest('tr') as HTMLElement
const PREVIEW = {
kind: 'ok',
data: {
response: {
zone: 'lan.test',
source: { server: 'mdns', type: 'Primary', serial: 3, records: 4, bytes: 90 },
warnings: [],
targets: [{ server: 'ndns', ok: true, writable: true, exists: true, type: 'Primary', serial: 4, needsOverwrite: true }],
},
},
} as never
async function startCopy() {
await userEvent.click(await screen.findByRole('button', { name: 'Actions: lan.test' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Copy to ndns' }))
await userEvent.click(await screen.findByRole('button', { name: 'Preview' }))
}
describe('ZonesMatrix', () => {
it('draws a row per zone with the presence on each server and a status', async () => {
mockMatrix()
render(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
expect(await screen.findByText('alike.test')).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: 'ndns' })).toBeInTheDocument()
expect(within(row('lan.test')).getByText('Serial differs')).toBeInTheDocument()
expect(within(row('mine.test')).getByText('Only on mdns')).toBeInTheDocument()
expect(within(row('mine.test')).getByText('Not present')).toBeInTheDocument()
expect(within(row('pair.test')).getByText('Secondary behind')).toBeInTheDocument()
expect(within(row('alike.test')).getByText('Same')).toBeInTheDocument()
})
it('filters with counts', async () => {
mockMatrix()
render(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
await screen.findByText('alike.test')
const filters = screen.getByRole('group', { name: 'Filter zones' })
expect(within(filters).getByRole('button', { name: 'Differs · 1' })).toBeInTheDocument()
await userEvent.click(within(filters).getByRole('button', { name: 'Only on one server · 1' }))
expect(screen.getByText('mine.test')).toBeInTheDocument()
expect(screen.queryByText('alike.test')).not.toBeInTheDocument()
await userEvent.click(within(filters).getByRole('button', { name: 'Differs · 1' }))
expect(screen.getByText('lan.test')).toBeInTheDocument()
expect(screen.queryByText('mine.test')).not.toBeInTheDocument()
})
it('opens a zone on the server chosen in the row menu', async () => {
mockMatrix()
const onOpen = vi.fn()
render(<ZonesMatrix token="t" canWrite onOpen={onOpen} />)
await userEvent.click(await screen.findByRole('button', { name: 'Actions: lan.test' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Open on ndns' }))
expect(onOpen).toHaveBeenCalledWith('ndns', 'lan.test')
})
it('gives a reader Open only', async () => {
mockMatrix()
render(<ZonesMatrix token="t" onOpen={() => {}} canWrite={false} />)
await userEvent.click(await screen.findByRole('button', { name: 'Actions: lan.test' }))
expect(screen.getByRole('menuitem', { name: 'Open on mdns' })).toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: 'Copy to ndns' })).not.toBeInTheDocument()
})
it('copies a zone: preview, Overwrite ticked, then the copy names the servers that were ticked', async () => {
mockMatrix()
vi.spyOn(api, 'previewZoneCopy').mockResolvedValue(PREVIEW)
const copy = vi.spyOn(api, 'copyZone').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Records replaced.' }] } } } as never)
render(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
await startCopy()
await userEvent.click(await screen.findByRole('checkbox', { name: 'Overwrite lan.test on ndns' }))
await userEvent.click(screen.getByRole('button', { name: 'Copy' }))
expect(await screen.findByText('Records replaced.')).toBeInTheDocument()
expect(copy).toHaveBeenCalledWith('t', 'mdns', 'lan.test', ['ndns'], ['ndns'])
})
it('keeps Copy off until the server that has the zone is ticked for Overwrite', async () => {
mockMatrix()
vi.spyOn(api, 'previewZoneCopy').mockResolvedValue(PREVIEW)
const copy = vi.spyOn(api, 'copyZone')
render(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
await startCopy()
const button = await screen.findByRole('button', { name: 'Copy' })
expect(button).toBeDisabled()
await userEvent.click(screen.getByRole('checkbox', { name: 'Overwrite lan.test on ndns' }))
expect(button).toBeEnabled()
await userEvent.click(screen.getByRole('checkbox', { name: 'Overwrite lan.test on ndns' }))
expect(button).toBeDisabled()
expect(copy).not.toHaveBeenCalled()
})
it('makes a secondary with the primary address prefilled from the server host', async () => {
mockMatrix()
vi.spyOn(settings, 'getTsigKeyNames').mockResolvedValue([])
const preview = vi.spyOn(api, 'previewSecondary').mockResolvedValue({
kind: 'ok',
data: {
response: {
zone: 'mine.test',
source: 'mdns',
target: 'ndns',
warnings: [],
steps: [
{ server: 'mdns', role: 'source', summary: 'step one', changes: true },
{ server: 'ndns', role: 'target', summary: 'step two', changes: true },
],
},
},
} as never)
const make = vi.spyOn(api, 'makeSecondary').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'mdns', ok: true }, { server: 'ndns', ok: true }] } } } as never)
render(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
await userEvent.click(await screen.findByRole('button', { name: 'Actions: mine.test' }))
await userEvent.click(screen.getByRole('menuitem', { name: 'Make secondary on ndns' }))
expect(await screen.findByLabelText('Address of mdns as ndns reaches it')).toHaveValue('mdns.example')
await userEvent.type(screen.getByLabelText('IP address of ndns as mdns sees it'), '203.0.113.9')
await userEvent.click(screen.getByRole('button', { name: 'Preview' }))
expect(await screen.findByText('step one')).toBeInTheDocument()
expect(preview).toHaveBeenCalledWith('t', { source: 'mdns', target: 'ndns', zone: 'mine.test', primaryAddress: 'mdns.example', targetAddress: '203.0.113.9', tsigKeyName: '' })
await userEvent.click(screen.getByRole('button', { name: 'Make secondary' }))
await screen.findByRole('list', { name: 'Results' })
expect(make).toHaveBeenCalledTimes(1)
})
it('offers Make secondary only where the zone is absent and the other side is a Primary', async () => {
mockMatrix()
render(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
await userEvent.click(await screen.findByRole('button', { name: 'Actions: lan.test' }))
expect(screen.getByRole('button', { name: 'Make secondary on ndns' })).toBeDisabled()
})
it('shows an empty state with an action, and an error with Retry', async () => {
mockMatrix({ servers: DATA.servers, zones: [] })
const { unmount } = render(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
expect(await screen.findByText('No zones')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument()
unmount()
vi.restoreAllMocks()
const spy = vi.spyOn(api, 'getZonesMatrix').mockResolvedValueOnce({ kind: 'error', message: 'boom' } as never).mockResolvedValue({ kind: 'ok', data: { response: DATA } } as never)
render(<ZonesMatrix token="t" canWrite onOpen={() => {}} />)
expect(await screen.findByText(/boom/)).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('alike.test')).toBeInTheDocument()
expect(spy).toHaveBeenCalledTimes(2)
})
})
+273
View File
@@ -0,0 +1,273 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { getZonesMatrix, type MatrixZone, type ZoneCell, type ZonesMatrixData } from '../../api/hub-zones'
import { typeLabel } from '../../api/zones'
import { Badge } from '@/components/reui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '../../ui/Button'
import { DataTable, type Col } from '../../ui/DataTable'
import { Empty, Failure } from '../../ui/Empty'
import { Menu } from '../../ui/Menu'
import { Panel } from '../../ui/Panel'
import { Segmented } from '../../ui/Segmented'
import { Tooltip } from '../../ui/Tooltip'
import { head } from '../../ui/Table'
import { RunFlow, pickerServers, type RunRequest } from '../apps/RunFlow'
import { copyRequest, secondaryRequest } from './ZonesFlows'
/*
Behind a hub, with "All servers" chosen: one row per zone name, one column per server, and whether
the servers agree. Opening a zone, copying it and making it a Secondary start from the row menu.
The numbers come from `hub/zones/matrix`, which sends names, types and serials, never records.
*/
type Filter = 'all' | 'differs' | 'only'
const DIFFERS = new Set(['serial-differs', 'type-differs'])
const FILTERS: Record<Filter, (z: MatrixZone) => boolean> = {
all: () => true,
differs: (z) => DIFFERS.has(z.status),
only: (z) => z.status === 'only-on' || z.status === 'missing-on',
}
/* The zone types a file copy handles; the matrix cell says if the zone is signed. */
const COPYABLE = new Set(['Primary', 'Forwarder'])
function Status({ zone }: { zone: MatrixZone }) {
switch (zone.status) {
case 'same':
return (
<span className="flex flex-wrap items-center gap-2">
<Badge variant="success-light">Same</Badge>
{zone.note === 'primary-secondary' && <Badge variant="info-light">Primary and Secondary</Badge>}
{zone.note === 'secondary-behind' && <Badge variant="warning-light">Secondary behind</Badge>}
</span>
)
case 'serial-differs':
return <Badge variant="warning-light">Serial differs</Badge>
case 'type-differs':
return <Badge variant="warning-light">Type differs</Badge>
case 'only-on':
return <Badge variant="info-light">Only on {zone.onlyOn}</Badge>
case 'missing-on':
return <Badge variant="info-light">Missing on {(zone.missingOn ?? []).join(', ')}</Badge>
default:
return (
<Tooltip text="A server did not answer, so this zone cannot be compared." placement="top">
<Badge variant="outline" tabIndex={0}>
Unknown
</Badge>
</Tooltip>
)
}
}
function Presence({ cell }: { cell: ZoneCell | undefined }) {
if (cell === undefined || cell.state === 'unknown') return <span className="text-sm text-muted-foreground">Unknown</span>
if (cell.state === 'absent') return <span className="text-sm text-muted-foreground">Not present</span>
return (
<span className="flex flex-wrap items-center gap-2 text-sm">
<span>{typeLabel(cell.type ?? '')}</span>
{typeof cell.serial === 'number' && <span className="text-xs text-muted-foreground tabular-nums">serial {cell.serial}</span>}
{cell.disabled === true && <Badge variant="destructive-light">Disabled</Badge>}
{cell.dnssec !== undefined && cell.dnssec !== 'Unsigned' && <Badge variant="info-light">DNSSEC</Badge>}
</span>
)
}
export function ZonesMatrix({
token,
canWrite,
refresh = 0,
onOpen,
}: {
token: string | null
canWrite: boolean
refresh?: number
/** Switches the selector to a server and opens the zone there. */
onOpen: (server: string, zone: string) => void
}) {
const [data, setData] = useState<ZonesMatrixData | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [filter, setFilter] = useState<Filter>('all')
const [request, setRequest] = useState<RunRequest | null>(null)
const load = useCallback(async () => {
const outcome = await getZonesMatrix(token)
if (outcome.kind === 'ok') {
setData(outcome.data.response)
setFailure(null)
return
}
setFailure(outcome.kind === 'error' ? outcome.message : 'The session ended.')
}, [token])
useEffect(() => {
void load()
}, [load, refresh])
const counts = useMemo(
() => (Object.keys(FILTERS) as Filter[]).reduce((acc, f) => ({ ...acc, [f]: (data?.zones ?? []).filter(FILTERS[f]).length }), {} as Record<Filter, number>),
[data],
)
if (failure !== null && data === null) {
return (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>Could not load the zones of the servers. {failure}</span>
<Button
variant="primary"
onClick={() => {
setFailure(null)
void load()
}}
>
Retry
</Button>
</span>
</Failure>
)
}
if (data === null) {
return (
<div className="flex flex-col gap-3" role="status" aria-label="Loading the zones of every server">
<Skeleton className="h-8 w-1/2" />
<Skeleton className="h-40 w-full" />
</div>
)
}
if (data.zones.length === 0) {
return (
<Empty
title="No zones"
actions={
<Button variant="primary" onClick={() => void load()}>
Refresh
</Button>
}
>
None of the servers that answered has a zone. Choose a server to add one.
</Empty>
)
}
const servers = data.servers
const rows = data.zones.filter(FILTERS[filter])
/* The menu of one row: open on a server, copy to another, make a Secondary there. */
function actions(zone: MatrixZone, d: ZonesMatrixData) {
const holders = d.servers.filter((s) => zone.cells[s.id]?.state === 'present')
const sources = d.servers.filter((s) => {
const c = zone.cells[s.id]
return c?.state === 'present' && COPYABLE.has(c.type ?? '') && (c.dnssec === undefined || c.dnssec === 'Unsigned')
})
const primaries = d.servers.filter((s) => zone.cells[s.id]?.state === 'present' && zone.cells[s.id].type === 'Primary')
const pickable = (source: string) => pickerServers(d.servers.filter((s) => s.id !== source), Object.fromEntries(d.servers.filter((s) => s.id !== source && !s.ok).map((s) => [s.id, 'Did not answer'])))
return (
<Menu label={`Actions: ${zone.name}`}>
{(close) => (
<>
{holders.map((s) => (
<Button
key={s.id}
size="sm"
onClick={() => {
close()
onOpen(s.id, zone.name)
}}
>
Open on {s.id}
</Button>
))}
{/* A server that is the only place to copy from is not a target: it would be copied onto itself. */}
{canWrite &&
d.servers.filter((dst) => sources.some((s) => s.id !== dst.id)).map((dst) => {
const source = sources.find((s) => s.id !== dst.id)
const ok = dst.ok && zone.cells[dst.id]?.state !== 'unknown'
return (
<Button
key={`copy-${dst.id}`}
size="sm"
disabled={source === undefined || !ok}
onClick={() => {
close()
if (source !== undefined) setRequest(copyRequest(token, source.id, zone.name, pickable(source.id), [dst.id]))
}}
>
Copy to {dst.id}
</Button>
)
})}
{canWrite &&
d.servers.filter((dst) => primaries.some((s) => s.id !== dst.id)).map((dst) => {
const source = primaries.find((s) => s.id !== dst.id)
const free = dst.ok && zone.cells[dst.id]?.state === 'absent'
return (
<Button
key={`secondary-${dst.id}`}
size="sm"
disabled={source === undefined || !free}
onClick={() => {
close()
if (source === undefined) return
setRequest(secondaryRequest(token, source.id, dst.id, zone.name, source.host, pickerServers([dst], {})))
}}
>
Make secondary on {dst.id}
</Button>
)
})}
</>
)}
</Menu>
)
}
const columns: Col<MatrixZone>[] = [
{ id: 'name', ...head({ children: 'Zone' }), cell: (z) => <span className="text-sm font-medium">{z.name === '' ? '<root>' : z.name}</span> },
...servers.map((s): Col<MatrixZone> => ({ id: `server-${s.id}`, ...head({ children: s.id }), cell: (z) => <Presence cell={z.cells[s.id]} /> })),
{ id: 'status', ...head({ children: 'Status' }), cell: (z) => <Status zone={z} /> },
{ id: 'actions', ...head({ children: <span className="sr-only">Actions</span> }), cell: (z) => actions(z, data) },
]
return (
<div className="flex flex-col gap-4">
<Segmented
label="Filter zones"
active={filter}
onChoose={setFilter}
options={[
{ id: 'all', label: `All · ${counts.all}` },
{ id: 'differs', label: `Differs · ${counts.differs}` },
{ id: 'only', label: `Only on one server · ${counts.only}` },
]}
/>
<Panel title="Zones by server" description="Where each zone is, its type and SOA serial">
{rows.length === 0 ? (
<Empty
title="No zones match"
actions={
<Button variant="primary" onClick={() => setFilter('all')}>
Show all
</Button>
}
>
No zone fits this filter.
</Empty>
) : (
<DataTable data={rows} columns={columns} getRowId={(z) => z.name} />
)}
</Panel>
{request !== null && (
<RunFlow
request={request}
onClose={(changed) => {
setRequest(null)
if (changed) void load()
}}
/>
)}
</div>
)
}