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 <noreply@anthropic.com>
This commit is contained in:
1 parent
44b6dc246c
commit
f4513533c4
22 files changed
+509
-534
No files matched your search
@@ -79,3 +79,11 @@
|
||||
| Table | Таблица строится на примитивах ReUI (`TableHeader`, `TableBody`, `TableFooter` и вид DataGrid). Перевод на TanStack `DataGrid` не сделан: строки и ячейки 23 таблиц несут свои атрибуты (классы строк, `colSpan`, `aria-sort` в `<th>`), а DataGrid отдаёт `<tr>`/`<td>` без хуков для них. Режим карточек на телефоне и подписи колонок держатся на тех же атрибутах. |
|
||||
|
||||
Сборка, `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.
|
||||
@@ -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; }
|
||||
}
|
||||
+178
-50
@@ -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<T>(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<string, IconName> = {
|
||||
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<boolean>('shell.rail', false))
|
||||
const [shut, setShut] = useState<string[]>([])
|
||||
const [pins, setPins] = useState<string[]>(() => stored<string[]>('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<ModalId | null>(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 (
|
||||
<div className={styles.nrow} key={`${where}-${sec.id}`}>
|
||||
<a
|
||||
className={styles.s}
|
||||
href={toTrail({ section: sec.id, sub: first })}
|
||||
data-active={here}
|
||||
aria-current={here ? 'page' : undefined}
|
||||
title={sec.id === 'blocking' ? blockingNow?.label : rail ? sec.label : undefined}
|
||||
onClick={(e) => {
|
||||
if (!plainClick(e)) return
|
||||
e.preventDefault()
|
||||
go(sec.id, first)
|
||||
}}
|
||||
>
|
||||
<span className={styles.ico}>
|
||||
<Icon name={ICONS[sec.id] ?? 'about'} size={16} />
|
||||
</span>
|
||||
<span className={styles.lbl}>{sec.label}</span>
|
||||
{sec.id === 'blocking' && <BlockingMark mark={blockingNow} />}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.star}
|
||||
data-on={pin}
|
||||
aria-pressed={pin}
|
||||
aria-label={`${pin ? 'Unpin' : 'Pin'} ${sec.label}`}
|
||||
onClick={() => togglePin(sec.id)}
|
||||
>
|
||||
{pin ? '\u2605' : '\u2606'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const crumbs = [info?.dnsServerDomain, current?.label, currentSub].filter((x): x is string => !!x)
|
||||
|
||||
return (
|
||||
<HandoffContext.Provider value={handoff}>
|
||||
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
|
||||
<SlotProvider>
|
||||
{(slot) => (
|
||||
<div className={styles.shell}>
|
||||
<div className={styles.shell} data-rail={rail}>
|
||||
{drawer && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -316,8 +411,15 @@ export function Shell({
|
||||
|
||||
<aside className={styles.side} data-open={drawer}>
|
||||
<div className={styles.sbrand}>
|
||||
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} /> Technitium
|
||||
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} />
|
||||
<span className={styles.lbl}>Technitium</span>
|
||||
<span className={`${styles.lbl} ${styles.sbtag}`}>console</span>
|
||||
</div>
|
||||
<button type="button" className={styles.sbsearch} onClick={() => setPalette(true)} aria-label="Search">
|
||||
<Icon name="menu" size={14} />
|
||||
<span className={styles.lbl}>Search</span>
|
||||
<kbd className={styles.lbl}>{'\u2318K'}</kbd>
|
||||
</button>
|
||||
<nav className={styles.slist} role="navigation" aria-label="Sections">
|
||||
{/*
|
||||
This is a navigation menu, and until now it claimed to be a `tablist`.
|
||||
@@ -338,47 +440,31 @@ export function Shell({
|
||||
`aria-current="page"` is reserved for ONE thing: the page you are on,
|
||||
which is the sub-section when there is one.
|
||||
*/}
|
||||
{GROUPS.map((group, g) => (
|
||||
<div className={styles.group} key={g}>
|
||||
{sections.filter((sec) => group.includes(sec.id)).map((sec) => {
|
||||
const active = sec.id === current?.id
|
||||
const first = sec.subs?.[0] ?? null
|
||||
return (
|
||||
<div key={sec.id}>
|
||||
<a
|
||||
className={styles.s}
|
||||
href={toTrail({ section: sec.id, sub: first })}
|
||||
data-active={active}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
title={sec.id === 'blocking' ? blockingNow?.label : undefined}
|
||||
onClick={(e) => {
|
||||
if (!plainClick(e)) return
|
||||
e.preventDefault()
|
||||
setActive(sec.id)
|
||||
setSub(first)
|
||||
setDrawer(false)
|
||||
}}
|
||||
>
|
||||
<span className={styles.ico}>
|
||||
<Icon name={ICONS[sec.id] ?? 'about'} size={16} />
|
||||
</span>
|
||||
{sec.label}
|
||||
{sec.id === 'blocking' && <BlockingMark mark={blockingNow} />}
|
||||
</a>
|
||||
{/*
|
||||
The sub-items are NOT here any more. The design puts a
|
||||
section's sub-navigation under its title, for a reason:
|
||||
nested here, nine panes run out of room the moment the rail
|
||||
drops to 60 px. What the sidebar gets back is
|
||||
its own shape: twelve entries, always the same twelve, instead
|
||||
of fourteen, eighteen or twenty-one depending on where you are.
|
||||
See `ui/SubTabs`.
|
||||
*/}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{pinned.length > 0 && (
|
||||
<div className={styles.group}>
|
||||
<div className={styles.grph}>Pinned</div>
|
||||
{pinned.map((sec) => navRow(sec, 'pin'))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{GROUPS.map((group) => {
|
||||
const items = sections.filter((sec) => group.ids.includes(sec.id))
|
||||
if (items.length === 0) return null
|
||||
const closed = shut.includes(group.name)
|
||||
return (
|
||||
<div className={styles.group} key={group.name}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.grph}
|
||||
aria-expanded={!closed}
|
||||
onClick={() => setShut((v) => (closed ? v.filter((x) => x !== group.name) : [...v, group.name]))}
|
||||
>
|
||||
{group.name}
|
||||
<span className={styles.caret} data-shut={closed}><Icon name="chevronDown" size={12} /></span>
|
||||
</button>
|
||||
{!closed && items.map((sec) => navRow(sec, 'main'))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 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())}
|
||||
>
|
||||
<Icon name="menu" size={18} />
|
||||
</button>
|
||||
<span className={styles.topBrand}>
|
||||
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} /> Technitium
|
||||
</span>
|
||||
<div className={styles.crumbs} role="group" aria-label="Breadcrumb">
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={i} data-last={i === crumbs.length - 1}>{c}</span>
|
||||
))}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger className={styles.status} aria-label="Server status">
|
||||
<i className={styles.pdot} data-tone={blockingNow?.tone ?? 'ok'} />
|
||||
<span className={styles.lbl}>{info?.dnsServerDomain ?? 'Server'}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className={styles.pop}>
|
||||
<dl>
|
||||
<dt>Server</dt><dd>{info?.dnsServerDomain ?? '-'}</dd>
|
||||
<dt>Version</dt><dd>{info?.version ?? '-'}</dd>
|
||||
<dt>Up since</dt><dd>{info?.uptimestamp ? new Date(info.uptimestamp).toLocaleString() : '-'}</dd>
|
||||
<dt>Blocking</dt><dd>{blockingNow ? blockingNow.label.replace(/^Blocking,?\s*/, '') : 'Enabled'}</dd>
|
||||
</dl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</header>
|
||||
|
||||
{/* Neither `tabpanel` nor `aria-labelledby`: ever since each section has its
|
||||
@@ -606,6 +708,32 @@ export function Shell({
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<CommandDialog open={palette} onOpenChange={setPalette} title="Search" description="Go to a section or an account action">
|
||||
<Command>
|
||||
<CommandInput placeholder="Go to..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>Nothing found.</CommandEmpty>
|
||||
<CommandGroup heading="Sections">
|
||||
{sections.flatMap((sec) => [
|
||||
<CommandItem key={sec.id} value={sec.label} onSelect={() => go(sec.id, sec.subs?.[0] ?? null)}>
|
||||
<Icon name={ICONS[sec.id] ?? 'about'} size={16} /> {sec.label}
|
||||
</CommandItem>,
|
||||
...(sec.subs ?? []).map((sb) => (
|
||||
<CommandItem key={`${sec.id}/${sb}`} value={`${sec.label} ${sb}`} onSelect={() => go(sec.id, sb)}>
|
||||
<Icon name="chevronRight" size={14} /> {sec.label} / {sb}
|
||||
</CommandItem>
|
||||
)),
|
||||
])}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Account">
|
||||
<CommandItem value="My Profile" onSelect={() => { setPalette(false); open('profile') }}>My Profile</CommandItem>
|
||||
<CommandItem value="Change Theme" onSelect={() => { setPalette(false); open('theme') }}>Change Theme</CommandItem>
|
||||
<CommandItem value="Logout" onSelect={() => { setPalette(false); onLogout() }}>Logout</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
|
||||
<MyProfile
|
||||
open={modal === 'profile'}
|
||||
onOpenChange={(o) => setModal(o ? 'profile' : null)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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> = {}): 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(<Cache token="t" />)
|
||||
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(<Cache token="t" />)
|
||||
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('<ROOT> in the crumbs goes back to the root, also upwards', async () => {
|
||||
const spy = answering(node({ domain: 'com' }))
|
||||
render(<Cache token="t" />)
|
||||
const crumbs = await screen.findByRole('navigation', { name: 'Node' })
|
||||
await userEvent.click(within(crumbs).getByRole('button', { name: '<ROOT>' }))
|
||||
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(<Cache token="t" />)
|
||||
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(<Cache token="t" />)
|
||||
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(<Cache token="t" />)
|
||||
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(<Cache token="t" />)
|
||||
expect(
|
||||
await screen.findByText('This node only contains sub-domains. Open one to see its records.'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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, `<ROOT>`, 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 (
|
||||
<nav className={styles.crumbs} aria-label="Node">
|
||||
{/* `Node` is the word the path already used: no new one. */}
|
||||
<span className={styles.pathLabel}>Node</span>
|
||||
<ol className={styles.crumbList}>
|
||||
<li>
|
||||
{atRoot ? (
|
||||
<span className={styles.crumbHere} aria-current="location"><ROOT></span>
|
||||
) : (
|
||||
<button type="button" className={styles.crumb} onClick={() => onNavigate('', true)}>
|
||||
<ROOT>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
{chain.map((parent) => (
|
||||
<li key={parent}>
|
||||
<span className={styles.pathSep} aria-hidden="true">›</span>
|
||||
<button type="button" className={styles.crumb} onClick={() => onNavigate(parent, true)}>
|
||||
<DomainName name={parent} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{!atRoot && (
|
||||
<li>
|
||||
<span className={styles.pathSep} aria-hidden="true">›</span>
|
||||
<span className={styles.crumbHere} aria-current="location">
|
||||
<DomainName name={label} />
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
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 (
|
||||
<ul className={styles.chips}>
|
||||
{zones.map((z) => {
|
||||
const own = suffix !== '' && z.endsWith(suffix) ? z.slice(0, -suffix.length) : z
|
||||
const rest = own === z ? '' : suffix
|
||||
return (
|
||||
<li key={z}>
|
||||
<button type="button" className={styles.chipNode} onClick={() => onNavigate(z)}>
|
||||
<span className={styles.chipName}>
|
||||
<DomainName name={own} />
|
||||
{rest !== '' && <span className={styles.chipRest}><DomainName name={rest} /></span>}
|
||||
</span>
|
||||
<Icon name="chevronRight" size={12} className={styles.chipCh} />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -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<Record<string, Level>>({})
|
||||
const [open, setOpen] = useState<Set<string>>(() => new Set(['']))
|
||||
const seen = useRef<Record<string, Level>>({})
|
||||
const lastEpoch = useRef(epoch)
|
||||
const own = useRef(current)
|
||||
own.current = current
|
||||
const levelsNow: Record<string, Level> = 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 <div className={styles.lvl}><Loading announce={false} /></div>
|
||||
if (level === 'failed') {
|
||||
return (
|
||||
<div className={styles.lvl}>
|
||||
<button type="button" className={styles.node} onClick={() => void read(parent)}>
|
||||
<span className={styles.label}>Could not read this node. Retry</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (level.length === 0) return null
|
||||
return (
|
||||
<div className={styles.lvl} role="group">
|
||||
{level.map((z) => {
|
||||
const isOpen = open.has(z)
|
||||
return (
|
||||
<div key={z} role="treeitem" aria-expanded={isOpen} aria-selected={z === selected}>
|
||||
<div className={styles.treeRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.treeToggle}
|
||||
aria-label={`${isOpen ? 'Collapse' : 'Expand'} ${z}`}
|
||||
onClick={() => toggle(z)}
|
||||
>
|
||||
<Icon name={isOpen ? 'chevronDown' : 'chevronRight'} size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.node}
|
||||
aria-current={z === selected ? 'true' : undefined}
|
||||
title={z}
|
||||
onClick={() => onSelect(z)}
|
||||
>
|
||||
<span className={styles.label}>{short(z, parent)}</span>
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && rows(z)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="tree" aria-label="Cached zones">
|
||||
<div className={styles.treeRow}>
|
||||
<button type="button" className={styles.treeToggle} aria-label={`${open.has('') ? 'Collapse' : 'Expand'} <ROOT>`} onClick={() => toggle('')}>
|
||||
<Icon name={open.has('') ? 'chevronDown' : 'chevronRight'} size={12} />
|
||||
</button>
|
||||
<button type="button" className={styles.node} aria-current={selected === '' ? 'true' : undefined} onClick={() => onSelect('')}>
|
||||
<span className={styles.label}><ROOT></span>
|
||||
</button>
|
||||
</div>
|
||||
{open.has('') && rows('')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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(<Cache token="t" />)
|
||||
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(<Cache token="t" nodes={NODES} clusterInitialised />)
|
||||
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 () => {
|
||||
|
||||
@@ -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(<Lists list={list} token="t" embedded={embedded} />)
|
||||
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(<Cache token="t" />)
|
||||
await screen.findByRole('status')
|
||||
expect(screen.getAllByText('Loading…')).toHaveLength(1)
|
||||
expect(screen.getAllByRole('status')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+55
-112
@@ -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 `<ROOT>` 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 && (
|
||||
<SectionHeader
|
||||
title={TITLE[list]}
|
||||
subtitle={isCache ? SUBTITLE.cache : undefined}
|
||||
actions={
|
||||
isCache ? (
|
||||
<Button variant="danger" disabled={busy} onClick={askFlushCache}>
|
||||
@@ -474,96 +484,11 @@ export function Lists({
|
||||
|
||||
{stale && <StaleData since={lastGood} onRetry={() => 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.
|
||||
*/
|
||||
<section className={styles.cachePanel} aria-label="Cache">
|
||||
<div className={styles.search}>
|
||||
<Field label={fieldName ?? 'Domain'}>
|
||||
{(id) => (
|
||||
<Input
|
||||
id={id}
|
||||
mono
|
||||
placeholder="example.com"
|
||||
value={field}
|
||||
onChange={(e) => setField(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') navigate(field)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button variant="primary" onClick={() => navigate(field)}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.where}>
|
||||
<Crumbs domain={domain} label={nodeTitle} onNavigate={navigate} />
|
||||
{/* 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 && (
|
||||
<div className={styles.countActs}>
|
||||
<Button size="sm" onClick={() => navigate(domain)}>
|
||||
Refresh
|
||||
</Button>
|
||||
{mayDelete && (
|
||||
<PermissionButton size="sm" variant="danger" disabled={busy} onClick={askDeleteCacheNode}>
|
||||
Delete
|
||||
</PermissionButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={styles.empty}><Loading /></div>
|
||||
) : (
|
||||
<>
|
||||
{/* 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 && (
|
||||
<div className={styles.children}>
|
||||
<span className={styles.childrenCount}>
|
||||
{zones.length === 1 ? '1 zone' : `${zones.length} zones`}
|
||||
</span>
|
||||
<Children domain={domain} zones={zones} onNavigate={navigate} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.count}>
|
||||
<span>
|
||||
{records.length} records at <span className={styles.mono}>{nodeTitle}</span>
|
||||
</span>
|
||||
</div>
|
||||
{records.length > 0 ? (
|
||||
<ResourceRecords records={records} withDnssec node={domain} />
|
||||
) : (
|
||||
<div className={styles.empty}>
|
||||
{/* "in the tree" left with the tree: the chips are right above. */}
|
||||
<Empty title="No records at this node">
|
||||
{zones.length > 0
|
||||
? 'This node only contains sub-domains. Open one to see its records.'
|
||||
: 'This node has no records and no sub-domains.'}
|
||||
</Empty>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
{(
|
||||
<div className={styles.split}>
|
||||
<div className={styles.tree}>
|
||||
<section className={styles.column} aria-label="Domain tree">
|
||||
<Frame dense className={styles.column} role="region" aria-label="Domain tree">
|
||||
<FramePanel className="flex min-h-0 flex-1 flex-col p-0">
|
||||
<div className={styles.browse}>
|
||||
{/* 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. */}
|
||||
<div className={styles.count}>
|
||||
<span>{zones.length === 1 ? '1 zone' : `${zones.length} zones`}</span>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<Tree
|
||||
domain={domain}
|
||||
domainIdn={node?.domainIdn}
|
||||
zones={zones}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
</div>
|
||||
{isCache ? (
|
||||
<div className={styles.body}>
|
||||
<CacheTree
|
||||
token={token}
|
||||
clusterNode={clusterNode}
|
||||
selected={domain}
|
||||
current={node == null ? null : { domain: node.domain, zones: node.zones }}
|
||||
epoch={treeEpoch}
|
||||
onSelect={navigate}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.count}>
|
||||
<span>{zones.length === 1 ? '1 zone' : `${zones.length} zones`}</span>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<Tree
|
||||
domain={domain}
|
||||
domainIdn={node?.domainIdn}
|
||||
zones={zones}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
|
||||
<div className={[styles.records, styles[list]].join(' ')}>
|
||||
<section className={styles.column} aria-label="Records">
|
||||
<Frame dense className={styles.column} role="region" aria-label="Records">
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div className={styles.identity}>
|
||||
<FrameHeader className={styles.identity}>
|
||||
<span className={styles.emblem}>
|
||||
<Icon name={list} size={15} />
|
||||
</span>
|
||||
@@ -645,7 +586,8 @@ export function Lists({
|
||||
<span className={styles.identityTitle}>{TITLE[list]}</span>
|
||||
<span className={styles.identitySub}>{SUBTITLE[list]}</span>
|
||||
</span>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex min-h-0 flex-1 flex-col p-0">
|
||||
|
||||
<Path domain={domain} label={nodeTitle} />
|
||||
|
||||
@@ -659,9 +601,10 @@ export function Lists({
|
||||
<div className={styles.count}>
|
||||
<span>
|
||||
{records.length} records at <span className={styles.mono}>{nodeTitle}</span>
|
||||
{isCache && zones.length > 0 && ` \u00b7 ${zones.length === 1 ? '1 sub-zone' : `${zones.length} sub-zones`}`}
|
||||
</span>
|
||||
<div className={styles.countActs}>
|
||||
<Button size="sm" onClick={() => navigate(domain)}>
|
||||
<Button size="sm" onClick={() => { setTreeEpoch((v) => v + 1); navigate(domain) }}>
|
||||
Refresh
|
||||
</Button>
|
||||
{/*
|
||||
@@ -677,7 +620,7 @@ export function Lists({
|
||||
variant="danger"
|
||||
disabled={busy}
|
||||
permission={canDelete ? undefined : `${TITLE[list]}.canDelete`}
|
||||
onClick={askDeleteDomain}
|
||||
onClick={isCache ? askDeleteCacheNode : askDeleteDomain}
|
||||
>
|
||||
Delete
|
||||
</PermissionButton>
|
||||
@@ -692,7 +635,7 @@ export function Lists({
|
||||
`role="status"` for one request read it out twice. */
|
||||
<div className={styles.empty}><Loading announce={false} /></div>
|
||||
) : records.length > 0 ? (
|
||||
<ResourceRecords records={records} withDnssec={false} node={domain} />
|
||||
<ResourceRecords records={records} withDnssec={isCache} node={domain} />
|
||||
) : (
|
||||
<div className={styles.empty}>
|
||||
<Empty title="No records at this node">
|
||||
@@ -703,10 +646,10 @@ export function Lists({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
<Confirm
|
||||
|
||||
@@ -105,7 +105,7 @@ its edge together).
|
||||
display: flex; align-items: center; gap: var(--s-6) var(--s-8); flex-wrap: wrap;
|
||||
min-height: var(--s-11);
|
||||
font-size: var(--t-1); font-weight: 600; letter-spacing: 0.09em;
|
||||
text-transform: uppercase; color: var(--mute);
|
||||
color: var(--mute);
|
||||
}
|
||||
.groupCtl { display: flex; align-items: flex-end; gap: var(--s-7); flex-wrap: wrap; }
|
||||
.field { display: flex; flex-direction: column; gap: var(--s-3); width: var(--ql-field, auto); }
|
||||
@@ -268,7 +268,7 @@ to become a summary of what is in front of you.
|
||||
}
|
||||
.legendTitle {
|
||||
font-size: var(--t-1); font-weight: 600; letter-spacing: 0.09em;
|
||||
text-transform: uppercase; color: var(--mute);
|
||||
color: var(--mute);
|
||||
}
|
||||
.legendItem { display: inline-flex; align-items: center; gap: var(--s-5); font-size: var(--t-2); }
|
||||
/* Each entry is the pill itself, the same one the Response Type cell carries,
|
||||
|
||||
@@ -11,7 +11,7 @@ the System / Light / Dark theme without a second palette.
|
||||
@import 'tailwindcss/utilities.css' layer(utilities);
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant dark (&:not(:is([data-theme='light'], [data-theme='light'] *)));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
|
||||
@@ -657,11 +657,11 @@ wherever the attribute says `light`.
|
||||
In a table, `height` on a cell is a MINIMUM: a header that genuinely needs two
|
||||
lines still grows.
|
||||
*/
|
||||
--row-header: 36px;
|
||||
--row-header: 40px;
|
||||
|
||||
--text-size: 10.5px;
|
||||
--text-weight: 600;
|
||||
--text-tracking: 0.05em;
|
||||
--text-size: 12px;
|
||||
--text-weight: 500;
|
||||
--text-tracking: 0;
|
||||
|
||||
--t-1: 12px; /* small-caps label, table header, pill (10) */
|
||||
--t-2: 12px; /* metadata, help under a field */
|
||||
|
||||
@@ -19,12 +19,13 @@ Administration, with the same values copied by hand.
|
||||
|
||||
.table th {
|
||||
text-align: left;
|
||||
font-size: var(--text-size);
|
||||
font-weight: var(--text-weight);
|
||||
letter-spacing: var(--text-tracking);
|
||||
text-transform: uppercase;
|
||||
/* The DataGrid head the other tables wear: a caption, not small caps. */
|
||||
font-size: var(--t-1);
|
||||
font-weight: 500;
|
||||
color: var(--mute);
|
||||
padding: 0 var(--s-5) var(--s-4) 0;
|
||||
height: var(--row-header);
|
||||
padding: 0 var(--s-5) 0 var(--s-5);
|
||||
background: color-mix(in oklab, var(--pan2) 30%, transparent);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ magazine kicker, which is decoration, and this is navigation.
|
||||
margin: 0;
|
||||
font-size: var(--t-7);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function Segmented<T extends string>({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex max-w-full min-w-0 gap-0.5 overflow-x-auto rounded-lg bg-muted p-0.5 [scrollbar-width:none]"
|
||||
className="inline-flex max-w-full min-w-0 gap-0.5 overflow-x-auto rounded-lg bg-muted p-[3px] [scrollbar-width:none]"
|
||||
role={asTabs ? 'tablist' : 'group'}
|
||||
aria-label={label}
|
||||
>
|
||||
@@ -41,7 +41,7 @@ export function Segmented<T extends string>({
|
||||
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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user