Hub stage 3: the console in hub mode
With info.hub the node selector reads Server / All servers, the default-credentials password prompt and the account actions the hub cannot serve are gone, the header status reports the servers an aggregate could not reach (hubWarnings, kept until the next aggregate), and merged Query Logs show a Server column. Without the flag nothing changes. The hub sends hubWarnings on every aggregate answer, empty when all servers answered, and never on a single-node one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f29a438c9
commit
48b5d9fb15
12 files changed
+360
-23
No files matched your search
+2
-1
@@ -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([])
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+4
-2
@@ -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<Json> {
|
||||
|
||||
@@ -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<T>(payload: Envelope, token?: string | null): ApiOutcome<T> {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
<ThemeProvider>
|
||||
<Shell session={session(hub)} onLogout={() => {}} forcePasswordChange={forcePasswordChange} />
|
||||
</ThemeProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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); }
|
||||
|
||||
+36
-15
@@ -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<string, Permission>
|
||||
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<ModalId | null>(forcePasswordChange ? 'password' : null)
|
||||
const [modal, setModal] = useState<ModalId | null>(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<string | undefined>(
|
||||
forcePasswordChange ? 'admin' : undefined,
|
||||
forcePasswordChange && !hub ? 'admin' : undefined,
|
||||
)
|
||||
const [sub, setSub] = useState<string | null>(initialRoute?.sub ?? null)
|
||||
const [displayName, setDisplayName] = useState(session.displayName)
|
||||
@@ -398,6 +403,7 @@ export function Shell({
|
||||
return (
|
||||
<HandoffContext.Provider value={handoff}>
|
||||
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
|
||||
<HubMode.Provider value={hub}>
|
||||
<SlotProvider>
|
||||
{(slot) => (
|
||||
<div className={styles.shell} data-rail={rail}>
|
||||
@@ -499,26 +505,31 @@ export function Shell({
|
||||
>
|
||||
{(close) => (
|
||||
<>
|
||||
<button type="button" onClick={() => { close(); open('profile') }}>
|
||||
My Profile
|
||||
</button>
|
||||
{/* The hub serves reads only: no profile, password, 2FA or tokens. */}
|
||||
{!hub && (
|
||||
<button type="button" onClick={() => { close(); open('profile') }}>
|
||||
My Profile
|
||||
</button>
|
||||
)}
|
||||
{/* 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' && (
|
||||
<button type="button" onClick={() => { close(); open('password') }}>
|
||||
Change Password
|
||||
</button>
|
||||
)}
|
||||
{session.type !== 'RemoteSSO' && (
|
||||
{!hub && session.type !== 'RemoteSSO' && (
|
||||
<button type="button" onClick={() => { close(); open('twofa') }}>
|
||||
Configure 2FA
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => { close(); open('token') }}>
|
||||
Create API Token
|
||||
</button>
|
||||
{!hub && (
|
||||
<button type="button" onClick={() => { close(); open('token') }}>
|
||||
Create API Token
|
||||
</button>
|
||||
)}
|
||||
{/* index.html:71: after the account's own entries, before the
|
||||
update notification. */}
|
||||
<button type="button" onClick={() => { close(); open('theme') }}>
|
||||
@@ -574,9 +585,13 @@ export function Shell({
|
||||
))}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger className={styles.status} data-attention={blockingNow != null} aria-label="Server status">
|
||||
<i className={styles.pdot} data-tone={blockingNow?.tone ?? 'ok'} />
|
||||
<span className={styles.lbl}>{blockingNow != null ? `Blocking ${blockingNow.text.toLowerCase()}` : (info?.dnsServerDomain ?? 'Server')}</span>
|
||||
<PopoverTrigger className={styles.status} data-attention={blockingNow != null || hubWarnings.length > 0} aria-label="Server status">
|
||||
<i className={styles.pdot} data-tone={hubWarnings.length > 0 ? 'warn' : (blockingNow?.tone ?? 'ok')} />
|
||||
<span className={styles.lbl}>
|
||||
{hubWarnings.length > 0
|
||||
? `${hubWarnings.length} ${hubWarnings.length === 1 ? 'server' : 'servers'} not answering`
|
||||
: blockingNow != null ? `Blocking ${blockingNow.text.toLowerCase()}` : (info?.dnsServerDomain ?? 'Server')}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className={styles.pop}>
|
||||
<dl>
|
||||
@@ -585,6 +600,11 @@ export function Shell({
|
||||
<dt>Up since</dt><dd>{info?.uptimestamp ? new Date(info.uptimestamp).toLocaleString() : '-'}</dd>
|
||||
<dt>Blocking</dt><dd>{blockingNow ? blockingNow.label.replace(/^Blocking,?\s*/, '') : 'Enabled'}</dd>
|
||||
</dl>
|
||||
{hubWarnings.length > 0 && (
|
||||
<ul className={styles.warnings} aria-label="Servers not answering">
|
||||
{hubWarnings.map((w) => (<li key={w}>{w}</li>))}
|
||||
</ul>
|
||||
)}
|
||||
<Versions
|
||||
token={session.token}
|
||||
serverVersion={info?.version}
|
||||
@@ -737,7 +757,7 @@ export function Shell({
|
||||
])}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Account">
|
||||
<CommandItem value="My Profile" onSelect={() => { setPalette(false); open('profile') }}>My Profile</CommandItem>
|
||||
{!hub && <CommandItem value="My Profile" onSelect={() => { setPalette(false); open('profile') }}>My Profile</CommandItem>}
|
||||
<CommandItem value="Change Theme" onSelect={() => { setPalette(false); open('theme') }}>Change Theme</CommandItem>
|
||||
<CommandItem value="Logout" onSelect={() => { setPalette(false); onLogout() }}>Logout</CommandItem>
|
||||
</CommandGroup>
|
||||
@@ -798,6 +818,7 @@ export function Shell({
|
||||
</div>
|
||||
)}
|
||||
</SlotProvider>
|
||||
</HubMode.Provider>
|
||||
</ThisServerName.Provider>
|
||||
</HandoffContext.Provider>
|
||||
)
|
||||
|
||||
@@ -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> = {}): 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(
|
||||
<HandoffContext.Provider value={{ queryDnsServer: vi.fn(), showQueryLogs: vi.fn() }}>
|
||||
<Logs token="t" sub="Query Logs" />
|
||||
</HandoffContext.Provider>,
|
||||
)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -948,6 +948,15 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
|
||||
td: () => ({ className: `${styles.mono} ${styles.stamp}` }),
|
||||
cell: (e) => (<Stamp text={dateTime(e.timestamp)} />),
|
||||
},
|
||||
/* 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',
|
||||
|
||||
@@ -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(
|
||||
<HubMode.Provider value>
|
||||
<ClusterNodeSelect nodes={NODES} initialised aggregate value={AGGREGATE} onChange={() => {}} label="Cluster Node" />
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
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(<ClusterNodeSelect nodes={NODES} initialised aggregate value={AGGREGATE} onChange={() => {}} label="Cluster Node" />)
|
||||
expect(screen.getByText('Cluster')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cluster Node')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<Field label={label}>
|
||||
<Field label={hub ? 'Server' : label}>
|
||||
{(id) => (
|
||||
<Select id={id} value={shown} disabled={disabled} onChange={(e) => onChange(e.target.value)}>
|
||||
{nodeOptions(nodes, { aggregate }).map((o) => (
|
||||
{nodeOptions(nodes, { aggregate, hub }).map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
|
||||
Reference in new issue
Block a user