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
+671
-261
No files matched your search
@@ -53,6 +53,23 @@ describe('mergeStats counters', () => {
|
|||||||
expect(Object.keys(merged.stats)).toEqual(Object.keys(stats()))
|
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', () => {
|
it('returns a single response with the same numbers', () => {
|
||||||
const merged = mergeStats([response()])
|
const merged = mergeStats([response()])
|
||||||
expect(merged.stats).toEqual(stats())
|
expect(merged.stats).toEqual(stats())
|
||||||
|
|||||||
+16
-1
@@ -162,6 +162,16 @@ export function mergeTopResponses(
|
|||||||
return { topDomains: [] }
|
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
|
`dashboard/stats/get`. Every numeric counter in `stats` is summed. That includes
|
||||||
`totalClients` (distinct clients), which over-counts a client seen by both servers:
|
`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> = {}
|
const stats: Record<string, number> = {}
|
||||||
for (const r of responses) {
|
for (const r of responses) {
|
||||||
for (const [key, value] of Object.entries(r.stats ?? {})) {
|
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 merged: DashboardStats = { ...first, stats }
|
||||||
const main = mergeChart(responses.map((r) => r.mainChartData), true)
|
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,
|
`:empty` is what keeps it from taking up room when nobody uses it: without it,
|
||||||
seven of eight screens would gain a blank gap.
|
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; }
|
.chromeSlot:empty { display: none; }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function AppCard({
|
|||||||
<li className={styles.gridRow} aria-label={app.name}>
|
<li className={styles.gridRow} aria-label={app.name}>
|
||||||
<div className={styles.nameCell}>
|
<div className={styles.nameCell}>
|
||||||
<h2>{app.name}</h2>
|
<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>
|
||||||
|
|
||||||
<div className={styles.mono}>
|
<div className={styles.mono}>
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import { StoreApps } from './StoreApps'
|
|||||||
import { Panel } from '../../ui/Panel'
|
import { Panel } from '../../ui/Panel'
|
||||||
import { PageTabs } from '../../ui/PageTabs'
|
import { PageTabs } from '../../ui/PageTabs'
|
||||||
import { UpdateApp } from './UpdateApp'
|
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 { Tag } from '../../ui/Tag'
|
||||||
import { useSort } from '../../ui/Table'
|
import { useSort } from '../../ui/Table'
|
||||||
import { Icon } from '../../ui/Icon'
|
import { Icon } from '../../ui/Icon'
|
||||||
@@ -266,9 +267,16 @@ export function Apps({
|
|||||||
{hubView === 'store' ? 'Back to apps' : 'App Store'}
|
{hubView === 'store' ? 'Back to apps' : 'App Store'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setFileDialog(true)}>Install from file</Button>
|
<Button onClick={() => setFileDialog(true)}>Install from file</Button>
|
||||||
<Button onClick={() => void exportFile()}>Export baselines</Button>
|
{/* The three that move a whole set of apps share one menu: five buttons made a row of their own. */}
|
||||||
<Button onClick={() => setImportDialog(true)}>Import baselines</Button>
|
<Menu label="Baselines" text="Baselines">
|
||||||
{isAdmin && <Button onClick={() => setProfilesDialog(true)}>Profiles</Button>}
|
{(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
|
) : undefined
|
||||||
}
|
}
|
||||||
@@ -347,12 +355,12 @@ export function Apps({
|
|||||||
{tab === 'store' && mayChange ? (
|
{tab === 'store' && mayChange ? (
|
||||||
<StoreApps token={token} node={requestNode} onChanged={() => void reload()} />
|
<StoreApps token={token} node={requestNode} onChanged={() => void reload()} />
|
||||||
) : apps === null ? (
|
) : apps === null ? (
|
||||||
<Loading />
|
<LoadingRegion height="h-48">Loading…</LoadingRegion>
|
||||||
) : failure ? (
|
) : failure ? (
|
||||||
/* Solid and with no invitation: whether there are apps is not known, so
|
/* 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
|
the store is not offered. The server's message goes in the notice
|
||||||
above. */
|
above. */
|
||||||
<Failure>Could not load the installed apps.</Failure>
|
<FailureRetry onRetry={() => void reload()}>Could not load the installed apps.</FailureRetry>
|
||||||
) : apps.length === 0 ? (
|
) : apps.length === 0 ? (
|
||||||
<Empty
|
<Empty
|
||||||
title="No apps installed"
|
title="No apps installed"
|
||||||
|
|||||||
@@ -232,12 +232,12 @@ describe('Export and import of baselines', () => {
|
|||||||
it('offers both to writers only', async () => {
|
it('offers both to writers only', async () => {
|
||||||
const { unmount } = mountApps(false)
|
const { unmount } = mountApps(false)
|
||||||
await screen.findByText('No apps installed')
|
await screen.findByText('No apps installed')
|
||||||
expect(screen.queryByRole('button', { name: 'Export baselines' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: 'Baselines' })).not.toBeInTheDocument()
|
||||||
expect(screen.queryByRole('button', { name: 'Import baselines' })).not.toBeInTheDocument()
|
|
||||||
unmount()
|
unmount()
|
||||||
mountApps(true)
|
mountApps(true)
|
||||||
expect(await screen.findByRole('button', { name: 'Export baselines' })).toBeInTheDocument()
|
await userEvent.click(await screen.findByRole('button', { name: 'Baselines' }))
|
||||||
expect(screen.getByRole('button', { name: 'Import baselines' })).toBeInTheDocument()
|
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 () => {
|
it('downloads the file the hub sends', async () => {
|
||||||
@@ -246,7 +246,8 @@ describe('Export and import of baselines', () => {
|
|||||||
const create = vi.fn(() => 'blob:x')
|
const create = vi.fn(() => 'blob:x')
|
||||||
Object.assign(URL, { createObjectURL: create, revokeObjectURL: vi.fn() })
|
Object.assign(URL, { createObjectURL: create, revokeObjectURL: vi.fn() })
|
||||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
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())
|
await waitFor(() => expect(click).toHaveBeenCalled())
|
||||||
expect(exported).toHaveBeenCalledWith('t')
|
expect(exported).toHaveBeenCalledWith('t')
|
||||||
expect(create).toHaveBeenCalled()
|
expect(create).toHaveBeenCalled()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getAppsMatrix, getDrift, listStoreApps, runHubApps, type AppsMatrixData
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { Button } from '../../ui/Button'
|
import { Button } from '../../ui/Button'
|
||||||
import { DataTable, type Col } from '../../ui/DataTable'
|
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 { Panel } from '../../ui/Panel'
|
||||||
import { Segmented } from '../../ui/Segmented'
|
import { Segmented } from '../../ui/Segmented'
|
||||||
import { Menu } from '../../ui/Menu'
|
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 }) {
|
function ServerCell({ app, id, data, drifted }: { app: MatrixApp; id: string; data: AppsMatrixData; drifted: boolean }) {
|
||||||
const cell = app.servers[id]
|
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>
|
if (cell === undefined || !cell.installed) return <span className="text-sm text-muted-foreground">Not installed</span>
|
||||||
return (
|
return (
|
||||||
<span className="flex flex-wrap items-center gap-2 text-sm">
|
<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])
|
}, [data])
|
||||||
|
|
||||||
const retry = (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => {
|
|
||||||
setFailure(null)
|
|
||||||
setData(null)
|
|
||||||
void load()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (failure !== null && data === null) {
|
if (failure !== null && data === null) {
|
||||||
return (
|
return (
|
||||||
<Failure>
|
<FailureRetry
|
||||||
<span className="flex flex-col items-center gap-3">
|
onRetry={() => {
|
||||||
<span>Could not load the apps of the servers. {failure}</span>
|
setFailure(null)
|
||||||
{retry}
|
setData(null)
|
||||||
</span>
|
void load()
|
||||||
</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) {
|
if (data.apps.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -249,7 +247,11 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st
|
|||||||
cell: (a) => (
|
cell: (a) => (
|
||||||
<span className="flex flex-col gap-1">
|
<span className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-medium">{a.name}</span>
|
<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>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function HubStore({
|
|||||||
</Tag>
|
</Tag>
|
||||||
))}
|
))}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { Failure } from '../../ui/Empty'
|
|||||||
import { Icon } from '../../ui/Icon'
|
import { Icon } from '../../ui/Icon'
|
||||||
import { Pagination } from '../../ui/Pagination'
|
import { Pagination } from '../../ui/Pagination'
|
||||||
import { Tag } from '../../ui/Tag'
|
import { Tag } from '../../ui/Tag'
|
||||||
import { UrlText } from '../../ui/UrlText'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||||
import { useHubWrite } from '../../lib/hub-write'
|
import { useHubWrite } from '../../lib/hub-write'
|
||||||
@@ -22,6 +21,7 @@ import { changeAppConfig, useAdvancedBlocking } from './advanced-blocking-state'
|
|||||||
import { missing, type Permissions } from './permissions'
|
import { missing, type Permissions } from './permissions'
|
||||||
import { pageOf } from './rules-model'
|
import { pageOf } from './rules-model'
|
||||||
import shared from './Blocking.module.css'
|
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
|
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) => {
|
cell: (r) => {
|
||||||
const f = (fields as readonly { field: GroupField; kind: string }[]).find((x) => x.field === r.field)
|
const f = (fields as readonly { field: GroupField; kind: string }[]).find((x) => x.field === r.field)
|
||||||
return (
|
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} />
|
<Icon name={f?.kind === 'allow' ? 'allowed' : 'blocked'} size={14} />
|
||||||
{labelOf(r.field)}
|
{labelOf(r.field)}
|
||||||
</span>
|
</span>
|
||||||
@@ -264,6 +264,7 @@ export function AdvancedBlockingEntries({
|
|||||||
{
|
{
|
||||||
id: 'value',
|
id: 'value',
|
||||||
header: kind === 'lists' ? 'List' : 'Rule',
|
header: kind === 'lists' ? 'List' : 'Rule',
|
||||||
|
td: () => ({ className: shared.entryCell }),
|
||||||
cell: (r) =>
|
cell: (r) =>
|
||||||
editing?.key === r.key ? (
|
editing?.key === r.key ? (
|
||||||
<Input
|
<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',
|
id: 'actions',
|
||||||
td: () => ({ className: shared.entryActions }),
|
td: () => ({ className: `${shared.entryActions} ${tbl.actionsCell}` }),
|
||||||
cell: (r) =>
|
cell: (r) =>
|
||||||
editing?.key === r.key ? (
|
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" variant="primary" disabled={busy} onClick={() => void save(r)}>Save</Button>
|
||||||
<Button size="sm" disabled={busy} onClick={() => { setEditing(null); setProblem(null) }}>Cancel</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}`}
|
<PermissionButton size="sm" permission={modifyNeed} disabled={off} aria-label={`Edit ${r.value}`}
|
||||||
onClick={() => { setEditing({ key: r.key, value: r.value }); setProblem(null) }}>
|
onClick={() => { setEditing({ key: r.key, value: r.value }); setProblem(null) }}>
|
||||||
Edit
|
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}.`)}>
|
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
|
Remove
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</span>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { PermissionButton } from '../../ui/PermissionButton'
|
|||||||
import { Confirm } from '../../ui/Confirm'
|
import { Confirm } from '../../ui/Confirm'
|
||||||
import { Notifier } from '../../ui/Notifier'
|
import { Notifier } from '../../ui/Notifier'
|
||||||
import { Failure, Loading } from '../../ui/Empty'
|
import { Failure, Loading } from '../../ui/Empty'
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { KpiTile } from '../dashboard/Figures'
|
||||||
import { RouteLink } from '../../ui/RouteLink'
|
import { RouteLink } from '../../ui/RouteLink'
|
||||||
import { Icon } from '../../ui/Icon'
|
import { Icon } from '../../ui/Icon'
|
||||||
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||||||
@@ -599,50 +601,39 @@ export function BlockLists({
|
|||||||
<Locked title="Allow List Domains" need={statsNeed} />
|
<Locked title="Allow List Domains" need={statsNeed} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
/* The Dashboard's KPI look, as the Overview draws it: a Frame of tiles. */
|
||||||
<Panel className={shared.centred}><Body>
|
<Frame className={shared.kpiPair}>
|
||||||
<div className={shared.kpiText}>
|
<FramePanel className={shared.kpiTiles}>
|
||||||
{countsFailed ? (
|
<KpiTile
|
||||||
<Failure>{COUNTS_FAILED}</Failure>
|
label="Block List Domains"
|
||||||
) : (
|
icon="logs"
|
||||||
<span className={`${shared.kpiValue}${reloading != null ? ` ${styles.stale}` : ''}`}>
|
colour="var(--ch-block)"
|
||||||
{counts ? counts.block.toLocaleString() : '—'}
|
figure={counts && !countsFailed ? counts.block.toLocaleString() : '\u2014'}
|
||||||
</span>
|
noData={counts == null || countsFailed}
|
||||||
)}
|
wrap
|
||||||
{reloading != null ? (
|
>
|
||||||
<span className={styles.pending}>{UPDATING}</span>
|
{countsFailed ? COUNTS_FAILED : reloading != null ? <span className={styles.pending}>{UPDATING}</span> : ' '}
|
||||||
) : (
|
</KpiTile>
|
||||||
<span className={shared.kpiSub}>{'\u00a0'}</span>
|
<KpiTile
|
||||||
)}
|
label="Allow List Domains"
|
||||||
<span className={shared.kpiLabel}>Block List Domains</span>
|
icon="check"
|
||||||
</div>
|
colour="var(--ch-ok)"
|
||||||
</Body></Panel>
|
figure={counts && !countsFailed ? counts.allow.toLocaleString() : '\u2014'}
|
||||||
<Panel className={shared.centred}><Body>
|
noData={counts == null || countsFailed}
|
||||||
<div className={shared.kpiText}>
|
wrap
|
||||||
{countsFailed ? (
|
>
|
||||||
<Failure>{COUNTS_FAILED}</Failure>
|
{countsFailed ? COUNTS_FAILED : reloading != null ? <span className={styles.pending}>{UPDATING}</span> : ' '}
|
||||||
) : (
|
</KpiTile>
|
||||||
<span className={`${shared.kpiValue}${reloading != null ? ` ${styles.stale}` : ''}`}>
|
</FramePanel>
|
||||||
{counts ? counts.allow.toLocaleString() : '—'}
|
</Frame>
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<Panel className={shared.centred}><Body>
|
<Panel className={shared.centred}><Body>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className={shared.kpi}>
|
<div className={shared.kpi}>
|
||||||
<div className={shared.kpiText}>
|
{/* Ordinary type: a date or "Not Scheduled" is a sentence, not a figure to read from afar. */}
|
||||||
<span className={shared.kpiValue}>{loading ? '—' : nextUpdateText(next)}</span>
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<span className={shared.kpiSub}>{'\u00a0'}</span>
|
<span className="text-sm font-medium text-foreground">{loading ? '\u2014' : nextUpdateText(next)}</span>
|
||||||
<span className={shared.kpiLabel}>
|
<span className="text-xs text-muted-foreground">
|
||||||
Next update{interval != null && interval > 0 ? ` · every ${interval} h` : ''}
|
Next update{interval != null && interval > 0 ? ` · every ${interval} h` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
/* 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. */
|
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); }
|
.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
|
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. */
|
/* 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); }
|
.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. */
|
/* 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); }
|
.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; }
|
.entryAdd > p { flex-basis: 100%; margin: 0; }
|
||||||
.entryFilter { padding: 0 var(--s-9) var(--s-5); }
|
.entryFilter { padding: 0 var(--s-9) var(--s-5); }
|
||||||
.entryValue { font-family: var(--font-mono); overflow-wrap: break-word; }
|
.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; }
|
.entryActions { text-align: right; white-space: nowrap; }
|
||||||
.dim { color: var(--mute); }
|
.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 === 'app' && <Tag tone="ok">Advanced Blocking</Tag>}
|
||||||
{state.kind === 'paused' && <Tag tone="acc">{untilText(state.till)}</Tag>}
|
{state.kind === 'paused' && <Tag tone="acc">{untilText(state.till)}</Tag>}
|
||||||
{state.kind === 'off' && <Tag>Disabled</Tag>}
|
{state.kind === 'off' && <Tag>Disabled</Tag>}
|
||||||
<span>
|
<span className={shared.bannerText}>
|
||||||
{state.kind === 'on'
|
{state.kind === 'on'
|
||||||
? 'Blocking is applied to every query'
|
? 'Blocking is applied to every query'
|
||||||
: state.kind === 'app'
|
: state.kind === 'app'
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as settings from '../../api/settings'
|
|||||||
import * as client from '../../api/client'
|
import * as client from '../../api/client'
|
||||||
import type { DnsSettings } from '../../api/settings'
|
import type { DnsSettings } from '../../api/settings'
|
||||||
import { settingsChanged } from '../../lib/settings-signal'
|
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'
|
import type { Permissions } from './permissions'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -96,6 +96,47 @@ describe('blocking state', () => {
|
|||||||
expect(screen.getByText('off')).toBeInTheDocument()
|
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 () => {
|
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)
|
const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
|
||||||
render(<><Probe permissions={S(false, false)} /><Probe enabled={false} /></>)
|
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;
|
`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
|
- when the Advanced Blocking app's config is written from this console, since the answer
|
||||||
below depends on it;
|
below depends on it;
|
||||||
- when the browser tab is shown again after being hidden: the state may have been
|
- when the browser tab is shown again after being hidden, if the last read is more than a
|
||||||
changed from elsewhere meanwhile, and a hidden tab is not kept current.
|
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.
|
Reads are numbered, so an older answer arriving last cannot overwrite a newer one.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** How long after `till` to read again. See above. */
|
/** How long after `till` to read again. See above. */
|
||||||
export const AFTER_PAUSE_MS = 1000
|
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
|
/** `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,
|
AT ONCE, which with a far-off `till` would read in a loop; capped, it reads,
|
||||||
finds the pause still on and waits again. */
|
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 viewNeed = enabled ? missing(permissions, 'Settings.canView') : 'Settings.canView'
|
||||||
const [base, setState] = useState<BlockingRead>(null)
|
const [base, setState] = useState<BlockingRead>(null)
|
||||||
const asked = useRef(0)
|
const asked = useRef(0)
|
||||||
|
const lastRead = useRef(0)
|
||||||
|
|
||||||
const reload = useCallback(() => {
|
const reload = useCallback(() => {
|
||||||
if (viewNeed != null) return
|
if (viewNeed != null) return
|
||||||
const mine = ++asked.current
|
const mine = ++asked.current
|
||||||
|
lastRead.current = Date.now()
|
||||||
void getSettings(token, node).then((s) => {
|
void getSettings(token, node).then((s) => {
|
||||||
if (mine !== asked.current) return
|
if (mine !== asked.current) return
|
||||||
setState(s == null ? 'failed' : stateOf(s.enableBlocking, s.temporaryDisableBlockingTill))
|
setState(s == null ? 'failed' : stateOf(s.enableBlocking, s.temporaryDisableBlockingTill))
|
||||||
@@ -99,7 +104,7 @@ export function useBlockingState(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const shown = () => {
|
const shown = () => {
|
||||||
if (document.visibilityState === 'visible') reload()
|
if (document.visibilityState === 'visible' && Date.now() - lastRead.current >= VISIBLE_REREAD_MS) reload()
|
||||||
}
|
}
|
||||||
document.addEventListener('visibilitychange', shown)
|
document.addEventListener('visibilitychange', shown)
|
||||||
return () => document.removeEventListener('visibilitychange', shown)
|
return () => document.removeEventListener('visibilitychange', shown)
|
||||||
@@ -215,11 +220,13 @@ export function useAllServersBlocking(
|
|||||||
const key = JSON.stringify(servers)
|
const key = JSON.stringify(servers)
|
||||||
const [rows, setRows] = useState<ServerBlocking[] | null>(null)
|
const [rows, setRows] = useState<ServerBlocking[] | null>(null)
|
||||||
const asked = useRef(0)
|
const asked = useRef(0)
|
||||||
|
const lastRead = useRef(0)
|
||||||
const active = enabled && canSettings && servers.length > 0
|
const active = enabled && canSettings && servers.length > 0
|
||||||
|
|
||||||
const reload = useCallback(() => {
|
const reload = useCallback(() => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const mine = ++asked.current
|
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) => {
|
void Promise.all((JSON.parse(key) as string[]).map((s) => readServerBlocking(token, s, canSettings, canApps))).then((r) => {
|
||||||
if (mine === asked.current) setRows(r)
|
if (mine === asked.current) setRows(r)
|
||||||
})
|
})
|
||||||
@@ -237,7 +244,7 @@ export function useAllServersBlocking(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const shown = () => {
|
const shown = () => {
|
||||||
if (document.visibilityState === 'visible') reload()
|
if (document.visibilityState === 'visible' && Date.now() - lastRead.current >= VISIBLE_REREAD_MS) reload()
|
||||||
}
|
}
|
||||||
document.addEventListener('visibilitychange', shown)
|
document.addEventListener('visibilitychange', shown)
|
||||||
return () => document.removeEventListener('visibilitychange', shown)
|
return () => document.removeEventListener('visibilitychange', shown)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { TopStats } from './TopStats'
|
|||||||
import { TopRowMenu } from './TopRowMenu'
|
import { TopRowMenu } from './TopRowMenu'
|
||||||
import { useDomainAction } from '../../lib/allow-block'
|
import { useDomainAction } from '../../lib/allow-block'
|
||||||
import { SectionHeader } from '../../ui/SectionHeader'
|
import { SectionHeader } from '../../ui/SectionHeader'
|
||||||
import { Loading } from '../../ui/Empty'
|
import { LoadingRegion } from '../../ui/Empty'
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import styles from './Dashboard.module.css'
|
import styles from './Dashboard.module.css'
|
||||||
@@ -443,7 +443,7 @@ export function Dashboard({
|
|||||||
: undefined}
|
: undefined}
|
||||||
>
|
>
|
||||||
<Body>
|
<Body>
|
||||||
{loading && <Loading compact />}
|
{loading && <LoadingRegion height="h-72" />}
|
||||||
{!loading && view && hasData(view.mainChartData) && (
|
{!loading && view && hasData(view.mainChartData) && (
|
||||||
<Line data={view.mainChartData} resetOn={data?.mainChartData} onPick={pickPoint} height={300} />
|
<Line data={view.mainChartData} resetOn={data?.mainChartData} onPick={pickPoint} height={300} />
|
||||||
)}
|
)}
|
||||||
@@ -495,7 +495,9 @@ export function Dashboard({
|
|||||||
rows={view?.topBlockedDomains ?? []}
|
rows={view?.topBlockedDomains ?? []}
|
||||||
onMore={() => setTop('TopBlockedDomains')}
|
onMore={() => setTop('TopBlockedDomains')}
|
||||||
failure={failure}
|
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} />}
|
menu={(f) => <TopRowMenu kind="TopBlockedDomains" name={f.name} node={node} action={action} />}
|
||||||
/>
|
/>
|
||||||
<Breakdown
|
<Breakdown
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||||
import { getHours } from '../../api/dashboard'
|
import { getHours } from '../../api/dashboard'
|
||||||
import { Body, Panel } from '../../ui/Panel'
|
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 { heatRows, hourRequests, level, stitchHours, weekSpan, type HeatCell } from './heatmap'
|
||||||
import { spanFilters } from './jump'
|
import { spanFilters } from './jump'
|
||||||
import type { QueryLogsFilters } from '../../app/handoff'
|
import type { QueryLogsFilters } from '../../app/handoff'
|
||||||
@@ -102,7 +102,7 @@ export function Heatmap({
|
|||||||
return (
|
return (
|
||||||
<Panel title="Queries per Hour, Last 7 Days">
|
<Panel title="Queries per Hour, Last 7 Days">
|
||||||
<Body>
|
<Body>
|
||||||
{state.kind === 'loading' && <Loading compact />}
|
{state.kind === 'loading' && <LoadingRegion height="h-40" />}
|
||||||
{state.kind === 'failed' && <Failure>Could not load this data.</Failure>}
|
{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
|
{/* A quiet week is drawn, not replaced by the empty box: zero is a true
|
||||||
figure, and every cell still says so. */}
|
figure, and every cell still says so. */}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function Top({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title={title}
|
title={<span className="whitespace-nowrap">{title}</span>}
|
||||||
className={styles.top}
|
className={styles.top}
|
||||||
actions={
|
actions={
|
||||||
<div className={styles.panelActions}>
|
<div className={styles.panelActions}>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Dhcp } from './Dhcp'
|
|||||||
import * as api from '../../api/dhcp'
|
import * as api from '../../api/dhcp'
|
||||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||||
import { HubWritable } from '../../lib/hub-write'
|
import { HubWritable } from '../../lib/hub-write'
|
||||||
|
import { WithChrome } from '../../test/chrome'
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks())
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
@@ -20,7 +21,9 @@ function hubDhcp(writable: string[], canModify = true) {
|
|||||||
return render(
|
return render(
|
||||||
<HubMode.Provider value>
|
<HubMode.Provider value>
|
||||||
<HubWritable.Provider value={writable}>
|
<HubWritable.Provider value={writable}>
|
||||||
<Dhcp token="t" sub="Scopes" nodes={NODES} clusterInitialised canModify={canModify} canDelete={canModify} />
|
<WithChrome>
|
||||||
|
<Dhcp token="t" sub="Scopes" nodes={NODES} clusterInitialised canModify={canModify} canDelete={canModify} />
|
||||||
|
</WithChrome>
|
||||||
</HubWritable.Provider>
|
</HubWritable.Provider>
|
||||||
</HubMode.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
|
the table's own footer (`dhcp.js:74`) and the rest of the console uses its
|
||||||
"there is nothing here" row.
|
"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: [] })
|
vi.spyOn(api, 'listLeases').mockResolvedValue({ kind: 'ok', data: [] })
|
||||||
render(<Dhcp token="t" sub="Leases" />)
|
render(<Dhcp token="t" sub="Leases" />)
|
||||||
|
|
||||||
const message = await screen.findByText('No Lease Found')
|
/* An empty state with a way out, and the figures still known: zero is a true count here. */
|
||||||
expect(message.closest('table')).not.toBeNull()
|
expect(await screen.findByText('No Lease Found')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Total Leases: 0')).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'Reload leases' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('each row offers only the conversion its type calls for', async () => {
|
it('each row offers only the conversion its type calls for', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { SubTabs } from '../../ui/SubTabs'
|
import { SubTabs } from '../../ui/SubTabs'
|
||||||
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
|
import { ServerSelect } from '../../ui/ServerSelect'
|
||||||
import { Leases } from './Leases'
|
import { Leases } from './Leases'
|
||||||
import { Scopes } from './Scopes'
|
import { Scopes } from './Scopes'
|
||||||
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||||
@@ -84,7 +84,8 @@ export function Dhcp({
|
|||||||
{/* Upstream mounts one here (`optDhcpClusterNode` / `optLogsClusterNode`).
|
{/* Upstream mounts one here (`optDhcpClusterNode` / `optLogsClusterNode`).
|
||||||
It draws nothing without a cluster, so it costs a single-server install
|
It draws nothing without a cluster, so it costs a single-server install
|
||||||
nothing. */}
|
nothing. */}
|
||||||
<ClusterNodeSelect
|
<ServerSelect
|
||||||
|
hint={<HubWriteHint write={write} />}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
initialised={clusterInitialised}
|
initialised={clusterInitialised}
|
||||||
value={node}
|
value={node}
|
||||||
@@ -92,7 +93,6 @@ export function Dhcp({
|
|||||||
disabled={nodeLocked}
|
disabled={nodeLocked}
|
||||||
label="Cluster Node"
|
label="Cluster Node"
|
||||||
/>
|
/>
|
||||||
<HubWriteHint write={write} />
|
|
||||||
{active === 'Scopes' ? (
|
{active === 'Scopes' ? (
|
||||||
<Scopes
|
<Scopes
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
@@ -103,7 +103,7 @@ export function Dhcp({
|
|||||||
onLockNode={setNodeLocked}
|
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')} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
+48
-22
@@ -14,7 +14,7 @@ import { Dialog } from '../../ui/Dialog'
|
|||||||
import { SectionHeader } from '../../ui/SectionHeader'
|
import { SectionHeader } from '../../ui/SectionHeader'
|
||||||
import { minuteStamp } from './dates'
|
import { minuteStamp } from './dates'
|
||||||
import type { Notice } from './notices'
|
import type { Notice } from './notices'
|
||||||
import { Failure, Loading } from '../../ui/Empty'
|
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||||
import { Tag } from '../../ui/Tag'
|
import { Tag } from '../../ui/Tag'
|
||||||
import tbl from '../../ui/Table.module.css'
|
import tbl from '../../ui/Table.module.css'
|
||||||
import { Panel } from '../../ui/Panel'
|
import { Panel } from '../../ui/Panel'
|
||||||
@@ -45,6 +45,8 @@ and the type tag are kept.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface LeasesProps {
|
export interface LeasesProps {
|
||||||
|
/** The empty state's way to the other sub-tab. */
|
||||||
|
onOpenScopes?: () => void
|
||||||
/** The section sub-navigation, drawn under the header. */
|
/** The section sub-navigation, drawn under the header. */
|
||||||
tabs?: ReactNode
|
tabs?: ReactNode
|
||||||
token: string | null
|
token: string | null
|
||||||
@@ -66,7 +68,7 @@ const KEYS: Keys<DhcpLease> = {
|
|||||||
expires: (l) => minuteStamp(l.leaseExpires),
|
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)
|
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. */
|
/* When the list was read: what "expiring in 10 min" counts from. Read with the list, not in render. */
|
||||||
const [readAt, setReadAt] = useState(0)
|
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 (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -187,26 +187,52 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
|
|||||||
|
|
||||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||||
|
|
||||||
{leases != null && !failure && (
|
{/* Waiting or failed, the figures are not known: the dash, never a zero. */}
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
label="Lease totals"
|
label="Lease totals"
|
||||||
items={[
|
items={
|
||||||
{ label: 'Leases', value: leases.length, icon: 'dhcp' },
|
leases == null || failure
|
||||||
{ 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' },
|
{ label: 'Leases', value: '\u2014', icon: 'dhcp' },
|
||||||
{
|
{ label: 'Reserved', value: '\u2014', icon: 'lock' },
|
||||||
label: 'Expiring in 10 min',
|
{ label: 'Scopes', value: '\u2014', icon: 'cache' },
|
||||||
value: leases.filter((l) => l.type !== 'Reserved' && Date.parse(l.leaseExpires) - readAt < 600000 && Date.parse(l.leaseExpires) > readAt).length,
|
{ label: 'Expiring in 10 min', value: '\u2014', icon: 'power' },
|
||||||
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' },
|
||||||
|
{
|
||||||
|
label: 'Expiring in 10 min',
|
||||||
|
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.
|
/* 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. */
|
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">
|
<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 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 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
|
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 { errorNotice, type Notice } from './notices'
|
||||||
import { formFromScope, blankForm, type ScopeForm as Form } from './model'
|
import { formFromScope, blankForm, type ScopeForm as Form } from './model'
|
||||||
import { ScopeForm } from './ScopeForm'
|
import { ScopeForm } from './ScopeForm'
|
||||||
import { Failure, Loading } from '../../ui/Empty'
|
import { FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||||
import { Tag } from '../../ui/Tag'
|
import { Tag } from '../../ui/Tag'
|
||||||
import tbl from '../../ui/Table.module.css'
|
import tbl from '../../ui/Table.module.css'
|
||||||
import styles from './Dhcp.module.css'
|
import styles from './Dhcp.module.css'
|
||||||
@@ -200,16 +200,6 @@ export function Scopes({
|
|||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
<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
|
<ScopeForm
|
||||||
key={editing.title + editing.form.oldName}
|
key={editing.title + editing.form.oldName}
|
||||||
title={editing.title}
|
title={editing.title}
|
||||||
@@ -226,8 +216,6 @@ export function Scopes({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scopes == null) return <Loading />
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@@ -249,10 +237,30 @@ export function Scopes({
|
|||||||
|
|
||||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
<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.
|
/* 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. */
|
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">
|
<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 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 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
|
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
|
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.
|
hand on each button, which is the symptom of a row that does not align.
|
||||||
*/
|
*/
|
||||||
|
.wrap { composes: screen from '../../ui/SectionHeader.module.css'; }
|
||||||
|
|
||||||
.flt {
|
.flt {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
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. */
|
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
|
/* `.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`. */
|
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'; }
|
.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 { useEffect, useRef, useState } from 'react'
|
||||||
import type { DnsClientRequest } from '../../app/handoff'
|
import type { DnsClientRequest } from '../../app/handoff'
|
||||||
import { PROTOCOLS, TYPES, prepareServer, resolve } from '../../api/dnsclient'
|
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 { Menu } from '../../ui/Menu'
|
||||||
import { loadQuickList, protocolForServer, serverListOptions, type QuickServer } from '../../lib/quick-lists'
|
import { loadQuickList, protocolForServer, serverListOptions, type QuickServer } from '../../lib/quick-lists'
|
||||||
import { SectionHeader } from '../../ui/SectionHeader'
|
import { SectionHeader } from '../../ui/SectionHeader'
|
||||||
import { Empty } from '../../ui/Empty'
|
import { Empty, FailureRetry } from '../../ui/Empty'
|
||||||
import { Raw } from '../../ui/Raw'
|
import { Raw } from '../../ui/Raw'
|
||||||
import { Details } from '../../ui/Details'
|
import { Details } from '../../ui/Details'
|
||||||
import styles from './DnsClient.module.css'
|
import styles from './DnsClient.module.css'
|
||||||
@@ -92,6 +92,8 @@ export function DnsClient({
|
|||||||
const [raw, setRaw] = useState<unknown[]>([])
|
const [raw, setRaw] = useState<unknown[]>([])
|
||||||
const [alert, setAlert] = useState<AlertState | null>(null)
|
const [alert, setAlert] = useState<AlertState | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
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) {
|
async function fire(runImport: boolean) {
|
||||||
// The order is upstream's: extract first, check afterwards.
|
// The order is upstream's: extract first, check afterwards.
|
||||||
@@ -124,8 +126,10 @@ export function DnsClient({
|
|||||||
setOutput(null)
|
setOutput(null)
|
||||||
setRaw([])
|
setRaw([])
|
||||||
setAlert(noticeFromFailure(outcome))
|
setAlert(noticeFromFailure(outcome))
|
||||||
|
setFailure(noticeFromFailure(outcome).text)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setFailure(null)
|
||||||
|
|
||||||
const r = outcome.data.response
|
const r = outcome.data.response
|
||||||
setOutput(JSON.stringify(r.result, null, 2))
|
setOutput(JSON.stringify(r.result, null, 2))
|
||||||
@@ -154,17 +158,17 @@ export function DnsClient({
|
|||||||
}, [request])
|
}, [request])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={styles.wrap}>
|
||||||
<SectionHeader title="DNS Client" subtitle="Resolve a name through this server or another resolver" />
|
<SectionHeader title="DNS Client" subtitle="Resolve a name through this server or another resolver" />
|
||||||
|
|
||||||
<ClusterNodeSelect
|
<ServerSelect
|
||||||
|
hint={<HubWriteHint write={write} />}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
initialised={clusterInitialised}
|
initialised={clusterInitialised}
|
||||||
value={clusterNode}
|
value={clusterNode}
|
||||||
onChange={setClusterNode}
|
onChange={setClusterNode}
|
||||||
label="Cluster Node"
|
label="Cluster Node"
|
||||||
/>
|
/>
|
||||||
<HubWriteHint write={write} />
|
|
||||||
|
|
||||||
<Notifier notice={alert} onClose={() => setAlert(null)} />
|
<Notifier notice={alert} onClose={() => setAlert(null)} />
|
||||||
|
|
||||||
@@ -219,7 +223,8 @@ export function DnsClient({
|
|||||||
</LabeledSelect>
|
</LabeledSelect>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.short}>
|
<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>)}
|
{PROTOCOLS.map((t) => <option key={t}>{t}</option>)}
|
||||||
</LabeledSelect>
|
</LabeledSelect>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,17 +235,23 @@ export function DnsClient({
|
|||||||
THIRD name for the same gesture, and the only one in the console
|
THIRD name for the same gesture, and the only one in the console
|
||||||
that came out at 34 px high. */}
|
that came out at 34 px high. */}
|
||||||
<Check label="Enable DNSSEC Validation" checked={dnssec} onChange={setDnssec} />
|
<Check label="Enable DNSSEC Validation" checked={dnssec} onChange={setDnssec} />
|
||||||
<Button variant="primary" disabled={busy} onClick={() => void fire(false)}>
|
<div className="flex items-center gap-2">
|
||||||
Resolve
|
<Button variant="primary" disabled={busy} onClick={() => void fire(false)}>
|
||||||
</Button>
|
Resolve
|
||||||
<PermissionButton permission={canImport ? undefined : 'DnsClient.canModify'} disabled={busy || !write.allowed} onClick={() => void fire(true)}>
|
</Button>
|
||||||
Import
|
<PermissionButton permission={canImport ? undefined : 'DnsClient.canModify'} disabled={busy || !write.allowed} onClick={() => void fire(true)}>
|
||||||
</PermissionButton>
|
Import
|
||||||
|
</PermissionButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{output === null ? (
|
{output === null ? (
|
||||||
<Empty>Run a query to see the response.</Empty>
|
failure !== null ? (
|
||||||
|
<FailureRetry onRetry={() => void fire(false)}>The query failed. {failure}</FailureRetry>
|
||||||
|
) : (
|
||||||
|
<Empty>Run a query to see the response.</Empty>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<Panel
|
<Panel
|
||||||
title="Response"
|
title="Response"
|
||||||
@@ -265,6 +276,6 @@ export function DnsClient({
|
|||||||
</Body>
|
</Body>
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { HubMode } from '../../ui/ClusterNodeSelect'
|
|||||||
import { HubWritable } from '../../lib/hub-write'
|
import { HubWritable } from '../../lib/hub-write'
|
||||||
import { HubServerIds } from '../../lib/hub-all'
|
import { HubServerIds } from '../../lib/hub-all'
|
||||||
import * as hubRun from '../../api/hub-run'
|
import * as hubRun from '../../api/hub-run'
|
||||||
|
import { WithChrome } from '../../test/chrome'
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks())
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
@@ -22,7 +23,9 @@ function hubCache(writable: string[], canDelete = true) {
|
|||||||
<HubMode.Provider value>
|
<HubMode.Provider value>
|
||||||
<HubWritable.Provider value={writable}>
|
<HubWritable.Provider value={writable}>
|
||||||
<HubServerIds.Provider value={['mdns', 'ndns']}>
|
<HubServerIds.Provider value={['mdns', 'ndns']}>
|
||||||
<Cache token="t" nodes={NODES} clusterInitialised canDelete={canDelete} />
|
<WithChrome>
|
||||||
|
<Cache token="t" nodes={NODES} clusterInitialised canDelete={canDelete} />
|
||||||
|
</WithChrome>
|
||||||
</HubServerIds.Provider>
|
</HubServerIds.Provider>
|
||||||
</HubWritable.Provider>
|
</HubWritable.Provider>
|
||||||
</HubMode.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: 'mdns', ok: true, message: 'Cache flushed.' }, { server: 'ndns', ok: false, message: 'ndns did not answer' }))
|
||||||
.mockResolvedValueOnce(done({ server: 'ndns', ok: true, message: 'Cache flushed.' }))
|
.mockResolvedValueOnce(done({ server: 'ndns', ok: true, message: 'Cache flushed.' }))
|
||||||
hubCache(['mdns', 'ndns'])
|
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')
|
const picker = await screen.findByRole('dialog')
|
||||||
await user.click(within(picker).getByRole('button', { name: 'Flush cache' }))
|
await user.click(within(picker).getByRole('button', { name: 'Flush cache' }))
|
||||||
expect(run).toHaveBeenNthCalledWith(1, 't', 'cache.flush', {}, ['mdns', 'ndns'])
|
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 user = userEvent.setup()
|
||||||
const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(done({ server: 'mdns', ok: true }))
|
const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(done({ server: 'mdns', ok: true }))
|
||||||
hubCache(['mdns'])
|
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')
|
const ask = await screen.findByRole('dialog')
|
||||||
await user.type(within(ask).getByLabelText('Domain'), 'bad domain')
|
await user.type(within(ask).getByLabelText('Domain'), 'bad domain')
|
||||||
await user.click(within(ask).getByRole('button', { name: 'Choose servers' }))
|
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 () => {
|
it('is not offered to a role that may not delete from the cache', async () => {
|
||||||
hubCache(['mdns', 'ndns'], false)
|
hubCache(['mdns', 'ndns'], false)
|
||||||
await screen.findByRole('button', { name: 'Flush Cache' })
|
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 { useContext, useState } from 'react'
|
||||||
import { useAllServersFlow } from '../../lib/hub-all'
|
import { useAllServersFlow } from '../../lib/hub-all'
|
||||||
import { AskDialog } from '../../ui/AskDialog'
|
import { AskDialog } from '../../ui/AskDialog'
|
||||||
import { Button } from '../../ui/Button'
|
import { Menu } from '../../ui/Menu'
|
||||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -24,20 +24,36 @@ export function CacheAllServers({ token }: { token: string | null }) {
|
|||||||
if (!hub) return null
|
if (!hub) return null
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
{/* One menu, not two more buttons next to the chosen server's own Flush Cache. */}
|
||||||
variant="danger"
|
<Menu label="All servers" text="All servers">
|
||||||
onClick={() =>
|
{(close) => (
|
||||||
start({
|
<>
|
||||||
action: 'cache.flush',
|
<button
|
||||||
title: 'Flush the cache on several servers',
|
type="button"
|
||||||
intro: 'The whole cache of each chosen server is emptied.',
|
onClick={() => {
|
||||||
confirmLabel: 'Flush cache',
|
close()
|
||||||
})
|
start({
|
||||||
}
|
action: 'cache.flush',
|
||||||
>
|
title: 'Flush the cache on several servers',
|
||||||
Flush on all servers
|
intro: 'The whole cache of each chosen server is emptied.',
|
||||||
</Button>
|
confirmLabel: 'Flush cache',
|
||||||
<Button onClick={() => setAsking(true)}>Delete a domain on all servers</Button>
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Flush on all servers
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
close()
|
||||||
|
setAsking(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete a domain on all servers
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
{asking && (
|
{asking && (
|
||||||
<AskDialog
|
<AskDialog
|
||||||
title="Delete a domain from the cache on all servers"
|
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)
|
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' } as never)
|
||||||
render(<Cache token="t" />)
|
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()
|
expect(screen.queryByText(/Could not refresh/)).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { KpiStrip } from '../../ui/KpiStrip'
|
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 { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||||
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||||
@@ -21,7 +22,7 @@ import { Confirm } from '../../ui/Confirm'
|
|||||||
import { Dialog } from '../../ui/Dialog'
|
import { Dialog } from '../../ui/Dialog'
|
||||||
import { Field, Input, LabeledTextarea } from '../../ui/Field'
|
import { Field, Input, LabeledTextarea } from '../../ui/Field'
|
||||||
import { SectionHeader } from '../../ui/SectionHeader'
|
import { SectionHeader } from '../../ui/SectionHeader'
|
||||||
import { Empty, Loading } from '../../ui/Empty'
|
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||||
import { StaleData } from '../StaleData'
|
import { StaleData } from '../StaleData'
|
||||||
import { Tree } from './Tree'
|
import { Tree } from './Tree'
|
||||||
import { CacheAllServers } from './CacheAllServers'
|
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
|
/* 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. */
|
to tell "failed" from "still loading", so it is said apart. */
|
||||||
const [failedOutside, setFailedOutside] = useState(false)
|
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
|
Stale data. The same gap Zones had, and the same design rule (old data never
|
||||||
looks like new data): the previous list
|
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)
|
const outcome = await listNode(list, token, domain, up ? 'up' : undefined, node)
|
||||||
if (outcome.kind === 'ok') {
|
if (outcome.kind === 'ok') {
|
||||||
setNode(outcome.data)
|
setNode(outcome.data)
|
||||||
|
setFailure(null)
|
||||||
setStale(false)
|
setStale(false)
|
||||||
setLastGood(new Date().toISOString())
|
setLastGood(new Date().toISOString())
|
||||||
hadData.current = true
|
hadData.current = true
|
||||||
@@ -328,6 +332,7 @@ export function Lists({
|
|||||||
*/
|
*/
|
||||||
if (!hadData.current) {
|
if (!hadData.current) {
|
||||||
if (host.current != null) setFailedOutside(true)
|
if (host.current != null) setFailedOutside(true)
|
||||||
|
setFailure(noticeFromFailure(outcome).text)
|
||||||
report(noticeFromFailure(outcome))
|
report(noticeFromFailure(outcome))
|
||||||
} else setStale(true)
|
} else setStale(true)
|
||||||
},
|
},
|
||||||
@@ -366,7 +371,16 @@ export function Lists({
|
|||||||
/* The first load, still in flight: `node` is null and nothing failed. A load
|
/* 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
|
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. */
|
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 domain = node?.domain ?? ''
|
||||||
const nodeTitle = domain === '' ? '<ROOT>' : (node?.domainIdn ?? 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
|
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). */}
|
and Delete all go to it (other-zones.js:20-61, 106). */}
|
||||||
{isCache && !embedded && (
|
{isCache && !embedded && (
|
||||||
<ClusterNodeSelect
|
<ServerSelect
|
||||||
|
hint={<HubWriteHint write={cacheWrite} />}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
initialised={clusterInitialised}
|
initialised={clusterInitialised}
|
||||||
value={clusterNode}
|
value={clusterNode}
|
||||||
@@ -481,7 +496,6 @@ export function Lists({
|
|||||||
label="Cluster Node"
|
label="Cluster Node"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isCache && !embedded && <HubWriteHint write={cacheWrite} />}
|
|
||||||
|
|
||||||
{/* On Cache the identity band's second line moves up under the title: the
|
{/* 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
|
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.
|
but it sets the notice, and that is what the reader has to see.
|
||||||
*/}
|
*/}
|
||||||
{loading ? (
|
{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
|
{/* 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
|
shows, which is the defect the tree had fixed on 2026-09-07 and this
|
||||||
side still had.
|
side still had.
|
||||||
*/}
|
*/}
|
||||||
{!loading && (
|
{!loading && !failed && (
|
||||||
<div className={styles.count}>
|
<div className={styles.count}>
|
||||||
<span>
|
<span>
|
||||||
{records.length} records at <span className={styles.mono}>{nodeTitle}</span>
|
{records.length} records at <span className={styles.mono}>{nodeTitle}</span>
|
||||||
@@ -666,7 +682,11 @@ export function Lists({
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
/* The tree's placeholder above already announced this wait: two
|
/* The tree's placeholder above already announced this wait: two
|
||||||
`role="status"` for one request read it out twice. */
|
`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 ? (
|
) : records.length > 0 ? (
|
||||||
<ResourceRecords records={records} withDnssec={isCache} node={domain} />
|
<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); }
|
.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 { composes: row from '../../ui/list.module.css'; justify-content: space-between; }
|
||||||
.logfile .name { font-family: var(--font-mono); }
|
.logfile .name { font-family: var(--font-mono); }
|
||||||
.sz { composes: annotationQuiet from '../../ui/text.module.css'; }
|
.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)
|
.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', () => {
|
describe('Logs › View Logs', () => {
|
||||||
it('it lists the files with their size and offers to delete them all', async () => {
|
it('it lists the files with their size and offers to delete them all', async () => {
|
||||||
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
|
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(await screen.findByText('2026-08-26')).toBeInTheDocument()
|
||||||
expect(screen.getByText('[20.48 KB]')).toBeInTheDocument()
|
expect(screen.getByText('[20.48 KB]')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: 'Delete All Logs' })).toBeInTheDocument()
|
await openDelete()
|
||||||
expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeInTheDocument()
|
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 () => {
|
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" />)
|
render(<Logs token="t" sub="View Logs" />)
|
||||||
|
|
||||||
expect(await screen.findByText('No Log File Was Found')).toBeInTheDocument()
|
expect(await screen.findByText('No Log File Was Found')).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('button', { name: 'Delete All Logs' })).not.toBeInTheDocument()
|
await openDelete()
|
||||||
expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeInTheDocument()
|
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 () => {
|
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" />)
|
render(<Logs token="t" sub="View Logs" />)
|
||||||
await screen.findByText('2026-08-26')
|
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(
|
expect(
|
||||||
screen.getByText('Are you sure you want to permanently delete all log files?'),
|
screen.getByText('Are you sure you want to permanently delete all log files?'),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
@@ -192,7 +200,8 @@ describe('Logs › View Logs', () => {
|
|||||||
render(<Logs token="t" sub="View Logs" />)
|
render(<Logs token="t" sub="View Logs" />)
|
||||||
await screen.findByText('2026-08-26')
|
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(
|
expect(
|
||||||
screen.getByText('Are you sure you want to permanently delete all stats files?'),
|
screen.getByText('Are you sure you want to permanently delete all stats files?'),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
@@ -219,12 +228,14 @@ describe('Logs › View Logs', () => {
|
|||||||
const { unmount } = render(
|
const { unmount } = render(
|
||||||
<Logs token="t" sub="View Logs" canDeleteLogs={false} canDeleteStats />,
|
<Logs token="t" sub="View Logs" canDeleteLogs={false} canDeleteStats />,
|
||||||
)
|
)
|
||||||
|
await openDelete()
|
||||||
expect(await screen.findByRole('button', { name: /Delete All Logs/ })).toBeDisabled()
|
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()
|
unmount()
|
||||||
|
|
||||||
render(<Logs token="t" sub="View Logs" canDeleteLogs canDeleteStats={false} />)
|
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()
|
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 })
|
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
|
||||||
render(<Logs token="t" sub="View Logs" canDeleteLogs={false} canDeleteStats={false} />)
|
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/ })
|
const stats = await screen.findByRole('button', { name: /Delete All Stats/ })
|
||||||
await user.hover(stats.parentElement!)
|
await user.hover(stats.parentElement!)
|
||||||
expect(await screen.findByText('Requires Dashboard: Delete')).toBeInTheDocument()
|
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 />)
|
render(<Logs token="t" sub="View Logs" canDeleteLogs canDeleteStats />)
|
||||||
|
|
||||||
expect(await screen.findByText('No Log File Was Found')).toBeInTheDocument()
|
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 { useAllServersFlow } from '../../lib/hub-all'
|
||||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||||
import { SubTabs } from '../../ui/SubTabs'
|
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 { QueryLogs } from './QueryLogs'
|
||||||
import { ViewLogs } from './ViewLogs'
|
import { ViewLogs } from './ViewLogs'
|
||||||
import type { QueryLogsRequest } from '../../app/handoff'
|
import type { QueryLogsRequest } from '../../app/handoff'
|
||||||
@@ -96,7 +97,8 @@ export function Logs({
|
|||||||
{/* Upstream mounts one here (`optDhcpClusterNode` / `optLogsClusterNode`).
|
{/* Upstream mounts one here (`optDhcpClusterNode` / `optLogsClusterNode`).
|
||||||
It draws nothing without a cluster, so it costs a single-server install
|
It draws nothing without a cluster, so it costs a single-server install
|
||||||
nothing. */}
|
nothing. */}
|
||||||
<ClusterNodeSelect
|
<ServerSelect
|
||||||
|
hint={active === 'Query Logs' && <HubWriteHint write={write} allServers={allServers} />}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
initialised={clusterInitialised}
|
initialised={clusterInitialised}
|
||||||
value={node}
|
value={node}
|
||||||
@@ -104,7 +106,6 @@ export function Logs({
|
|||||||
aggregate={aggregateShown}
|
aggregate={aggregateShown}
|
||||||
label="Cluster Node"
|
label="Cluster Node"
|
||||||
/>
|
/>
|
||||||
{active === 'Query Logs' && <HubWriteHint write={write} allServers={allServers} />}
|
|
||||||
{active === 'Query Logs' ? (
|
{active === 'Query Logs' ? (
|
||||||
<HubNode.Provider value={hub ? node : ''}>
|
<HubNode.Provider value={hub ? node : ''}>
|
||||||
<QueryLogs
|
<QueryLogs
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { Button } from '../../ui/Button'
|
|||||||
import { Input, Select } from '../../ui/Field'
|
import { Input, Select } from '../../ui/Field'
|
||||||
import { SectionHeader } from '../../ui/SectionHeader'
|
import { SectionHeader } from '../../ui/SectionHeader'
|
||||||
import { toIso, dateTime } from './dates'
|
import { toIso, dateTime } from './dates'
|
||||||
import { Loading } from '../../ui/Empty'
|
import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||||
import { Check } from '../../ui/Check'
|
import { Check } from '../../ui/Check'
|
||||||
import { Tag } from '../../ui/Tag'
|
import { Tag } from '../../ui/Tag'
|
||||||
import { DataTable } from '../../ui/DataTable'
|
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 [apps, setApps] = useState<{ name: string; classPaths: string[] }[] | null>(null)
|
||||||
const [f, setF] = useState<Filters>(() => defaultFilters('', ''))
|
const [f, setF] = useState<Filters>(() => defaultFilters('', ''))
|
||||||
const [page, setPage] = useState<QueryLogPage | null>(null)
|
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 [notice, setNotice] = useState<Notice | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [live, setLive] = 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 (outcome.kind !== 'ok') {
|
||||||
if (!live) {
|
if (!live) {
|
||||||
setNotice(noticeFromFailure(outcome))
|
setNotice(noticeFromFailure(outcome))
|
||||||
|
setQueryFailure(noticeFromFailure(outcome).text)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setQueryFailure(null)
|
||||||
setPage(outcome.data.response)
|
setPage(outcome.data.response)
|
||||||
},
|
},
|
||||||
[token, params, reveal],
|
[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.
|
run, so the screen sat dead without saying why.
|
||||||
*/
|
*/
|
||||||
setApps([])
|
setApps([])
|
||||||
|
setAppsFailed(true)
|
||||||
setNotice(noticeFromFailure(outcome))
|
setNotice(noticeFromFailure(outcome))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setAppsFailed(false)
|
||||||
const list = appsWithQueryLogs(outcome.data.response.apps ?? [])
|
const list = appsWithQueryLogs(outcome.data.response.apps ?? [])
|
||||||
setApps(list)
|
setApps(list)
|
||||||
const first = list[0]
|
const first = list[0]
|
||||||
@@ -516,7 +524,7 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
|
|||||||
return () => {
|
return () => {
|
||||||
live = false
|
live = false
|
||||||
}
|
}
|
||||||
}, [token])
|
}, [token, appsTry])
|
||||||
|
|
||||||
/* logs.js:610. While "Live Update" is checked, it repeats every 2 s. */
|
/* logs.js:610. While "Live Update" is checked, it repeats every 2 s. */
|
||||||
useEffect(() => {
|
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). */
|
/* What the folded panel lists (FOLD_QUERY). */
|
||||||
const active = activeFilters(f, defaultFilters(apps[0]?.name ?? '', apps[0]?.classPaths[0] ?? ''))
|
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>
|
||||||
</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 && (
|
{page != null && (
|
||||||
<Panel title="Query Logs" description="Logging app, dates, client, domain and response type">
|
<Panel title="Query Logs" description="Logging app, dates, client, domain and response type">
|
||||||
<div className={styles.count}>
|
<div className={styles.count}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
deleteAllLogs,
|
deleteAllLogs,
|
||||||
deleteLog,
|
deleteLog,
|
||||||
@@ -15,7 +15,10 @@ import { LogText, stampWidth } from './LogText'
|
|||||||
import { PermissionButton } from '../../ui/PermissionButton'
|
import { PermissionButton } from '../../ui/PermissionButton'
|
||||||
import { Button } from '../../ui/Button'
|
import { Button } from '../../ui/Button'
|
||||||
import { SectionHeader } from '../../ui/SectionHeader'
|
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 styles from './Logs.module.css'
|
||||||
import { Body, Panel } from '../../ui/Panel'
|
import { Body, Panel } from '../../ui/Panel'
|
||||||
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||||||
@@ -95,7 +98,10 @@ export function ViewLogs({
|
|||||||
canDeleteLogs = true,
|
canDeleteLogs = true,
|
||||||
canDeleteStats = true,
|
canDeleteStats = true,
|
||||||
}: ViewLogsProps) {
|
}: ViewLogsProps) {
|
||||||
|
const hub = useContext(HubMode)
|
||||||
const [files, setFiles] = useState<LogFile[] | null>(null)
|
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 [open, setOpen] = useState<string | null>(null)
|
||||||
const [body, setBody] = useState<string | null>(null)
|
const [body, setBody] = useState<string | null>(null)
|
||||||
const [loadingBody, setLoadingBody] = useState(false)
|
const [loadingBody, setLoadingBody] = useState(false)
|
||||||
@@ -108,9 +114,11 @@ export function ViewLogs({
|
|||||||
const r = await listLogFiles(token, node)
|
const r = await listLogFiles(token, node)
|
||||||
if (r.kind === 'ok') {
|
if (r.kind === 'ok') {
|
||||||
setFiles(r.data)
|
setFiles(r.data)
|
||||||
|
setFailed(false)
|
||||||
return r.data
|
return r.data
|
||||||
}
|
}
|
||||||
setFiles([])
|
setFiles([])
|
||||||
|
setFailed(true)
|
||||||
setNotice(noticeFromFailure(r))
|
setNotice(noticeFromFailure(r))
|
||||||
return null
|
return null
|
||||||
}, [token, node])
|
}, [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 }> = {
|
const CONFIRM_TEXT: Record<Confirmation, { title: string; text: string; label: string }> = {
|
||||||
log: {
|
log: {
|
||||||
title: 'Delete Log',
|
title: 'Delete Log',
|
||||||
@@ -253,24 +259,31 @@ export function ViewLogs({
|
|||||||
controls, among the nine compared with upstream, that "does not ask for
|
controls, among the nine compared with upstream, that "does not ask for
|
||||||
the permission of the screen whose name it carries".
|
the permission of the screen whose name it carries".
|
||||||
*/}
|
*/}
|
||||||
{files.length > 0 && (
|
{/* Behind a hub both are refused, so neither is offered. Without one they share a menu:
|
||||||
<PermissionButton
|
two destructive buttons stacked the header into two rows. */}
|
||||||
variant="danger"
|
{!hub && (
|
||||||
disabled={busy}
|
<Menu label="Delete all" text="Delete all">
|
||||||
permission={canDeleteLogs ? undefined : 'Logs.canDelete'}
|
{(close) => (
|
||||||
onClick={() => setConfirm('allLogs')}
|
<>
|
||||||
>
|
{files != null && files.length > 0 &&
|
||||||
Delete All Logs
|
(canDeleteLogs ? (
|
||||||
</PermissionButton>
|
<button type="button" data-variant="danger" disabled={busy} onClick={() => { close(); setConfirm('allLogs') }}>
|
||||||
)}
|
Delete All Logs
|
||||||
<PermissionButton
|
</button>
|
||||||
variant="danger"
|
) : (
|
||||||
disabled={busy}
|
<LockedItem need="Logs.canDelete" placement="left">Delete All Logs</LockedItem>
|
||||||
permission={canDeleteStats ? undefined : 'Dashboard.canDelete'}
|
))}
|
||||||
onClick={() => setConfirm('allStats')}
|
{canDeleteStats ? (
|
||||||
>
|
<button type="button" data-variant="danger" disabled={busy} onClick={() => { close(); setConfirm('allStats') }}>
|
||||||
Delete All Stats
|
Delete All Stats
|
||||||
</PermissionButton></>}
|
</button>
|
||||||
|
) : (
|
||||||
|
<LockedItem need="Dashboard.canDelete" placement="left">Delete All Stats</LockedItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
)}</>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||||
@@ -278,11 +291,19 @@ export function ViewLogs({
|
|||||||
<div className={styles.split}>
|
<div className={styles.split}>
|
||||||
<Panel title="Log Files" description="Server log files by date">
|
<Panel title="Log Files" description="Server log files by date">
|
||||||
<Body className={styles.pbList}>
|
<Body className={styles.pbList}>
|
||||||
{files.length === 0 ? (
|
{files == null ? (
|
||||||
<Empty>
|
<LoadingRegion height="h-40" />
|
||||||
{notice?.type === 'danger'
|
) : failed ? (
|
||||||
? 'Unable to load the log files.'
|
<FailureRetry onRetry={() => { setFiles(null); void load() }}>Unable to load the log files.</FailureRetry>
|
||||||
: 'No Log File Was Found'}
|
) : files.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
actions={
|
||||||
|
<Button variant="primary" onClick={() => { setFiles(null); void load() }}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
No Log File Was Found
|
||||||
</Empty>
|
</Empty>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.logfiles}>
|
<div className={styles.logfiles}>
|
||||||
@@ -303,7 +324,13 @@ export function ViewLogs({
|
|||||||
</Body>
|
</Body>
|
||||||
</Panel>
|
</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
|
<Panel
|
||||||
className={styles.reader}
|
className={styles.reader}
|
||||||
title={<span className={styles.mono}>{open}</span>}
|
title={<span className={styles.mono}>{open}</span>}
|
||||||
@@ -326,7 +353,7 @@ export function ViewLogs({
|
|||||||
>
|
>
|
||||||
<Body className={styles.readerBody}>
|
<Body className={styles.readerBody}>
|
||||||
{loadingBody ? (
|
{loadingBody ? (
|
||||||
<Loading />
|
<LoadingRegion height="h-48" />
|
||||||
) : (
|
) : (
|
||||||
/*
|
/*
|
||||||
The server's error is drawn HERE and not as a notice, because the
|
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()
|
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 () => {
|
it('the active sub-tab arrives by prop: the sub-navigation belongs to the Shell', async () => {
|
||||||
server()
|
server()
|
||||||
await mount({ sub: 'Logging' })
|
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 { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { nodeArg, useHubWrite } from '../../lib/hub-write'
|
import { nodeArg, useHubWrite } from '../../lib/hub-write'
|
||||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||||
@@ -35,7 +36,7 @@ import { Blocking } from './panes/Blocking'
|
|||||||
import { ProxyForwarders } from './panes/ProxyForwarders'
|
import { ProxyForwarders } from './panes/ProxyForwarders'
|
||||||
import { Logging } from './panes/Logging'
|
import { Logging } from './panes/Logging'
|
||||||
import { BackupDialog, Confirm, RestoreDialog } from './dialogs'
|
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 styles from './Settings.module.css'
|
||||||
import panelFormStyles from '../../ui/PanelForm.module.css'
|
import panelFormStyles from '../../ui/PanelForm.module.css'
|
||||||
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||||||
@@ -298,9 +299,35 @@ export function Settings({
|
|||||||
}, [indexed, active, loading])
|
}, [indexed, active, loading])
|
||||||
const here = useSectionInView(sections.map((x) => x.id))
|
const here = useSectionInView(sections.map((x) => x.id))
|
||||||
|
|
||||||
if (loading) return <Loading />
|
/* The selector is drawn in every state: a slow or silent server must not take away the way
|
||||||
if (form == null || settings == null) {
|
to choose the other one. */
|
||||||
return <Failure>Unable to load the DNS Server settings.</Failure>
|
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)
|
const en = enabled(form)
|
||||||
@@ -501,14 +528,7 @@ export function Settings({
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{hub && canModify && <CompareServersButton token={token} scope="settings" />}
|
{hub && canModify && <CompareServersButton token={token} scope="settings" />}
|
||||||
<ClusterNodeSelect
|
{serverSelect}
|
||||||
nodes={nodes}
|
|
||||||
initialised={clusterInitialised}
|
|
||||||
aggregate={!hub}
|
|
||||||
value={node}
|
|
||||||
onChange={setNode}
|
|
||||||
label="Cluster Node"
|
|
||||||
/>
|
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission={canBackup ? undefined : 'Settings.canDelete'}
|
permission={canBackup ? undefined : 'Settings.canDelete'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -549,7 +569,7 @@ export function Settings({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HubWriteHint write={write} />
|
{!hub && <HubWriteHint write={write} />}
|
||||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from '../../api/zones'
|
} from '../../api/zones'
|
||||||
import { Button } from '../../ui/Button'
|
import { Button } from '../../ui/Button'
|
||||||
import { Tick } from '../../ui/Tick'
|
import { Tick } from '../../ui/Tick'
|
||||||
import { Loading } from '../../ui/Empty'
|
import { FailureRetry, LoadingRegion } from '../../ui/Empty'
|
||||||
import { StaleData } from '../StaleData'
|
import { StaleData } from '../StaleData'
|
||||||
import { Field, Input, Select } from '../../ui/Field'
|
import { Field, Input, Select } from '../../ui/Field'
|
||||||
import { SectionHeader } from '../../ui/SectionHeader'
|
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.
|
fixed for on this screen. Measured on 2026-09-07 with the state matrix.
|
||||||
*/
|
*/
|
||||||
const [loaded, setLoaded] = useState(false)
|
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 [pageNumber, setPageNumber] = useState(1)
|
||||||
const [totalPages, setTotalPages] = useState(1)
|
const [totalPages, setTotalPages] = useState(1)
|
||||||
const [totalZones, setTotalZones] = useState(0)
|
const [totalZones, setTotalZones] = useState(0)
|
||||||
@@ -191,7 +193,10 @@ export function ZoneList({
|
|||||||
*/
|
*/
|
||||||
const hadData = lastGood != null
|
const hadData = lastGood != null
|
||||||
setStale(hadData)
|
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
|
/* 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
|
is a bad one. Leaving `loaded` false here would leave the screen saying
|
||||||
"loading" over a failure that has already been reported. */
|
"loading" over a failure that has already been reported. */
|
||||||
@@ -201,6 +206,7 @@ export function ZoneList({
|
|||||||
|
|
||||||
const data = r.data
|
const data = r.data
|
||||||
setStale(false)
|
setStale(false)
|
||||||
|
setFailure(null)
|
||||||
setLastGood(new Date().toISOString())
|
setLastGood(new Date().toISOString())
|
||||||
setZones(data.zones)
|
setZones(data.zones)
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
@@ -395,6 +401,8 @@ export function ZoneList({
|
|||||||
const shown = view === 'all' ? zones : zones.filter(VIEWS[view])
|
const shown = view === 'all' ? zones : zones.filter(VIEWS[view])
|
||||||
const { rows: visibleZones, sort, toggle } = useSort(KEYS, shown)
|
const { rows: visibleZones, sort, toggle } = useSort(KEYS, shown)
|
||||||
const kpi = summary(all)
|
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
|
const allChecked = zones.length > 0 && checkedOnes.length === zones.length
|
||||||
|
|
||||||
@@ -439,10 +447,10 @@ export function ZoneList({
|
|||||||
<KpiStrip
|
<KpiStrip
|
||||||
label="Zone totals"
|
label="Zone totals"
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Zones', value: all.length || totalZones, icon: 'zones' },
|
{ label: 'Zones', value: noData ? '\u2014' : all.length || totalZones, icon: 'zones' },
|
||||||
{ label: 'Enabled', value: kpi.enabled, icon: 'check', tone: 'ok' },
|
{ label: 'Enabled', value: noData ? '\u2014' : kpi.enabled, icon: 'check', tone: noData ? undefined : 'ok' },
|
||||||
{ label: 'Signed with DNSSEC', value: kpi.signed, icon: 'lock' },
|
{ label: 'Signed with DNSSEC', value: noData ? '\u2014' : kpi.signed, icon: 'lock' },
|
||||||
{ label: 'Secondary pending sync', value: kpi.pending, icon: 'convert', tone: kpi.pending > 0 ? 'warn' : undefined },
|
{ 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)} />}
|
{stale && <StaleData since={lastGood} onRetry={() => void load(pageNumber)} />}
|
||||||
|
|
||||||
{!loaded ? (
|
{!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>),
|
status: (<Tag tone={stateTone}>{state}</Tag>),
|
||||||
serial: z.soaSerial ?? ' ',
|
serial: z.soaSerial ?? '\u2014',
|
||||||
expiry: date(z.expiry),
|
expiry: date(z.expiry),
|
||||||
modified: date(z.lastModified),
|
modified: date(z.lastModified),
|
||||||
actions: (
|
actions: (
|
||||||
|
|||||||
@@ -45,4 +45,17 @@ describe('a zone list that has not answered yet', () => {
|
|||||||
render(<ZoneList {...props} />)
|
render(<ZoneList {...props} />)
|
||||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
|
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)
|
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' } as never)
|
||||||
render(<Zones token="t" canModify canDelete />)
|
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()
|
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 { useContext, useState } from 'react'
|
||||||
import { AGGREGATE, HubMode } from '../../ui/ClusterNodeSelect'
|
import { AGGREGATE, HubMode } from '../../ui/ClusterNodeSelect'
|
||||||
import { ZonesMatrix } from './ZonesMatrix'
|
import { ZonesMatrix } from './ZonesMatrix'
|
||||||
@@ -154,7 +154,8 @@ export function Zones({
|
|||||||
same state here, since it is the same question. The list's reloads
|
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
|
the list (index.html:436); the records view's is always disabled
|
||||||
(index.html:548). */}
|
(index.html:548). */}
|
||||||
<ClusterNodeSelect
|
<ServerSelect
|
||||||
|
hint={!matrixMode && <HubWriteHint write={write} />}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
initialised={clusterInitialised}
|
initialised={clusterInitialised}
|
||||||
aggregate={hub}
|
aggregate={hub}
|
||||||
@@ -166,7 +167,6 @@ export function Zones({
|
|||||||
disabled={open != null}
|
disabled={open != null}
|
||||||
label="Cluster Node"
|
label="Cluster Node"
|
||||||
/>
|
/>
|
||||||
{!matrixMode && <HubWriteHint write={write} />}
|
|
||||||
|
|
||||||
{matrixMode ? (
|
{matrixMode ? (
|
||||||
<ZonesMatrix
|
<ZonesMatrix
|
||||||
|
|||||||
@@ -290,7 +290,8 @@ describe('SessionProvider', () => {
|
|||||||
vi.spyOn(client, 'apiRequest').mockResolvedValue(session())
|
vi.spyOn(client, 'apiRequest').mockResolvedValue(session())
|
||||||
window.history.replaceState(null, '', '/settings/')
|
window.history.replaceState(null, '', '/settings/')
|
||||||
mount()
|
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.
|
// Being "in Settings" and nothing more is half a page: the address is completed.
|
||||||
await waitFor(() => expect(window.location.pathname).toBe('/settings/general/'))
|
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 type { ReactNode } from 'react'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Button } from './Button'
|
||||||
import styles from './Empty.module.css'
|
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 }) {
|
export function Failure({ children }: { children: ReactNode }) {
|
||||||
return <div className={styles.failure}>{children}</div>
|
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