From f4513533c465f075c09f565c0d6e59c803b8d785 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 27 Oct 2026 09:35:41 +0700 Subject: [PATCH] App shell as the canvas draws it, full lazy Cache tree, styles aligned with the parent projects Shell: collapsible rail, DNS/Server groups with pins, always-on header with breadcrumbs and server status, Ctrl/Cmd+K command palette. Cache: left column is a lazily expanded tree of every zone, right column the selected node's records; both columns are ReUI Frames. Styles: dark variant follows data-theme (field and outline fills), 32 px selects, 36 px segmented strip, 40 px caption table heads, 56 px rows, 20/24/32 page padding. Co-Authored-By: Claude Sonnet 5.5 --- docs/reui-migration-plan.md | 8 + src/app/Shell.module.css | 76 ++++++- src/app/Shell.tsx | 228 ++++++++++++++++----- src/screens/blocking/Blocking.module.css | 2 +- src/screens/blocking/Overview.module.css | 2 +- src/screens/dashboard/Dashboard.module.css | 2 +- src/screens/lists/Cache.nav.test.tsx | 80 -------- src/screens/lists/CacheNav.tsx | 118 ----------- src/screens/lists/CacheTree.tsx | 148 +++++++++++++ src/screens/lists/Lists.module.css | 158 ++------------ src/screens/lists/Lists.test.tsx | 10 +- src/screens/lists/Lists.tree.test.tsx | 4 +- src/screens/lists/Lists.tsx | 167 +++++---------- src/screens/logs/Logs.module.css | 4 +- src/theme/globals.css | 2 +- src/theme/tokens.css | 8 +- src/ui/EditableTable.module.css | 11 +- src/ui/SectionHeader.module.css | 2 +- src/ui/Segmented.tsx | 4 +- src/ui/Select.module.css | 2 +- src/ui/Table.module.css | 5 +- src/ui/label.module.css | 2 +- 22 files changed, 509 insertions(+), 534 deletions(-) delete mode 100644 src/screens/lists/Cache.nav.test.tsx delete mode 100644 src/screens/lists/CacheNav.tsx create mode 100644 src/screens/lists/CacheTree.tsx diff --git a/docs/reui-migration-plan.md b/docs/reui-migration-plan.md index 2056748..548c4f9 100644 --- a/docs/reui-migration-plan.md +++ b/docs/reui-migration-plan.md @@ -79,3 +79,11 @@ | Table | Таблица строится на примитивах ReUI (`TableHeader`, `TableBody`, `TableFooter` и вид DataGrid). Перевод на TanStack `DataGrid` не сделан: строки и ячейки 23 таблиц несут свои атрибуты (классы строк, `colSpan`, `aria-sort` в ``), а DataGrid отдаёт ``/`` без хуков для них. Режим карточек на телефоне и подписи колонок держатся на тех же атрибутах. | Сборка, `tsc`, `lint:colours`, `oxlint` проходят. Тесты (`npx vitest run --maxWorkers=3`): 28 падений из 2076, все они падали и до миграции (локаль Windows и отсутствие коммита). + +## App shell as the canvas draws it + +`src/app/Shell.tsx` now follows the combined canvas: a 240 px sidebar that folds to a 60 px rail (kept in `localStorage`), a brand row with a search button, the sections under collapsible DNS and Server headings, a star on each to pin it into a Pinned group, and a header that is always there with the sidebar toggle, breadcrumbs and a server-status popover (domain, version, up since, blocking). Ctrl/Cmd+K opens a command palette (`ui/command`) that goes to any section or sub-section. Not carried over: the canvas's app switcher, since the other apps' addresses are not known here. + +## Styles aligned with the parent projects + +Measured against `reui.css` of the combined canvas (MikrotikManager v2): the ReUI `dark:` variant now answers to the console's own theme (`globals.css`), so fields and outline buttons carry the `input/30` fill the parents have; selects are 32 px like fields; the segmented control is the 36 px strip with 30 px tabs; table heads are 40 px captions (12 px, 500, no small caps) in every table including the permission matrix and the editable tables; rows are at least 56 px; the page pads 20/24/32 and titles track -0.01em. Cache's two columns are ReUI Frames with the full lazy tree on the left. diff --git a/src/app/Shell.module.css b/src/app/Shell.module.css index 6163ae0..9e4a2ca 100644 --- a/src/app/Shell.module.css +++ b/src/app/Shell.module.css @@ -50,12 +50,12 @@ panel, which had 377 px free (42 % of its height). On narrow screens it is needed, because the side panel is a closed drawer and its trigger has to be outside it. */ -.rtop { display:none; } + /* At the foot of the side panel, hence `margin-top:auto`: that is what pushes it down however short the list of sections is. */ .footSide { margin-top:auto; padding: var(--s-8) var(--s-9); border-top:1px solid var(--line); display:flex; flex-direction:column; gap: var(--s-4); } /* It opens upwards: it sits at the foot of the window, not under a header. */ -.body { padding: var(--s-11) var(--s-11) var(--s-11); min-width:0; flex:1; } +.body { padding: 20px var(--s-11) 32px; min-width:0; flex:1; } /* A screen that reads a file (View Logs) is as tall as the window and not as its content: it marks its root and gets a flex column to grow into, down to the footer. No other screen is touched. */ @@ -172,3 +172,75 @@ 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:empty { display: none; } + +/* +The canvas's shell. The sidebar is a collapsible rail (240 px, 60 with the labels +gone), its sections sit under collapsible group headings and can be pinned with a +star, and the header is always there: toggle, breadcrumbs and the server's status. +*/ +.shell { grid-template-columns: 240px 1fr; transition: grid-template-columns 0.18s ease; } +.shell[data-rail='true'] { grid-template-columns: 60px 1fr; } +.sbtag { margin-left: auto; font: 400 var(--t-2)/1 var(--font-mono); color: var(--mute); } +.sbsearch { + display: flex; align-items: center; gap: var(--s-6); + margin: var(--s-7) var(--s-6) 0; height: 32px; padding: 0 var(--s-7); + border: 1px solid var(--line); border-radius: var(--r-2); background: var(--pan2); + color: var(--mute); font: 400 var(--t-3)/1 var(--font-ui); cursor: pointer; +} +.sbsearch:hover { color: var(--ink); } +.sbsearch kbd { margin-left: auto; font: 400 var(--t-2)/1 var(--font-mono); } +.grph { + display: flex; align-items: center; justify-content: space-between; width: 100%; + padding: var(--s-4) var(--s-5); background: none; border: 0; cursor: pointer; + font: 500 var(--t-2)/1.3 var(--font-ui); letter-spacing: .04em; text-transform: uppercase; color: var(--mute); +} +.caret { display: inline-flex; transition: transform var(--t-fast); } +.caret[data-shut='true'] { transform: rotate(-90deg); } +.nrow { position: relative; display: flex; align-items: center; } +.star { + position: absolute; right: var(--s-3); width: 24px; height: 24px; padding: 0; + background: none; border: 0; border-radius: var(--r-2); color: var(--mute); cursor: pointer; + opacity: 0; transition: opacity var(--t-fast); +} +.nrow:hover .star, .star:focus-visible, .star[data-on='true'] { opacity: 1; } +.star[data-on='true'] { color: var(--acc); } + +.rtop { + display: flex; align-items: center; gap: var(--s-7); + padding: 0 var(--s-9); height: var(--top-bar); + border-bottom: 1px solid var(--line); background: var(--pan); + position: sticky; top: 0; z-index: var(--layer-sticky); +} +.shell { --stuck-top: var(--top-bar); } +.hamburger { + display: inline-flex; align-items: center; justify-content: center; + width: 32px; height: 32px; margin: 0; + background: none; border: 1px solid var(--line); border-radius: var(--r-2); + color: var(--ink); cursor: pointer; +} +.crumbs { display: flex; align-items: center; gap: var(--s-6); min-width: 0; font: 400 var(--t-4)/1 var(--font-ui); color: var(--mute); } +.crumbs span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.crumbs span + span::before { content: '/'; margin-right: var(--s-6); color: var(--line2); } +.crumbs span[data-last='true'] { color: var(--ink); font-weight: 500; } +.status { + margin-left: auto; display: inline-flex; align-items: center; gap: var(--s-5); + height: 32px; padding: 0 var(--s-7); background: none; border: 1px solid var(--line); + border-radius: var(--r-2); color: var(--ink); font: 400 var(--t-3)/1 var(--font-mono); cursor: pointer; +} +.pdot { width: 8px; height: 8px; border-radius: 99px; background: var(--ok, currentColor); } +.pdot[data-tone='warn'], .pdot[data-tone='warning'] { background: var(--warn, currentColor); } +.pdot[data-tone='bad'], .pdot[data-tone='danger'] { background: var(--bad, currentColor); } +.pop dl { display: grid; grid-template-columns: auto 1fr; gap: var(--s-4) var(--s-8); margin: 0; font-size: var(--t-3); } +.pop dt { color: var(--mute); } +.pop dd { margin: 0; text-align: right; font-family: var(--font-mono); } + +.shell[data-rail='true'] .lbl, .shell[data-rail='true'] .grph, .shell[data-rail='true'] .star { display: none; } +.shell[data-rail='true'] .s, .shell[data-rail='true'] .sbsearch, .shell[data-rail='true'] .sbrand { justify-content: center; } +.shell[data-rail='true'] .footSide { display: none; } + +@media (max-width: 900px) { + .shell, .shell[data-rail='true'] { grid-template-columns: 1fr; } + .shell[data-rail='true'] .lbl, .shell[data-rail='true'] .grph, .shell[data-rail='true'] .footSide { display: revert; } + .shell[data-rail='true'] .s, .shell[data-rail='true'] .sbsearch, .shell[data-rail='true'] .sbrand { justify-content: flex-start; } + .status .lbl { display: none; } +} diff --git a/src/app/Shell.tsx b/src/app/Shell.tsx index 8cb6716..43735e1 100644 --- a/src/app/Shell.tsx +++ b/src/app/Shell.tsx @@ -29,6 +29,8 @@ import styles from './Shell.module.css' import { Icon, type IconName } from '../ui/Icon' import { publicUrl } from './base' import { Menu } from '../ui/Menu' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { ThisServerName } from '../ui/ClusterNodeSelect' import { FooterLinks } from '../ui/FooterLinks' import { Versions } from './Versions' @@ -54,12 +56,29 @@ The sidebar's three groups. It is not a new taxonomy: it is upstream's own order with a gap where the kind of task already changed. What you operate daily, what you configure and what you consult. */ -const GROUPS: string[][] = [ - ['dashboard', 'zones', 'cache', 'blocking', 'apps', 'dnsclient'], - ['settings', 'dhcp', 'admin'], - ['logs', 'about'], +const GROUPS: { name: string; ids: string[] }[] = [ + { name: 'DNS', ids: ['dashboard', 'zones', 'cache', 'blocking', 'apps', 'dnsclient'] }, + { name: 'Server', ids: ['settings', 'dhcp', 'admin', 'logs', 'about'] }, ] +/* The sidebar's own preferences, per browser, as the canvas keeps them: the rail + and the pinned sections. A browser that refuses storage starts from the defaults. */ +function stored(key: string, fallback: T): T { + try { + const v = localStorage.getItem(key) + return v == null ? fallback : (JSON.parse(v) as T) + } catch { + return fallback + } +} +function keep(key: string, value: unknown) { + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + /* nothing to do */ + } +} + const ICONS: Record = { dashboard: 'dashboard', zones: 'zones', cache: 'cache', blocking: 'blocked', apps: 'apps', dnsclient: 'dnsclient', settings: 'settings', @@ -120,6 +139,33 @@ export function Shell({ }) const [active, setActive] = useState(() => initialRoute?.section ?? sections[0]?.id ?? 'about') const [drawer, setDrawer] = useState(false) + const [rail, setRail] = useState(() => stored('shell.rail', false)) + const [shut, setShut] = useState([]) + const [pins, setPins] = useState(() => stored('shell.pins', [])) + const [palette, setPalette] = useState(false) + function toggleRail() { + setRail((v) => { + keep('shell.rail', !v) + return !v + }) + } + function togglePin(id: string) { + setPins((v) => { + const next = v.includes(id) ? v.filter((x) => x !== id) : [...v, id] + keep('shell.pins', next) + return next + }) + } + useEffect(() => { + function onKey(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault() + setPalette((v) => !v) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) const [modal, setModal] = useState(forcePasswordChange ? 'password' : null) /* The factory password travels only with the opening the login caused; from the user menu `showChangePasswordModal()` is called with nothing (main.js). */ @@ -299,12 +345,61 @@ export function Shell({ return () => window.removeEventListener('popstate', onChanged) }, [sections]) + const pinned = sections.filter((sec) => pins.includes(sec.id)) + + function go(id: string, to: string | null) { + setActive(id) + setSub(to) + setDrawer(false) + setPalette(false) + } + + function navRow(sec: (typeof sections)[number], where: 'pin' | 'main') { + const here = sec.id === current?.id + const first = sec.subs?.[0] ?? null + const pin = pins.includes(sec.id) + return ( + + ) + } + + const crumbs = [info?.dnsServerDomain, current?.label, currentSub].filter((x): x is string => !!x) + return ( {(slot) => ( -
+
{drawer && ( {/* The server and the account, at the foot. When narrow you reach it via the drawer. */} @@ -475,13 +561,29 @@ export function Shell({ className={styles.hamburger} aria-label="Menu" aria-expanded={drawer} - onClick={() => setDrawer((v) => !v)} + onClick={() => (window.matchMedia('(max-width: 900px)').matches ? setDrawer((v) => !v) : toggleRail())} > - - Technitium - +
+ {crumbs.map((c, i) => ( + {c} + ))} +
+ + + + {info?.dnsServerDomain ?? 'Server'} + + +
+
Server
{info?.dnsServerDomain ?? '-'}
+
Version
{info?.version ?? '-'}
+
Up since
{info?.uptimestamp ? new Date(info.uptimestamp).toLocaleString() : '-'}
+
Blocking
{blockingNow ? blockingNow.label.replace(/^Blocking,?\s*/, '') : 'Enabled'}
+
+
+
{/* Neither `tabpanel` nor `aria-labelledby`: ever since each section has its @@ -606,6 +708,32 @@ export function Shell({
+ + + + + Nothing found. + + {sections.flatMap((sec) => [ + go(sec.id, sec.subs?.[0] ?? null)}> + {sec.label} + , + ...(sec.subs ?? []).map((sb) => ( + go(sec.id, sb)}> + {sec.label} / {sb} + + )), + ])} + + + { setPalette(false); open('profile') }}>My Profile + { setPalette(false); open('theme') }}>Change Theme + { setPalette(false); onLogout() }}>Logout + + + + + setModal(o ? 'profile' : null)} diff --git a/src/screens/blocking/Blocking.module.css b/src/screens/blocking/Blocking.module.css index ff7dd50..1da596a 100644 --- a/src/screens/blocking/Blocking.module.css +++ b/src/screens/blocking/Blocking.module.css @@ -34,7 +34,7 @@ the button that acts on it. .kpiPaired { display: grid; grid-template-columns: repeat(2, max-content); column-gap: var(--s-9); } .kpiRow { display: contents; } .kpiPaired > .kpiLabel { grid-column: 1 / -1; } -.kpiLabel { font-size: var(--t-1); font-weight: var(--text-weight); letter-spacing: var(--text-tracking); text-transform: uppercase; color: var(--mute); } +.kpiLabel { font-size: var(--t-1); font-weight: var(--text-weight); letter-spacing: var(--text-tracking); color: var(--mute); } /* A table inside a panel: the panel is already its frame. ui/Table draws a border, diff --git a/src/screens/blocking/Overview.module.css b/src/screens/blocking/Overview.module.css index 06d7da6..9716d9c 100644 --- a/src/screens/blocking/Overview.module.css +++ b/src/screens/blocking/Overview.module.css @@ -70,7 +70,7 @@ the other lacks. /* The ring, with the share written in its hole. */ .ring { position: relative; } .ringValue { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; font-family: var(--font-mono); font-size: var(--t-6); font-weight: 600; pointer-events: none; } -.ringLabel { display: block; font-family: var(--font-ui); font-size: var(--t-1); font-weight: var(--text-weight); letter-spacing: var(--text-tracking); text-transform: uppercase; color: var(--mute); } +.ringLabel { display: block; font-family: var(--font-ui); font-size: var(--t-1); font-weight: var(--text-weight); letter-spacing: var(--text-tracking); color: var(--mute); } /* The status block and "Check, block or allow a domain" share a row, and the grid diff --git a/src/screens/dashboard/Dashboard.module.css b/src/screens/dashboard/Dashboard.module.css index 11e9dea..f6634f6 100644 --- a/src/screens/dashboard/Dashboard.module.css +++ b/src/screens/dashboard/Dashboard.module.css @@ -162,7 +162,7 @@ The three breakdowns: each a title, a stacked bar and its legend (`Breakdown.tsx .breakdown { padding-top: var(--s-8); } .parts { display:flex; flex-direction:column; gap: var(--s-8); padding: 0 var(--s-8) var(--s-7); } .part { min-width: 0; } -.partTitle { margin: 0 0 var(--s-3); font-size: var(--t-1); font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: var(--mute); } +.partTitle { margin: 0 0 var(--s-3); font-size: var(--t-1); font-weight: 600; color: var(--mute); } .stack { display:flex; gap: var(--s-1); height: var(--s-6); border-radius: var(--r-1); overflow:hidden; } .segment { display:block; flex-basis: 0; min-width: var(--s-1); height: 100%; padding: 0; border: 0; border-radius: 0; } button.segment { cursor: pointer; } diff --git a/src/screens/lists/Cache.nav.test.tsx b/src/screens/lists/Cache.nav.test.tsx deleted file mode 100644 index 0eb8d5f..0000000 --- a/src/screens/lists/Cache.nav.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/* -Cache's one panel (2026-10-16): the crumbs go up, the chips go down, and both -send exactly what the tree sent. The tree stays the navigation of Allowed and -Blocked, which `Lists.tree.test.tsx` keeps covering. -*/ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { render, screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { Cache } from './Lists' -import * as api from '../../api/zonelists' -import type { ListNode } from '../../api/zonelists' - -afterEach(() => vi.restoreAllMocks()) - -function node(p: Partial = {}): ListNode { - return { domain: '', zones: [], records: [], ...p } -} - -function answering(n: ListNode) { - return vi.spyOn(api, 'listNode').mockResolvedValue({ kind: 'ok', data: n }) -} - -describe('Cache moves through one band', () => { - it('has no tree any more', async () => { - answering(node({ zones: ['com'] })) - render() - await screen.findByRole('button', { name: 'com' }) - expect(screen.queryByLabelText('Domain tree')).not.toBeInTheDocument() - }) - - it('an ancestor in the crumbs goes up with direction=up, as the [up] link does', async () => { - const spy = answering(node({ domain: 'www.google.com' })) - render() - const crumbs = await screen.findByRole('navigation', { name: 'Node' }) - await userEvent.click(within(crumbs).getByRole('button', { name: 'google.com' })) - const up = spy.mock.calls.find((c) => c[2] === 'google.com') - expect(up?.[3]).toBe('up') - }) - - it(' in the crumbs goes back to the root, also upwards', async () => { - const spy = answering(node({ domain: 'com' })) - render() - const crumbs = await screen.findByRole('navigation', { name: 'Node' }) - await userEvent.click(within(crumbs).getByRole('button', { name: '' })) - const calls = spy.mock.calls.filter((c) => c[2] === '') - expect(calls[calls.length - 1][3]).toBe('up') - }) - - it('the open node is the last crumb and is not a button', async () => { - answering(node({ domain: 'google.com' })) - render() - const crumbs = await screen.findByRole('navigation', { name: 'Node' }) - expect(within(crumbs).queryByRole('button', { name: 'google.com' })).toBeNull() - expect(crumbs.querySelector('[aria-current="location"]')).toHaveTextContent('google.com') - }) - - it('a chip opens its child with the full name and no direction', async () => { - const spy = answering(node({ domain: 'google.com', zones: ['www.google.com', 'l.google.com'] })) - render() - expect(await screen.findByText('2 zones')).toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'www.google.com' })) - const down = spy.mock.calls.find((c) => c[2] === 'www.google.com') - expect(down?.[3]).toBeUndefined() - }) - - it('a leaf draws no zone band', async () => { - answering(node({ domain: 'www.google.com' })) - render() - await screen.findByText(/0 records at/) - expect(screen.queryByText('0 zones')).not.toBeInTheDocument() - }) - - it('the empty text no longer points at a tree', async () => { - answering(node({ domain: 'google.com', zones: ['www.google.com'] })) - render() - expect( - await screen.findByText('This node only contains sub-domains. Open one to see its records.'), - ).toBeInTheDocument() - }) -}) diff --git a/src/screens/lists/CacheNav.tsx b/src/screens/lists/CacheNav.tsx deleted file mode 100644 index d23b10e..0000000 --- a/src/screens/lists/CacheNav.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { ancestors } from './Tree' -import { DomainName } from '../../ui/DomainName' -import { Icon } from '../../ui/Icon' -import styles from './Lists.module.css' - -/* -How Cache moves through its tree, in one band instead of three places. - -Until 2026-10-16 Cache had the tree of Allowed and Blocked: a 328 px column with -the field, ``, the chain of ancestors and the children, and beside it a -second, non-clickable path over the records. Two drawings of the same chain, and -the one you could click was the one furthest from what you were reading. At -`com` the tree was 35 rows tall and the four records sat 1,371 px down the page. - -Upstream draws a flat list (other-zones.js:120-137): `[refresh]`, `[up]` and the -children. The same three moves are here, each where it reads: - - · up: the crumbs. Every ancestor is upstream's `[up]` taken one or more times, - so it is sent with `direction=up`, exactly as the tree sent it; - · down: the children, as chips under the node; - · refresh: the `Refresh` button beside the crumbs, which reads the node again. - -Nothing is sent that the tree did not send, and the tree is still the -navigation of Allowed and Blocked inside Rules, which this does not touch. -*/ - -/** Where you are, and the way back up. The open node is the last crumb and is - * not a button: there is nowhere to go from it but where you already are. */ -export function Crumbs({ - domain, - label, - onNavigate, -}: { - /** The open node. Empty string is the root. */ - domain: string - /** What to call it: the IDN form when there is one. */ - label: string - onNavigate: (domain: string, up?: boolean) => void -}) { - const chain = ancestors(domain) - const atRoot = domain === '' - - return ( - - ) -} - -/* -The children of the open node, as chips that open it. - -The server sends them as FULL names (`clients4.google.com` under `google.com`), -and the full name is what is sent back, as the tree did. What changes is the -weight: the part that is new at this level is drawn in ink and the parent's -suffix, the same on every chip, in the faint grey, so a row of 35 children reads -as 35 different words and not as 35 copies of `.com`. The accessible name, the -copied text and find-in-page still get the whole name. -*/ -export function Children({ - domain, - zones, - onNavigate, -}: { - domain: string - zones: string[] - onNavigate: (domain: string) => void -}) { - const suffix = domain === '' ? '' : `.${domain}` - - return ( -
    - {zones.map((z) => { - const own = suffix !== '' && z.endsWith(suffix) ? z.slice(0, -suffix.length) : z - const rest = own === z ? '' : suffix - return ( -
  • - -
  • - ) - })} -
- ) -} diff --git a/src/screens/lists/CacheTree.tsx b/src/screens/lists/CacheTree.tsx new file mode 100644 index 0000000..40f2517 --- /dev/null +++ b/src/screens/lists/CacheTree.tsx @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { listNode } from '../../api/zonelists' +import { Icon } from '../../ui/Icon' +import { Loading } from '../../ui/Empty' +import styles from './Lists.module.css' +import { ancestors } from './Tree' + +/* +The cache as a whole tree, the way the screen reads best: every zone opens in +place, and what the selected node holds is on the right. + +The server answers one level at a time (`cache/list` with a domain), so the tree +asks for a node's children when it is opened for the first time and keeps them +until `epoch` changes (a flush, a delete, a refresh or another cluster node). The +selected domain is always open down to itself, so Browse and the records' own +links land on a visible row. +*/ + +type Level = string[] | 'loading' | 'failed' + +/** The name under its parent: `www` under `google.com`, the whole name under the root. */ +function short(domain: string, parent: string): string { + return parent !== '' && domain.endsWith(`.${parent}`) ? domain.slice(0, -(parent.length + 1)) : domain +} + +export function CacheTree({ + token, + clusterNode, + selected, + current, + epoch, + onSelect, +}: { + token: string | null + clusterNode: string + selected: string + /** The selected node as the screen has already read it: its children are not asked for twice. */ + current: { domain: string; zones: string[] } | null + /** Changes when what the tree has read is out of date. */ + epoch: number + onSelect: (domain: string) => void +}) { + const [levels, setLevels] = useState>({}) + const [open, setOpen] = useState>(() => new Set([''])) + const seen = useRef>({}) + const lastEpoch = useRef(epoch) + const own = useRef(current) + own.current = current + const levelsNow: Record = current == null ? levels : { ...levels, [current.domain]: current.zones } + + const read = useCallback( + async (domain: string) => { + seen.current = { ...seen.current, [domain]: 'loading' } + setLevels(seen.current) + const outcome = await listNode('cache', token, domain, undefined, clusterNode) + seen.current = { ...seen.current, [domain]: outcome.kind === 'ok' ? outcome.data.zones : 'failed' } + setLevels(seen.current) + }, + [token, clusterNode], + ) + + /* A stale tree is forgotten and the open nodes are read again; the selected + node's chain is opened and read if it has not been. */ + useEffect(() => { + const fresh = lastEpoch.current !== epoch + lastEpoch.current = epoch + if (fresh) seen.current = {} + if (own.current != null) seen.current = { ...seen.current, [own.current.domain]: own.current.zones } + setLevels(seen.current) + const chain = selected === '' ? [''] : ['', ...ancestors(selected), selected] + const wanted = new Set([...(fresh ? open : []), ...chain]) + setOpen((v) => new Set([...(fresh ? v : v), ...chain])) + for (const d of wanted) if (seen.current[d] == null && d !== own.current?.domain) void read(d) + // `open` is read only to refresh what was already open. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [epoch, selected, read]) + + function toggle(domain: string) { + const opening = !open.has(domain) + setOpen((v) => { + const next = new Set(v) + if (opening) next.add(domain) + else next.delete(domain) + return next + }) + if (opening && seen.current[domain] == null && domain !== own.current?.domain) void read(domain) + } + + function rows(parent: string) { + const level = levelsNow[parent] + if (level === 'loading' || level == null) return
+ if (level === 'failed') { + return ( +
+ +
+ ) + } + if (level.length === 0) return null + return ( +
+ {level.map((z) => { + const isOpen = open.has(z) + return ( +
+
+ + +
+ {isOpen && rows(z)} +
+ ) + })} +
+ ) + } + + return ( +
+
+ + +
+ {open.has('') && rows('')} +
+ ) +} diff --git a/src/screens/lists/Lists.module.css b/src/screens/lists/Lists.module.css index 60b0bce..e4067de 100644 --- a/src/screens/lists/Lists.module.css +++ b/src/screens/lists/Lists.module.css @@ -26,7 +26,7 @@ inside itself rather than growing the page. align-items: stretch; } .split > * { min-width: 0; display: flex; flex-direction: column; } -.column { composes: panel from '../../ui/Panel.module.css'; flex: 1; display: flex; flex-direction: column; min-height: 0; } +.column { flex: 1; min-height: 0; } .body { flex: 1; min-height: 0; overflow: auto; padding: var(--s-8); } .bodyFlush { flex: 1; min-height: 0; overflow: auto; } @@ -57,15 +57,14 @@ seven. .cache { --kind: var(--ch-cache); } .allowed { --kind: var(--ch-ok); } .blocked { --kind: var(--ch-block); } -.records > .column { border-top: 3px solid var(--kind); } .identity { display: flex; + flex-direction: row; align-items: center; + justify-content: flex-start; + text-align: left; gap: var(--s-6); - padding: var(--s-6) var(--s-9); - border-bottom: 1px solid var(--line); - background: var(--pan); } .emblem { width: 26px; @@ -105,7 +104,7 @@ second clickable path would be behaviour upstream does not have. So it is a } .pathLabel { font: 650 var(--t-1) var(--font-ui); - text-transform: uppercase; + letter-spacing: 0.06em; color: var(--faint); margin-right: var(--s-1); @@ -115,143 +114,6 @@ second clickable path would be behaviour upstream does not have. So it is a .pathSep { color: var(--faint); opacity: 0.6; } .pathHere { color: var(--ink); font-weight: 650; } -/* -CACHE, ONE PANEL (2026-10-16). Cache leaves the two-column split: the tree -column becomes two bands inside the records panel (the crumbs that go up and the -chips that go down, `CacheNav.tsx`), so the screen has one place to look and -the records get the full width. The identity colour stays as the panel's top -rule, the same `--ch-cache` as before; the band that repeated "Cache" under the -section title is gone, and its sentence is the header's subtitle. -*/ -.cachePanel { - composes: panel from '../../ui/Panel.module.css'; - border-top: 3px solid var(--ch-cache); -} - -/* The field takes the width; Browse stays its own size beside it. */ -.search { - display: flex; - gap: var(--s-5); - align-items: flex-end; - padding: var(--s-8) var(--s-9); - border-bottom: 1px solid var(--line); -} -.search > div { flex: 1; min-width: 0; } - -/* The node's line: where you are on the left, what you can do to it on the right. */ -.where { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: var(--s-5) var(--s-8); - padding: var(--s-5) var(--s-9); - border-bottom: 1px solid var(--line2); - background: var(--pan2); -} -.crumbs { - display: flex; - align-items: center; - gap: var(--s-3); - min-width: 0; - font: var(--t-4) var(--font-mono); -} -.crumbList { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--s-1) var(--s-3); - margin: 0; - padding: 0; - list-style: none; - min-width: 0; -} -.crumbList > li { display: flex; align-items: center; gap: var(--s-3); min-width: 0; } -/* -An ancestor is a link-weight button: muted until pointed at, never boxed. Boxing -it would make three ancestors look like three actions on the same footing as -Refresh and Delete beside them. `.hitArea` gives the 24 px target without -growing the line. -*/ -.crumb { - composes: hitArea from '../../ui/interaction.module.css'; - border: 0; - padding: 0; - background: transparent; - font: inherit; - color: var(--mute); - cursor: pointer; - text-align: left; -} -.crumb:hover { color: var(--acc-ink); text-decoration: underline; } -.crumbHere { color: var(--ink); font-weight: 650; } - -/* -The children. A chip per name, wrapping, with the count in front as its label. -They scroll inside the band past five rows and a half: the showcase's `com` has -35 children and fits in five at 1440, but a production `com` can hold thousands, -and the records must not be pushed off the screen by the way down to them. The -half row is the signal: a band cut exactly between two rows of chips looks -complete, and the ones below it would never be found. -*/ -.children { - display: flex; - align-items: flex-start; - gap: var(--s-5) var(--s-8); - padding: var(--s-6) var(--s-9); - border-bottom: 1px solid var(--line2); -} -.childrenCount { - flex: none; - padding-top: var(--s-2); - font-size: var(--t-3); - color: var(--faint); - white-space: nowrap; -} -.chips { - display: flex; - flex-wrap: wrap; - gap: var(--s-3); - margin: 0; - padding: 0; - list-style: none; - min-width: 0; - /* Five and a half chips tall: each is its line (1.4em), its padding and its - two borders, and the gaps between them. */ - font-size: var(--t-4); - line-height: 1.4; - max-height: calc(5.5 * (1.4em + 2 * var(--s-2) + var(--s-1)) + 5 * var(--s-3)); - overflow: auto; -} -.chips > li { display: flex; min-width: 0; max-width: 100%; } -.chipNode { - display: inline-flex; - align-items: center; - gap: var(--s-1); - max-width: 100%; - padding: var(--s-2) var(--s-4) var(--s-2) var(--s-5); - border: 1px solid var(--line); - border-radius: var(--r-2); - background: var(--pan2); - font: inherit; - font-family: var(--font-mono); - color: var(--ink); - cursor: pointer; - text-align: left; -} -.chipNode:hover { border-color: var(--line-hover); background: var(--hover-strong); } -.chipNode:hover .chipCh { color: var(--acc-ink); } -.chipName { min-width: 0; } -.chipRest { color: var(--faint); } -.chipCh { flex: none; margin-left: var(--s-1); color: var(--faint); } - -/* Below the rail the crumbs take their own line over the node's buttons, and - the count sits over its chips instead of beside them. */ -@media (max-width: 560px) { - .children { flex-direction: column; gap: var(--s-3); } - .chips { max-height: calc(6.5 * (1.4em + 2 * var(--s-2) + var(--s-1)) + 6 * var(--s-3)); } -} - /* 4 · The tree itself, at the weight it deserves. */ .browse { display: flex; gap: var(--s-3); align-items: flex-end; padding: var(--s-7) var(--s-8); border-bottom: 1px solid var(--line); } .browse > div { flex: 1; min-width: 0; } @@ -358,3 +220,13 @@ and scrolls inside), so the records are not always below the fold. .split { grid-template-columns: minmax(0, 1fr); gap: var(--s-8); } .tree .body { max-height: 250px; } } + +/* The cache tree: a row is the node and, before it, the button that opens it. */ +.treeRow { display: flex; align-items: center; gap: var(--s-1); } +.treeRow > .node { flex: 1; min-width: 0; } +.treeToggle { + flex: none; display: inline-flex; align-items: center; justify-content: center; + width: 24px; height: 24px; padding: 0; border: 0; border-radius: var(--r-2); + background: transparent; color: var(--mute); cursor: pointer; +} +.treeToggle:hover { background: var(--hover-strong); color: var(--ink); } diff --git a/src/screens/lists/Lists.test.tsx b/src/screens/lists/Lists.test.tsx index 1f95032..a4e4b3c 100644 --- a/src/screens/lists/Lists.test.tsx +++ b/src/screens/lists/Lists.test.tsx @@ -164,13 +164,15 @@ describe('Cache', () => { const spy = vi.spyOn(api, 'flushCache').mockResolvedValue(OK) const list = withNode(node({ domain: 'home.test', records: [REG_CACHE] })) render() - await userEvent.click(await screen.findByRole('button', { name: 'Flush Cache' })) + await screen.findByRole('button', { name: 'Flush Cache' }) + const before = list.mock.calls.filter((c) => c[2] === '').length + await userEvent.click(screen.getByRole('button', { name: 'Flush Cache' })) await confirm('Flush Cache') expect(spy).toHaveBeenCalled() expect( await screen.findByText('DNS Server cache was flushed successfully.'), ).toBeInTheDocument() - expect(list.mock.calls[list.mock.calls.length - 1][2]).toBe('') + expect(list.mock.calls.filter((c) => c[2] === '').length).toBeGreaterThan(before) }) /* In cache the Delete button depends on the NODE, not on it having records @@ -316,9 +318,7 @@ describe('cache: the cluster node', () => { render() await screen.findByRole('button', { name: 'Delete' }) await pickB() - const last = list.mock.calls[list.mock.calls.length - 1] - expect(last[2]).toBe('home.test') - expect(last[4]).toBe('b.example') + expect(list.mock.calls.some((c) => c[2] === 'home.test' && c[4] === 'b.example')).toBe(true) }) it('Flush and Delete go to the chosen node', async () => { diff --git a/src/screens/lists/Lists.tree.test.tsx b/src/screens/lists/Lists.tree.test.tsx index c63c8f9..9510789 100644 --- a/src/screens/lists/Lists.tree.test.tsx +++ b/src/screens/lists/Lists.tree.test.tsx @@ -88,7 +88,7 @@ describe('cache and policy are told apart', () => { ])('%#: the identity band names the screen and what it holds', async (list, embedded, title, sub) => { withNode(node()) render() - expect(await screen.findByText(sub)).toBeInTheDocument() + expect((await screen.findAllByText(sub)).length).toBeGreaterThan(0) expect(screen.getAllByText(title).length).toBeGreaterThan(0) }) }) @@ -153,7 +153,7 @@ describe('loading is not emptiness, on either side', () => { vi.spyOn(client, 'apiRequest').mockImplementation(() => pending() as never) render() await screen.findByRole('status') - expect(screen.getAllByText('Loading…')).toHaveLength(1) + expect(screen.getAllByRole('status')).toHaveLength(1) }) }) diff --git a/src/screens/lists/Lists.tsx b/src/screens/lists/Lists.tsx index 9cfc24f..0968d0e 100644 --- a/src/screens/lists/Lists.tsx +++ b/src/screens/lists/Lists.tsx @@ -22,7 +22,8 @@ import { Empty, Loading } from '../../ui/Empty' import { StaleData } from '../StaleData' import { Tree } from './Tree' import { Path } from './Path' -import { Crumbs, Children } from './CacheNav' +import { CacheTree } from './CacheTree' +import { Frame, FrameHeader, FramePanel } from '@/components/reui/frame' import { ResourceRecords } from './Records' import styles from './Lists.module.css' import { noticeFromFailure, type Notice } from '../../lib/notice' @@ -280,6 +281,9 @@ export function Lists({ primary.current = primaryNodeName(nodes, clusterInitialised) }, [nodes, clusterInitialised]) + /* Bumped whenever what the cache tree has read is out of date. */ + const [treeEpoch, setTreeEpoch] = useState(0) + const load = useCallback( async (domain: string, up?: boolean, fromPrimary = false) => { /* other-zones.js:269 and 434: after a change, Allowed and Blocked are read @@ -374,7 +378,10 @@ export function Lists({ text: 'DNS Server cache was flushed successfully.', }, // Upstream leaves the list at `` and hides the viewer. - () => load(''), + async () => { + setTreeEpoch((v) => v + 1) + await load('') + }, ), }) } @@ -392,7 +399,10 @@ export function Lists({ title: 'Deleted!', text: `Cached zone '${nodeTitle}' was deleted successfully.`, }, - () => load(parentDomain(nodeTitle) ?? '', true), + async () => { + setTreeEpoch((v) => v + 1) + await load(parentDomain(nodeTitle) ?? '', true) + }, ), }) } @@ -447,6 +457,7 @@ export function Lists({ onChange={(n) => { cacheNode.current = n setClusterNode(n) + setTreeEpoch((v) => v + 1) void load(domain) }} label="Cluster Node" @@ -459,7 +470,6 @@ export function Lists({ {!embedded && ( @@ -474,96 +484,11 @@ export function Lists({ {stale && void load(node?.domain ?? '')} />} - {isCache ? ( - /* - Cache, one panel (2026-10-16). Top to bottom it is the order you use it in: - type a name, see where you are and go up, see what hangs below and go down, - read the records. The tree column is gone from this screen only; see - `CacheNav.tsx` for why each move is the one upstream makes. - - Measured on the showcase. At `com`, 1440: the page was 1,371 px tall (the - 35-row tree set it) and is 990, with the four records rows at 85 to 106 - px instead of 140 to 160. At the root the page went from 3,307 to 2,125. - At 768 the table started at y=619, under the tree, and starts at 359; at - 390 on `google.com` at 663 and now 496, with no sideways scroll. - */ -
-
- - {(id) => ( - setField(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') navigate(field) - }} - /> - )} - - -
- -
- - {/* Refresh and Delete act on the node, so they sit on the node's line. - Hidden while the first read is in flight, as the count bar was. */} - {!loading && ( -
- - {mayDelete && ( - - Delete - - )} -
- )} -
- - {loading ? ( -
- ) : ( - <> - {/* No chips, no band: a leaf saying `0 zones` on a line of its own - was a strip of nothing above the records. */} - {zones.length > 0 && ( -
- - {zones.length === 1 ? '1 zone' : `${zones.length} zones`} - - -
- )} -
- - {records.length} records at {nodeTitle} - -
- {records.length > 0 ? ( - - ) : ( -
- {/* "in the tree" left with the tree: the chips are right above. */} - - {zones.length > 0 - ? 'This node only contains sub-domains. Open one to see its records.' - : 'This node has no records and no sub-domains.'} - -
- )} - - )} -
- ) : ( + {(
-
+ +
{/* The field is called "Domain" and the button "Browse". Upstream puts no label here (only the `placeholder`), so this one is an @@ -609,24 +534,40 @@ export function Lists({ <> {/* The count lives in the count bar, like the records one: in the header it looked exactly like a status pill. */} -
- {zones.length === 1 ? '1 zone' : `${zones.length} zones`} -
-
- -
+ {isCache ? ( +
+ +
+ ) : ( + <> +
+ {zones.length === 1 ? '1 zone' : `${zones.length} zones`} +
+
+ +
+ + )} )} -
+ +
-
+ {/* Cache is what the server resolved and stored; Allowed and Blocked are the administrator's decisions. Three screens, one component, and until @@ -637,7 +578,7 @@ export function Lists({ rule `theme/tokens.css` already applies to itself in the seven conditions of Logs: colour reinforces, it is never the only channel. */} -
+ @@ -645,7 +586,8 @@ export function Lists({ {TITLE[list]} {SUBTITLE[list]} -
+ + @@ -659,9 +601,10 @@ export function Lists({
{records.length} records at {nodeTitle} + {isCache && zones.length > 0 && ` \u00b7 ${zones.length === 1 ? '1 sub-zone' : `${zones.length} sub-zones`}`}
- {/* @@ -677,7 +620,7 @@ export function Lists({ variant="danger" disabled={busy} permission={canDelete ? undefined : `${TITLE[list]}.canDelete`} - onClick={askDeleteDomain} + onClick={isCache ? askDeleteCacheNode : askDeleteDomain} > Delete @@ -692,7 +635,7 @@ export function Lists({ `role="status"` for one request read it out twice. */
) : records.length > 0 ? ( - + ) : (
@@ -703,10 +646,10 @@ export function Lists({
)}
-
+ +
- )} ({ }) { return (
@@ -41,7 +41,7 @@ export function Segmented({ className={cn( 'flex-none rounded-md border-0 bg-transparent px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50', 'aria-pressed:bg-card aria-pressed:text-foreground aria-pressed:shadow-xs aria-selected:bg-card aria-selected:text-foreground aria-selected:shadow-xs', - compact ? 'h-6 text-xs' : 'h-7', + compact ? 'h-6 text-xs' : 'h-[30px]', )} role={asTabs ? 'tab' : undefined} aria-selected={asTabs ? o.id === active : undefined} diff --git a/src/ui/Select.module.css b/src/ui/Select.module.css index 6ee481d..4e31aaf 100644 --- a/src/ui/Select.module.css +++ b/src/ui/Select.module.css @@ -10,7 +10,7 @@ to weigh the same. What changes is that this one opens. gap: var(--s-6); width: 100%; min-height: var(--field-height); - padding: var(--s-5) var(--s-7); + padding: calc((var(--field-height) - var(--field-line) - 2px) / 2) var(--s-7); border: 1px solid var(--line-field); border-radius: var(--r-3); background-color: var(--field); diff --git a/src/ui/Table.module.css b/src/ui/Table.module.css index 17d2e96..f9e8259 100644 --- a/src/ui/Table.module.css +++ b/src/ui/Table.module.css @@ -61,7 +61,7 @@ the tint. /* The last row carries no line: the panel border closes it. */ .table tbody tr:last-child td { border-bottom: 0; } -.table tbody tr { transition: background var(--t-fast); } +.table tbody tr { transition: background var(--t-fast); height: 56px; } .table tbody tr:hover { background: var(--hover); } /* @@ -383,6 +383,7 @@ line and count twice. */ .stack[data-stacked] > tbody > tr, .stack[data-stacked] > tfoot > tr { + height: auto; display: flex; flex-wrap: wrap; align-items: flex-start; @@ -452,7 +453,7 @@ empty alternative text keeps a screen reader from reading it twice. font-size: var(--text-size); font-weight: var(--text-weight); letter-spacing: var(--text-tracking); - text-transform: uppercase; + color: var(--mute); white-space: nowrap; } diff --git a/src/ui/label.module.css b/src/ui/label.module.css index e759a7f..1596b59 100644 --- a/src/ui/label.module.css +++ b/src/ui/label.module.css @@ -18,6 +18,6 @@ It is applied with `composes:`, not by copying it. font-size: var(--text-size); font-weight: var(--text-weight); letter-spacing: var(--text-tracking); - text-transform: uppercase; + color: var(--mute); }