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>` без хуков для них. Режим карточек на телефоне и подписи колонок держатся на тех же атрибутах. |
|
| 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 и отсутствие коммита).
|
Сборка, `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
|
needed, because the side panel is a closed drawer and its trigger has to be
|
||||||
outside it.
|
outside it.
|
||||||
*/
|
*/
|
||||||
.rtop { display:none; }
|
|
||||||
/* At the foot of the side panel, hence `margin-top:auto`: that is what pushes it
|
/* At the foot of the side panel, hence `margin-top:auto`: that is what pushes it
|
||||||
down however short the list of sections is. */
|
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); }
|
.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. */
|
/* 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
|
/* 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
|
content: it marks its root and gets a flex column to grow into, down to the
|
||||||
footer. No other screen is touched. */
|
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 { display: flex; align-items: center; gap: var(--s-6); margin-bottom: var(--s-8); }
|
||||||
.chromeSlot:empty { display: none; }
|
.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 { Icon, type IconName } from '../ui/Icon'
|
||||||
import { publicUrl } from './base'
|
import { publicUrl } from './base'
|
||||||
import { Menu } from '../ui/Menu'
|
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 { ThisServerName } from '../ui/ClusterNodeSelect'
|
||||||
import { FooterLinks } from '../ui/FooterLinks'
|
import { FooterLinks } from '../ui/FooterLinks'
|
||||||
import { Versions } from './Versions'
|
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
|
with a gap where the kind of task already changed. What you operate daily, what you
|
||||||
configure and what you consult.
|
configure and what you consult.
|
||||||
*/
|
*/
|
||||||
const GROUPS: string[][] = [
|
const GROUPS: { name: string; ids: string[] }[] = [
|
||||||
['dashboard', 'zones', 'cache', 'blocking', 'apps', 'dnsclient'],
|
{ name: 'DNS', ids: ['dashboard', 'zones', 'cache', 'blocking', 'apps', 'dnsclient'] },
|
||||||
['settings', 'dhcp', 'admin'],
|
{ name: 'Server', ids: ['settings', 'dhcp', 'admin', 'logs', 'about'] },
|
||||||
['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> = {
|
const ICONS: Record<string, IconName> = {
|
||||||
dashboard: 'dashboard', zones: 'zones', cache: 'cache', blocking: 'blocked',
|
dashboard: 'dashboard', zones: 'zones', cache: 'cache', blocking: 'blocked',
|
||||||
apps: 'apps', dnsclient: 'dnsclient', settings: 'settings',
|
apps: 'apps', dnsclient: 'dnsclient', settings: 'settings',
|
||||||
@@ -120,6 +139,33 @@ export function Shell({
|
|||||||
})
|
})
|
||||||
const [active, setActive] = useState(() => initialRoute?.section ?? sections[0]?.id ?? 'about')
|
const [active, setActive] = useState(() => initialRoute?.section ?? sections[0]?.id ?? 'about')
|
||||||
const [drawer, setDrawer] = useState(false)
|
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)
|
const [modal, setModal] = useState<ModalId | null>(forcePasswordChange ? 'password' : null)
|
||||||
/* The factory password travels only with the opening the login caused; from the
|
/* The factory password travels only with the opening the login caused; from the
|
||||||
user menu `showChangePasswordModal()` is called with nothing (main.js). */
|
user menu `showChangePasswordModal()` is called with nothing (main.js). */
|
||||||
@@ -299,12 +345,61 @@ export function Shell({
|
|||||||
return () => window.removeEventListener('popstate', onChanged)
|
return () => window.removeEventListener('popstate', onChanged)
|
||||||
}, [sections])
|
}, [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 (
|
return (
|
||||||
<HandoffContext.Provider value={handoff}>
|
<HandoffContext.Provider value={handoff}>
|
||||||
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
|
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
|
||||||
<SlotProvider>
|
<SlotProvider>
|
||||||
{(slot) => (
|
{(slot) => (
|
||||||
<div className={styles.shell}>
|
<div className={styles.shell} data-rail={rail}>
|
||||||
{drawer && (
|
{drawer && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -316,8 +411,15 @@ export function Shell({
|
|||||||
|
|
||||||
<aside className={styles.side} data-open={drawer}>
|
<aside className={styles.side} data-open={drawer}>
|
||||||
<div className={styles.sbrand}>
|
<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>
|
</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">
|
<nav className={styles.slist} role="navigation" aria-label="Sections">
|
||||||
{/*
|
{/*
|
||||||
This is a navigation menu, and until now it claimed to be a `tablist`.
|
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,
|
`aria-current="page"` is reserved for ONE thing: the page you are on,
|
||||||
which is the sub-section when there is one.
|
which is the sub-section when there is one.
|
||||||
*/}
|
*/}
|
||||||
{GROUPS.map((group, g) => (
|
{pinned.length > 0 && (
|
||||||
<div className={styles.group} key={g}>
|
<div className={styles.group}>
|
||||||
{sections.filter((sec) => group.includes(sec.id)).map((sec) => {
|
<div className={styles.grph}>Pinned</div>
|
||||||
const active = sec.id === current?.id
|
{pinned.map((sec) => navRow(sec, 'pin'))}
|
||||||
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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</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>
|
</nav>
|
||||||
|
|
||||||
{/* The server and the account, at the foot. When narrow you reach it via the 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}
|
className={styles.hamburger}
|
||||||
aria-label="Menu"
|
aria-label="Menu"
|
||||||
aria-expanded={drawer}
|
aria-expanded={drawer}
|
||||||
onClick={() => setDrawer((v) => !v)}
|
onClick={() => (window.matchMedia('(max-width: 900px)').matches ? setDrawer((v) => !v) : toggleRail())}
|
||||||
>
|
>
|
||||||
<Icon name="menu" size={18} />
|
<Icon name="menu" size={18} />
|
||||||
</button>
|
</button>
|
||||||
<span className={styles.topBrand}>
|
<div className={styles.crumbs} role="group" aria-label="Breadcrumb">
|
||||||
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} /> Technitium
|
{crumbs.map((c, i) => (
|
||||||
</span>
|
<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>
|
</header>
|
||||||
|
|
||||||
{/* Neither `tabpanel` nor `aria-labelledby`: ever since each section has its
|
{/* Neither `tabpanel` nor `aria-labelledby`: ever since each section has its
|
||||||
@@ -606,6 +708,32 @@ export function Shell({
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</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
|
<MyProfile
|
||||||
open={modal === 'profile'}
|
open={modal === 'profile'}
|
||||||
onOpenChange={(o) => setModal(o ? 'profile' : null)}
|
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); }
|
.kpiPaired { display: grid; grid-template-columns: repeat(2, max-content); column-gap: var(--s-9); }
|
||||||
.kpiRow { display: contents; }
|
.kpiRow { display: contents; }
|
||||||
.kpiPaired > .kpiLabel { grid-column: 1 / -1; }
|
.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,
|
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. */
|
/* The ring, with the share written in its hole. */
|
||||||
.ring { position: relative; }
|
.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; }
|
.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
|
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); }
|
.breakdown { padding-top: var(--s-8); }
|
||||||
.parts { display:flex; flex-direction:column; gap: var(--s-8); padding: 0 var(--s-8) var(--s-7); }
|
.parts { display:flex; flex-direction:column; gap: var(--s-8); padding: 0 var(--s-8) var(--s-7); }
|
||||||
.part { min-width: 0; }
|
.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; }
|
.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; }
|
.segment { display:block; flex-basis: 0; min-width: var(--s-1); height: 100%; padding: 0; border: 0; border-radius: 0; }
|
||||||
button.segment { cursor: pointer; }
|
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;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.split > * { min-width: 0; display: flex; flex-direction: column; }
|
.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); }
|
.body { flex: 1; min-height: 0; overflow: auto; padding: var(--s-8); }
|
||||||
.bodyFlush { flex: 1; min-height: 0; overflow: auto; }
|
.bodyFlush { flex: 1; min-height: 0; overflow: auto; }
|
||||||
|
|
||||||
@@ -57,15 +57,14 @@ seven.
|
|||||||
.cache { --kind: var(--ch-cache); }
|
.cache { --kind: var(--ch-cache); }
|
||||||
.allowed { --kind: var(--ch-ok); }
|
.allowed { --kind: var(--ch-ok); }
|
||||||
.blocked { --kind: var(--ch-block); }
|
.blocked { --kind: var(--ch-block); }
|
||||||
.records > .column { border-top: 3px solid var(--kind); }
|
|
||||||
|
|
||||||
.identity {
|
.identity {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
gap: var(--s-6);
|
gap: var(--s-6);
|
||||||
padding: var(--s-6) var(--s-9);
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
background: var(--pan);
|
|
||||||
}
|
}
|
||||||
.emblem {
|
.emblem {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
@@ -105,7 +104,7 @@ second clickable path would be behaviour upstream does not have. So it is a
|
|||||||
}
|
}
|
||||||
.pathLabel {
|
.pathLabel {
|
||||||
font: 650 var(--t-1) var(--font-ui);
|
font: 650 var(--t-1) var(--font-ui);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
margin-right: var(--s-1);
|
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; }
|
.pathSep { color: var(--faint); opacity: 0.6; }
|
||||||
.pathHere { color: var(--ink); font-weight: 650; }
|
.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. */
|
/* 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 { 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; }
|
.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); }
|
.split { grid-template-columns: minmax(0, 1fr); gap: var(--s-8); }
|
||||||
.tree .body { max-height: 250px; }
|
.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 spy = vi.spyOn(api, 'flushCache').mockResolvedValue(OK)
|
||||||
const list = withNode(node({ domain: 'home.test', records: [REG_CACHE] }))
|
const list = withNode(node({ domain: 'home.test', records: [REG_CACHE] }))
|
||||||
render(<Cache token="t" />)
|
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')
|
await confirm('Flush Cache')
|
||||||
expect(spy).toHaveBeenCalled()
|
expect(spy).toHaveBeenCalled()
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText('DNS Server cache was flushed successfully.'),
|
await screen.findByText('DNS Server cache was flushed successfully.'),
|
||||||
).toBeInTheDocument()
|
).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
|
/* 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 />)
|
render(<Cache token="t" nodes={NODES} clusterInitialised />)
|
||||||
await screen.findByRole('button', { name: 'Delete' })
|
await screen.findByRole('button', { name: 'Delete' })
|
||||||
await pickB()
|
await pickB()
|
||||||
const last = list.mock.calls[list.mock.calls.length - 1]
|
expect(list.mock.calls.some((c) => c[2] === 'home.test' && c[4] === 'b.example')).toBe(true)
|
||||||
expect(last[2]).toBe('home.test')
|
|
||||||
expect(last[4]).toBe('b.example')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Flush and Delete go to the chosen node', async () => {
|
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) => {
|
])('%#: the identity band names the screen and what it holds', async (list, embedded, title, sub) => {
|
||||||
withNode(node())
|
withNode(node())
|
||||||
render(<Lists list={list} token="t" embedded={embedded} />)
|
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)
|
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)
|
vi.spyOn(client, 'apiRequest').mockImplementation(() => pending() as never)
|
||||||
render(<Cache token="t" />)
|
render(<Cache token="t" />)
|
||||||
await screen.findByRole('status')
|
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 { StaleData } from '../StaleData'
|
||||||
import { Tree } from './Tree'
|
import { Tree } from './Tree'
|
||||||
import { Path } from './Path'
|
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 { ResourceRecords } from './Records'
|
||||||
import styles from './Lists.module.css'
|
import styles from './Lists.module.css'
|
||||||
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||||||
@@ -280,6 +281,9 @@ export function Lists({
|
|||||||
primary.current = primaryNodeName(nodes, clusterInitialised)
|
primary.current = primaryNodeName(nodes, clusterInitialised)
|
||||||
}, [nodes, clusterInitialised])
|
}, [nodes, clusterInitialised])
|
||||||
|
|
||||||
|
/* Bumped whenever what the cache tree has read is out of date. */
|
||||||
|
const [treeEpoch, setTreeEpoch] = useState(0)
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
async (domain: string, up?: boolean, fromPrimary = false) => {
|
async (domain: string, up?: boolean, fromPrimary = false) => {
|
||||||
/* other-zones.js:269 and 434: after a change, Allowed and Blocked are read
|
/* 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.',
|
text: 'DNS Server cache was flushed successfully.',
|
||||||
},
|
},
|
||||||
// Upstream leaves the list at `<ROOT>` and hides the viewer.
|
// 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!',
|
title: 'Deleted!',
|
||||||
text: `Cached zone '${nodeTitle}' was deleted successfully.`,
|
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) => {
|
onChange={(n) => {
|
||||||
cacheNode.current = n
|
cacheNode.current = n
|
||||||
setClusterNode(n)
|
setClusterNode(n)
|
||||||
|
setTreeEpoch((v) => v + 1)
|
||||||
void load(domain)
|
void load(domain)
|
||||||
}}
|
}}
|
||||||
label="Cluster Node"
|
label="Cluster Node"
|
||||||
@@ -459,7 +470,6 @@ export function Lists({
|
|||||||
{!embedded && (
|
{!embedded && (
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={TITLE[list]}
|
title={TITLE[list]}
|
||||||
subtitle={isCache ? SUBTITLE.cache : undefined}
|
|
||||||
actions={
|
actions={
|
||||||
isCache ? (
|
isCache ? (
|
||||||
<Button variant="danger" disabled={busy} onClick={askFlushCache}>
|
<Button variant="danger" disabled={busy} onClick={askFlushCache}>
|
||||||
@@ -474,96 +484,11 @@ export function Lists({
|
|||||||
|
|
||||||
{stale && <StaleData since={lastGood} onRetry={() => void load(node?.domain ?? '')} />}
|
{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.split}>
|
||||||
<div className={styles.tree}>
|
<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}>
|
<div className={styles.browse}>
|
||||||
{/* The field is called "Domain" and the button "Browse". Upstream puts
|
{/* The field is called "Domain" and the button "Browse". Upstream puts
|
||||||
no label here (only the `placeholder`), so this one is an
|
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
|
{/* The count lives in the count bar, like the records one: in the
|
||||||
header it looked exactly like a status pill. */}
|
header it looked exactly like a status pill. */}
|
||||||
<div className={styles.count}>
|
{isCache ? (
|
||||||
<span>{zones.length === 1 ? '1 zone' : `${zones.length} zones`}</span>
|
<div className={styles.body}>
|
||||||
</div>
|
<CacheTree
|
||||||
<div className={styles.body}>
|
token={token}
|
||||||
<Tree
|
clusterNode={clusterNode}
|
||||||
domain={domain}
|
selected={domain}
|
||||||
domainIdn={node?.domainIdn}
|
current={node == null ? null : { domain: node.domain, zones: node.zones }}
|
||||||
zones={zones}
|
epoch={treeEpoch}
|
||||||
onNavigate={navigate}
|
onSelect={navigate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className={[styles.records, styles[list]].join(' ')}>
|
<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
|
Cache is what the server resolved and stored; Allowed and Blocked are
|
||||||
the administrator's decisions. Three screens, one component, and until
|
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
|
rule `theme/tokens.css` already applies to itself in the seven
|
||||||
conditions of Logs: colour reinforces, it is never the only channel.
|
conditions of Logs: colour reinforces, it is never the only channel.
|
||||||
*/}
|
*/}
|
||||||
<div className={styles.identity}>
|
<FrameHeader className={styles.identity}>
|
||||||
<span className={styles.emblem}>
|
<span className={styles.emblem}>
|
||||||
<Icon name={list} size={15} />
|
<Icon name={list} size={15} />
|
||||||
</span>
|
</span>
|
||||||
@@ -645,7 +586,8 @@ export function Lists({
|
|||||||
<span className={styles.identityTitle}>{TITLE[list]}</span>
|
<span className={styles.identityTitle}>{TITLE[list]}</span>
|
||||||
<span className={styles.identitySub}>{SUBTITLE[list]}</span>
|
<span className={styles.identitySub}>{SUBTITLE[list]}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex min-h-0 flex-1 flex-col p-0">
|
||||||
|
|
||||||
<Path domain={domain} label={nodeTitle} />
|
<Path domain={domain} label={nodeTitle} />
|
||||||
|
|
||||||
@@ -659,9 +601,10 @@ export function Lists({
|
|||||||
<div className={styles.count}>
|
<div className={styles.count}>
|
||||||
<span>
|
<span>
|
||||||
{records.length} records at <span className={styles.mono}>{nodeTitle}</span>
|
{records.length} records at <span className={styles.mono}>{nodeTitle}</span>
|
||||||
|
{isCache && zones.length > 0 && ` \u00b7 ${zones.length === 1 ? '1 sub-zone' : `${zones.length} sub-zones`}`}
|
||||||
</span>
|
</span>
|
||||||
<div className={styles.countActs}>
|
<div className={styles.countActs}>
|
||||||
<Button size="sm" onClick={() => navigate(domain)}>
|
<Button size="sm" onClick={() => { setTreeEpoch((v) => v + 1); navigate(domain) }}>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
{/*
|
{/*
|
||||||
@@ -677,7 +620,7 @@ export function Lists({
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
permission={canDelete ? undefined : `${TITLE[list]}.canDelete`}
|
permission={canDelete ? undefined : `${TITLE[list]}.canDelete`}
|
||||||
onClick={askDeleteDomain}
|
onClick={isCache ? askDeleteCacheNode : askDeleteDomain}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
@@ -692,7 +635,7 @@ export function Lists({
|
|||||||
`role="status"` for one request read it out twice. */
|
`role="status"` for one request read it out twice. */
|
||||||
<div className={styles.empty}><Loading announce={false} /></div>
|
<div className={styles.empty}><Loading announce={false} /></div>
|
||||||
) : records.length > 0 ? (
|
) : records.length > 0 ? (
|
||||||
<ResourceRecords records={records} withDnssec={false} node={domain} />
|
<ResourceRecords records={records} withDnssec={isCache} node={domain} />
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
<Empty title="No records at this node">
|
<Empty title="No records at this node">
|
||||||
@@ -703,10 +646,10 @@ export function Lists({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Confirm
|
<Confirm
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ its edge together).
|
|||||||
display: flex; align-items: center; gap: var(--s-6) var(--s-8); flex-wrap: wrap;
|
display: flex; align-items: center; gap: var(--s-6) var(--s-8); flex-wrap: wrap;
|
||||||
min-height: var(--s-11);
|
min-height: var(--s-11);
|
||||||
font-size: var(--t-1); font-weight: 600; letter-spacing: 0.09em;
|
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; }
|
.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); }
|
.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 {
|
.legendTitle {
|
||||||
font-size: var(--t-1); font-weight: 600; letter-spacing: 0.09em;
|
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); }
|
.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,
|
/* 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 'tailwindcss/utilities.css' layer(utilities);
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:not(:is([data-theme='light'], [data-theme='light'] *)));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--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
|
In a table, `height` on a cell is a MINIMUM: a header that genuinely needs two
|
||||||
lines still grows.
|
lines still grows.
|
||||||
*/
|
*/
|
||||||
--row-header: 36px;
|
--row-header: 40px;
|
||||||
|
|
||||||
--text-size: 10.5px;
|
--text-size: 12px;
|
||||||
--text-weight: 600;
|
--text-weight: 500;
|
||||||
--text-tracking: 0.05em;
|
--text-tracking: 0;
|
||||||
|
|
||||||
--t-1: 12px; /* small-caps label, table header, pill (10) */
|
--t-1: 12px; /* small-caps label, table header, pill (10) */
|
||||||
--t-2: 12px; /* metadata, help under a field */
|
--t-2: 12px; /* metadata, help under a field */
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ Administration, with the same values copied by hand.
|
|||||||
|
|
||||||
.table th {
|
.table th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: var(--text-size);
|
/* The DataGrid head the other tables wear: a caption, not small caps. */
|
||||||
font-weight: var(--text-weight);
|
font-size: var(--t-1);
|
||||||
letter-spacing: var(--text-tracking);
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--mute);
|
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);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ magazine kicker, which is decoration, and this is navigation.
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--t-7);
|
font-size: var(--t-7);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.025em;
|
letter-spacing: -0.01em;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function Segmented<T extends string>({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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'}
|
role={asTabs ? 'tablist' : 'group'}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
@@ -41,7 +41,7 @@ export function Segmented<T extends string>({
|
|||||||
className={cn(
|
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',
|
'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',
|
'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}
|
role={asTabs ? 'tab' : undefined}
|
||||||
aria-selected={asTabs ? o.id === active : 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);
|
gap: var(--s-6);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: var(--field-height);
|
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: 1px solid var(--line-field);
|
||||||
border-radius: var(--r-3);
|
border-radius: var(--r-3);
|
||||||
background-color: var(--field);
|
background-color: var(--field);
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ the tint.
|
|||||||
/* The last row carries no line: the panel border closes it. */
|
/* The last row carries no line: the panel border closes it. */
|
||||||
.table tbody tr:last-child td { border-bottom: 0; }
|
.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); }
|
.table tbody tr:hover { background: var(--hover); }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -383,6 +383,7 @@ line and count twice.
|
|||||||
*/
|
*/
|
||||||
.stack[data-stacked] > tbody > tr,
|
.stack[data-stacked] > tbody > tr,
|
||||||
.stack[data-stacked] > tfoot > tr {
|
.stack[data-stacked] > tfoot > tr {
|
||||||
|
height: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: flex-start;
|
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-size: var(--text-size);
|
||||||
font-weight: var(--text-weight);
|
font-weight: var(--text-weight);
|
||||||
letter-spacing: var(--text-tracking);
|
letter-spacing: var(--text-tracking);
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--mute);
|
color: var(--mute);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,6 @@ It is applied with `composes:`, not by copying it.
|
|||||||
font-size: var(--text-size);
|
font-size: var(--text-size);
|
||||||
font-weight: var(--text-weight);
|
font-weight: var(--text-weight);
|
||||||
letter-spacing: var(--text-tracking);
|
letter-spacing: var(--text-tracking);
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--mute);
|
color: var(--mute);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user