UI polish across sections: one server selector, skeletons, inline errors
- 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:
1 parent
172e06f1ce
commit
ba3cdd08d2
43 files changed
+638
-228
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
/*
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
@@ -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'
|
||||
|
||||
@@ -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} /></>)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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'; }
|
||||
/*
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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'; }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
{/*
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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/'))
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user