UI polish across sections: one server selector, skeletons, inline errors
CI / check (push) Successful in 2m39s
Hub / check (push) Successful in 50s
CI / image (push) Successful in 1m30s
Hub / image (push) Successful in 1m37s

- Every section puts the hub's server selector in the chrome slot, the
  same place and gap as the Dashboard.
- Data regions load with a Skeleton and fail inline with Retry; KPIs show
  the no-data dash instead of 0 when a server did not answer. Settings
  keeps its header and selector while loading or failing.
- Cache, Apps and Logs headers group secondary or hub-refused actions in
  menus; app descriptions clamp to two lines; Unknown cells say why.
- DNS Client: Protocol label, Resolve and Import together. DHCP and Query
  Logs empty states with actions. View Logs list capped with scroll.
- Blocking: Lists KPI tiles like the Overview, app list table fits its
  panel with one-line URLs. Status re-reads on tab return at most once
  a minute.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-02 21:42:28 +07:00
1 parent 172e06f1ce
commit ba3cdd08d2
43 files changed
+638 -228

No files matched your search

+17
View File
@@ -53,6 +53,23 @@ describe('mergeStats counters', () => {
expect(Object.keys(merged.stats)).toEqual(Object.keys(stats()))
})
it('sums what each server holds: zones, cache, allowed, blocked and the two lists', () => {
const a = response({ stats: stats({ zones: 3, cachedEntries: 400, allowedZones: 1, blockedZones: 2, allowListZones: 1, blockListZones: 1000 }) })
const b = response({ stats: stats({ zones: 5, cachedEntries: 50, allowedZones: 4, blockedZones: 6, allowListZones: 2, blockListZones: 10 }) })
const merged = mergeStats([a, b])
expect(merged.stats).toMatchObject({ zones: 8, cachedEntries: 450, allowedZones: 5, blockedZones: 8, allowListZones: 3, blockListZones: 1010 })
})
it('counts a numeric string, and a server that left a field out as none', () => {
const a = response({ stats: { ...stats(), zones: '4' as unknown as number } })
const b = response({ stats: { totalQueries: 1 } })
const merged = mergeStats([a, b])
expect(merged.stats.zones).toBe(4)
expect(merged.stats.allowedZones).toBe(1)
expect(merged.stats.totalQueries).toBe(101)
expect(mergeStats([b]).stats.blockListZones).toBe(0)
})
it('returns a single response with the same numbers', () => {
const merged = mergeStats([response()])
expect(merged.stats).toEqual(stats())
+16 -1
View File
@@ -162,6 +162,16 @@ export function mergeTopResponses(
return { topDomains: [] }
}
/* A counter as a number: a number, or a numeric string some servers send. Anything else is not a counter. */
function counter(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) return Number(value)
return null
}
/* What a server holds, as the "Server" panel of the Dashboard counts it. */
const HELD = ['zones', 'cachedEntries', 'allowedZones', 'blockedZones', 'allowListZones', 'blockListZones']
/*
`dashboard/stats/get`. Every numeric counter in `stats` is summed. That includes
`totalClients` (distinct clients), which over-counts a client seen by both servers:
@@ -174,9 +184,14 @@ export function mergeStats(responses: DashboardStats[]): DashboardStats {
const stats: Record<string, number> = {}
for (const r of responses) {
for (const [key, value] of Object.entries(r.stats ?? {})) {
if (typeof value === 'number') stats[key] = (stats[key] ?? 0) + value
const n = counter(value)
if (n !== null) stats[key] = (stats[key] ?? 0) + n
}
}
/* The Dashboard's "Server" panel counts what each server holds (zones, cache, allow and block
lists). Those are sums too, and a server that left one out counts as none, so the field is
never missing from the total. */
for (const key of HELD) stats[key] ??= 0
const merged: DashboardStats = { ...first, stats }
const main = mergeChart(responses.map((r) => r.mainChartData), true)
+1 -1
View File
@@ -173,7 +173,7 @@ it to hang a control off would be undoing that in order to follow a drawing.
`:empty` is what keeps it from taking up room when nobody uses it: without it,
seven of eight screens would gain a blank gap.
*/
.chromeSlot { display: flex; align-items: center; gap: var(--s-6); margin-bottom: var(--s-8); }
.chromeSlot { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-6); margin-bottom: var(--s-8); }
.chromeSlot:empty { display: none; }
/*
+1 -1
View File
@@ -41,7 +41,7 @@ export function AppCard({
<li className={styles.gridRow} aria-label={app.name}>
<div className={styles.nameCell}>
<h2>{app.name}</h2>
{app.description && <p className={styles.desc}>{app.description}</p>}
{app.description && <p className={`${styles.desc} line-clamp-2`} title={app.description}>{app.description}</p>}
</div>
<div className={styles.mono}>
+14 -6
View File
@@ -18,7 +18,8 @@ import { StoreApps } from './StoreApps'
import { Panel } from '../../ui/Panel'
import { PageTabs } from '../../ui/PageTabs'
import { UpdateApp } from './UpdateApp'
import { Empty, Failure, Loading } from '../../ui/Empty'
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
import { Menu } from '../../ui/Menu'
import { Tag } from '../../ui/Tag'
import { useSort } from '../../ui/Table'
import { Icon } from '../../ui/Icon'
@@ -266,9 +267,16 @@ export function Apps({
{hubView === 'store' ? 'Back to apps' : 'App Store'}
</Button>
<Button onClick={() => setFileDialog(true)}>Install from file</Button>
<Button onClick={() => void exportFile()}>Export baselines</Button>
<Button onClick={() => setImportDialog(true)}>Import baselines</Button>
{isAdmin && <Button onClick={() => setProfilesDialog(true)}>Profiles</Button>}
{/* The three that move a whole set of apps share one menu: five buttons made a row of their own. */}
<Menu label="Baselines" text="Baselines">
{(close) => (
<>
<button type="button" onClick={() => { close(); void exportFile() }}>Export baselines</button>
<button type="button" onClick={() => { close(); setImportDialog(true) }}>Import baselines</button>
{isAdmin && <button type="button" onClick={() => { close(); setProfilesDialog(true) }}>Profiles</button>}
</>
)}
</Menu>
</>
) : undefined
}
@@ -347,12 +355,12 @@ export function Apps({
{tab === 'store' && mayChange ? (
<StoreApps token={token} node={requestNode} onChanged={() => void reload()} />
) : apps === null ? (
<Loading />
<LoadingRegion height="h-48">Loading…</LoadingRegion>
) : failure ? (
/* Solid and with no invitation: whether there are apps is not known, so
the store is not offered. The server's message goes in the notice
above. */
<Failure>Could not load the installed apps.</Failure>
<FailureRetry onRetry={() => void reload()}>Could not load the installed apps.</FailureRetry>
) : apps.length === 0 ? (
<Empty
title="No apps installed"
+6 -5
View File
@@ -232,12 +232,12 @@ describe('Export and import of baselines', () => {
it('offers both to writers only', async () => {
const { unmount } = mountApps(false)
await screen.findByText('No apps installed')
expect(screen.queryByRole('button', { name: 'Export baselines' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Import baselines' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Baselines' })).not.toBeInTheDocument()
unmount()
mountApps(true)
expect(await screen.findByRole('button', { name: 'Export baselines' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Import baselines' })).toBeInTheDocument()
await userEvent.click(await screen.findByRole('button', { name: 'Baselines' }))
expect(await screen.findByRole('menuitem', { name: 'Export baselines' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Import baselines' })).toBeInTheDocument()
})
it('downloads the file the hub sends', async () => {
@@ -246,7 +246,8 @@ describe('Export and import of baselines', () => {
const create = vi.fn(() => 'blob:x')
Object.assign(URL, { createObjectURL: create, revokeObjectURL: vi.fn() })
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await userEvent.click(await screen.findByRole('button', { name: 'Export baselines' }))
await userEvent.click(await screen.findByRole('button', { name: 'Baselines' }))
await userEvent.click(await screen.findByRole('menuitem', { name: 'Export baselines' }))
await waitFor(() => expect(click).toHaveBeenCalled())
expect(exported).toHaveBeenCalledWith('t')
expect(create).toHaveBeenCalled()
+22 -20
View File
@@ -3,7 +3,7 @@ import { getAppsMatrix, getDrift, listStoreApps, runHubApps, type AppsMatrixData
import { Badge } from '@/components/reui/badge'
import { Button } from '../../ui/Button'
import { DataTable, type Col } from '../../ui/DataTable'
import { Empty, Failure, Loading } from '../../ui/Empty'
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
import { Panel } from '../../ui/Panel'
import { Segmented } from '../../ui/Segmented'
import { Menu } from '../../ui/Menu'
@@ -75,7 +75,15 @@ function ConfigState({ app, data, onOpen }: { app: MatrixApp; data: AppsMatrixDa
function ServerCell({ app, id, data, drifted }: { app: MatrixApp; id: string; data: AppsMatrixData; drifted: boolean }) {
const cell = app.servers[id]
if (data.servers.find((s) => s.id === id)?.ok === false) return <span className="text-sm text-muted-foreground">Unknown</span>
if (data.servers.find((s) => s.id === id)?.ok === false) {
return (
<Tooltip text={`${id} did not answer`} placement="top">
<span className="text-sm text-muted-foreground" tabIndex={0}>
Unknown
</span>
</Tooltip>
)
}
if (cell === undefined || !cell.installed) return <span className="text-sm text-muted-foreground">Not installed</span>
return (
<span className="flex flex-wrap items-center gap-2 text-sm">
@@ -135,30 +143,20 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
)
}, [data])
const retry = (
<Button
variant="primary"
onClick={() => {
if (failure !== null && data === null) {
return (
<FailureRetry
onRetry={() => {
setFailure(null)
setData(null)
void load()
}}
>
Retry
</Button>
)
if (failure !== null && data === null) {
return (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>Could not load the apps of the servers. {failure}</span>
{retry}
</span>
</Failure>
Could not load the apps of the servers. {failure}
</FailureRetry>
)
}
if (data === null) return <Loading>Loading the apps of every server…</Loading>
if (data === null) return <LoadingRegion height="h-64">Loading the apps of every server…</LoadingRegion>
if (data.apps.length === 0) {
return (
@@ -249,7 +247,11 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
cell: (a) => (
<span className="flex flex-col gap-1">
<span className="text-sm font-medium">{a.name}</span>
{a.description !== '' && <span className="text-xs text-muted-foreground">{a.description}</span>}
{a.description !== '' && (
<span className="line-clamp-2 text-xs text-muted-foreground" title={a.description}>
{a.description}
</span>
)}
</span>
),
},
+1 -1
View File
@@ -98,7 +98,7 @@ export function HubStore({
</Tag>
))}
</span>
<span className="text-xs text-muted-foreground">{app.description}</span>
<span className="line-clamp-2 text-xs text-muted-foreground" title={app.description}>{app.description}</span>
</div>
{canWrite && (
<div className="flex items-center gap-2">
@@ -8,7 +8,6 @@ import { Failure } from '../../ui/Empty'
import { Icon } from '../../ui/Icon'
import { Pagination } from '../../ui/Pagination'
import { Tag } from '../../ui/Tag'
import { UrlText } from '../../ui/UrlText'
import { Skeleton } from '@/components/ui/skeleton'
import { HubWriteHint } from '../../ui/HubWriteHint'
import { useHubWrite } from '../../lib/hub-write'
@@ -22,6 +21,7 @@ import { changeAppConfig, useAdvancedBlocking } from './advanced-blocking-state'
import { missing, type Permissions } from './permissions'
import { pageOf } from './rules-model'
import shared from './Blocking.module.css'
import tbl from '../../ui/Table.module.css'
/*
The Advanced Blocking app's own lists (Lists tab) or rules (Rules tab), listed per group
@@ -254,7 +254,7 @@ export function AdvancedBlockingEntries({
cell: (r) => {
const f = (fields as readonly { field: GroupField; kind: string }[]).find((x) => x.field === r.field)
return (
<span className="inline-flex items-center gap-1">
<span className="inline-flex items-center gap-1 whitespace-nowrap">
<Icon name={f?.kind === 'allow' ? 'allowed' : 'blocked'} size={14} />
{labelOf(r.field)}
</span>
@@ -264,6 +264,7 @@ export function AdvancedBlockingEntries({
{
id: 'value',
header: kind === 'lists' ? 'List' : 'Rule',
td: () => ({ className: shared.entryCell }),
cell: (r) =>
editing?.key === r.key ? (
<Input
@@ -283,20 +284,22 @@ export function AdvancedBlockingEntries({
}}
/>
) : (
kind === 'lists' ? <UrlText url={r.value} /> : <span className={shared.entryValue}>{r.value}</span>
kind === 'lists'
? <span className={shared.entryUrl} title={r.value}>{r.value}</span>
: <span className={shared.entryValue}>{r.value}</span>
),
},
{
id: 'actions',
td: () => ({ className: shared.entryActions }),
td: () => ({ className: `${shared.entryActions} ${tbl.actionsCell}` }),
cell: (r) =>
editing?.key === r.key ? (
<span className="inline-flex gap-2">
<div className={tbl.actions}>
<Button size="sm" variant="primary" disabled={busy} onClick={() => void save(r)}>Save</Button>
<Button size="sm" disabled={busy} onClick={() => { setEditing(null); setProblem(null) }}>Cancel</Button>
</span>
</div>
) : (
<span className="inline-flex gap-2">
<div className={tbl.actions}>
<PermissionButton size="sm" permission={modifyNeed} disabled={off} aria-label={`Edit ${r.value}`}
onClick={() => { setEditing({ key: r.key, value: r.value }); setProblem(null) }}>
Edit
@@ -305,7 +308,7 @@ export function AdvancedBlockingEntries({
onClick={() => void run((root2) => (locate(root2, r) < 0 ? null : removeEntry(root2, r.group, r.field, locate(root2, r))), `Removed from the group ${r.group} of ${appName}.`)}>
Remove
</PermissionButton>
</span>
</div>
),
},
]}
+31 -40
View File
@@ -17,6 +17,8 @@ import { PermissionButton } from '../../ui/PermissionButton'
import { Confirm } from '../../ui/Confirm'
import { Notifier } from '../../ui/Notifier'
import { Failure, Loading } from '../../ui/Empty'
import { Frame, FramePanel } from '@/components/reui/frame'
import { KpiTile } from '../dashboard/Figures'
import { RouteLink } from '../../ui/RouteLink'
import { Icon } from '../../ui/Icon'
import { noticeFromFailure, type Notice } from '../../lib/notice'
@@ -599,50 +601,39 @@ export function BlockLists({
<Locked title="Allow List Domains" need={statsNeed} />
</>
) : (
<>
<Panel className={shared.centred}><Body>
<div className={shared.kpiText}>
{countsFailed ? (
<Failure>{COUNTS_FAILED}</Failure>
) : (
<span className={`${shared.kpiValue}${reloading != null ? ` ${styles.stale}` : ''}`}>
{counts ? counts.block.toLocaleString() : '—'}
</span>
)}
{reloading != null ? (
<span className={styles.pending}>{UPDATING}</span>
) : (
<span className={shared.kpiSub}>{'\u00a0'}</span>
)}
<span className={shared.kpiLabel}>Block List Domains</span>
</div>
</Body></Panel>
<Panel className={shared.centred}><Body>
<div className={shared.kpiText}>
{countsFailed ? (
<Failure>{COUNTS_FAILED}</Failure>
) : (
<span className={`${shared.kpiValue}${reloading != null ? ` ${styles.stale}` : ''}`}>
{counts ? counts.allow.toLocaleString() : '—'}
</span>
)}
{reloading != null ? (
<span className={styles.pending}>{UPDATING}</span>
) : (
<span className={shared.kpiSub}>{'\u00a0'}</span>
)}
<span className={shared.kpiLabel}>Allow List Domains</span>
</div>
</Body></Panel>
</>
/* The Dashboard's KPI look, as the Overview draws it: a Frame of tiles. */
<Frame className={shared.kpiPair}>
<FramePanel className={shared.kpiTiles}>
<KpiTile
label="Block List Domains"
icon="logs"
colour="var(--ch-block)"
figure={counts && !countsFailed ? counts.block.toLocaleString() : '\u2014'}
noData={counts == null || countsFailed}
wrap
>
{countsFailed ? COUNTS_FAILED : reloading != null ? <span className={styles.pending}>{UPDATING}</span> : ' '}
</KpiTile>
<KpiTile
label="Allow List Domains"
icon="check"
colour="var(--ch-ok)"
figure={counts && !countsFailed ? counts.allow.toLocaleString() : '\u2014'}
noData={counts == null || countsFailed}
wrap
>
{countsFailed ? COUNTS_FAILED : reloading != null ? <span className={styles.pending}>{UPDATING}</span> : ' '}
</KpiTile>
</FramePanel>
</Frame>
)}
<Panel className={shared.centred}><Body>
<div className="flex flex-col gap-4">
<div className={shared.kpi}>
<div className={shared.kpiText}>
<span className={shared.kpiValue}>{loading ? '—' : nextUpdateText(next)}</span>
<span className={shared.kpiSub}>{'\u00a0'}</span>
<span className={shared.kpiLabel}>
{/* Ordinary type: a date or "Not Scheduled" is a sentence, not a figure to read from afar. */}
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">{loading ? '\u2014' : nextUpdateText(next)}</span>
<span className="text-xs text-muted-foreground">
Next update{interval != null && interval > 0 ? ` · every ${interval} h` : ''}
</span>
</div>
+11 -1
View File
@@ -7,6 +7,9 @@ What the three Blocking tabs share. Every value is a token of theme/tokens.css.
/* Lists: the third card carries a date and a button, so its column never goes below
what they need on one line; with room to spare the three are equal. */
.kpis3 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(max-content, 1fr); gap: var(--s-9); }
/* Lists: the two list figures are one Frame of tiles, as the Overview draws its strip. */
.kpiPair { grid-column: span 2; }
.kpiTiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); padding: 0; }
/*
A panel whose content sits in its middle. A grid row stretches its panels to the
@@ -83,7 +86,9 @@ second frame, with rounded corners showing under the panel's header. Written und
/* The canvas's state strip above the tabs. */
.banner { display:flex; flex-wrap:wrap; align-items:center; gap: var(--s-7); padding: var(--s-7) var(--s-9); margin-bottom: var(--s-9); border:1px solid var(--line); border-radius: var(--r-3); background: color-mix(in oklch, var(--pan2) 50%, transparent); font-size: 13px; color: var(--mute); }
.bannerAct { margin-left: auto; }
.bannerAct { margin-left: auto; flex: none; }
/* The sentence is what wraps, not the action beside it. */
.bannerText { flex: 1 1 14rem; min-width: 0; }
/* The Advanced Blocking panels of the Lists and Rules tabs: the add row, the filter, a value and its buttons. */
.entryAdd { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--s-5); }
@@ -91,5 +96,10 @@ second frame, with rounded corners showing under the panel's header. Written und
.entryAdd > p { flex-basis: 100%; margin: 0; }
.entryFilter { padding: 0 var(--s-9) var(--s-5); }
.entryValue { font-family: var(--font-mono); overflow-wrap: break-word; }
/* A list URL on one line; the whole of it is the title. */
/* The value column takes what the others leave, and no more: without `max-width: 0` a
long URL widens the table past its panel and pushes Edit and Remove out of view. */
.entryCell { width: 100%; max-width: 0; }
.entryUrl { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); }
.entryActions { text-align: right; white-space: nowrap; }
.dim { color: var(--mute); }
+1 -1
View File
@@ -80,7 +80,7 @@ function Banner({ token, permissions, node }: { token: string | null; permission
{state.kind === 'app' && <Tag tone="ok">Advanced Blocking</Tag>}
{state.kind === 'paused' && <Tag tone="acc">{untilText(state.till)}</Tag>}
{state.kind === 'off' && <Tag>Disabled</Tag>}
<span>
<span className={shared.bannerText}>
{state.kind === 'on'
? 'Blocking is applied to every query'
: state.kind === 'app'
+42 -1
View File
@@ -4,7 +4,7 @@ import * as settings from '../../api/settings'
import * as client from '../../api/client'
import type { DnsSettings } from '../../api/settings'
import { settingsChanged } from '../../lib/settings-signal'
import { blockingMark, stateOf, useBlockingState } from './blocking-state'
import { VISIBLE_REREAD_MS, blockingMark, stateOf, useAllServersBlocking, useBlockingState } from './blocking-state'
import type { Permissions } from './permissions'
afterEach(() => {
@@ -96,6 +96,47 @@ describe('blocking state', () => {
expect(screen.getByText('off')).toBeInTheDocument()
})
it('a tab shown again reads only if the last read is a minute old', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: true } as DnsSettings)
render(<Probe />)
expect(await screen.findByText('on')).toBeInTheDocument()
expect(read).toHaveBeenCalledTimes(1)
await act(() => vi.advanceTimersByTimeAsync(VISIBLE_REREAD_MS - 5_000))
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
expect(read).toHaveBeenCalledTimes(1)
await act(() => vi.advanceTimersByTimeAsync(10_000))
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
expect(read).toHaveBeenCalledTimes(2)
// A write of our own reads at once, whatever the age of the last read, and restarts the minute.
act(() => settingsChanged())
expect(read).toHaveBeenCalledTimes(3)
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
expect(read).toHaveBeenCalledTimes(3)
})
it('the servers of All servers are not asked again by every return to the tab', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: true } as DnsSettings)
function All() {
const rows = useAllServersBlocking('T', { Settings: { canView: true, canModify: false, canDelete: false } }, ['a', 'b'], true)
return <output>{rows == null ? 'none' : `rows ${rows.length}`}</output>
}
render(<All />)
expect(await screen.findByText('rows 2')).toBeInTheDocument()
expect(read).toHaveBeenCalledTimes(2)
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
expect(read).toHaveBeenCalledTimes(2)
await act(() => vi.advanceTimersByTimeAsync(VISIBLE_REREAD_MS + 1_000))
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
expect(read).toHaveBeenCalledTimes(4)
})
it('without Settings.canView, or disabled, it reads nothing, even on a write', async () => {
const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
render(<><Probe permissions={S(false, false)} /><Probe enabled={false} /></>)
+11 -4
View File
@@ -24,13 +24,16 @@ When it reads:
`till` finds blocking back on; the second also absorbs a small clock skew;
- when the Advanced Blocking app's config is written from this console, since the answer
below depends on it;
- when the browser tab is shown again after being hidden: the state may have been
changed from elsewhere meanwhile, and a hidden tab is not kept current.
- when the browser tab is shown again after being hidden, if the last read is more than a
minute old: the state may have been changed from elsewhere meanwhile, and a hidden tab is
not kept current.
Reads are numbered, so an older answer arriving last cannot overwrite a newer one.
*/
/** How long after `till` to read again. See above. */
export const AFTER_PAUSE_MS = 1000
/** A tab shown again re-reads only if the last read is older than this: flicking between tabs is not a reason to ask every server again. */
export const VISIBLE_REREAD_MS = 60_000
/** `setTimeout`'s ceiling (2^31 - 1 ms, about 24.8 days). Past it the browser fires
AT ONCE, which with a far-off `till` would read in a loop; capped, it reads,
finds the pause still on and waits again. */
@@ -79,10 +82,12 @@ export function useBlockingState(
const viewNeed = enabled ? missing(permissions, 'Settings.canView') : 'Settings.canView'
const [base, setState] = useState<BlockingRead>(null)
const asked = useRef(0)
const lastRead = useRef(0)
const reload = useCallback(() => {
if (viewNeed != null) return
const mine = ++asked.current
lastRead.current = Date.now()
void getSettings(token, node).then((s) => {
if (mine !== asked.current) return
setState(s == null ? 'failed' : stateOf(s.enableBlocking, s.temporaryDisableBlockingTill))
@@ -99,7 +104,7 @@ export function useBlockingState(
useEffect(() => {
const shown = () => {
if (document.visibilityState === 'visible') reload()
if (document.visibilityState === 'visible' && Date.now() - lastRead.current >= VISIBLE_REREAD_MS) reload()
}
document.addEventListener('visibilitychange', shown)
return () => document.removeEventListener('visibilitychange', shown)
@@ -215,11 +220,13 @@ export function useAllServersBlocking(
const key = JSON.stringify(servers)
const [rows, setRows] = useState<ServerBlocking[] | null>(null)
const asked = useRef(0)
const lastRead = useRef(0)
const active = enabled && canSettings && servers.length > 0
const reload = useCallback(() => {
if (!active) return
const mine = ++asked.current
lastRead.current = Date.now()
void Promise.all((JSON.parse(key) as string[]).map((s) => readServerBlocking(token, s, canSettings, canApps))).then((r) => {
if (mine === asked.current) setRows(r)
})
@@ -237,7 +244,7 @@ export function useAllServersBlocking(
useEffect(() => {
if (!active) return
const shown = () => {
if (document.visibilityState === 'visible') reload()
if (document.visibilityState === 'visible' && Date.now() - lastRead.current >= VISIBLE_REREAD_MS) reload()
}
document.addEventListener('visibilitychange', shown)
return () => document.removeEventListener('visibilitychange', shown)
+5 -3
View File
@@ -20,7 +20,7 @@ import { TopStats } from './TopStats'
import { TopRowMenu } from './TopRowMenu'
import { useDomainAction } from '../../lib/allow-block'
import { SectionHeader } from '../../ui/SectionHeader'
import { Loading } from '../../ui/Empty'
import { LoadingRegion } from '../../ui/Empty'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Input } from '@/components/ui/input'
import styles from './Dashboard.module.css'
@@ -443,7 +443,7 @@ export function Dashboard({
: undefined}
>
<Body>
{loading && <Loading compact />}
{loading && <LoadingRegion height="h-72" />}
{!loading && view && hasData(view.mainChartData) && (
<Line data={view.mainChartData} resetOn={data?.mainChartData} onPick={pickPoint} height={300} />
)}
@@ -495,7 +495,9 @@ export function Dashboard({
rows={view?.topBlockedDomains ?? []}
onMore={() => setTop('TopBlockedDomains')}
failure={failure}
beforeMore={<BlockingMenu token={token} onNotice={setNotice} node={hub ? node : ''} allowed={canBlock} />}
/* With "All servers" the menu would only be a disabled button squeezing the title onto two
lines: it is drawn once a server is chosen. */
beforeMore={hub && node === AGGREGATE ? undefined : <BlockingMenu token={token} onNotice={setNotice} node={hub ? node : ''} allowed={canBlock} />}
menu={(f) => <TopRowMenu kind="TopBlockedDomains" name={f.name} node={node} action={action} />}
/>
<Breakdown
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { getHours } from '../../api/dashboard'
import { Body, Panel } from '../../ui/Panel'
import { Failure, Loading } from '../../ui/Empty'
import { Failure, LoadingRegion } from '../../ui/Empty'
import { heatRows, hourRequests, level, stitchHours, weekSpan, type HeatCell } from './heatmap'
import { spanFilters } from './jump'
import type { QueryLogsFilters } from '../../app/handoff'
@@ -102,7 +102,7 @@ export function Heatmap({
return (
<Panel title="Queries per Hour, Last 7 Days">
<Body>
{state.kind === 'loading' && <Loading compact />}
{state.kind === 'loading' && <LoadingRegion height="h-40" />}
{state.kind === 'failed' && <Failure>Could not load this data.</Failure>}
{/* A quiet week is drawn, not replaced by the empty box: zero is a true
figure, and every cell still says so. */}
+1 -1
View File
@@ -65,7 +65,7 @@ export function Top({
return (
<Panel
title={title}
title={<span className="whitespace-nowrap">{title}</span>}
className={styles.top}
actions={
<div className={styles.panelActions}>
+3
View File
@@ -5,6 +5,7 @@ import { Dhcp } from './Dhcp'
import * as api from '../../api/dhcp'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { HubWritable } from '../../lib/hub-write'
import { WithChrome } from '../../test/chrome'
afterEach(() => vi.restoreAllMocks())
@@ -20,7 +21,9 @@ function hubDhcp(writable: string[], canModify = true) {
return render(
<HubMode.Provider value>
<HubWritable.Provider value={writable}>
<WithChrome>
<Dhcp token="t" sub="Scopes" nodes={NODES} clusterInitialised canModify={canModify} canDelete={canModify} />
</WithChrome>
</HubWritable.Provider>
</HubMode.Provider>,
)
+4 -4
View File
@@ -88,13 +88,13 @@ describe('DHCP › Leases', () => {
the table's own footer (`dhcp.js:74`) and the rest of the console uses its
"there is nothing here" row.
*/
it('with no leases it says so INSIDE the table, and the footer keeps counting', async () => {
it('with no leases it says so, with a way out', async () => {
vi.spyOn(api, 'listLeases').mockResolvedValue({ kind: 'ok', data: [] })
render(<Dhcp token="t" sub="Leases" />)
const message = await screen.findByText('No Lease Found')
expect(message.closest('table')).not.toBeNull()
expect(screen.getByText('Total Leases: 0')).toBeInTheDocument()
/* An empty state with a way out, and the figures still known: zero is a true count here. */
expect(await screen.findByText('No Lease Found')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Reload leases' })).toBeInTheDocument()
})
it('each row offers only the conversion its type calls for', async () => {
+4 -4
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { SubTabs } from '../../ui/SubTabs'
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { ServerSelect } from '../../ui/ServerSelect'
import { Leases } from './Leases'
import { Scopes } from './Scopes'
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
@@ -84,7 +84,8 @@ export function Dhcp({
{/* Upstream mounts one here (`optDhcpClusterNode` / `optLogsClusterNode`).
It draws nothing without a cluster, so it costs a single-server install
nothing. */}
<ClusterNodeSelect
<ServerSelect
hint={<HubWriteHint write={write} />}
nodes={nodes}
initialised={clusterInitialised}
value={node}
@@ -92,7 +93,6 @@ export function Dhcp({
disabled={nodeLocked}
label="Cluster Node"
/>
<HubWriteHint write={write} />
{active === 'Scopes' ? (
<Scopes
tabs={tabs}
@@ -103,7 +103,7 @@ export function Dhcp({
onLockNode={setNodeLocked}
/>
) : (
<Leases tabs={tabs} token={token} node={node} canModify={canModify} canDelete={canDelete} />
<Leases tabs={tabs} token={token} node={node} canModify={canModify} canDelete={canDelete} onOpenScopes={() => onSubChange?.('Scopes')} />
)}
</>
)
+37 -11
View File
@@ -14,7 +14,7 @@ import { Dialog } from '../../ui/Dialog'
import { SectionHeader } from '../../ui/SectionHeader'
import { minuteStamp } from './dates'
import type { Notice } from './notices'
import { Failure, Loading } from '../../ui/Empty'
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
import { Tag } from '../../ui/Tag'
import tbl from '../../ui/Table.module.css'
import { Panel } from '../../ui/Panel'
@@ -45,6 +45,8 @@ and the type tag are kept.
*/
export interface LeasesProps {
/** The empty state's way to the other sub-tab. */
onOpenScopes?: () => void
/** The section sub-navigation, drawn under the header. */
tabs?: ReactNode
token: string | null
@@ -66,7 +68,7 @@ const KEYS: Keys<DhcpLease> = {
expires: (l) => minuteStamp(l.leaseExpires),
}
export function Leases({ tabs, token, node = '', canModify = true, canDelete = true }: LeasesProps) {
export function Leases({ tabs, token, node = '', canModify = true, canDelete = true, onOpenScopes }: LeasesProps) {
const [leases, setLeases] = useState<DhcpLease[] | null>(null)
/* When the list was read: what "expiring in 10 min" counts from. Read with the list, not in render. */
const [readAt, setReadAt] = useState(0)
@@ -174,8 +176,6 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
})
}
if (leases == null) return <Loading />
return (
<div className={styles.wrap}>
<SectionHeader
@@ -187,10 +187,18 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
<Notifier notice={notice} onClose={() => setNotice(null)} />
{leases != null && !failure && (
{/* Waiting or failed, the figures are not known: the dash, never a zero. */}
<KpiStrip
label="Lease totals"
items={[
items={
leases == null || failure
? [
{ label: 'Leases', value: '\u2014', icon: 'dhcp' },
{ label: 'Reserved', value: '\u2014', icon: 'lock' },
{ label: 'Scopes', value: '\u2014', icon: 'cache' },
{ label: 'Expiring in 10 min', value: '\u2014', icon: 'power' },
]
: [
{ label: 'Leases', value: leases.length, icon: 'dhcp' },
{ label: 'Reserved', value: leases.filter((l) => l.type === 'Reserved').length, icon: 'lock' },
{ label: 'Scopes', value: new Set(leases.map((l) => l.scope)).size, icon: 'cache' },
@@ -199,14 +207,32 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
value: leases.filter((l) => l.type !== 'Reserved' && Date.parse(l.leaseExpires) - readAt < 600000 && Date.parse(l.leaseExpires) > readAt).length,
icon: 'power',
},
]}
]
}
/>
)}
{failure ? (
{leases == null ? (
<LoadingRegion height="h-48" />
) : failure ? (
/* Solid, not dashed, and with no count: how many there are is not known.
The server's message goes in the notice at the top. */
<Failure>Could not load the list.</Failure>
<FailureRetry onRetry={() => void load()}>Could not load the list.</FailureRetry>
) : leases.length === 0 ? (
<Panel title="Leases" description="Scope, hardware address, IP address, host name, type and expiry">
<Empty
title="No Lease Found"
actions={
<>
<Button variant="primary" onClick={() => void load()}>
Reload leases
</Button>
{onOpenScopes != null && <Button onClick={onOpenScopes}>Open Scopes</Button>}
</>
}
>
No client holds a lease from this server yet.
</Empty>
</Panel>
) : (
<>
<Panel title="Leases" description="Scope, hardware address, IP address, host name, type and expiry">
@@ -301,7 +327,7 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
]}
/>
<div className={styles.total}>
<div className={`${styles.total} px-4 py-3`}>
{/* The footer is the count and nothing else. When there are no rows,
the one that says so is the table itself, with its centred row, like
the rest of the console and like upstream (`dhcp.js:74`); here it was
+24 -16
View File
@@ -17,7 +17,7 @@ import { SectionHeader } from '../../ui/SectionHeader'
import { errorNotice, type Notice } from './notices'
import { formFromScope, blankForm, type ScopeForm as Form } from './model'
import { ScopeForm } from './ScopeForm'
import { Failure, Loading } from '../../ui/Empty'
import { FailureRetry, LoadingRegion } from '../../ui/Empty'
import { Tag } from '../../ui/Tag'
import tbl from '../../ui/Table.module.css'
import styles from './Dhcp.module.css'
@@ -200,16 +200,6 @@ export function Scopes({
<div className={styles.wrap}>
<Notifier notice={notice} onClose={() => setNotice(null)} />
{scopes != null && !failure && (
<KpiStrip
label="Scope totals"
items={[
{ label: 'Scopes', value: scopes.length, icon: 'cache' },
{ label: 'Enabled', value: scopes.filter((s) => s.enabled).length, icon: 'check', tone: 'ok' },
{ label: 'Disabled', value: scopes.filter((s) => !s.enabled).length, icon: 'power' },
]}
/>
)}
<ScopeForm
key={editing.title + editing.form.oldName}
title={editing.title}
@@ -226,8 +216,6 @@ export function Scopes({
)
}
if (scopes == null) return <Loading />
return (
<div className={styles.wrap}>
<SectionHeader
@@ -249,10 +237,30 @@ export function Scopes({
<Notifier notice={notice} onClose={() => setNotice(null)} />
{failure ? (
{/* Waiting or failed, the figures are not known: the dash, never a zero. */}
<KpiStrip
label="Scope totals"
items={
scopes == null || failure
? [
{ label: 'Scopes', value: '\u2014', icon: 'cache' },
{ label: 'Enabled', value: '\u2014', icon: 'check' },
{ label: 'Disabled', value: '\u2014', icon: 'power' },
]
: [
{ label: 'Scopes', value: scopes.length, icon: 'cache' },
{ label: 'Enabled', value: scopes.filter((s) => s.enabled).length, icon: 'check', tone: 'ok' },
{ label: 'Disabled', value: scopes.filter((s) => !s.enabled).length, icon: 'power' },
]
}
/>
{scopes == null ? (
<LoadingRegion height="h-48" />
) : failure ? (
/* Solid, not dashed, and with no count: how many there are is not known.
The server's message goes in the notice at the top. */
<Failure>Could not load the list.</Failure>
<FailureRetry onRetry={() => void load()}>Could not load the list.</FailureRetry>
) : (
<>
<Panel title="Scopes" description="Address pools and their options">
@@ -354,7 +362,7 @@ export function Scopes({
]}
/>
<div className={styles.total}>
<div className={`${styles.total} px-4 py-3`}>
{/* The footer is the count and nothing else. When there are no rows,
the one that says so is the table itself, with its centred row, like
the rest of the console and like upstream (`dhcp.js:74`); here it was
+3 -1
View File
@@ -3,6 +3,8 @@ The query bar. `align-items: flex-end` is what lines the buttons up with the
bottom edge of the fields; before it was achieved with a `marginBottom: 1` put by
hand on each button, which is the symptom of a row that does not align.
*/
.wrap { composes: screen from '../../ui/SectionHeader.module.css'; }
.flt {
display: flex;
align-items: flex-end;
@@ -22,7 +24,7 @@ hand on each button, which is the symptom of a row that does not align.
it carries the field's height and not an invented margin. */
/* `.chk` went in the redesign: a third name for the settings checkbox, and the only
place in the console where it measured 34 px high. It uses `ui/Check`. */
.panel { margin-bottom: var(--s-9); }
.panel { margin-bottom: 0; }
.meta { composes: annotationQuiet from '../../ui/text.module.css'; }
/*
+18 -7
View File
@@ -1,4 +1,4 @@
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { ServerSelect } from '../../ui/ServerSelect'
import { useEffect, useRef, useState } from 'react'
import type { DnsClientRequest } from '../../app/handoff'
import { PROTOCOLS, TYPES, prepareServer, resolve } from '../../api/dnsclient'
@@ -12,7 +12,7 @@ import { Field, Input, LabeledInput, LabeledSelect } from '../../ui/Field'
import { Menu } from '../../ui/Menu'
import { loadQuickList, protocolForServer, serverListOptions, type QuickServer } from '../../lib/quick-lists'
import { SectionHeader } from '../../ui/SectionHeader'
import { Empty } from '../../ui/Empty'
import { Empty, FailureRetry } from '../../ui/Empty'
import { Raw } from '../../ui/Raw'
import { Details } from '../../ui/Details'
import styles from './DnsClient.module.css'
@@ -92,6 +92,8 @@ export function DnsClient({
const [raw, setRaw] = useState<unknown[]>([])
const [alert, setAlert] = useState<AlertState | null>(null)
const [busy, setBusy] = useState(false)
/* Why the last query failed, drawn where its response would be: no answer is not an empty answer. */
const [failure, setFailure] = useState<string | null>(null)
async function fire(runImport: boolean) {
// The order is upstream's: extract first, check afterwards.
@@ -124,8 +126,10 @@ export function DnsClient({
setOutput(null)
setRaw([])
setAlert(noticeFromFailure(outcome))
setFailure(noticeFromFailure(outcome).text)
return
}
setFailure(null)
const r = outcome.data.response
setOutput(JSON.stringify(r.result, null, 2))
@@ -154,17 +158,17 @@ export function DnsClient({
}, [request])
return (
<>
<div className={styles.wrap}>
<SectionHeader title="DNS Client" subtitle="Resolve a name through this server or another resolver" />
<ClusterNodeSelect
<ServerSelect
hint={<HubWriteHint write={write} />}
nodes={nodes}
initialised={clusterInitialised}
value={clusterNode}
onChange={setClusterNode}
label="Cluster Node"
/>
<HubWriteHint write={write} />
<Notifier notice={alert} onClose={() => setAlert(null)} />
@@ -219,7 +223,8 @@ export function DnsClient({
</LabeledSelect>
</div>
<div className={styles.short}>
<LabeledSelect label="DNS-over-" value={protocol} onChange={(e) => setProtocol(e.target.value)}>
{/* Upstream's label is "DNS-over-" (the protocol's suffix); on its own it was cut off. */}
<LabeledSelect label="Protocol" value={protocol} onChange={(e) => setProtocol(e.target.value)}>
{PROTOCOLS.map((t) => <option key={t}>{t}</option>)}
</LabeledSelect>
</div>
@@ -230,6 +235,7 @@ export function DnsClient({
THIRD name for the same gesture, and the only one in the console
that came out at 34 px high. */}
<Check label="Enable DNSSEC Validation" checked={dnssec} onChange={setDnssec} />
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy} onClick={() => void fire(false)}>
Resolve
</Button>
@@ -237,10 +243,15 @@ export function DnsClient({
Import
</PermissionButton>
</div>
</div>
</Panel>
{output === null ? (
failure !== null ? (
<FailureRetry onRetry={() => void fire(false)}>The query failed. {failure}</FailureRetry>
) : (
<Empty>Run a query to see the response.</Empty>
)
) : (
<Panel
title="Response"
@@ -265,6 +276,6 @@ export function DnsClient({
</Body>
</Panel>
)}
</>
</div>
)
}
+8 -3
View File
@@ -7,6 +7,7 @@ import { HubMode } from '../../ui/ClusterNodeSelect'
import { HubWritable } from '../../lib/hub-write'
import { HubServerIds } from '../../lib/hub-all'
import * as hubRun from '../../api/hub-run'
import { WithChrome } from '../../test/chrome'
afterEach(() => vi.restoreAllMocks())
@@ -22,7 +23,9 @@ function hubCache(writable: string[], canDelete = true) {
<HubMode.Provider value>
<HubWritable.Provider value={writable}>
<HubServerIds.Provider value={['mdns', 'ndns']}>
<WithChrome>
<Cache token="t" nodes={NODES} clusterInitialised canDelete={canDelete} />
</WithChrome>
</HubServerIds.Provider>
</HubWritable.Provider>
</HubMode.Provider>,
@@ -62,7 +65,8 @@ describe('Cache behind a hub, on several servers', () => {
.mockResolvedValueOnce(done({ server: 'mdns', ok: true, message: 'Cache flushed.' }, { server: 'ndns', ok: false, message: 'ndns did not answer' }))
.mockResolvedValueOnce(done({ server: 'ndns', ok: true, message: 'Cache flushed.' }))
hubCache(['mdns', 'ndns'])
await user.click(await screen.findByRole('button', { name: 'Flush on all servers' }))
await user.click(await screen.findByRole('button', { name: 'All servers' }))
await user.click(await screen.findByRole('menuitem', { name: 'Flush on all servers' }))
const picker = await screen.findByRole('dialog')
await user.click(within(picker).getByRole('button', { name: 'Flush cache' }))
expect(run).toHaveBeenNthCalledWith(1, 't', 'cache.flush', {}, ['mdns', 'ndns'])
@@ -76,7 +80,8 @@ describe('Cache behind a hub, on several servers', () => {
const user = userEvent.setup()
const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(done({ server: 'mdns', ok: true }))
hubCache(['mdns'])
await user.click(await screen.findByRole('button', { name: 'Delete a domain on all servers' }))
await user.click(await screen.findByRole('button', { name: 'All servers' }))
await user.click(await screen.findByRole('menuitem', { name: 'Delete a domain on all servers' }))
const ask = await screen.findByRole('dialog')
await user.type(within(ask).getByLabelText('Domain'), 'bad domain')
await user.click(within(ask).getByRole('button', { name: 'Choose servers' }))
@@ -93,6 +98,6 @@ describe('Cache behind a hub, on several servers', () => {
it('is not offered to a role that may not delete from the cache', async () => {
hubCache(['mdns', 'ndns'], false)
await screen.findByRole('button', { name: 'Flush Cache' })
expect(screen.queryByRole('button', { name: 'Flush on all servers' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'All servers' })).not.toBeInTheDocument()
})
})
+23 -7
View File
@@ -1,7 +1,7 @@
import { useContext, useState } from 'react'
import { useAllServersFlow } from '../../lib/hub-all'
import { AskDialog } from '../../ui/AskDialog'
import { Button } from '../../ui/Button'
import { Menu } from '../../ui/Menu'
import { HubMode } from '../../ui/ClusterNodeSelect'
/*
@@ -24,20 +24,36 @@ export function CacheAllServers({ token }: { token: string | null }) {
if (!hub) return null
return (
<>
<Button
variant="danger"
onClick={() =>
{/* One menu, not two more buttons next to the chosen server's own Flush Cache. */}
<Menu label="All servers" text="All servers">
{(close) => (
<>
<button
type="button"
onClick={() => {
close()
start({
action: 'cache.flush',
title: 'Flush the cache on several servers',
intro: 'The whole cache of each chosen server is emptied.',
confirmLabel: 'Flush cache',
})
}
}}
>
Flush on all servers
</Button>
<Button onClick={() => setAsking(true)}>Delete a domain on all servers</Button>
</button>
<button
type="button"
onClick={() => {
close()
setAsking(true)
}}
>
Delete a domain on all servers
</button>
</>
)}
</Menu>
{asking && (
<AskDialog
title="Delete a domain from the cache on all servers"
+3 -1
View File
@@ -63,7 +63,9 @@ describe('stale data in the lists', () => {
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' } as never)
render(<Cache token="t" />)
expect(await screen.findByText(/boom/)).toBeInTheDocument()
/* The notice carries the server's message and the region repeats it beside its Retry. */
expect((await screen.findAllByText(/boom/)).length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.queryByText(/Could not refresh/)).not.toBeInTheDocument()
})
+28 -8
View File
@@ -1,5 +1,6 @@
import { KpiStrip } from '../../ui/KpiStrip'
import { ClusterNodeSelect, HubMode, primaryNodeName } from '../../ui/ClusterNodeSelect'
import { HubMode, primaryNodeName } from '../../ui/ClusterNodeSelect'
import { ServerSelect } from '../../ui/ServerSelect'
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
@@ -21,7 +22,7 @@ import { Confirm } from '../../ui/Confirm'
import { Dialog } from '../../ui/Dialog'
import { Field, Input, LabeledTextarea } from '../../ui/Field'
import { SectionHeader } from '../../ui/SectionHeader'
import { Empty, Loading } from '../../ui/Empty'
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
import { StaleData } from '../StaleData'
import { Tree } from './Tree'
import { CacheAllServers } from './CacheAllServers'
@@ -265,6 +266,8 @@ export function Lists({
/* A first read that failed and was reported to the host: there is no notice here
to tell "failed" from "still loading", so it is said apart. */
const [failedOutside, setFailedOutside] = useState(false)
/* Why the first read failed, drawn in the regions: a failed read has no records, which is not an empty node. */
const [failure, setFailure] = useState<string | null>(null)
/*
Stale data. The same gap Zones had, and the same design rule (old data never
looks like new data): the previous list
@@ -310,6 +313,7 @@ export function Lists({
const outcome = await listNode(list, token, domain, up ? 'up' : undefined, node)
if (outcome.kind === 'ok') {
setNode(outcome.data)
setFailure(null)
setStale(false)
setLastGood(new Date().toISOString())
hadData.current = true
@@ -328,6 +332,7 @@ export function Lists({
*/
if (!hadData.current) {
if (host.current != null) setFailedOutside(true)
setFailure(noticeFromFailure(outcome).text)
report(noticeFromFailure(outcome))
} else setStale(true)
},
@@ -366,7 +371,16 @@ export function Lists({
/* The first load, still in flight: `node` is null and nothing failed. A load
that FAILED leaves `node` null too, but it sets the notice, and then this is
not loading, it is a failure with nothing behind it. */
const loading = node == null && notice == null && !failedOutside
const failed = node == null && (notice != null || failedOutside || failure != null)
const loading = node == null && !failed
/* Retry asks for the root again, with every trace of the failed read gone. */
const retry = () => {
setNotice(null)
setFailedOutside(false)
setFailure(null)
void load('')
}
const domain = node?.domain ?? ''
const nodeTitle = domain === '' ? '<ROOT>' : (node?.domainIdn ?? domain)
@@ -468,7 +482,8 @@ export function Lists({
Changing it reloads the open domain from that node, and the list, Flush
and Delete all go to it (other-zones.js:20-61, 106). */}
{isCache && !embedded && (
<ClusterNodeSelect
<ServerSelect
hint={<HubWriteHint write={cacheWrite} />}
nodes={nodes}
initialised={clusterInitialised}
value={clusterNode}
@@ -481,7 +496,6 @@ export function Lists({
label="Cluster Node"
/>
)}
{isCache && !embedded && <HubWriteHint write={cacheWrite} />}
{/* On Cache the identity band's second line moves up under the title: the
screen is one panel now, and a band naming the panel under a header
@@ -562,7 +576,9 @@ export function Lists({
but it sets the notice, and that is what the reader has to see.
*/}
{loading ? (
<div className={styles.body}><Loading /></div>
<div className={styles.body}><LoadingRegion height="h-48" /></div>
) : failed ? (
<div className={styles.body}><Empty compact>The domain tree could not be read.</Empty></div>
) : (
<>
{/* The count lives in the count bar, like the records one: in the
@@ -630,7 +646,7 @@ export function Lists({
shows, which is the defect the tree had fixed on 2026-09-07 and this
side still had.
*/}
{!loading && (
{!loading && !failed && (
<div className={styles.count}>
<span>
{records.length} records at <span className={styles.mono}>{nodeTitle}</span>
@@ -666,7 +682,11 @@ export function Lists({
{loading ? (
/* The tree's placeholder above already announced this wait: two
`role="status"` for one request read it out twice. */
<div className={styles.empty}><Loading announce={false} /></div>
<div className={styles.empty}><LoadingRegion announce={false} height="h-48" /></div>
) : failed ? (
<div className={styles.empty}>
<FailureRetry onRetry={retry}>Could not read the records. {failure}</FailureRetry>
</div>
) : records.length > 0 ? (
<ResourceRecords records={records} withDnssec={isCache} node={domain} />
) : (
+1 -1
View File
@@ -59,7 +59,7 @@ the file list keeps its own height), its body, and `Raw`'s `fill` box at the end
.pbList { padding: var(--s-5); }
.logfiles { display: flex; flex-direction: column; gap: var(--s-1); }
.logfiles { display: flex; flex-direction: column; gap: var(--s-1); max-height: 60dvh; overflow-y: auto; }
.logfile { composes: row from '../../ui/list.module.css'; justify-content: space-between; }
.logfile .name { font-family: var(--font-mono); }
.sz { composes: annotationQuiet from '../../ui/text.module.css'; }
+22 -9
View File
@@ -70,6 +70,11 @@ function withApps(list: InstalledApp[]) {
.mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: { apps: list } } } as never)
}
/* The two destructive verbs share the header's "Delete all" menu. */
async function openDelete() {
await userEvent.setup().click(await screen.findByRole('button', { name: 'Delete all' }))
}
describe('Logs › View Logs', () => {
it('it lists the files with their size and offers to delete them all', async () => {
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
@@ -77,8 +82,9 @@ describe('Logs › View Logs', () => {
expect(await screen.findByText('2026-08-26')).toBeInTheDocument()
expect(screen.getByText('[20.48 KB]')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete All Logs' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeInTheDocument()
await openDelete()
expect(screen.getByRole('menuitem', { name: 'Delete All Logs' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Delete All Stats' })).toBeInTheDocument()
})
it('with no files it says \"No Log File Was Found\" and \"Delete All Logs\" disappears, but NOT \"Delete All Stats\"', async () => {
@@ -86,8 +92,9 @@ describe('Logs › View Logs', () => {
render(<Logs token="t" sub="View Logs" />)
expect(await screen.findByText('No Log File Was Found')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete All Logs' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeInTheDocument()
await openDelete()
expect(screen.queryByRole('menuitem', { name: 'Delete All Logs' })).not.toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Delete All Stats' })).toBeInTheDocument()
})
it('opening a file asks for only the first 2 MB and draws it', async () => {
@@ -172,7 +179,8 @@ describe('Logs › View Logs', () => {
render(<Logs token="t" sub="View Logs" />)
await screen.findByText('2026-08-26')
await user.click(screen.getByRole('button', { name: 'Delete All Logs' }))
await user.click(await screen.findByRole('button', { name: 'Delete all' }))
await user.click(screen.getByRole('menuitem', { name: 'Delete All Logs' }))
expect(
screen.getByText('Are you sure you want to permanently delete all log files?'),
).toBeInTheDocument()
@@ -192,7 +200,8 @@ describe('Logs › View Logs', () => {
render(<Logs token="t" sub="View Logs" />)
await screen.findByText('2026-08-26')
await user.click(screen.getByRole('button', { name: 'Delete All Stats' }))
await user.click(await screen.findByRole('button', { name: 'Delete all' }))
await user.click(screen.getByRole('menuitem', { name: 'Delete All Stats' }))
expect(
screen.getByText('Are you sure you want to permanently delete all stats files?'),
).toBeInTheDocument()
@@ -219,12 +228,14 @@ describe('Logs › View Logs', () => {
const { unmount } = render(
<Logs token="t" sub="View Logs" canDeleteLogs={false} canDeleteStats />,
)
await openDelete()
expect(await screen.findByRole('button', { name: /Delete All Logs/ })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeEnabled()
expect(screen.getByRole('menuitem', { name: 'Delete All Stats' })).toBeEnabled()
unmount()
render(<Logs token="t" sub="View Logs" canDeleteLogs canDeleteStats={false} />)
expect(await screen.findByRole('button', { name: 'Delete All Logs' })).toBeEnabled()
await openDelete()
expect(await screen.findByRole('menuitem', { name: 'Delete All Logs' })).toBeEnabled()
expect(screen.getByRole('button', { name: /Delete All Stats/ })).toBeDisabled()
})
@@ -237,6 +248,7 @@ describe('Logs › View Logs', () => {
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
render(<Logs token="t" sub="View Logs" canDeleteLogs={false} canDeleteStats={false} />)
await user.click(await screen.findByRole('button', { name: 'Delete all' }))
const stats = await screen.findByRole('button', { name: /Delete All Stats/ })
await user.hover(stats.parentElement!)
expect(await screen.findByText('Requires Dashboard: Delete')).toBeInTheDocument()
@@ -254,7 +266,8 @@ describe('Logs › View Logs', () => {
render(<Logs token="t" sub="View Logs" canDeleteLogs canDeleteStats />)
expect(await screen.findByText('No Log File Was Found')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Delete All Logs/ })).not.toBeInTheDocument()
await openDelete()
expect(screen.queryByRole('menuitem', { name: /Delete All Logs/ })).not.toBeInTheDocument()
})
})
+4 -3
View File
@@ -3,7 +3,8 @@ import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { useAllServersFlow } from '../../lib/hub-all'
import { HubWriteHint } from '../../ui/HubWriteHint'
import { SubTabs } from '../../ui/SubTabs'
import { AGGREGATE, ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
import { AGGREGATE, HubMode } from '../../ui/ClusterNodeSelect'
import { ServerSelect } from '../../ui/ServerSelect'
import { QueryLogs } from './QueryLogs'
import { ViewLogs } from './ViewLogs'
import type { QueryLogsRequest } from '../../app/handoff'
@@ -96,7 +97,8 @@ export function Logs({
{/* Upstream mounts one here (`optDhcpClusterNode` / `optLogsClusterNode`).
It draws nothing without a cluster, so it costs a single-server install
nothing. */}
<ClusterNodeSelect
<ServerSelect
hint={active === 'Query Logs' && <HubWriteHint write={write} allServers={allServers} />}
nodes={nodes}
initialised={clusterInitialised}
value={node}
@@ -104,7 +106,6 @@ export function Logs({
aggregate={aggregateShown}
label="Cluster Node"
/>
{active === 'Query Logs' && <HubWriteHint write={write} allServers={allServers} />}
{active === 'Query Logs' ? (
<HubNode.Provider value={hub ? node : ''}>
<QueryLogs
+39 -3
View File
@@ -19,7 +19,7 @@ import { Button } from '../../ui/Button'
import { Input, Select } from '../../ui/Field'
import { SectionHeader } from '../../ui/SectionHeader'
import { toIso, dateTime } from './dates'
import { Loading } from '../../ui/Empty'
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
import { Check } from '../../ui/Check'
import { Tag } from '../../ui/Tag'
import { DataTable } from '../../ui/DataTable'
@@ -331,6 +331,10 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
const [apps, setApps] = useState<{ name: string; classPaths: string[] }[] | null>(null)
const [f, setF] = useState<Filters>(() => defaultFilters('', ''))
const [page, setPage] = useState<QueryLogPage | null>(null)
/* Why the last query (or the read of the logging apps) failed: a failure has no rows, which is not an empty log. */
const [queryFailure, setQueryFailure] = useState<string | null>(null)
const [appsFailed, setAppsFailed] = useState(false)
const [appsTry, setAppsTry] = useState(0)
const [notice, setNotice] = useState<Notice | null>(null)
const [busy, setBusy] = useState(false)
const [live, setLive] = useState(false)
@@ -444,10 +448,12 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
if (outcome.kind !== 'ok') {
if (!live) {
setNotice(noticeFromFailure(outcome))
setQueryFailure(noticeFromFailure(outcome).text)
}
return
}
setQueryFailure(null)
setPage(outcome.data.response)
},
[token, params, reveal],
@@ -490,9 +496,11 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
run, so the screen sat dead without saying why.
*/
setApps([])
setAppsFailed(true)
setNotice(noticeFromFailure(outcome))
return
}
setAppsFailed(false)
const list = appsWithQueryLogs(outcome.data.response.apps ?? [])
setApps(list)
const first = list[0]
@@ -516,7 +524,7 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
return () => {
live = false
}
}, [token])
}, [token, appsTry])
/* logs.js:610. While "Live Update" is checked, it repeats every 2 s. */
useEffect(() => {
@@ -599,7 +607,14 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
}
}
if (apps == null) return <Loading />
if (apps == null) {
return (
<div className={styles.wrap}>
<SectionHeader title="Logs" subtitle="Server log files and the query log of the installed logging app" tabs={tabs} />
<LoadingRegion height="h-48" />
</div>
)
}
/* What the folded panel lists (FOLD_QUERY). */
const active = activeFilters(f, defaultFilters(apps[0]?.name ?? '', apps[0]?.classPaths[0] ?? ''))
@@ -917,6 +932,27 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
</div>
</div>
{page == null && (
busy ? (
<LoadingRegion height="h-48" />
) : appsFailed ? (
<FailureRetry onRetry={() => { setApps(null); setAppsTry((n) => n + 1) }}>Could not read the logging apps.</FailureRetry>
) : queryFailure !== null ? (
<FailureRetry onRetry={() => void query(f.pageNumber, false)}>Could not read the query log. {queryFailure}</FailureRetry>
) : (
<Empty
title="No query yet"
actions={
<Button variant="primary" disabled={live} onClick={() => void query(f.pageNumber, false)}>
Run query
</Button>
}
>
Choose the filters and run a query to read the log.
</Empty>
)
)}
{page != null && (
<Panel title="Query Logs" description="Logging app, dates, client, domain and response type">
<div className={styles.count}>
+54 -27
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
import {
deleteAllLogs,
deleteLog,
@@ -15,7 +15,10 @@ import { LogText, stampWidth } from './LogText'
import { PermissionButton } from '../../ui/PermissionButton'
import { Button } from '../../ui/Button'
import { SectionHeader } from '../../ui/SectionHeader'
import {Empty, Loading} from '../../ui/Empty'
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
import { Menu } from '../../ui/Menu'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { LockedItem } from '../blocking/Locked'
import styles from './Logs.module.css'
import { Body, Panel } from '../../ui/Panel'
import { noticeFromFailure, type Notice } from '../../lib/notice'
@@ -95,7 +98,10 @@ export function ViewLogs({
canDeleteLogs = true,
canDeleteStats = true,
}: ViewLogsProps) {
const hub = useContext(HubMode)
const [files, setFiles] = useState<LogFile[] | null>(null)
/* The list could not be read: that is not a server without log files. */
const [failed, setFailed] = useState(false)
const [open, setOpen] = useState<string | null>(null)
const [body, setBody] = useState<string | null>(null)
const [loadingBody, setLoadingBody] = useState(false)
@@ -108,9 +114,11 @@ export function ViewLogs({
const r = await listLogFiles(token, node)
if (r.kind === 'ok') {
setFiles(r.data)
setFailed(false)
return r.data
}
setFiles([])
setFailed(true)
setNotice(noticeFromFailure(r))
return null
}, [token, node])
@@ -211,8 +219,6 @@ export function ViewLogs({
})
}
if (files == null) return <Loading />
const CONFIRM_TEXT: Record<Confirmation, { title: string; text: string; label: string }> = {
log: {
title: 'Delete Log',
@@ -253,24 +259,31 @@ export function ViewLogs({
controls, among the nine compared with upstream, that "does not ask for
the permission of the screen whose name it carries".
*/}
{files.length > 0 && (
<PermissionButton
variant="danger"
disabled={busy}
permission={canDeleteLogs ? undefined : 'Logs.canDelete'}
onClick={() => setConfirm('allLogs')}
>
{/* Behind a hub both are refused, so neither is offered. Without one they share a menu:
two destructive buttons stacked the header into two rows. */}
{!hub && (
<Menu label="Delete all" text="Delete all">
{(close) => (
<>
{files != null && files.length > 0 &&
(canDeleteLogs ? (
<button type="button" data-variant="danger" disabled={busy} onClick={() => { close(); setConfirm('allLogs') }}>
Delete All Logs
</PermissionButton>
)}
<PermissionButton
variant="danger"
disabled={busy}
permission={canDeleteStats ? undefined : 'Dashboard.canDelete'}
onClick={() => setConfirm('allStats')}
>
</button>
) : (
<LockedItem need="Logs.canDelete" placement="left">Delete All Logs</LockedItem>
))}
{canDeleteStats ? (
<button type="button" data-variant="danger" disabled={busy} onClick={() => { close(); setConfirm('allStats') }}>
Delete All Stats
</PermissionButton></>}
</button>
) : (
<LockedItem need="Dashboard.canDelete" placement="left">Delete All Stats</LockedItem>
)}
</>
)}
</Menu>
)}</>}
/>
<Notifier notice={notice} onClose={() => setNotice(null)} />
@@ -278,11 +291,19 @@ export function ViewLogs({
<div className={styles.split}>
<Panel title="Log Files" description="Server log files by date">
<Body className={styles.pbList}>
{files.length === 0 ? (
<Empty>
{notice?.type === 'danger'
? 'Unable to load the log files.'
: 'No Log File Was Found'}
{files == null ? (
<LoadingRegion height="h-40" />
) : failed ? (
<FailureRetry onRetry={() => { setFiles(null); void load() }}>Unable to load the log files.</FailureRetry>
) : files.length === 0 ? (
<Empty
actions={
<Button variant="primary" onClick={() => { setFiles(null); void load() }}>
Refresh
</Button>
}
>
No Log File Was Found
</Empty>
) : (
<div className={styles.logfiles}>
@@ -303,7 +324,13 @@ export function ViewLogs({
</Body>
</Panel>
{open != null && (
{open == null ? (
<Panel title="Log viewer" description="The content of the file you choose" className={styles.reader}>
<Body>
<Empty title="Choose a log file">Pick a file from the list to read it here.</Empty>
</Body>
</Panel>
) : (
<Panel
className={styles.reader}
title={<span className={styles.mono}>{open}</span>}
@@ -326,7 +353,7 @@ export function ViewLogs({
>
<Body className={styles.readerBody}>
{loadingBody ? (
<Loading />
<LoadingRegion height="h-48" />
) : (
/*
The server's error is drawn HERE and not as a notice, because the
+17
View File
@@ -41,6 +41,23 @@ describe('Settings: loading', () => {
expect(await screen.findByText('Unable to load the DNS Server settings.')).toBeInTheDocument()
})
it('keeps the header (and with it the server selector) while the read is slow', async () => {
vi.spyOn(client, 'apiRequest').mockImplementation(() => new Promise<never>(() => {}) as never)
render(<Settings token="tok" />)
expect(await screen.findByRole('heading', { name: 'Settings' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Loading')
})
it('and when the read fails, the header stays and Retry reads again', async () => {
const request = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' })
render(<Settings token="tok" />)
expect(await screen.findByText('Unable to load the DNS Server settings.')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument()
const asked = request.mock.calls.length
screen.getByRole('button', { name: 'Retry' }).click()
await waitFor(() => expect(request.mock.calls.length).toBeGreaterThan(asked))
})
it('the active sub-tab arrives by prop: the sub-navigation belongs to the Shell', async () => {
server()
await mount({ sub: 'Logging' })
+34 -14
View File
@@ -1,4 +1,5 @@
import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { ServerSelect } from '../../ui/ServerSelect'
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { nodeArg, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
@@ -35,7 +36,7 @@ import { Blocking } from './panes/Blocking'
import { ProxyForwarders } from './panes/ProxyForwarders'
import { Logging } from './panes/Logging'
import { BackupDialog, Confirm, RestoreDialog } from './dialogs'
import { Failure, Loading } from '../../ui/Empty'
import { FailureRetry, LoadingRegion } from '../../ui/Empty'
import styles from './Settings.module.css'
import panelFormStyles from '../../ui/PanelForm.module.css'
import { noticeFromFailure, type Notice } from '../../lib/notice'
@@ -298,9 +299,35 @@ export function Settings({
}, [indexed, active, loading])
const here = useSectionInView(sections.map((x) => x.id))
if (loading) return <Loading />
if (form == null || settings == null) {
return <Failure>Unable to load the DNS Server settings.</Failure>
/* The selector is drawn in every state: a slow or silent server must not take away the way
to choose the other one. */
const serverSelect = (
<ServerSelect
hint={<HubWriteHint write={write} />}
nodes={nodes}
initialised={clusterInitialised}
aggregate={!hub}
value={node}
onChange={setNode}
label="Cluster Node"
/>
)
if (loading || form == null || settings == null) {
return (
<div className={styles.wrap}>
<SectionHeader
title="Settings"
subtitle="Server configuration"
tabs={<SubTabs label="Settings sections" section="settings" tabs={SUB_TABS} active={active} onChoose={(t) => onSubChange?.(t as SubTab)} />}
actions={serverSelect}
/>
{loading ? (
<LoadingRegion height="h-96">Loading…</LoadingRegion>
) : (
<FailureRetry onRetry={() => void load()}>Unable to load the DNS Server settings.</FailureRetry>
)}
</div>
)
}
const en = enabled(form)
@@ -501,14 +528,7 @@ export function Settings({
actions={
<>
{hub && canModify && <CompareServersButton token={token} scope="settings" />}
<ClusterNodeSelect
nodes={nodes}
initialised={clusterInitialised}
aggregate={!hub}
value={node}
onChange={setNode}
label="Cluster Node"
/>
{serverSelect}
<PermissionButton
permission={canBackup ? undefined : 'Settings.canDelete'}
onClick={() => {
@@ -549,7 +569,7 @@ export function Settings({
}
/>
<HubWriteHint write={write} />
{!hub && <HubWriteHint write={write} />}
<Notifier notice={notice} onClose={() => setNotice(null)} />
{/*
+18 -8
View File
@@ -17,7 +17,7 @@ import {
} from '../../api/zones'
import { Button } from '../../ui/Button'
import { Tick } from '../../ui/Tick'
import { Loading } from '../../ui/Empty'
import { FailureRetry, LoadingRegion } from '../../ui/Empty'
import { StaleData } from '../StaleData'
import { Field, Input, Select } from '../../ui/Field'
import { SectionHeader } from '../../ui/SectionHeader'
@@ -129,6 +129,8 @@ export function ZoneList({
fixed for on this screen. Measured on 2026-09-07 with the state matrix.
*/
const [loaded, setLoaded] = useState(false)
/* Why the first read failed, drawn in the region. A failed read has no zones: it is not an empty list. */
const [failure, setFailure] = useState<string | null>(null)
const [pageNumber, setPageNumber] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [totalZones, setTotalZones] = useState(0)
@@ -191,7 +193,10 @@ export function ZoneList({
*/
const hadData = lastGood != null
setStale(hadData)
if (!hadData) onNotice(noticeFromFailure(r))
if (!hadData) {
onNotice(noticeFromFailure(r))
setFailure(noticeFromFailure(r).text)
}
/* A first load that failed is no longer waiting: it has an answer, and it
is a bad one. Leaving `loaded` false here would leave the screen saying
"loading" over a failure that has already been reported. */
@@ -201,6 +206,7 @@ export function ZoneList({
const data = r.data
setStale(false)
setFailure(null)
setLastGood(new Date().toISOString())
setZones(data.zones)
setLoaded(true)
@@ -395,6 +401,8 @@ export function ZoneList({
const shown = view === 'all' ? zones : zones.filter(VIEWS[view])
const { rows: visibleZones, sort, toggle } = useSort(KEYS, shown)
const kpi = summary(all)
/* No answer yet, or none that came: the figures are unknown, which is not zero. */
const noData = !loaded || failure !== null
const allChecked = zones.length > 0 && checkedOnes.length === zones.length
@@ -439,10 +447,10 @@ export function ZoneList({
<KpiStrip
label="Zone totals"
items={[
{ label: 'Zones', value: all.length || totalZones, icon: 'zones' },
{ label: 'Enabled', value: kpi.enabled, icon: 'check', tone: 'ok' },
{ label: 'Signed with DNSSEC', value: kpi.signed, icon: 'lock' },
{ label: 'Secondary pending sync', value: kpi.pending, icon: 'convert', tone: kpi.pending > 0 ? 'warn' : undefined },
{ label: 'Zones', value: noData ? '\u2014' : all.length || totalZones, icon: 'zones' },
{ label: 'Enabled', value: noData ? '\u2014' : kpi.enabled, icon: 'check', tone: noData ? undefined : 'ok' },
{ label: 'Signed with DNSSEC', value: noData ? '\u2014' : kpi.signed, icon: 'lock' },
{ label: 'Secondary pending sync', value: noData ? '\u2014' : kpi.pending, icon: 'convert', tone: !noData && kpi.pending > 0 ? 'warn' : undefined },
]}
/>
@@ -549,7 +557,9 @@ export function ZoneList({
{stale && <StaleData since={lastGood} onRetry={() => void load(pageNumber)} />}
{!loaded ? (
<Loading />
<LoadingRegion height="h-64" />
) : failure !== null ? (
<FailureRetry onRetry={() => void load(pageNumber)}>Could not load the zones. {failure}</FailureRetry>
) : (
<>
{/*
@@ -801,7 +811,7 @@ function zoneCells(p: RowProps) {
</>
),
status: (<Tag tone={stateTone}>{state}</Tag>),
serial: z.soaSerial ?? ' ',
serial: z.soaSerial ?? '\u2014',
expiry: date(z.expiry),
modified: date(z.lastModified),
actions: (
+13
View File
@@ -45,4 +45,17 @@ describe('a zone list that has not answered yet', () => {
render(<ZoneList {...props} />)
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
})
it('a failed read is an error with Retry, figures that are not known and no empty state', async () => {
const request = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'mdns did not answer: timed out after 8000 ms' } as never)
render(<ZoneList {...props} />)
expect(await screen.findByText(/Could not load the zones\. mdns did not answer/)).toBeInTheDocument()
expect(screen.queryByText('No Zone Found')).not.toBeInTheDocument()
const kpis = screen.getByRole('group', { name: 'Zone totals' })
expect(kpis).toHaveTextContent(String.fromCharCode(0x2014))
expect(kpis).not.toHaveTextContent('0')
request.mockResolvedValue({ kind: 'ok', data: { response: { zones: [], pageNumber: 1, totalPages: 1, totalZones: 0 } } } as never)
screen.getByRole('button', { name: 'Retry' }).click()
expect(await screen.findByText('No Zone Found')).toBeInTheDocument()
})
})
+2 -1
View File
@@ -157,7 +157,8 @@ describe('who reports the failure', () => {
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' } as never)
render(<Zones token="t" canModify canDelete />)
expect(await screen.findByText(/boom/)).toBeInTheDocument()
/* The notice and the region both carry it: the region stays when the notice is dismissed. */
expect((await screen.findAllByText(/boom/)).length).toBeGreaterThan(0)
expect(screen.queryByText(/Could not refresh/)).not.toBeInTheDocument()
})
})
+3 -3
View File
@@ -1,4 +1,4 @@
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { ServerSelect } from '../../ui/ServerSelect'
import { useContext, useState } from 'react'
import { AGGREGATE, HubMode } from '../../ui/ClusterNodeSelect'
import { ZonesMatrix } from './ZonesMatrix'
@@ -154,7 +154,8 @@ export function Zones({
same state here, since it is the same question. The list's reloads
the list (index.html:436); the records view's is always disabled
(index.html:548). */}
<ClusterNodeSelect
<ServerSelect
hint={!matrixMode && <HubWriteHint write={write} />}
nodes={nodes}
initialised={clusterInitialised}
aggregate={hub}
@@ -166,7 +167,6 @@ export function Zones({
disabled={open != null}
label="Cluster Node"
/>
{!matrixMode && <HubWriteHint write={write} />}
{matrixMode ? (
<ZonesMatrix
+2 -1
View File
@@ -290,7 +290,8 @@ describe('SessionProvider', () => {
vi.spyOn(client, 'apiRequest').mockResolvedValue(session())
window.history.replaceState(null, '', '/settings/')
mount()
await screen.findByRole('navigation')
// The sidebar and, while Settings has not read yet, its own sub-tab bar.
await screen.findAllByRole('navigation')
// Being "in Settings" and nothing more is half a page: the address is completed.
await waitFor(() => expect(window.location.pathname).toBe('/settings/general/'))
+16
View File
@@ -0,0 +1,16 @@
import type { ReactNode } from 'react'
import { SlotProvider } from '../app/ChromeSlot'
/* The shell's chrome slot, for a test of a screen that puts its server selector there. */
export function WithChrome({ children }: { children: ReactNode }) {
return (
<SlotProvider>
{(ref) => (
<>
<div ref={ref} />
{children}
</>
)}
</SlotProvider>
)
}
+42
View File
@@ -1,4 +1,6 @@
import type { ReactNode } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from './Button'
import styles from './Empty.module.css'
/*
@@ -105,3 +107,43 @@ nothing passes would be inventing API ahead of its use.
export function Failure({ children }: { children: ReactNode }) {
return <div className={styles.failure}>{children}</div>
}
/*
OURS. `Loading` for a data REGION, drawn as the region's own layout: a bar for the filter
or header line and a block for the body (the picture `ZonesMatrix` already used). The wait
is still announced once, through `role="status"` and the words for a reader, so a region
that says "Loading the apps of every server…" keeps saying it.
*/
export function LoadingRegion({
children = 'Loading…',
height = 'h-40',
announce = true,
}: {
/** What is loading, for a reader: "Loading the apps of every server…". */
children?: ReactNode
/** The body's height, as the region's real content would take. */
height?: string
announce?: boolean
}) {
return (
<div role={announce ? 'status' : undefined} className="flex flex-col gap-3">
<span className="sr-only">{children}</span>
<Skeleton className="h-8 w-1/2" />
<Skeleton className={`${height} w-full`} />
</div>
)
}
/* `Failure` with the one action it asked for: the same read, again. */
export function FailureRetry({ children, onRetry }: { children: ReactNode; onRetry: () => void }) {
return (
<Failure>
<span className="flex flex-col items-center gap-3">
<span>{children}</span>
<Button variant="primary" onClick={onRetry}>
Retry
</Button>
</span>
</Failure>
)
}
+23
View File
@@ -0,0 +1,23 @@
import type { ComponentProps, ReactNode } from 'react'
import { useContext } from 'react'
import { InChrome } from '../app/ChromeSlot'
import { ClusterNodeSelect, HubMode } from './ClusterNodeSelect'
/*
OURS. The server selector of a section, drawn the same way on every page.
Behind a hub it goes up into the chrome slot, exactly as the Dashboard's does (compact
"Server" field, the same gap to the title on every page), with the line that says why the
controls are read only beside it. Without a hub nothing changes: upstream's node selector
stays where the section put it.
*/
export function ServerSelect({ hint, ...props }: ComponentProps<typeof ClusterNodeSelect> & { hint?: ReactNode }) {
const hub = useContext(HubMode)
if (!hub) return <ClusterNodeSelect {...props} />
return (
<InChrome>
<ClusterNodeSelect {...props} />
{hint}
</InChrome>
)
}