From ba3cdd08d299fce974b364f0ef5853e71969a3bc Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 2 Nov 2026 21:42:28 +0700 Subject: [PATCH] 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 --- hub/merge.test.ts | 17 ++++ hub/merge.ts | 17 +++- src/app/Shell.module.css | 2 +- src/screens/apps/AppCard.tsx | 2 +- src/screens/apps/Apps.tsx | 20 +++-- src/screens/apps/AppsHistory.test.tsx | 11 +-- src/screens/apps/AppsMatrix.tsx | 48 ++++++----- src/screens/apps/HubStore.tsx | 2 +- .../blocking/AdvancedBlockingEntries.tsx | 19 +++-- src/screens/blocking/BlockLists.tsx | 71 +++++++--------- src/screens/blocking/Blocking.module.css | 12 ++- src/screens/blocking/Blocking.tsx | 2 +- src/screens/blocking/blocking-state.test.tsx | 43 +++++++++- src/screens/blocking/blocking-state.ts | 15 +++- src/screens/dashboard/Dashboard.tsx | 8 +- src/screens/dashboard/Heatmap.tsx | 4 +- src/screens/dashboard/Panels.tsx | 2 +- src/screens/dhcp/Dhcp.hub.test.tsx | 5 +- src/screens/dhcp/Dhcp.test.tsx | 8 +- src/screens/dhcp/Dhcp.tsx | 8 +- src/screens/dhcp/Leases.tsx | 70 ++++++++++----- src/screens/dhcp/Scopes.tsx | 40 +++++---- src/screens/dnsclient/DnsClient.module.css | 4 +- src/screens/dnsclient/DnsClient.tsx | 39 ++++++--- src/screens/lists/Cache.hub.test.tsx | 13 ++- src/screens/lists/CacheAllServers.tsx | 46 ++++++---- src/screens/lists/Lists.stale.test.tsx | 4 +- src/screens/lists/Lists.tsx | 36 ++++++-- src/screens/logs/Logs.module.css | 2 +- src/screens/logs/Logs.test.tsx | 31 +++++-- src/screens/logs/Logs.tsx | 7 +- src/screens/logs/QueryLogs.tsx | 42 ++++++++- src/screens/logs/ViewLogs.tsx | 85 ++++++++++++------- src/screens/settings/Settings.test.tsx | 17 ++++ src/screens/settings/Settings.tsx | 48 ++++++++--- src/screens/zones/ZoneList.tsx | 26 ++++-- src/screens/zones/Zones.loading.test.tsx | 13 +++ src/screens/zones/Zones.stale.test.tsx | 3 +- src/screens/zones/Zones.tsx | 6 +- src/session/SessionProvider.test.tsx | 3 +- src/test/chrome.tsx | 16 ++++ src/ui/Empty.tsx | 42 +++++++++ src/ui/ServerSelect.tsx | 23 +++++ 43 files changed, 671 insertions(+), 261 deletions(-) create mode 100644 src/test/chrome.tsx create mode 100644 src/ui/ServerSelect.tsx diff --git a/hub/merge.test.ts b/hub/merge.test.ts index 051cad9..e703924 100644 --- a/hub/merge.test.ts +++ b/hub/merge.test.ts @@ -53,6 +53,23 @@ describe('mergeStats counters', () => { expect(Object.keys(merged.stats)).toEqual(Object.keys(stats())) }) + it('sums what each server holds: zones, cache, allowed, blocked and the two lists', () => { + const a = response({ stats: stats({ zones: 3, cachedEntries: 400, allowedZones: 1, blockedZones: 2, allowListZones: 1, blockListZones: 1000 }) }) + const b = response({ stats: stats({ zones: 5, cachedEntries: 50, allowedZones: 4, blockedZones: 6, allowListZones: 2, blockListZones: 10 }) }) + const merged = mergeStats([a, b]) + expect(merged.stats).toMatchObject({ zones: 8, cachedEntries: 450, allowedZones: 5, blockedZones: 8, allowListZones: 3, blockListZones: 1010 }) + }) + + it('counts a numeric string, and a server that left a field out as none', () => { + const a = response({ stats: { ...stats(), zones: '4' as unknown as number } }) + const b = response({ stats: { totalQueries: 1 } }) + const merged = mergeStats([a, b]) + expect(merged.stats.zones).toBe(4) + expect(merged.stats.allowedZones).toBe(1) + expect(merged.stats.totalQueries).toBe(101) + expect(mergeStats([b]).stats.blockListZones).toBe(0) + }) + it('returns a single response with the same numbers', () => { const merged = mergeStats([response()]) expect(merged.stats).toEqual(stats()) diff --git a/hub/merge.ts b/hub/merge.ts index 4e0ae1f..7e97202 100644 --- a/hub/merge.ts +++ b/hub/merge.ts @@ -162,6 +162,16 @@ export function mergeTopResponses( return { topDomains: [] } } +/* A counter as a number: a number, or a numeric string some servers send. Anything else is not a counter. */ +function counter(value: unknown): number | null { + if (typeof value === 'number') return Number.isFinite(value) ? value : null + if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) return Number(value) + return null +} + +/* What a server holds, as the "Server" panel of the Dashboard counts it. */ +const HELD = ['zones', 'cachedEntries', 'allowedZones', 'blockedZones', 'allowListZones', 'blockListZones'] + /* `dashboard/stats/get`. Every numeric counter in `stats` is summed. That includes `totalClients` (distinct clients), which over-counts a client seen by both servers: @@ -174,9 +184,14 @@ export function mergeStats(responses: DashboardStats[]): DashboardStats { const stats: Record = {} for (const r of responses) { for (const [key, value] of Object.entries(r.stats ?? {})) { - if (typeof value === 'number') stats[key] = (stats[key] ?? 0) + value + const n = counter(value) + if (n !== null) stats[key] = (stats[key] ?? 0) + n } } + /* The Dashboard's "Server" panel counts what each server holds (zones, cache, allow and block + lists). Those are sums too, and a server that left one out counts as none, so the field is + never missing from the total. */ + for (const key of HELD) stats[key] ??= 0 const merged: DashboardStats = { ...first, stats } const main = mergeChart(responses.map((r) => r.mainChartData), true) diff --git a/src/app/Shell.module.css b/src/app/Shell.module.css index 8a78d78..2d00298 100644 --- a/src/app/Shell.module.css +++ b/src/app/Shell.module.css @@ -173,7 +173,7 @@ it to hang a control off would be undoing that in order to follow a drawing. `:empty` is what keeps it from taking up room when nobody uses it: without it, seven of eight screens would gain a blank gap. */ -.chromeSlot { display: flex; align-items: center; gap: var(--s-6); margin-bottom: var(--s-8); } +.chromeSlot { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-6); margin-bottom: var(--s-8); } .chromeSlot:empty { display: none; } /* diff --git a/src/screens/apps/AppCard.tsx b/src/screens/apps/AppCard.tsx index 4b5da72..94574dc 100644 --- a/src/screens/apps/AppCard.tsx +++ b/src/screens/apps/AppCard.tsx @@ -41,7 +41,7 @@ export function AppCard({
  • {app.name}

    - {app.description &&

    {app.description}

    } + {app.description &&

    {app.description}

    }
    diff --git a/src/screens/apps/Apps.tsx b/src/screens/apps/Apps.tsx index f1daf0e..32429a3 100644 --- a/src/screens/apps/Apps.tsx +++ b/src/screens/apps/Apps.tsx @@ -18,7 +18,8 @@ import { StoreApps } from './StoreApps' import { Panel } from '../../ui/Panel' import { PageTabs } from '../../ui/PageTabs' import { UpdateApp } from './UpdateApp' -import { Empty, Failure, Loading } from '../../ui/Empty' +import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty' +import { Menu } from '../../ui/Menu' import { Tag } from '../../ui/Tag' import { useSort } from '../../ui/Table' import { Icon } from '../../ui/Icon' @@ -266,9 +267,16 @@ export function Apps({ {hubView === 'store' ? 'Back to apps' : 'App Store'} - - - {isAdmin && } + {/* The three that move a whole set of apps share one menu: five buttons made a row of their own. */} + + {(close) => ( + <> + + + {isAdmin && } + + )} + ) : undefined } @@ -347,12 +355,12 @@ export function Apps({ {tab === 'store' && mayChange ? ( void reload()} /> ) : apps === null ? ( - + Loading… ) : failure ? ( /* Solid and with no invitation: whether there are apps is not known, so the store is not offered. The server's message goes in the notice above. */ - Could not load the installed apps. + void reload()}>Could not load the installed apps. ) : apps.length === 0 ? ( { it('offers both to writers only', async () => { const { unmount } = mountApps(false) await screen.findByText('No apps installed') - expect(screen.queryByRole('button', { name: 'Export baselines' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Import baselines' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Baselines' })).not.toBeInTheDocument() unmount() mountApps(true) - expect(await screen.findByRole('button', { name: 'Export baselines' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Import baselines' })).toBeInTheDocument() + await userEvent.click(await screen.findByRole('button', { name: 'Baselines' })) + expect(await screen.findByRole('menuitem', { name: 'Export baselines' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Import baselines' })).toBeInTheDocument() }) it('downloads the file the hub sends', async () => { @@ -246,7 +246,8 @@ describe('Export and import of baselines', () => { const create = vi.fn(() => 'blob:x') Object.assign(URL, { createObjectURL: create, revokeObjectURL: vi.fn() }) const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) - await userEvent.click(await screen.findByRole('button', { name: 'Export baselines' })) + await userEvent.click(await screen.findByRole('button', { name: 'Baselines' })) + await userEvent.click(await screen.findByRole('menuitem', { name: 'Export baselines' })) await waitFor(() => expect(click).toHaveBeenCalled()) expect(exported).toHaveBeenCalledWith('t') expect(create).toHaveBeenCalled() diff --git a/src/screens/apps/AppsMatrix.tsx b/src/screens/apps/AppsMatrix.tsx index 93f5ed0..42e3a5e 100644 --- a/src/screens/apps/AppsMatrix.tsx +++ b/src/screens/apps/AppsMatrix.tsx @@ -3,7 +3,7 @@ import { getAppsMatrix, getDrift, listStoreApps, runHubApps, type AppsMatrixData import { Badge } from '@/components/reui/badge' import { Button } from '../../ui/Button' import { DataTable, type Col } from '../../ui/DataTable' -import { Empty, Failure, Loading } from '../../ui/Empty' +import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty' import { Panel } from '../../ui/Panel' import { Segmented } from '../../ui/Segmented' import { Menu } from '../../ui/Menu' @@ -75,7 +75,15 @@ function ConfigState({ app, data, onOpen }: { app: MatrixApp; data: AppsMatrixDa function ServerCell({ app, id, data, drifted }: { app: MatrixApp; id: string; data: AppsMatrixData; drifted: boolean }) { const cell = app.servers[id] - if (data.servers.find((s) => s.id === id)?.ok === false) return Unknown + if (data.servers.find((s) => s.id === id)?.ok === false) { + return ( + + + Unknown + + + ) + } if (cell === undefined || !cell.installed) return Not installed return ( @@ -135,30 +143,20 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st ) }, [data]) - const retry = ( - - ) - if (failure !== null && data === null) { return ( - - - Could not load the apps of the servers. {failure} - {retry} - - + { + setFailure(null) + setData(null) + void load() + }} + > + Could not load the apps of the servers. {failure} + ) } - if (data === null) return Loading the apps of every server… + if (data === null) return Loading the apps of every server… if (data.apps.length === 0) { return ( @@ -249,7 +247,11 @@ export function AppsMatrix({ token, canWrite = false, refresh = 0 }: { token: st cell: (a) => ( {a.name} - {a.description !== '' && {a.description}} + {a.description !== '' && ( + + {a.description} + + )} ), }, diff --git a/src/screens/apps/HubStore.tsx b/src/screens/apps/HubStore.tsx index 521f2ef..bca12d1 100644 --- a/src/screens/apps/HubStore.tsx +++ b/src/screens/apps/HubStore.tsx @@ -98,7 +98,7 @@ export function HubStore({ ))} - {app.description} + {app.description}
    {canWrite && (
    diff --git a/src/screens/blocking/AdvancedBlockingEntries.tsx b/src/screens/blocking/AdvancedBlockingEntries.tsx index ee8b8de..648e599 100644 --- a/src/screens/blocking/AdvancedBlockingEntries.tsx +++ b/src/screens/blocking/AdvancedBlockingEntries.tsx @@ -8,7 +8,6 @@ import { Failure } from '../../ui/Empty' import { Icon } from '../../ui/Icon' import { Pagination } from '../../ui/Pagination' import { Tag } from '../../ui/Tag' -import { UrlText } from '../../ui/UrlText' import { Skeleton } from '@/components/ui/skeleton' import { HubWriteHint } from '../../ui/HubWriteHint' import { useHubWrite } from '../../lib/hub-write' @@ -22,6 +21,7 @@ import { changeAppConfig, useAdvancedBlocking } from './advanced-blocking-state' import { missing, type Permissions } from './permissions' import { pageOf } from './rules-model' import shared from './Blocking.module.css' +import tbl from '../../ui/Table.module.css' /* The Advanced Blocking app's own lists (Lists tab) or rules (Rules tab), listed per group @@ -254,7 +254,7 @@ export function AdvancedBlockingEntries({ cell: (r) => { const f = (fields as readonly { field: GroupField; kind: string }[]).find((x) => x.field === r.field) return ( - + {labelOf(r.field)} @@ -264,6 +264,7 @@ export function AdvancedBlockingEntries({ { id: 'value', header: kind === 'lists' ? 'List' : 'Rule', + td: () => ({ className: shared.entryCell }), cell: (r) => editing?.key === r.key ? ( ) : ( - kind === 'lists' ? : {r.value} + kind === 'lists' + ? {r.value} + : {r.value} ), }, { id: 'actions', - td: () => ({ className: shared.entryActions }), + td: () => ({ className: `${shared.entryActions} ${tbl.actionsCell}` }), cell: (r) => editing?.key === r.key ? ( - +
    - +
    ) : ( - +
    { setEditing({ key: r.key, value: r.value }); setProblem(null) }}> Edit @@ -305,7 +308,7 @@ export function AdvancedBlockingEntries({ onClick={() => void run((root2) => (locate(root2, r) < 0 ? null : removeEntry(root2, r.group, r.field, locate(root2, r))), `Removed from the group ${r.group} of ${appName}.`)}> Remove - +
    ), }, ]} diff --git a/src/screens/blocking/BlockLists.tsx b/src/screens/blocking/BlockLists.tsx index e0bef66..a703195 100644 --- a/src/screens/blocking/BlockLists.tsx +++ b/src/screens/blocking/BlockLists.tsx @@ -17,6 +17,8 @@ import { PermissionButton } from '../../ui/PermissionButton' import { Confirm } from '../../ui/Confirm' import { Notifier } from '../../ui/Notifier' import { Failure, Loading } from '../../ui/Empty' +import { Frame, FramePanel } from '@/components/reui/frame' +import { KpiTile } from '../dashboard/Figures' import { RouteLink } from '../../ui/RouteLink' import { Icon } from '../../ui/Icon' import { noticeFromFailure, type Notice } from '../../lib/notice' @@ -599,50 +601,39 @@ export function BlockLists({ ) : ( - <> - -
    - {countsFailed ? ( - {COUNTS_FAILED} - ) : ( - - {counts ? counts.block.toLocaleString() : '—'} - - )} - {reloading != null ? ( - {UPDATING} - ) : ( - {'\u00a0'} - )} - Block List Domains -
    -
    - -
    - {countsFailed ? ( - {COUNTS_FAILED} - ) : ( - - {counts ? counts.allow.toLocaleString() : '—'} - - )} - {reloading != null ? ( - {UPDATING} - ) : ( - {'\u00a0'} - )} - Allow List Domains -
    -
    - + /* The Dashboard's KPI look, as the Overview draws it: a Frame of tiles. */ + + + + {countsFailed ? COUNTS_FAILED : reloading != null ? {UPDATING} : ' '} + + + {countsFailed ? COUNTS_FAILED : reloading != null ? {UPDATING} : ' '} + + + )}
    -
    - {loading ? '—' : nextUpdateText(next)} - {'\u00a0'} - + {/* Ordinary type: a date or "Not Scheduled" is a sentence, not a figure to read from afar. */} +
    + {loading ? '\u2014' : nextUpdateText(next)} + Next update{interval != null && interval > 0 ? ` · every ${interval} h` : ''}
    diff --git a/src/screens/blocking/Blocking.module.css b/src/screens/blocking/Blocking.module.css index a82a1b7..b5a60f7 100644 --- a/src/screens/blocking/Blocking.module.css +++ b/src/screens/blocking/Blocking.module.css @@ -7,6 +7,9 @@ What the three Blocking tabs share. Every value is a token of theme/tokens.css. /* Lists: the third card carries a date and a button, so its column never goes below what they need on one line; with room to spare the three are equal. */ .kpis3 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(max-content, 1fr); gap: var(--s-9); } +/* Lists: the two list figures are one Frame of tiles, as the Overview draws its strip. */ +.kpiPair { grid-column: span 2; } +.kpiTiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); padding: 0; } /* A panel whose content sits in its middle. A grid row stretches its panels to the @@ -83,7 +86,9 @@ second frame, with rounded corners showing under the panel's header. Written und /* The canvas's state strip above the tabs. */ .banner { display:flex; flex-wrap:wrap; align-items:center; gap: var(--s-7); padding: var(--s-7) var(--s-9); margin-bottom: var(--s-9); border:1px solid var(--line); border-radius: var(--r-3); background: color-mix(in oklch, var(--pan2) 50%, transparent); font-size: 13px; color: var(--mute); } -.bannerAct { margin-left: auto; } +.bannerAct { margin-left: auto; flex: none; } +/* The sentence is what wraps, not the action beside it. */ +.bannerText { flex: 1 1 14rem; min-width: 0; } /* The Advanced Blocking panels of the Lists and Rules tabs: the add row, the filter, a value and its buttons. */ .entryAdd { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--s-5); } @@ -91,5 +96,10 @@ second frame, with rounded corners showing under the panel's header. Written und .entryAdd > p { flex-basis: 100%; margin: 0; } .entryFilter { padding: 0 var(--s-9) var(--s-5); } .entryValue { font-family: var(--font-mono); overflow-wrap: break-word; } +/* A list URL on one line; the whole of it is the title. */ +/* The value column takes what the others leave, and no more: without `max-width: 0` a + long URL widens the table past its panel and pushes Edit and Remove out of view. */ +.entryCell { width: 100%; max-width: 0; } +.entryUrl { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); } .entryActions { text-align: right; white-space: nowrap; } .dim { color: var(--mute); } diff --git a/src/screens/blocking/Blocking.tsx b/src/screens/blocking/Blocking.tsx index 52be0f9..06cedbf 100644 --- a/src/screens/blocking/Blocking.tsx +++ b/src/screens/blocking/Blocking.tsx @@ -80,7 +80,7 @@ function Banner({ token, permissions, node }: { token: string | null; permission {state.kind === 'app' && Advanced Blocking} {state.kind === 'paused' && {untilText(state.till)}} {state.kind === 'off' && Disabled} - + {state.kind === 'on' ? 'Blocking is applied to every query' : state.kind === 'app' diff --git a/src/screens/blocking/blocking-state.test.tsx b/src/screens/blocking/blocking-state.test.tsx index 505e52e..756813b 100644 --- a/src/screens/blocking/blocking-state.test.tsx +++ b/src/screens/blocking/blocking-state.test.tsx @@ -4,7 +4,7 @@ import * as settings from '../../api/settings' import * as client from '../../api/client' import type { DnsSettings } from '../../api/settings' import { settingsChanged } from '../../lib/settings-signal' -import { blockingMark, stateOf, useBlockingState } from './blocking-state' +import { VISIBLE_REREAD_MS, blockingMark, stateOf, useAllServersBlocking, useBlockingState } from './blocking-state' import type { Permissions } from './permissions' afterEach(() => { @@ -96,6 +96,47 @@ describe('blocking state', () => { expect(screen.getByText('off')).toBeInTheDocument() }) + it('a tab shown again reads only if the last read is a minute old', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: true } as DnsSettings) + render() + 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 {rows == null ? 'none' : `rows ${rows.length}`} + } + render() + expect(await screen.findByText('rows 2')).toBeInTheDocument() + expect(read).toHaveBeenCalledTimes(2) + + act(() => { document.dispatchEvent(new Event('visibilitychange')) }) + expect(read).toHaveBeenCalledTimes(2) + + await act(() => vi.advanceTimersByTimeAsync(VISIBLE_REREAD_MS + 1_000)) + act(() => { document.dispatchEvent(new Event('visibilitychange')) }) + expect(read).toHaveBeenCalledTimes(4) + }) + it('without Settings.canView, or disabled, it reads nothing, even on a write', async () => { const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings) render(<>) diff --git a/src/screens/blocking/blocking-state.ts b/src/screens/blocking/blocking-state.ts index 02b5955..121247e 100644 --- a/src/screens/blocking/blocking-state.ts +++ b/src/screens/blocking/blocking-state.ts @@ -24,13 +24,16 @@ When it reads: `till` finds blocking back on; the second also absorbs a small clock skew; - when the Advanced Blocking app's config is written from this console, since the answer below depends on it; -- when the browser tab is shown again after being hidden: the state may have been - changed from elsewhere meanwhile, and a hidden tab is not kept current. +- when the browser tab is shown again after being hidden, if the last read is more than a + minute old: the state may have been changed from elsewhere meanwhile, and a hidden tab is + not kept current. Reads are numbered, so an older answer arriving last cannot overwrite a newer one. */ /** How long after `till` to read again. See above. */ export const AFTER_PAUSE_MS = 1000 +/** A tab shown again re-reads only if the last read is older than this: flicking between tabs is not a reason to ask every server again. */ +export const VISIBLE_REREAD_MS = 60_000 /** `setTimeout`'s ceiling (2^31 - 1 ms, about 24.8 days). Past it the browser fires AT ONCE, which with a far-off `till` would read in a loop; capped, it reads, finds the pause still on and waits again. */ @@ -79,10 +82,12 @@ export function useBlockingState( const viewNeed = enabled ? missing(permissions, 'Settings.canView') : 'Settings.canView' const [base, setState] = useState(null) const asked = useRef(0) + const lastRead = useRef(0) const reload = useCallback(() => { if (viewNeed != null) return const mine = ++asked.current + lastRead.current = Date.now() void getSettings(token, node).then((s) => { if (mine !== asked.current) return setState(s == null ? 'failed' : stateOf(s.enableBlocking, s.temporaryDisableBlockingTill)) @@ -99,7 +104,7 @@ export function useBlockingState( useEffect(() => { const shown = () => { - if (document.visibilityState === 'visible') reload() + if (document.visibilityState === 'visible' && Date.now() - lastRead.current >= VISIBLE_REREAD_MS) reload() } document.addEventListener('visibilitychange', shown) return () => document.removeEventListener('visibilitychange', shown) @@ -215,11 +220,13 @@ export function useAllServersBlocking( const key = JSON.stringify(servers) const [rows, setRows] = useState(null) const asked = useRef(0) + const lastRead = useRef(0) const active = enabled && canSettings && servers.length > 0 const reload = useCallback(() => { if (!active) return const mine = ++asked.current + lastRead.current = Date.now() void Promise.all((JSON.parse(key) as string[]).map((s) => readServerBlocking(token, s, canSettings, canApps))).then((r) => { if (mine === asked.current) setRows(r) }) @@ -237,7 +244,7 @@ export function useAllServersBlocking( useEffect(() => { if (!active) return const shown = () => { - if (document.visibilityState === 'visible') reload() + if (document.visibilityState === 'visible' && Date.now() - lastRead.current >= VISIBLE_REREAD_MS) reload() } document.addEventListener('visibilitychange', shown) return () => document.removeEventListener('visibilitychange', shown) diff --git a/src/screens/dashboard/Dashboard.tsx b/src/screens/dashboard/Dashboard.tsx index 0d68dd5..ed81377 100644 --- a/src/screens/dashboard/Dashboard.tsx +++ b/src/screens/dashboard/Dashboard.tsx @@ -20,7 +20,7 @@ import { TopStats } from './TopStats' import { TopRowMenu } from './TopRowMenu' import { useDomainAction } from '../../lib/allow-block' import { SectionHeader } from '../../ui/SectionHeader' -import { Loading } from '../../ui/Empty' +import { LoadingRegion } from '../../ui/Empty' import { Frame, FramePanel } from '@/components/reui/frame' import { Input } from '@/components/ui/input' import styles from './Dashboard.module.css' @@ -443,7 +443,7 @@ export function Dashboard({ : undefined} > - {loading && } + {loading && } {!loading && view && hasData(view.mainChartData) && ( )} @@ -495,7 +495,9 @@ export function Dashboard({ rows={view?.topBlockedDomains ?? []} onMore={() => setTop('TopBlockedDomains')} failure={failure} - beforeMore={} + /* 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 : } menu={(f) => } /> - {state.kind === 'loading' && } + {state.kind === 'loading' && } {state.kind === 'failed' && Could not load this data.} {/* A quiet week is drawn, not replaced by the empty box: zero is a true figure, and every cell still says so. */} diff --git a/src/screens/dashboard/Panels.tsx b/src/screens/dashboard/Panels.tsx index d25f7cf..66f020b 100644 --- a/src/screens/dashboard/Panels.tsx +++ b/src/screens/dashboard/Panels.tsx @@ -65,7 +65,7 @@ export function Top({ return ( {title}} className={styles.top} actions={
    diff --git a/src/screens/dhcp/Dhcp.hub.test.tsx b/src/screens/dhcp/Dhcp.hub.test.tsx index a9c2374..1da0980 100644 --- a/src/screens/dhcp/Dhcp.hub.test.tsx +++ b/src/screens/dhcp/Dhcp.hub.test.tsx @@ -5,6 +5,7 @@ import { Dhcp } from './Dhcp' import * as api from '../../api/dhcp' import { HubMode } from '../../ui/ClusterNodeSelect' import { HubWritable } from '../../lib/hub-write' +import { WithChrome } from '../../test/chrome' afterEach(() => vi.restoreAllMocks()) @@ -20,7 +21,9 @@ function hubDhcp(writable: string[], canModify = true) { return render( - + + + , ) diff --git a/src/screens/dhcp/Dhcp.test.tsx b/src/screens/dhcp/Dhcp.test.tsx index 96248ec..503bad3 100644 --- a/src/screens/dhcp/Dhcp.test.tsx +++ b/src/screens/dhcp/Dhcp.test.tsx @@ -88,13 +88,13 @@ describe('DHCP › Leases', () => { the table's own footer (`dhcp.js:74`) and the rest of the console uses its "there is nothing here" row. */ - it('with no leases it says so INSIDE the table, and the footer keeps counting', async () => { + it('with no leases it says so, with a way out', async () => { vi.spyOn(api, 'listLeases').mockResolvedValue({ kind: 'ok', data: [] }) render() - const message = await screen.findByText('No Lease Found') - expect(message.closest('table')).not.toBeNull() - expect(screen.getByText('Total Leases: 0')).toBeInTheDocument() + /* An empty state with a way out, and the figures still known: zero is a true count here. */ + expect(await screen.findByText('No Lease Found')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Reload leases' })).toBeInTheDocument() }) it('each row offers only the conversion its type calls for', async () => { diff --git a/src/screens/dhcp/Dhcp.tsx b/src/screens/dhcp/Dhcp.tsx index 39a4132..e8fbbff 100644 --- a/src/screens/dhcp/Dhcp.tsx +++ b/src/screens/dhcp/Dhcp.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { SubTabs } from '../../ui/SubTabs' -import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect' +import { ServerSelect } from '../../ui/ServerSelect' import { Leases } from './Leases' import { Scopes } from './Scopes' import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write' @@ -84,7 +84,8 @@ export function Dhcp({ {/* Upstream mounts one here (`optDhcpClusterNode` / `optLogsClusterNode`). It draws nothing without a cluster, so it costs a single-server install nothing. */} - } nodes={nodes} initialised={clusterInitialised} value={node} @@ -92,7 +93,6 @@ export function Dhcp({ disabled={nodeLocked} label="Cluster Node" /> - {active === 'Scopes' ? ( ) : ( - + onSubChange?.('Scopes')} /> )} ) diff --git a/src/screens/dhcp/Leases.tsx b/src/screens/dhcp/Leases.tsx index 69a3618..0c74885 100644 --- a/src/screens/dhcp/Leases.tsx +++ b/src/screens/dhcp/Leases.tsx @@ -14,7 +14,7 @@ import { Dialog } from '../../ui/Dialog' import { SectionHeader } from '../../ui/SectionHeader' import { minuteStamp } from './dates' import type { Notice } from './notices' -import { Failure, Loading } from '../../ui/Empty' +import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty' import { Tag } from '../../ui/Tag' import tbl from '../../ui/Table.module.css' import { Panel } from '../../ui/Panel' @@ -45,6 +45,8 @@ and the type tag are kept. */ export interface LeasesProps { + /** The empty state's way to the other sub-tab. */ + onOpenScopes?: () => void /** The section sub-navigation, drawn under the header. */ tabs?: ReactNode token: string | null @@ -66,7 +68,7 @@ const KEYS: Keys = { 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(null) /* When the list was read: what "expiring in 10 min" counts from. Read with the list, not in render. */ const [readAt, setReadAt] = useState(0) @@ -174,8 +176,6 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t }) } - if (leases == null) return - return (
    setNotice(null)} /> - {leases != null && !failure && ( - 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', - }, - ]} - /> - )} + {/* Waiting or failed, the figures are not known: the dash, never a zero. */} + 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 ? ( + + ) : failure ? ( /* Solid, not dashed, and with no count: how many there are is not known. The server's message goes in the notice at the top. */ - Could not load the list. + void load()}>Could not load the list. + ) : leases.length === 0 ? ( + + + + {onOpenScopes != null && } + + } + > + No client holds a lease from this server yet. + + ) : ( <> @@ -301,7 +327,7 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t ]} /> -
    +
    {/* The footer is the count and nothing else. When there are no rows, the one that says so is the table itself, with its centred row, like the rest of the console and like upstream (`dhcp.js:74`); here it was diff --git a/src/screens/dhcp/Scopes.tsx b/src/screens/dhcp/Scopes.tsx index fbaa516..68752a2 100644 --- a/src/screens/dhcp/Scopes.tsx +++ b/src/screens/dhcp/Scopes.tsx @@ -17,7 +17,7 @@ import { SectionHeader } from '../../ui/SectionHeader' import { errorNotice, type Notice } from './notices' import { formFromScope, blankForm, type ScopeForm as Form } from './model' import { ScopeForm } from './ScopeForm' -import { Failure, Loading } from '../../ui/Empty' +import { FailureRetry, LoadingRegion } from '../../ui/Empty' import { Tag } from '../../ui/Tag' import tbl from '../../ui/Table.module.css' import styles from './Dhcp.module.css' @@ -200,16 +200,6 @@ export function Scopes({
    setNotice(null)} /> - {scopes != null && !failure && ( - s.enabled).length, icon: 'check', tone: 'ok' }, - { label: 'Disabled', value: scopes.filter((s) => !s.enabled).length, icon: 'power' }, - ]} - /> - )} - return (
    setNotice(null)} /> - {failure ? ( + {/* Waiting or failed, the figures are not known: the dash, never a zero. */} + s.enabled).length, icon: 'check', tone: 'ok' }, + { label: 'Disabled', value: scopes.filter((s) => !s.enabled).length, icon: 'power' }, + ] + } + /> + + {scopes == null ? ( + + ) : failure ? ( /* Solid, not dashed, and with no count: how many there are is not known. The server's message goes in the notice at the top. */ - Could not load the list. + void load()}>Could not load the list. ) : ( <> @@ -354,7 +362,7 @@ export function Scopes({ ]} /> -
    +
    {/* The footer is the count and nothing else. When there are no rows, the one that says so is the table itself, with its centred row, like the rest of the console and like upstream (`dhcp.js:74`); here it was diff --git a/src/screens/dnsclient/DnsClient.module.css b/src/screens/dnsclient/DnsClient.module.css index 3017b52..b28e2fb 100644 --- a/src/screens/dnsclient/DnsClient.module.css +++ b/src/screens/dnsclient/DnsClient.module.css @@ -3,6 +3,8 @@ The query bar. `align-items: flex-end` is what lines the buttons up with the bottom edge of the fields; before it was achieved with a `marginBottom: 1` put by hand on each button, which is the symptom of a row that does not align. */ +.wrap { composes: screen from '../../ui/SectionHeader.module.css'; } + .flt { display: flex; align-items: flex-end; @@ -22,7 +24,7 @@ hand on each button, which is the symptom of a row that does not align. it carries the field's height and not an invented margin. */ /* `.chk` went in the redesign: a third name for the settings checkbox, and the only place in the console where it measured 34 px high. It uses `ui/Check`. */ -.panel { margin-bottom: var(--s-9); } +.panel { margin-bottom: 0; } .meta { composes: annotationQuiet from '../../ui/text.module.css'; } /* diff --git a/src/screens/dnsclient/DnsClient.tsx b/src/screens/dnsclient/DnsClient.tsx index ebbdb9f..94ac1ef 100644 --- a/src/screens/dnsclient/DnsClient.tsx +++ b/src/screens/dnsclient/DnsClient.tsx @@ -1,4 +1,4 @@ -import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect' +import { ServerSelect } from '../../ui/ServerSelect' import { useEffect, useRef, useState } from 'react' import type { DnsClientRequest } from '../../app/handoff' import { PROTOCOLS, TYPES, prepareServer, resolve } from '../../api/dnsclient' @@ -12,7 +12,7 @@ import { Field, Input, LabeledInput, LabeledSelect } from '../../ui/Field' import { Menu } from '../../ui/Menu' import { loadQuickList, protocolForServer, serverListOptions, type QuickServer } from '../../lib/quick-lists' import { SectionHeader } from '../../ui/SectionHeader' -import { Empty } from '../../ui/Empty' +import { Empty, FailureRetry } from '../../ui/Empty' import { Raw } from '../../ui/Raw' import { Details } from '../../ui/Details' import styles from './DnsClient.module.css' @@ -92,6 +92,8 @@ export function DnsClient({ const [raw, setRaw] = useState([]) const [alert, setAlert] = useState(null) const [busy, setBusy] = useState(false) + /* Why the last query failed, drawn where its response would be: no answer is not an empty answer. */ + const [failure, setFailure] = useState(null) async function fire(runImport: boolean) { // The order is upstream's: extract first, check afterwards. @@ -124,8 +126,10 @@ export function DnsClient({ setOutput(null) setRaw([]) setAlert(noticeFromFailure(outcome)) + setFailure(noticeFromFailure(outcome).text) return } + setFailure(null) const r = outcome.data.response setOutput(JSON.stringify(r.result, null, 2)) @@ -154,17 +158,17 @@ export function DnsClient({ }, [request]) return ( - <> +
    - } nodes={nodes} initialised={clusterInitialised} value={clusterNode} onChange={setClusterNode} label="Cluster Node" /> - setAlert(null)} /> @@ -219,7 +223,8 @@ export function DnsClient({
    - setProtocol(e.target.value)}> + {/* Upstream's label is "DNS-over-" (the protocol's suffix); on its own it was cut off. */} + setProtocol(e.target.value)}> {PROTOCOLS.map((t) => )}
    @@ -230,17 +235,23 @@ export function DnsClient({ THIRD name for the same gesture, and the only one in the console that came out at 34 px high. */} - - void fire(true)}> - Import - +
    + + void fire(true)}> + Import + +
    {output === null ? ( - Run a query to see the response. + failure !== null ? ( + void fire(false)}>The query failed. {failure} + ) : ( + Run a query to see the response. + ) ) : ( )} - +
    ) } diff --git a/src/screens/lists/Cache.hub.test.tsx b/src/screens/lists/Cache.hub.test.tsx index d70ced3..578e05e 100644 --- a/src/screens/lists/Cache.hub.test.tsx +++ b/src/screens/lists/Cache.hub.test.tsx @@ -7,6 +7,7 @@ import { HubMode } from '../../ui/ClusterNodeSelect' import { HubWritable } from '../../lib/hub-write' import { HubServerIds } from '../../lib/hub-all' import * as hubRun from '../../api/hub-run' +import { WithChrome } from '../../test/chrome' afterEach(() => vi.restoreAllMocks()) @@ -22,7 +23,9 @@ function hubCache(writable: string[], canDelete = true) { - + + + , @@ -62,7 +65,8 @@ describe('Cache behind a hub, on several servers', () => { .mockResolvedValueOnce(done({ server: 'mdns', ok: true, message: 'Cache flushed.' }, { server: 'ndns', ok: false, message: 'ndns did not answer' })) .mockResolvedValueOnce(done({ server: 'ndns', ok: true, message: 'Cache flushed.' })) hubCache(['mdns', 'ndns']) - await user.click(await screen.findByRole('button', { name: 'Flush on all servers' })) + await user.click(await screen.findByRole('button', { name: 'All servers' })) + await user.click(await screen.findByRole('menuitem', { name: 'Flush on all servers' })) const picker = await screen.findByRole('dialog') await user.click(within(picker).getByRole('button', { name: 'Flush cache' })) expect(run).toHaveBeenNthCalledWith(1, 't', 'cache.flush', {}, ['mdns', 'ndns']) @@ -76,7 +80,8 @@ describe('Cache behind a hub, on several servers', () => { const user = userEvent.setup() const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(done({ server: 'mdns', ok: true })) hubCache(['mdns']) - await user.click(await screen.findByRole('button', { name: 'Delete a domain on all servers' })) + await user.click(await screen.findByRole('button', { name: 'All servers' })) + await user.click(await screen.findByRole('menuitem', { name: 'Delete a domain on all servers' })) const ask = await screen.findByRole('dialog') await user.type(within(ask).getByLabelText('Domain'), 'bad domain') await user.click(within(ask).getByRole('button', { name: 'Choose servers' })) @@ -93,6 +98,6 @@ describe('Cache behind a hub, on several servers', () => { it('is not offered to a role that may not delete from the cache', async () => { hubCache(['mdns', 'ndns'], false) await screen.findByRole('button', { name: 'Flush Cache' }) - expect(screen.queryByRole('button', { name: 'Flush on all servers' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'All servers' })).not.toBeInTheDocument() }) }) diff --git a/src/screens/lists/CacheAllServers.tsx b/src/screens/lists/CacheAllServers.tsx index 6230b9e..681f7c4 100644 --- a/src/screens/lists/CacheAllServers.tsx +++ b/src/screens/lists/CacheAllServers.tsx @@ -1,7 +1,7 @@ import { useContext, useState } from 'react' import { useAllServersFlow } from '../../lib/hub-all' import { AskDialog } from '../../ui/AskDialog' -import { Button } from '../../ui/Button' +import { Menu } from '../../ui/Menu' import { HubMode } from '../../ui/ClusterNodeSelect' /* @@ -24,20 +24,36 @@ export function CacheAllServers({ token }: { token: string | null }) { if (!hub) return null return ( <> - - + {/* One menu, not two more buttons next to the chosen server's own Flush Cache. */} + + {(close) => ( + <> + + + + )} + {asking && ( { vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' } as never) render() - 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() }) diff --git a/src/screens/lists/Lists.tsx b/src/screens/lists/Lists.tsx index 0560f6b..419f6dc 100644 --- a/src/screens/lists/Lists.tsx +++ b/src/screens/lists/Lists.tsx @@ -1,5 +1,6 @@ import { KpiStrip } from '../../ui/KpiStrip' -import { ClusterNodeSelect, HubMode, primaryNodeName } from '../../ui/ClusterNodeSelect' +import { HubMode, primaryNodeName } from '../../ui/ClusterNodeSelect' +import { ServerSelect } from '../../ui/ServerSelect' import { useCallback, useContext, useEffect, useRef, useState } from 'react' import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write' import { HubWriteHint } from '../../ui/HubWriteHint' @@ -21,7 +22,7 @@ import { Confirm } from '../../ui/Confirm' import { Dialog } from '../../ui/Dialog' import { Field, Input, LabeledTextarea } from '../../ui/Field' import { SectionHeader } from '../../ui/SectionHeader' -import { Empty, Loading } from '../../ui/Empty' +import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty' import { StaleData } from '../StaleData' import { Tree } from './Tree' import { CacheAllServers } from './CacheAllServers' @@ -265,6 +266,8 @@ export function Lists({ /* A first read that failed and was reported to the host: there is no notice here to tell "failed" from "still loading", so it is said apart. */ const [failedOutside, setFailedOutside] = useState(false) + /* Why the first read failed, drawn in the regions: a failed read has no records, which is not an empty node. */ + const [failure, setFailure] = useState(null) /* Stale data. The same gap Zones had, and the same design rule (old data never looks like new data): the previous list @@ -310,6 +313,7 @@ export function Lists({ const outcome = await listNode(list, token, domain, up ? 'up' : undefined, node) if (outcome.kind === 'ok') { setNode(outcome.data) + setFailure(null) setStale(false) setLastGood(new Date().toISOString()) hadData.current = true @@ -328,6 +332,7 @@ export function Lists({ */ if (!hadData.current) { if (host.current != null) setFailedOutside(true) + setFailure(noticeFromFailure(outcome).text) report(noticeFromFailure(outcome)) } else setStale(true) }, @@ -366,7 +371,16 @@ export function Lists({ /* The first load, still in flight: `node` is null and nothing failed. A load that FAILED leaves `node` null too, but it sets the notice, and then this is not loading, it is a failure with nothing behind it. */ - const loading = node == null && notice == null && !failedOutside + const failed = node == null && (notice != null || failedOutside || failure != null) + const loading = node == null && !failed + + /* Retry asks for the root again, with every trace of the failed read gone. */ + const retry = () => { + setNotice(null) + setFailedOutside(false) + setFailure(null) + void load('') + } const domain = node?.domain ?? '' const nodeTitle = domain === '' ? '' : (node?.domainIdn ?? domain) @@ -468,7 +482,8 @@ export function Lists({ Changing it reloads the open domain from that node, and the list, Flush and Delete all go to it (other-zones.js:20-61, 106). */} {isCache && !embedded && ( - } nodes={nodes} initialised={clusterInitialised} value={clusterNode} @@ -481,7 +496,6 @@ export function Lists({ label="Cluster Node" /> )} - {isCache && !embedded && } {/* On Cache the identity band's second line moves up under the title: the screen is one panel now, and a band naming the panel under a header @@ -562,7 +576,9 @@ export function Lists({ but it sets the notice, and that is what the reader has to see. */} {loading ? ( -
    +
    + ) : failed ? ( +
    The domain tree could not be read.
    ) : ( <> {/* The count lives in the count bar, like the records one: in the @@ -630,7 +646,7 @@ export function Lists({ shows, which is the defect the tree had fixed on 2026-09-07 and this side still had. */} - {!loading && ( + {!loading && !failed && (
    {records.length} records at {nodeTitle} @@ -666,7 +682,11 @@ export function Lists({ {loading ? ( /* The tree's placeholder above already announced this wait: two `role="status"` for one request read it out twice. */ -
    +
    + ) : failed ? ( +
    + Could not read the records. {failure} +
    ) : records.length > 0 ? ( ) : ( diff --git a/src/screens/logs/Logs.module.css b/src/screens/logs/Logs.module.css index ad02d7a..0d3291a 100644 --- a/src/screens/logs/Logs.module.css +++ b/src/screens/logs/Logs.module.css @@ -59,7 +59,7 @@ the file list keeps its own height), its body, and `Raw`'s `fill` box at the end .pbList { padding: var(--s-5); } -.logfiles { display: flex; flex-direction: column; gap: var(--s-1); } +.logfiles { display: flex; flex-direction: column; gap: var(--s-1); max-height: 60dvh; overflow-y: auto; } .logfile { composes: row from '../../ui/list.module.css'; justify-content: space-between; } .logfile .name { font-family: var(--font-mono); } .sz { composes: annotationQuiet from '../../ui/text.module.css'; } diff --git a/src/screens/logs/Logs.test.tsx b/src/screens/logs/Logs.test.tsx index 757e392..17ed21b 100644 --- a/src/screens/logs/Logs.test.tsx +++ b/src/screens/logs/Logs.test.tsx @@ -70,6 +70,11 @@ function withApps(list: InstalledApp[]) { .mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: { apps: list } } } as never) } +/* The two destructive verbs share the header's "Delete all" menu. */ +async function openDelete() { + await userEvent.setup().click(await screen.findByRole('button', { name: 'Delete all' })) +} + describe('Logs › View Logs', () => { it('it lists the files with their size and offers to delete them all', async () => { vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES }) @@ -77,8 +82,9 @@ describe('Logs › View Logs', () => { expect(await screen.findByText('2026-08-26')).toBeInTheDocument() expect(screen.getByText('[20.48 KB]')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Delete All Logs' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeInTheDocument() + await openDelete() + expect(screen.getByRole('menuitem', { name: 'Delete All Logs' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Delete All Stats' })).toBeInTheDocument() }) it('with no files it says \"No Log File Was Found\" and \"Delete All Logs\" disappears, but NOT \"Delete All Stats\"', async () => { @@ -86,8 +92,9 @@ describe('Logs › View Logs', () => { render() expect(await screen.findByText('No Log File Was Found')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Delete All Logs' })).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeInTheDocument() + await openDelete() + expect(screen.queryByRole('menuitem', { name: 'Delete All Logs' })).not.toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Delete All Stats' })).toBeInTheDocument() }) it('opening a file asks for only the first 2 MB and draws it', async () => { @@ -172,7 +179,8 @@ describe('Logs › View Logs', () => { render() await screen.findByText('2026-08-26') - await user.click(screen.getByRole('button', { name: 'Delete All Logs' })) + await user.click(await screen.findByRole('button', { name: 'Delete all' })) + await user.click(screen.getByRole('menuitem', { name: 'Delete All Logs' })) expect( screen.getByText('Are you sure you want to permanently delete all log files?'), ).toBeInTheDocument() @@ -192,7 +200,8 @@ describe('Logs › View Logs', () => { render() await screen.findByText('2026-08-26') - await user.click(screen.getByRole('button', { name: 'Delete All Stats' })) + await user.click(await screen.findByRole('button', { name: 'Delete all' })) + await user.click(screen.getByRole('menuitem', { name: 'Delete All Stats' })) expect( screen.getByText('Are you sure you want to permanently delete all stats files?'), ).toBeInTheDocument() @@ -219,12 +228,14 @@ describe('Logs › View Logs', () => { const { unmount } = render( , ) + await openDelete() expect(await screen.findByRole('button', { name: /Delete All Logs/ })).toBeDisabled() - expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeEnabled() + expect(screen.getByRole('menuitem', { name: 'Delete All Stats' })).toBeEnabled() unmount() render() - expect(await screen.findByRole('button', { name: 'Delete All Logs' })).toBeEnabled() + await openDelete() + expect(await screen.findByRole('menuitem', { name: 'Delete All Logs' })).toBeEnabled() expect(screen.getByRole('button', { name: /Delete All Stats/ })).toBeDisabled() }) @@ -237,6 +248,7 @@ describe('Logs › View Logs', () => { vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES }) render() + await user.click(await screen.findByRole('button', { name: 'Delete all' })) const stats = await screen.findByRole('button', { name: /Delete All Stats/ }) await user.hover(stats.parentElement!) expect(await screen.findByText('Requires Dashboard: Delete')).toBeInTheDocument() @@ -254,7 +266,8 @@ describe('Logs › View Logs', () => { render() 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() }) }) diff --git a/src/screens/logs/Logs.tsx b/src/screens/logs/Logs.tsx index 72dbae2..9c17b45 100644 --- a/src/screens/logs/Logs.tsx +++ b/src/screens/logs/Logs.tsx @@ -3,7 +3,8 @@ import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write' import { useAllServersFlow } from '../../lib/hub-all' import { HubWriteHint } from '../../ui/HubWriteHint' import { SubTabs } from '../../ui/SubTabs' -import { AGGREGATE, ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect' +import { AGGREGATE, HubMode } from '../../ui/ClusterNodeSelect' +import { ServerSelect } from '../../ui/ServerSelect' import { QueryLogs } from './QueryLogs' import { ViewLogs } from './ViewLogs' import type { QueryLogsRequest } from '../../app/handoff' @@ -96,7 +97,8 @@ export function Logs({ {/* Upstream mounts one here (`optDhcpClusterNode` / `optLogsClusterNode`). It draws nothing without a cluster, so it costs a single-server install nothing. */} - } nodes={nodes} initialised={clusterInitialised} value={node} @@ -104,7 +106,6 @@ export function Logs({ aggregate={aggregateShown} label="Cluster Node" /> - {active === 'Query Logs' && } {active === 'Query Logs' ? ( (null) const [f, setF] = useState(() => defaultFilters('', '')) const [page, setPage] = useState(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(null) + const [appsFailed, setAppsFailed] = useState(false) + const [appsTry, setAppsTry] = useState(0) const [notice, setNotice] = useState(null) const [busy, setBusy] = useState(false) const [live, setLive] = useState(false) @@ -444,10 +448,12 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on if (outcome.kind !== 'ok') { if (!live) { setNotice(noticeFromFailure(outcome)) + setQueryFailure(noticeFromFailure(outcome).text) } return } + setQueryFailure(null) setPage(outcome.data.response) }, [token, params, reveal], @@ -490,9 +496,11 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on run, so the screen sat dead without saying why. */ setApps([]) + setAppsFailed(true) setNotice(noticeFromFailure(outcome)) return } + setAppsFailed(false) const list = appsWithQueryLogs(outcome.data.response.apps ?? []) setApps(list) const first = list[0] @@ -516,7 +524,7 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on return () => { live = false } - }, [token]) + }, [token, appsTry]) /* logs.js:610. While "Live Update" is checked, it repeats every 2 s. */ useEffect(() => { @@ -599,7 +607,14 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on } } - if (apps == null) return + if (apps == null) { + return ( +
    + + +
    + ) + } /* What the folded panel lists (FOLD_QUERY). */ const active = activeFilters(f, defaultFilters(apps[0]?.name ?? '', apps[0]?.classPaths[0] ?? '')) @@ -917,6 +932,27 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true, on
    + {page == null && ( + busy ? ( + + ) : appsFailed ? ( + { setApps(null); setAppsTry((n) => n + 1) }}>Could not read the logging apps. + ) : queryFailure !== null ? ( + void query(f.pageNumber, false)}>Could not read the query log. {queryFailure} + ) : ( + void query(f.pageNumber, false)}> + Run query + + } + > + Choose the filters and run a query to read the log. + + ) + )} + {page != null && (
    diff --git a/src/screens/logs/ViewLogs.tsx b/src/screens/logs/ViewLogs.tsx index c5fe5f4..f2071a6 100644 --- a/src/screens/logs/ViewLogs.tsx +++ b/src/screens/logs/ViewLogs.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useContext, useEffect, useRef, useState } from 'react' import { deleteAllLogs, deleteLog, @@ -15,7 +15,10 @@ import { LogText, stampWidth } from './LogText' import { PermissionButton } from '../../ui/PermissionButton' import { Button } from '../../ui/Button' import { SectionHeader } from '../../ui/SectionHeader' -import {Empty, Loading} from '../../ui/Empty' +import { Empty, FailureRetry, LoadingRegion } from '../../ui/Empty' +import { Menu } from '../../ui/Menu' +import { HubMode } from '../../ui/ClusterNodeSelect' +import { LockedItem } from '../blocking/Locked' import styles from './Logs.module.css' import { Body, Panel } from '../../ui/Panel' import { noticeFromFailure, type Notice } from '../../lib/notice' @@ -95,7 +98,10 @@ export function ViewLogs({ canDeleteLogs = true, canDeleteStats = true, }: ViewLogsProps) { + const hub = useContext(HubMode) const [files, setFiles] = useState(null) + /* The list could not be read: that is not a server without log files. */ + const [failed, setFailed] = useState(false) const [open, setOpen] = useState(null) const [body, setBody] = useState(null) const [loadingBody, setLoadingBody] = useState(false) @@ -108,9 +114,11 @@ export function ViewLogs({ const r = await listLogFiles(token, node) if (r.kind === 'ok') { setFiles(r.data) + setFailed(false) return r.data } setFiles([]) + setFailed(true) setNotice(noticeFromFailure(r)) return null }, [token, node]) @@ -211,8 +219,6 @@ export function ViewLogs({ }) } - if (files == null) return - const CONFIRM_TEXT: Record = { log: { title: 'Delete Log', @@ -253,24 +259,31 @@ export function ViewLogs({ controls, among the nine compared with upstream, that "does not ask for the permission of the screen whose name it carries". */} - {files.length > 0 && ( - setConfirm('allLogs')} - > - Delete All Logs - - )} - setConfirm('allStats')} - > - Delete All Stats - } + {/* Behind a hub both are refused, so neither is offered. Without one they share a menu: + two destructive buttons stacked the header into two rows. */} + {!hub && ( + + {(close) => ( + <> + {files != null && files.length > 0 && + (canDeleteLogs ? ( + + ) : ( + Delete All Logs + ))} + {canDeleteStats ? ( + + ) : ( + Delete All Stats + )} + + )} + + )}} /> setNotice(null)} /> @@ -278,11 +291,19 @@ export function ViewLogs({
    - {files.length === 0 ? ( - - {notice?.type === 'danger' - ? 'Unable to load the log files.' - : 'No Log File Was Found'} + {files == null ? ( + + ) : failed ? ( + { setFiles(null); void load() }}>Unable to load the log files. + ) : files.length === 0 ? ( + { setFiles(null); void load() }}> + Refresh + + } + > + No Log File Was Found ) : (
    @@ -303,7 +324,13 @@ export function ViewLogs({ - {open != null && ( + {open == null ? ( + + + Pick a file from the list to read it here. + + + ) : ( {open}} @@ -326,7 +353,7 @@ export function ViewLogs({ > {loadingBody ? ( - + ) : ( /* The server's error is drawn HERE and not as a notice, because the diff --git a/src/screens/settings/Settings.test.tsx b/src/screens/settings/Settings.test.tsx index 3f8b0b7..70a4b63 100644 --- a/src/screens/settings/Settings.test.tsx +++ b/src/screens/settings/Settings.test.tsx @@ -41,6 +41,23 @@ describe('Settings: loading', () => { expect(await screen.findByText('Unable to load the DNS Server settings.')).toBeInTheDocument() }) + it('keeps the header (and with it the server selector) while the read is slow', async () => { + vi.spyOn(client, 'apiRequest').mockImplementation(() => new Promise(() => {}) as never) + render() + 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() + expect(await screen.findByText('Unable to load the DNS Server settings.')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument() + const asked = request.mock.calls.length + screen.getByRole('button', { name: 'Retry' }).click() + await waitFor(() => expect(request.mock.calls.length).toBeGreaterThan(asked)) + }) + it('the active sub-tab arrives by prop: the sub-navigation belongs to the Shell', async () => { server() await mount({ sub: 'Logging' }) diff --git a/src/screens/settings/Settings.tsx b/src/screens/settings/Settings.tsx index e649d59..96572f3 100644 --- a/src/screens/settings/Settings.tsx +++ b/src/screens/settings/Settings.tsx @@ -1,4 +1,5 @@ -import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect' +import { HubMode } from '../../ui/ClusterNodeSelect' +import { ServerSelect } from '../../ui/ServerSelect' import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' import { nodeArg, useHubWrite } from '../../lib/hub-write' import { HubWriteHint } from '../../ui/HubWriteHint' @@ -35,7 +36,7 @@ import { Blocking } from './panes/Blocking' import { ProxyForwarders } from './panes/ProxyForwarders' import { Logging } from './panes/Logging' import { BackupDialog, Confirm, RestoreDialog } from './dialogs' -import { Failure, Loading } from '../../ui/Empty' +import { FailureRetry, LoadingRegion } from '../../ui/Empty' import styles from './Settings.module.css' import panelFormStyles from '../../ui/PanelForm.module.css' import { noticeFromFailure, type Notice } from '../../lib/notice' @@ -298,9 +299,35 @@ export function Settings({ }, [indexed, active, loading]) const here = useSectionInView(sections.map((x) => x.id)) - if (loading) return - if (form == null || settings == null) { - return Unable to load the DNS Server settings. + /* The selector is drawn in every state: a slow or silent server must not take away the way + to choose the other one. */ + const serverSelect = ( + } + nodes={nodes} + initialised={clusterInitialised} + aggregate={!hub} + value={node} + onChange={setNode} + label="Cluster Node" + /> + ) + if (loading || form == null || settings == null) { + return ( +
    + onSubChange?.(t as SubTab)} />} + actions={serverSelect} + /> + {loading ? ( + Loading… + ) : ( + void load()}>Unable to load the DNS Server settings. + )} +
    + ) } const en = enabled(form) @@ -501,14 +528,7 @@ export function Settings({ actions={ <> {hub && canModify && } - + {serverSelect} { @@ -549,7 +569,7 @@ export function Settings({ } /> - + {!hub && } setNotice(null)} /> {/* diff --git a/src/screens/zones/ZoneList.tsx b/src/screens/zones/ZoneList.tsx index 7419028..3398884 100644 --- a/src/screens/zones/ZoneList.tsx +++ b/src/screens/zones/ZoneList.tsx @@ -17,7 +17,7 @@ import { } from '../../api/zones' import { Button } from '../../ui/Button' import { Tick } from '../../ui/Tick' -import { Loading } from '../../ui/Empty' +import { FailureRetry, LoadingRegion } from '../../ui/Empty' import { StaleData } from '../StaleData' import { Field, Input, Select } from '../../ui/Field' import { SectionHeader } from '../../ui/SectionHeader' @@ -129,6 +129,8 @@ export function ZoneList({ fixed for on this screen. Measured on 2026-09-07 with the state matrix. */ const [loaded, setLoaded] = useState(false) + /* Why the first read failed, drawn in the region. A failed read has no zones: it is not an empty list. */ + const [failure, setFailure] = useState(null) const [pageNumber, setPageNumber] = useState(1) const [totalPages, setTotalPages] = useState(1) const [totalZones, setTotalZones] = useState(0) @@ -191,7 +193,10 @@ export function ZoneList({ */ const hadData = lastGood != null setStale(hadData) - if (!hadData) onNotice(noticeFromFailure(r)) + if (!hadData) { + onNotice(noticeFromFailure(r)) + setFailure(noticeFromFailure(r).text) + } /* A first load that failed is no longer waiting: it has an answer, and it is a bad one. Leaving `loaded` false here would leave the screen saying "loading" over a failure that has already been reported. */ @@ -201,6 +206,7 @@ export function ZoneList({ const data = r.data setStale(false) + setFailure(null) setLastGood(new Date().toISOString()) setZones(data.zones) setLoaded(true) @@ -395,6 +401,8 @@ export function ZoneList({ const shown = view === 'all' ? zones : zones.filter(VIEWS[view]) const { rows: visibleZones, sort, toggle } = useSort(KEYS, shown) const kpi = summary(all) + /* No answer yet, or none that came: the figures are unknown, which is not zero. */ + const noData = !loaded || failure !== null const allChecked = zones.length > 0 && checkedOnes.length === zones.length @@ -439,10 +447,10 @@ export function ZoneList({ 0 ? 'warn' : undefined }, + { label: 'Zones', value: noData ? '\u2014' : all.length || totalZones, icon: 'zones' }, + { label: 'Enabled', value: noData ? '\u2014' : kpi.enabled, icon: 'check', tone: noData ? undefined : 'ok' }, + { label: 'Signed with DNSSEC', value: noData ? '\u2014' : kpi.signed, icon: 'lock' }, + { label: 'Secondary pending sync', value: noData ? '\u2014' : kpi.pending, icon: 'convert', tone: !noData && kpi.pending > 0 ? 'warn' : undefined }, ]} /> @@ -549,7 +557,9 @@ export function ZoneList({ {stale && void load(pageNumber)} />} {!loaded ? ( - + + ) : failure !== null ? ( + void load(pageNumber)}>Could not load the zones. {failure} ) : ( <> {/* @@ -801,7 +811,7 @@ function zoneCells(p: RowProps) { ), status: ({state}), - serial: z.soaSerial ?? ' ', + serial: z.soaSerial ?? '\u2014', expiry: date(z.expiry), modified: date(z.lastModified), actions: ( diff --git a/src/screens/zones/Zones.loading.test.tsx b/src/screens/zones/Zones.loading.test.tsx index 8ed6cb4..cb6ee63 100644 --- a/src/screens/zones/Zones.loading.test.tsx +++ b/src/screens/zones/Zones.loading.test.tsx @@ -45,4 +45,17 @@ describe('a zone list that has not answered yet', () => { render() 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() + 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() + }) }) diff --git a/src/screens/zones/Zones.stale.test.tsx b/src/screens/zones/Zones.stale.test.tsx index 5eeca2f..5d4afd5 100644 --- a/src/screens/zones/Zones.stale.test.tsx +++ b/src/screens/zones/Zones.stale.test.tsx @@ -157,7 +157,8 @@ describe('who reports the failure', () => { vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' } as never) render() - 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() }) }) diff --git a/src/screens/zones/Zones.tsx b/src/screens/zones/Zones.tsx index b030e54..7072618 100644 --- a/src/screens/zones/Zones.tsx +++ b/src/screens/zones/Zones.tsx @@ -1,4 +1,4 @@ -import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect' +import { ServerSelect } from '../../ui/ServerSelect' import { useContext, useState } from 'react' import { AGGREGATE, HubMode } from '../../ui/ClusterNodeSelect' import { ZonesMatrix } from './ZonesMatrix' @@ -154,7 +154,8 @@ export function Zones({ same state here, since it is the same question. The list's reloads the list (index.html:436); the records view's is always disabled (index.html:548). */} - } nodes={nodes} initialised={clusterInitialised} aggregate={hub} @@ -166,7 +167,6 @@ export function Zones({ disabled={open != null} label="Cluster Node" /> - {!matrixMode && } {matrixMode ? ( { vi.spyOn(client, 'apiRequest').mockResolvedValue(session()) window.history.replaceState(null, '', '/settings/') mount() - await screen.findByRole('navigation') + // The sidebar and, while Settings has not read yet, its own sub-tab bar. + await screen.findAllByRole('navigation') // Being "in Settings" and nothing more is half a page: the address is completed. await waitFor(() => expect(window.location.pathname).toBe('/settings/general/')) diff --git a/src/test/chrome.tsx b/src/test/chrome.tsx new file mode 100644 index 0000000..3a76dcc --- /dev/null +++ b/src/test/chrome.tsx @@ -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 ( + + {(ref) => ( + <> +
    + {children} + + )} + + ) +} diff --git a/src/ui/Empty.tsx b/src/ui/Empty.tsx index 5362738..d6d6e88 100644 --- a/src/ui/Empty.tsx +++ b/src/ui/Empty.tsx @@ -1,4 +1,6 @@ import type { ReactNode } from 'react' +import { Skeleton } from '@/components/ui/skeleton' +import { Button } from './Button' import styles from './Empty.module.css' /* @@ -105,3 +107,43 @@ nothing passes would be inventing API ahead of its use. export function Failure({ children }: { children: ReactNode }) { return
    {children}
    } + +/* +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 ( +
    + {children} + + +
    + ) +} + +/* `Failure` with the one action it asked for: the same read, again. */ +export function FailureRetry({ children, onRetry }: { children: ReactNode; onRetry: () => void }) { + return ( + + + {children} + + + + ) +} diff --git a/src/ui/ServerSelect.tsx b/src/ui/ServerSelect.tsx new file mode 100644 index 0000000..31a8c8f --- /dev/null +++ b/src/ui/ServerSelect.tsx @@ -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 & { hint?: ReactNode }) { + const hub = useContext(HubMode) + if (!hub) return + return ( + + + {hint} + + ) +}