diff --git a/hub/server.test.ts b/hub/server.test.ts index 8ffd66e..38aa3f5 100644 --- a/hub/server.test.ts +++ b/hub/server.test.ts @@ -209,6 +209,7 @@ describe('single node', () => { expect(b.calls[0].query.get('token')).toBe('token-b') expect(b.calls[0].query.has('node')).toBe(false) expect(b.calls[0].query.get('type')).toBe('LastDay') + expect(body.hubWarnings).toBeUndefined() }) it('reports an unreachable server by its id', async () => { @@ -235,7 +236,7 @@ describe('aggregate', () => { const { body } = await call('dashboard/stats/get', params, token) expect(body.response.stats.totalQueries).toBe(400) expect(body.response.mainChartData.datasets[0].data).toEqual([400, 400]) - expect(body.hubWarnings).toBeUndefined() + expect(body.hubWarnings).toEqual([]) } }) diff --git a/hub/server.ts b/hub/server.ts index 1f0560a..66e9ad2 100644 --- a/hub/server.ts +++ b/hub/server.ts @@ -118,8 +118,10 @@ export function createHub(config: HubConfig): http.Server { }) } - function ok(response: unknown, warnings: string[] = []): Json { - return warnings.length > 0 ? { status: 'ok', response, hubWarnings: warnings } : { status: 'ok', response } + /* `warnings` is given only by the aggregate answers (empty when every server answered), so the + console can tell "all back" from "not an aggregate" and clear its warning only on the former. */ + function ok(response: unknown, warnings?: string[]): Json { + return warnings === undefined ? { status: 'ok', response } : { status: 'ok', response, hubWarnings: warnings } } async function single(server: HubServer, apiPath: string, params: Params): Promise { diff --git a/src/api/client.ts b/src/api/client.ts index d67775e..1b62c73 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,5 @@ import { urlApi } from '../app/base' +import { setHubWarnings } from './hub-warnings' /* The console's HTTP client. A replica of the old console's `HTTPRequest` helper (upstream/master:DnsServerCore/www/js/common.js:28). @@ -66,6 +67,8 @@ export interface ApiOptions { interface Envelope { status?: string errorMessage?: string + /** Only from the hub: servers that did not answer an aggregate call. */ + hubWarnings?: unknown } /* @@ -101,6 +104,8 @@ cannot go through it because their good answer is not JSON (`allowed/export`, export function envelopeOutcome(payload: Envelope, token?: string | null): ApiOutcome { switch (payload.status) { case 'ok': + // Only an aggregate answer carries the field; a per-node call must not clear it. + if (payload.hubWarnings !== undefined) setHubWarnings(payload.hubWarnings) return { kind: 'ok', data: payload as T } case 'invalid-token': onExpired?.(token) diff --git a/src/api/hub-warnings.ts b/src/api/hub-warnings.ts new file mode 100644 index 0000000..3c6be13 --- /dev/null +++ b/src/api/hub-warnings.ts @@ -0,0 +1,34 @@ +import { useSyncExternalStore } from 'react' + +/* +What the hub says about the servers behind it. On an aggregate call it adds a +top-level `hubWarnings: string[]` to the envelope when a server did not answer +("ndns: timed out after 8000 ms"). A server never sends it, so the list stays empty +outside the hub. + +The envelope is read in one place (`client.ts`), far from the header that shows +this, so the latest list lives here and the header subscribes. Every ok response +sets it: an answer without warnings clears it. +*/ +let current: readonly string[] = [] +const listeners = new Set<() => void>() + +export function setHubWarnings(next: unknown): void { + const list = Array.isArray(next) ? next.filter((w): w is string => typeof w === 'string') : [] + if (list.length === current.length && list.every((w, i) => w === current[i])) return + current = list + for (const fn of listeners) fn() +} + +export function getHubWarnings(): readonly string[] { + return current +} + +export function subscribeHubWarnings(fn: () => void): () => void { + listeners.add(fn) + return () => listeners.delete(fn) +} + +export function useHubWarnings(): readonly string[] { + return useSyncExternalStore(subscribeHubWarnings, getHubWarnings, getHubWarnings) +} diff --git a/src/api/logs.ts b/src/api/logs.ts index 52c3f9e..2c80e73 100644 --- a/src/api/logs.ts +++ b/src/api/logs.ts @@ -60,6 +60,8 @@ export interface QueryLogEntry { qtype: string | null qclass: string | null answer: string | null + /** Only from the hub: the server this merged row came from. */ + node?: string } export interface QueryLogPage { diff --git a/src/app/Shell.hub.test.tsx b/src/app/Shell.hub.test.tsx new file mode 100644 index 0000000..0bc5ecc --- /dev/null +++ b/src/app/Shell.hub.test.tsx @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Shell, type ShellSession } from './Shell' +import { ThemeProvider } from '../theme/ThemeProvider' +import * as client from '../api/client' +import { setHubWarnings } from '../api/hub-warnings' +import { forgetRoot } from './route' + +/* +The console behind a hub (`info.hub`): no Change Password at login, no account entries +the hub cannot serve, and a header that says when a server did not answer. +*/ + +function session(hub: boolean): ShellSession { + const all = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs'] + return { + token: 'tok', + displayName: 'Administrator', + username: 'admin', + info: { + version: '1.0.0', + uptimestamp: '2026-10-15T08:00:00Z', + dnsServerDomain: 'hub', + ...(hub ? { hub: true } : {}), + permissions: Object.fromEntries(all.map((s) => [s, { canView: s === 'Dashboard', canModify: false, canDelete: false }])), + }, + } +} + +function mount(hub: boolean, forcePasswordChange = false) { + return render( + + {}} forcePasswordChange={forcePasswordChange} /> + , + ) +} + +beforeEach(() => { + localStorage.clear() + forgetRoot() + setHubWarnings([]) + window.history.replaceState(null, '', '/about/') + vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: {} } } as never) +}) +afterEach(() => vi.restoreAllMocks()) + +describe('Shell on a hub', () => { + it('does not open Change Password after a factory-credentials login', () => { + mount(true, true) + expect(screen.queryByRole('dialog', { name: /change password/i })).not.toBeInTheDocument() + }) + + it('still opens it on a server', async () => { + mount(false, true) + expect(await screen.findByRole('dialog', { name: /change password/i })).toBeInTheDocument() + }) + + it('leaves out the account entries the hub cannot serve', async () => { + mount(true) + await userEvent.click(screen.getByRole('button', { name: /Administrator/ })) + for (const name of ['My Profile', 'Change Password', 'Configure 2FA', 'Create API Token']) { + expect(screen.queryByRole('menuitem', { name })).not.toBeInTheDocument() + } + expect(screen.getByRole('menuitem', { name: 'Change Theme' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Logout' })).toBeInTheDocument() + }) + + it('keeps them on a server', async () => { + mount(false) + await userEvent.click(screen.getByRole('button', { name: /Administrator/ })) + expect(screen.getByRole('menuitem', { name: 'My Profile' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Create API Token' })).toBeInTheDocument() + }) + + it('the header shows the servers that did not answer, read from an api response', async () => { + mount(true) + expect(screen.getByRole('button', { name: 'Server status' })).toHaveAttribute('data-attention', 'false') + + // An aggregate response carries the warning; `apiRequest` reads it in one place. + vi.restoreAllMocks() + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ status: 'ok', response: {}, hubWarnings: ['ndns: timed out after 8000 ms'] })), + ) + await act(async () => { + await client.apiRequest('dashboard/stats/get', { token: 'tok' }) + }) + + const status = screen.getByRole('button', { name: 'Server status' }) + expect(status).toHaveTextContent('1 server not answering') + expect(status).toHaveAttribute('data-attention', 'true') + await userEvent.click(status) + expect(screen.getByText('ndns: timed out after 8000 ms')).toBeInTheDocument() + + await act(async () => { + setHubWarnings(['a: x', 'b: y']) + }) + expect(status).toHaveTextContent('2 servers not answering') + // The next answer without warnings clears it. + await act(async () => { + setHubWarnings([]) + }) + expect(status).not.toHaveTextContent('not answering') + }) + + /* Per-node calls (live metrics, settings) arrive right after an aggregate and say nothing. */ + it('an ok response without the field leaves the warning, and an empty list clears it', async () => { + mount(true) + vi.restoreAllMocks() + const answer = (body: object) => + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ status: 'ok', response: {}, ...body }))) + answer({ hubWarnings: ['ndns: ECONNREFUSED'] }) + await act(async () => { + await client.apiRequest('dashboard/stats/get', { token: 'tok' }) + }) + const status = screen.getByRole('button', { name: 'Server status' }) + expect(status).toHaveTextContent('1 server not answering') + + answer({}) + await act(async () => { + await client.apiRequest('settings/get', { token: 'tok', node: 'dns' }) + }) + expect(status).toHaveTextContent('1 server not answering') + + answer({ hubWarnings: [] }) + await act(async () => { + await client.apiRequest('dashboard/stats/get', { token: 'tok' }) + }) + expect(status).not.toHaveTextContent('not answering') + }) +}) diff --git a/src/app/Shell.module.css b/src/app/Shell.module.css index 543c218..8a78d78 100644 --- a/src/app/Shell.module.css +++ b/src/app/Shell.module.css @@ -233,6 +233,8 @@ star, and the header is always there: toggle, breadcrumbs and the server's statu .pdot { width: 8px; height: 8px; border-radius: 99px; background: var(--ok, currentColor); } .pdot[data-tone='warn'], .pdot[data-tone='warning'] { background: var(--warn, currentColor); } .pdot[data-tone='bad'], .pdot[data-tone='danger'] { background: var(--bad, currentColor); } +.warnings { margin: var(--s-6) 0 0; padding: 0; list-style: none; display: grid; gap: var(--s-3); font: 400 var(--t-3)/1.3 var(--font-mono); color: var(--ink); } +.warnings li::before { content: ''; display: inline-block; width: 6px; height: 6px; margin-right: var(--s-4); border-radius: 99px; background: var(--warn); } .pop dl { display: grid; grid-template-columns: auto 1fr; gap: var(--s-4) var(--s-8); margin: 0; font-size: var(--t-3); } .pop dt { color: var(--mute); } .pop dd { margin: 0; text-align: right; font-family: var(--font-mono); } diff --git a/src/app/Shell.tsx b/src/app/Shell.tsx index 222a402..80c2a09 100644 --- a/src/app/Shell.tsx +++ b/src/app/Shell.tsx @@ -31,11 +31,12 @@ import { publicUrl } from './base' import { Menu } from '../ui/Menu' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' -import { ThisServerName } from '../ui/ClusterNodeSelect' +import { HubMode, ThisServerName } from '../ui/ClusterNodeSelect' import { FooterLinks } from '../ui/FooterLinks' import { Versions } from './Versions' import { BlockingMark } from './BlockingMark' import { useBlockingMark } from '../screens/blocking/blocking-state' +import { useHubWarnings } from '../api/hub-warnings' import { Confirm } from '../ui/Confirm' import { Notifier } from '../ui/Notifier' import type { Notice } from '../lib/notice' @@ -102,6 +103,8 @@ export interface ShellSession { dnsServerDomain: string permissions?: Record clusterInitialized?: boolean + /** Only on the hub (`hub/`): a read-only console over several servers. */ + hub?: boolean /** Only on an initialised cluster (WebServiceAuthApi.cs:104-106). */ clusterDomain?: string clusterNodes?: { name: string; type: string }[] @@ -125,6 +128,8 @@ export function Shell({ forcePasswordChange?: boolean }) { const permissions = session.info?.permissions + const hub = session.info?.hub === true + const hubWarnings = useHubWarnings() // Memoised: if it is recreated on every render, the `hashchange` resubscribes on each. const sections = useMemo(() => visibleSections(permissions), [permissions]) /* The starting section comes from the address bar if it carries one, and only @@ -166,11 +171,11 @@ export function Shell({ window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, []) - const [modal, setModal] = useState(forcePasswordChange ? 'password' : null) + const [modal, setModal] = useState(forcePasswordChange && !hub ? 'password' : null) /* The factory password travels only with the opening the login caused; from the user menu `showChangePasswordModal()` is called with nothing (main.js). */ const [forcedCurrentPassword, setForcedCurrentPassword] = useState( - forcePasswordChange ? 'admin' : undefined, + forcePasswordChange && !hub ? 'admin' : undefined, ) const [sub, setSub] = useState(initialRoute?.sub ?? null) const [displayName, setDisplayName] = useState(session.displayName) @@ -398,6 +403,7 @@ export function Shell({ return ( + {(slot) => (
@@ -499,26 +505,31 @@ export function Shell({ > {(close) => ( <> - + {/* The hub serves reads only: no profile, password, 2FA or tokens. */} + {!hub && ( + + )} {/* main.js:71-87 (v15.5.1) switches on `sessionData.type`: `RemoteSSO` hides both, `RemoteLDAP` hides only the password (the directory owns it, the 2FA is still this server's) and `Local` or anything else shows both. */} - {session.type !== 'RemoteSSO' && session.type !== 'RemoteLDAP' && ( + {!hub && session.type !== 'RemoteSSO' && session.type !== 'RemoteLDAP' && ( )} - {session.type !== 'RemoteSSO' && ( + {!hub && session.type !== 'RemoteSSO' && ( )} - + {!hub && ( + + )} {/* index.html:71: after the account's own entries, before the update notification. */}
- - - {blockingNow != null ? `Blocking ${blockingNow.text.toLowerCase()}` : (info?.dnsServerDomain ?? 'Server')} + 0} aria-label="Server status"> + 0 ? 'warn' : (blockingNow?.tone ?? 'ok')} /> + + {hubWarnings.length > 0 + ? `${hubWarnings.length} ${hubWarnings.length === 1 ? 'server' : 'servers'} not answering` + : blockingNow != null ? `Blocking ${blockingNow.text.toLowerCase()}` : (info?.dnsServerDomain ?? 'Server')} +
@@ -585,6 +600,11 @@ export function Shell({
Up since
{info?.uptimestamp ? new Date(info.uptimestamp).toLocaleString() : '-'}
Blocking
{blockingNow ? blockingNow.label.replace(/^Blocking,?\s*/, '') : 'Enabled'}
+ {hubWarnings.length > 0 && ( +
    + {hubWarnings.map((w) => (
  • {w}
  • ))} +
+ )} - { setPalette(false); open('profile') }}>My Profile + {!hub && { setPalette(false); open('profile') }}>My Profile} { setPalette(false); open('theme') }}>Change Theme { setPalette(false); onLogout() }}>Logout @@ -798,6 +818,7 @@ export function Shell({ )}
+
) diff --git a/src/screens/logs/QueryLogs.hub.test.tsx b/src/screens/logs/QueryLogs.hub.test.tsx new file mode 100644 index 0000000..78574da --- /dev/null +++ b/src/screens/logs/QueryLogs.hub.test.tsx @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Logs } from './Logs' +import * as logs from '../../api/logs' +import * as apps from '../../api/apps' +import type { QueryLogEntry } from '../../api/logs' +import { HandoffContext } from '../../app/handoff' + +/* The hub tags the rows it merged with the server they came from. */ + +afterEach(() => vi.restoreAllMocks()) + +function entry(rowNumber: number, extra: Partial = {}): QueryLogEntry { + return { + rowNumber, + timestamp: '2026-08-26T05:32:14Z', + clientIpAddress: '10.0.0.7', + protocol: 'Udp', + responseType: 'Recursive', + rcode: 'NoError', + qname: `r${rowNumber}.example`, + qtype: 'A', + qclass: 'IN', + answer: null, + ...extra, + } +} + +async function show(entries: QueryLogEntry[]) { + vi.spyOn(apps, 'listApps').mockResolvedValue({ + kind: 'ok', + data: { + status: 'ok', + response: { + apps: [ + { + name: 'Query Logs (Sqlite)', + description: '', + version: '8.0', + dnsApps: [ + { + classPath: 'QueryLogsSqlite.App', + description: '', + recordDataTemplate: null, + isAppRecordRequestHandler: false, + isRequestController: false, + isAuthoritativeRequestHandler: false, + isRequestBlockingHandler: false, + isQueryLogger: true, + isQueryLogs: true, + isPostProcessor: false, + }, + ], + }, + ], + }, + }, + } as never) + vi.spyOn(logs, 'queryLogs').mockResolvedValue({ + kind: 'ok', + data: { response: { pageNumber: 1, totalPages: 1, totalEntries: entries.length, entries } }, + } as never) + render( + + + , + ) + await userEvent.click(await screen.findByRole('button', { name: 'Query' })) + await screen.findByText('r1.example') +} + +describe('Query Logs: the Server column', () => { + it('appears, after Timestamp, when rows carry a node', async () => { + await show([entry(1, { node: 'dns' }), entry(2, { node: 'ndns' })]) + const headers = screen.getAllByRole('columnheader').map((h) => h.textContent) + expect(headers.indexOf('Server')).toBe(headers.indexOf('Timestamp') + 1) + expect(screen.getByText('dns')).toBeInTheDocument() + expect(screen.getByText('ndns')).toBeInTheDocument() + }) + + it('is absent when no row has one', async () => { + await show([entry(1), entry(2)]) + expect(screen.queryByRole('columnheader', { name: 'Server' })).not.toBeInTheDocument() + }) +}) diff --git a/src/screens/logs/QueryLogs.tsx b/src/screens/logs/QueryLogs.tsx index 8687282..6f57569 100644 --- a/src/screens/logs/QueryLogs.tsx +++ b/src/screens/logs/QueryLogs.tsx @@ -948,6 +948,15 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) { td: () => ({ className: `${styles.mono} ${styles.stamp}` }), cell: (e) => (), }, + /* Only the hub tags rows with the server they came from. */ + ...(page.entries.some((e) => e.node != null) + ? [{ + id: 'cNode', + header: 'Server', + td: () => ({ className: styles.mono }), + cell: (e: QueryLogEntry) => e.node ?? '', + }] + : []), { id: 'c2', header: 'Client IP Address', diff --git a/src/ui/ClusterNodeSelect.hub.test.tsx b/src/ui/ClusterNodeSelect.hub.test.tsx new file mode 100644 index 0000000..0c8c021 --- /dev/null +++ b/src/ui/ClusterNodeSelect.hub.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ClusterNodeSelect, HubMode, nodeOptions, AGGREGATE } from './ClusterNodeSelect' + +const NODES = [ + { name: 'dns', type: 'Primary' }, + { name: 'ndns', type: 'Secondary' }, +] + +/* Behind a hub the nodes are independent servers, not a cluster. */ +describe('hub mode', () => { + it('names the aggregate "All servers" and lists servers without a type', () => { + expect(nodeOptions(NODES, { aggregate: true, hub: true })).toEqual([ + { value: AGGREGATE, label: 'All servers' }, + { value: 'dns', label: 'dns' }, + { value: 'ndns', label: 'ndns' }, + ]) + }) + + it('shows All servers and labels the selector Server, whatever label the screen asks for', () => { + render( + + {}} label="Cluster Node" /> + , + ) + expect(screen.getByText('All servers')).toBeInTheDocument() + expect(screen.getByText('Server')).toBeInTheDocument() + expect(screen.queryByText('Cluster Node')).not.toBeInTheDocument() + }) + + it('keeps the cluster wording outside the hub', () => { + render( {}} label="Cluster Node" />) + expect(screen.getByText('Cluster')).toBeInTheDocument() + expect(screen.getByText('Cluster Node')).toBeInTheDocument() + }) +}) diff --git a/src/ui/ClusterNodeSelect.tsx b/src/ui/ClusterNodeSelect.tsx index 3316ad4..6e8fd8f 100644 --- a/src/ui/ClusterNodeSelect.tsx +++ b/src/ui/ClusterNodeSelect.tsx @@ -11,6 +11,13 @@ instead of the empty-value dash. */ export const ThisServerName = createContext('') +/* +True when the console talks to a hub (`info.hub` in the session) and not to a +Technitium server. The "nodes" are then independent servers, so the words change: +"All servers" for the aggregate, "Server" for the selector, and no `(primary)` tag. +*/ +export const HubMode = createContext(false) + /* Which cluster node a screen is looking at. @@ -39,10 +46,10 @@ export const AGGREGATE = 'cluster' export function nodeOptions( nodes: ClusterNode[], - { aggregate = false }: { aggregate?: boolean } = {}, + { aggregate = false, hub = false }: { aggregate?: boolean; hub?: boolean } = {}, ): { value: string; label: string }[] { - const out = aggregate ? [{ value: AGGREGATE, label: 'Cluster' }] : [] - for (const n of nodes) out.push({ value: n.name, label: `${n.name} (${n.type.toLowerCase()})` }) + const out = aggregate ? [{ value: AGGREGATE, label: hub ? 'All servers' : 'Cluster' }] : [] + for (const n of nodes) out.push({ value: n.name, label: hub ? n.name : `${n.name} (${n.type.toLowerCase()})` }) return out } @@ -71,6 +78,7 @@ export function ClusterNodeSelect({ disabled?: boolean }) { const thisServer = useContext(ThisServerName) + const hub = useContext(HubMode) if (!initialised) return null /* Only this server's own name stands in for the empty value: it is the node the @@ -79,10 +87,10 @@ export function ClusterNodeSelect({ const shown = value === '' && !aggregate && nodes.some((n) => n.name === thisServer) ? thisServer : value return ( - + {(id) => (