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:
1 parent
34de910e04
commit
392b644faa
25 files changed
+4865
-22
No files matched your search
@@ -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) } })
|
||||
@@ -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 } })
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}'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'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)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user