Match the Technitium canvas: palette, shell, KPI strips, framed tables
CI / check (push) Failing after 37s
CI / image (push) Skipped

The live UI did not follow the canvas because its base layer differed: dark
palette in hex (now the canvas oklch values), 13 px body text, flat Frame panels.

- Tokens: canvas palette, 14 px body, 1.4 line height; Frame without `dense`
  gets the 3 px ring; Panel takes a description line.
- Shell: 56 px brand button with version, 11 px group headings, user card at the
  foot, versions moved into the header status popover, Home / group / section
  crumbs, amber "Attention" status when blocking is off or paused.
- New KpiStrip and PageTabs; Dashboard KPIs in three frames and a Refresh button.
- Zones: KPI strip, tab with count, views, framed table with the canvas columns.
- Apps: Installed / App Store tabs and a grid instead of cards (store inline).
- Blocking: state banner and tab counts; one "Blocking" title.
- Settings: actions in the header, label-and-help rows with the control on the right.
- DHCP, Administration, DNS Client, Logs, Cache, About: subtitles, framed tables,
  KPI strips where the data exists; Login cover with the three-point list.
- docs/compose.shnt.example.yaml: production compose with the console image.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 12:47:27 +07:00
1 parent ea98a33b25
commit 87281a87d3
53 files changed
+1011 -487

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
version: "3.8"
services:
traefik:
image: traefik:v3
container_name: traefik
restart: unless-stopped
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=dns-net"
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.websecure.http3"
- "--entrypoints.dot.address=:853"
- "--entrypoints.doq.address=:853/udp"
- "--entrypoints.cluster.address=:53443"
- "--certificatesresolvers.myresolver.acme.dnschallenge=true"
- "--certificatesresolvers.myresolver.acme.dnschallenge.provider=cloudflare"
- "--certificatesresolvers.myresolver.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53"
- "--certificatesresolvers.myresolver.acme.dnschallenge.delaybeforecheck=60s"
- "[email protected]"
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
environment:
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
ports:
- target: 80
published: 80
protocol: tcp
mode: host
- target: 443
published: 443
protocol: tcp
mode: host
- target: 443
published: 443
protocol: udp
mode: host
- target: 853
published: 853
protocol: tcp
mode: host
- target: 853
published: 853
protocol: udp
mode: host
- target: 53443
published: 53443
protocol: tcp
mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
- ./traefik/dynamic:/etc/traefik/dynamic:ro
networks:
- dns-net
technitium-smart:
image: technitium/dns-server:latest
container_name: technitium-smart
restart: unless-stopped
networks:
- dns-net
cap_add:
- NET_ADMIN
- NET_RAW
- NET_BIND_SERVICE
environment:
- DNS_SERVER_ADMIN_PASSWORD=${DNS_SERVER_ADMIN_PASSWORD}
- DNS_SERVER_ENABLE_DNS_OVER_HTTP=true
- DNS_SERVER_ENABLE_DNS_OVER_TLS=false
- DNS_SERVER_WEB_SERVICE_ENABLE_HTTPS=true
- DNS_SERVER_WEB_SERVICE_HTTPS_PORT=53443
- DNS_SERVER_WEB_SERVICE_USE_SELF_SIGNED_CERT=true
# Новая консоль: папку со статикой отдаёт том technitium-console (нужен Technitium 15.5+)
- DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH=/opt/technitium-console
- TZ=Asia/Novosibirsk
- ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
- DOTNET_SYSTEM_NET_SOCKETS_INLINE_RECEIVE=1
volumes:
- technitium-data:/etc/dns
- technitium-console:/opt/technitium-console:ro
# AXFR зоны кластера (TLSA). Если 53 уже слушает не этот контейнер — удалите блок.
# Файрволом пускайте TCP/53 только с IP соседних нод.
# ports:
# - target: 53
# published: 53
# protocol: tcp
# mode: host
# - target: 53
# published: 53
# protocol: udp
# mode: host
labels:
- "traefik.enable=true"
- "traefik.docker.network=dns-net"
- "traefik.http.middlewares.old-panel-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.old-panel-headers.headers.customrequestheaders.X-Forwarded-Host=old.dns.shnt.top"
# DoH
- "traefik.http.routers.doh.rule=(Host(`dns.shnt.top`) || HostRegexp(`^[a-z0-9-]+\\.dns\\.shnt\\.top$`) || Host(`ndns.shnt.top`) || HostRegexp(`^[a-z0-9-]+\\.ndns\\.shnt\\.top$`)) && PathPrefix(`/dns-query`)"
- "traefik.http.routers.doh.entrypoints=websecure"
- "traefik.http.routers.doh.tls.certresolver=myresolver"
- "traefik.http.routers.doh.tls.domains[0].main=dns.shnt.top"
- "traefik.http.routers.doh.tls.domains[0].sans=*.dns.shnt.top"
- "traefik.http.routers.doh.tls.domains[1].main=ndns.shnt.top"
- "traefik.http.routers.doh.tls.domains[1].sans=*.ndns.shnt.top"
- "traefik.http.routers.doh.priority=1000"
- "traefik.http.routers.doh.service=doh-svc"
- "traefik.http.services.doh-svc.loadbalancer.server.port=8080"
- "traefik.http.services.doh-svc.loadbalancer.server.scheme=http"
# Панель
- "traefik.http.routers.panel-old.rule=Host(`dns.shnt.top`) || Host(`old.dns.shnt.top`) || Host(`ndns.shnt.top`) || HostRegexp(`^[a-z0-9-]+\\.ndns\\.shnt\\.top$`)"
- "traefik.http.routers.panel-old.entrypoints=websecure"
- "traefik.http.routers.panel-old.tls.certresolver=myresolver"
- "traefik.http.routers.panel-old.tls.domains[0].main=dns.shnt.top"
- "traefik.http.routers.panel-old.tls.domains[0].sans=*.dns.shnt.top"
- "traefik.http.routers.panel-old.tls.domains[1].main=ndns.shnt.top"
- "traefik.http.routers.panel-old.tls.domains[1].sans=*.ndns.shnt.top"
- "traefik.http.routers.panel-old.priority=10"
- "traefik.http.routers.panel-old.middlewares=old-panel-headers"
- "traefik.http.routers.panel-old.service=panel-old-svc"
- "traefik.http.services.panel-old-svc.loadbalancer.server.port=5380"
# DoT
- "traefik.tcp.routers.dot.rule=HostSNI(`dns.shnt.top`) || HostSNIRegexp(`^.+\\.dns\\.shnt\\.top$`) || HostSNI(`ndns.shnt.top`) || HostSNIRegexp(`^.+\\.ndns\\.shnt\\.top$`)"
- "traefik.tcp.routers.dot.entrypoints=dot"
- "traefik.tcp.routers.dot.tls=true"
- "traefik.tcp.routers.dot.tls.certresolver=myresolver"
- "traefik.tcp.routers.dot.tls.domains[0].main=dns.shnt.top"
- "traefik.tcp.routers.dot.tls.domains[0].sans=*.dns.shnt.top"
- "traefik.tcp.routers.dot.tls.domains[1].main=ndns.shnt.top"
- "traefik.tcp.routers.dot.tls.domains[1].sans=*.ndns.shnt.top"
- "traefik.tcp.routers.dot.tls.options=dot@file"
- "traefik.tcp.routers.dot.service=dot-svc"
- "traefik.tcp.services.dot-svc.loadbalancer.server.port=538"
- "traefik.tcp.services.dot-svc.loadbalancer.proxyprotocol.version=2"
# DoQ
- "traefik.udp.routers.doq.entrypoints=doq"
- "traefik.udp.routers.doq.service=doq-svc"
- "traefik.udp.services.doq-svc.loadbalancer.server.port=8530"
# Кластер: TLS passthrough на Technitium :53443 (без certresolver и Proxy Protocol)
- "traefik.tcp.routers.cluster.rule=HostSNI(`ndns.shnt.top`) || HostSNIRegexp(`^.+\\.ndns\\.shnt\\.top$`) || HostSNI(`dns.shnt.top`) || HostSNIRegexp(`^.+\\.dns\\.shnt\\.top$`)"
- "traefik.tcp.routers.cluster.entrypoints=cluster"
- "traefik.tcp.routers.cluster.tls.passthrough=true"
- "traefik.tcp.routers.cluster.service=cluster-svc"
- "traefik.tcp.services.cluster-svc.loadbalancer.server.port=53443"
# Копирует новую консоль в том technitium-console и завершается.
# Без labels Traefik: наружу этот контейнер ничего не отдаёт.
# Обновление: docker compose pull technitium-console && docker compose up -d technitium-console
technitium-console:
image: git.shx.one/denozord/technitium-console:latest
container_name: technitium-console
restart: "no"
volumes:
- technitium-console:/target
networks:
- dns-net
networks:
dns-net:
driver: bridge
name: dns-net
volumes:
technitium-data:
technitium-console:
+41 -2
View File
@@ -2,7 +2,7 @@
against 163 with tabs on top, and the second row of stacked tabs disappears. */
.shell { min-height:100vh; background:var(--bg); color:var(--ink); display:grid; grid-template-columns:240px 1fr; align-items:start; }
.side { background:var(--pan); border-right:1px solid var(--line); align-self:stretch; position:sticky; top:0; height:100vh; overflow:auto; display:flex; flex-direction:column; }
.sbrand { display:flex; align-items:center; gap: var(--s-6); padding: 0 var(--s-10); height:48px; border-bottom:1px solid var(--line); font-weight: 600; letter-spacing:-.01em; color:var(--ink); }
.sbrand { display:flex; align-items:center; gap: var(--s-5); padding: 0 var(--s-10); height:56px; border-bottom:1px solid var(--line); font-weight: 600; letter-spacing:-.01em; color:var(--ink); }
/*
Technitium's logo, not a "T" in an amber square.
@@ -11,7 +11,10 @@ The initial was our own invention: the logo exists, it travels in
someone else's product's mark with a letter of our own is exactly what a fork
must not do.
*/
.mark { width:22px; height:22px; flex:none; }
.mark { width:32px; height:32px; flex:none; border-radius: var(--r-3); background: var(--acc); padding: 4px; object-fit: contain; }
.bname { display:flex; flex-direction:column; min-width:0; line-height:1.2; }
.bname b { font-weight:600; font-size:14px; }
.bname small { font-weight:400; font-size:12px; color: var(--mute); }
/*
The items sat 1 px from each other with 7 px of vertical padding: the list read
as a block of text, not as twelve destinations. And there was no separation
@@ -244,3 +247,39 @@ star, and the header is always there: toggle, breadcrumbs and the server's statu
.shell[data-rail='true'] .s, .shell[data-rail='true'] .sbsearch, .shell[data-rail='true'] .sbrand { justify-content: flex-start; }
.status .lbl { display: none; }
}
/*
The canvas's geometry, to the pixel (`reui.css` of the Technitium canvas): a 240 px
sidebar with 8 px of padding, a 56 px brand button, 32 px rows in the foreground
colour, 11 px uppercase group headings, and a 48 px header on the page colour.
*/
.side { padding: var(--s-5); gap: 1px; background: var(--pan); }
.sbrand { height: auto; padding: var(--s-3); border: 0; border-radius: var(--r-3); gap: var(--s-5); }
.sbsearch { margin: var(--s-3) 2px var(--s-3); border-radius: var(--r-2); font-size: 13px; background: color-mix(in oklch, var(--pan2) 50%, transparent); }
.slist { padding: 0; gap: 1px; }
.group { gap: 1px; }
.group + .group { margin-top: 0; }
.grph { padding: 10px var(--s-5) var(--s-2); font: 600 11px/1.3 var(--font-ui); letter-spacing: .05em; color: color-mix(in oklch, var(--ink) 62%, transparent); }
.s { gap: var(--s-5); color: var(--ink); border-radius: var(--r-3); }
.s:hover { background: color-mix(in oklch, var(--pan2) 60%, transparent); }
.s[data-active='true'] { background: var(--pan2); }
.footSide { padding: var(--s-5) 0 0; border-top: 0; }
.rtop { height: 48px; padding: 6px var(--s-11); gap: 6px; background: var(--bg); box-shadow: none; }
.hamburger { border-color: transparent; color: var(--mute); border-radius: var(--r-3); }
.hamburger:hover { background: color-mix(in oklch, var(--pan2) 70%, transparent); color: var(--ink); }
.crumbs { font-size: 14px; }
.crumbs span { color: var(--mute); }
.crumbs span + span::before { color: var(--mute); }
.crumbs span[data-last='true'] { color: var(--ink); font-weight: 500; }
.status { border-radius: var(--r-3); font: 500 13px/1 var(--font-ui); }
.updown { margin-left: auto; color: var(--mute); display: inline-flex; }
.footSide :global(button), .footSide > button { text-align: left; }
.av { display:inline-flex; align-items:center; justify-content:center; flex:none; width:32px; height:32px; border-radius:50%; border:1px solid var(--line); background: var(--bg); color: var(--ink); font: 500 12px/1 var(--font-ui); }
.footSide { gap: 0; }
.footSide > div > button, .footSide > button { padding: var(--s-5); background: color-mix(in oklch, var(--pan2) 45%, transparent); border-radius: var(--r-3); font-weight: 600; gap: var(--s-5); justify-content: flex-start; }
.footSide > div > button > svg, .footSide > button > svg { margin-left: auto; }
.pop { width: 320px; }
/* Blocking off or paused: the canvas's amber "Attention" button. */
.status[data-attention='true'] { border-color: color-mix(in oklch, var(--warn) 40%, var(--line)); background: color-mix(in oklch, var(--warn) 12%, transparent); color: var(--ink); }
.status[data-attention='true'] .pdot { background: var(--warn); }
+26 -15
View File
@@ -392,7 +392,8 @@ export function Shell({
)
}
const crumbs = [info?.dnsServerDomain, current?.label, currentSub].filter((x): x is string => !!x)
const group = GROUPS.find((g) => current != null && g.ids.includes(current.id))?.name
const crumbs = ['Home', group, current?.label, currentSub].filter((x): x is string => !!x)
return (
<HandoffContext.Provider value={handoff}>
@@ -411,13 +412,16 @@ 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} />
<span className={styles.lbl}>Technitium</span>
<span className={`${styles.lbl} ${styles.sbtag}`}>console</span>
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={32} height={32} />
<span className={`${styles.lbl} ${styles.bname}`}>
<b>Technitium DNS</b>
<small>console {__CONSOLE_VERSION__}</small>
</span>
<span className={`${styles.lbl} ${styles.updown}`} aria-hidden="true"><Icon name="chevronDown" size={14} /></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>
<span className={styles.lbl}>Search and commands</span>
<kbd className={styles.lbl}>{'\u2318K'}</kbd>
</button>
<nav className={styles.slist} role="navigation" aria-label="Sections">
@@ -486,14 +490,13 @@ export function Shell({
domain heads it because the block is about that server, which is the
pairing upstream makes in its panel title.
*/}
<Versions
token={session.token}
serverVersion={info?.version}
domain={info?.dnsServerDomain}
markHidden={markHidden}
canCheckConsole={permissions?.DnsClient?.canView === true}
/>
<Menu label={displayName} text={displayName} anchor="left" asRow>
<Menu
label={displayName}
text={displayName}
anchor="left"
asRow
lead={<span className={styles.av} aria-hidden="true">{displayName.slice(0, 2).toUpperCase()}</span>}
>
{(close) => (
<>
<button type="button" onClick={() => { close(); open('profile') }}>
@@ -571,9 +574,9 @@ export function Shell({
))}
</div>
<Popover>
<PopoverTrigger className={styles.status} aria-label="Server status">
<PopoverTrigger className={styles.status} data-attention={blockingNow != null} aria-label="Server status">
<i className={styles.pdot} data-tone={blockingNow?.tone ?? 'ok'} />
<span className={styles.lbl}>{info?.dnsServerDomain ?? 'Server'}</span>
<span className={styles.lbl}>{blockingNow != null ? 'Attention' : (info?.dnsServerDomain ?? 'Server')}</span>
</PopoverTrigger>
<PopoverContent align="end" className={styles.pop}>
<dl>
@@ -582,6 +585,14 @@ export function Shell({
<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>
<Versions
token={session.token}
serverVersion={info?.version}
domain={info?.dnsServerDomain}
markHidden={markHidden}
canCheckConsole={permissions?.DnsClient?.canView === true}
/>
</PopoverContent>
</Popover>
</header>
+2 -1
View File
@@ -27,7 +27,8 @@ above it below. The form's side keeps what it always had: the card centred on
}
.card {
composes: panel from '../ui/Panel.module.css';
width: 340px;
width: 420px;
max-width: 100%;
padding: var(--s-11) var(--s-11);
}
/*
+23 -90
View File
@@ -1,101 +1,34 @@
/*
The login cover (OURS, see `LoginCover.tsx`). The social banner, redrawn with the
console's tokens so it follows the theme: `--field` is the deepest surface each
theme has (darker than the page in dark, lighter than it in light), which is what
separates the cover from the form's side in both. Every text token drawn on it is
measured on `field` by `dev/theme-contrast.mjs`.
Three states, set by the width alone:
above 960 a column beside the form, the amber rule down its left edge
560 to 960 a header above the form, the rule along its top
under 560 a compact header: name and headline, no answer block, so the form
starts within the first third of a phone's screen
The login cover (OURS, see `LoginCover.tsx`): the canvas's left column. A name with
its version, a headline, one sentence and a framed list of three things. On the page
colour, so it follows the theme; the card of the form sits beside it. Stacked above
the form below 960 px, where the list goes.
*/
.cover {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--s-12);
gap: var(--s-11);
min-width: 0;
padding: calc(var(--s-12) * 2);
background: var(--field);
border-right: 1px solid var(--line2);
}
/* The rule: the console's own selected-nav amber, as in the banner. */
.cover::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: var(--s-3);
background: var(--acc);
}
.name {
display: flex;
align-items: center;
gap: var(--s-7);
margin: 0;
font-family: var(--font-mono);
font-weight: 600;
font-size: var(--t-6);
letter-spacing: 0.01em;
color: var(--mute);
}
.dot { flex: none; width: var(--s-7); height: var(--s-7); border-radius: var(--r-full); background: var(--acc); }
.version { font-weight: 400; font-size: var(--t-4); color: var(--faint); }
.headline {
margin: 0;
max-width: 14em;
font-weight: 700;
font-size: clamp(var(--t-7), 3vw, var(--t-8));
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--ink);
text-wrap: balance;
}
.answer { font-family: var(--font-mono); font-size: clamp(var(--t-5), 1.2vw, var(--t-6)); line-height: 1.9; }
.section { display: block; color: var(--faint); }
.records { margin: 0; padding: 0; list-style: none; }
/* Three columns, so a value that does not fit wraps under itself and never
pushes the row wider than the cover. */
.record {
display: grid;
grid-template-columns: 10ch max-content minmax(0, 1fr);
column-gap: 2ch;
animation: settle var(--t-medium) both;
animation-delay: calc(var(--row) * 70ms);
}
.key { color: var(--ink); }
.type { color: var(--acc-ink); font-weight: 600; }
.value { color: var(--ink); }
@keyframes settle {
from { opacity: 0; transform: translateY(var(--s-2)); }
}
@media (prefers-reduced-motion: reduce) {
.record { animation: none; }
padding: var(--s-12) calc(var(--s-12) * 1.5);
background: var(--bg);
}
.name { display: flex; align-items: center; gap: var(--s-8); margin: 0; }
.mark { width: 40px; height: 40px; flex: none; border-radius: var(--r-4); background: var(--acc); padding: 6px; object-fit: contain; }
.who { display: flex; flex-direction: column; line-height: 1.25; }
.who b { font-size: 16px; font-weight: 600; color: var(--ink); }
.who small { font-size: 13px; color: var(--mute); }
.headline { margin: 0; font-size: 30px; line-height: 1.2; font-weight: 600; letter-spacing: -0.02em; color: var(--ink); }
.lead { margin: 0; font-size: 15px; line-height: 1.55; color: var(--mute); max-width: 420px; }
.perks { max-width: 420px; }
.list { list-style: none; margin: 0; padding: var(--s-2) 0; }
.perk { display: flex; align-items: flex-start; gap: var(--s-8); padding: var(--s-7) var(--s-10); }
.perk b { display: block; font-size: 14px; font-weight: 500; color: var(--ink); }
.perk small { font-size: 13px; color: var(--mute); }
.ico { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 40px; height: 40px; border: 1px solid var(--line); border-radius: var(--r-3); background: var(--pan2); color: var(--ink); }
@media (max-width: 960px) {
.cover {
gap: var(--s-10);
padding: var(--s-12) var(--s-11) var(--s-11);
border-right: 0;
border-bottom: 1px solid var(--line2);
}
.cover::before { inset: 0 0 auto 0; width: auto; height: var(--s-3); }
.answer { font-size: var(--t-4); }
}
@media (max-width: 560px) {
.cover { gap: var(--s-5); padding: var(--s-11) var(--s-10) var(--s-10); }
.name { font-size: var(--t-4); gap: var(--s-5); }
.dot { width: var(--s-5); height: var(--s-5); }
.version { font-size: var(--t-3); }
.headline { max-width: none; font-size: var(--t-6); }
.answer { display: none; }
.cover { padding: var(--s-11) var(--s-10) 0; gap: var(--s-9); }
.headline { font-size: 24px; }
.lead, .perks { display: none; }
}
+10 -23
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Login } from './Login'
import { COVER_HEADLINE, COVER_NAME, COVER_RECORDS, LoginCover } from './LoginCover'
import { COVER_HEADLINE, COVER_LEAD, COVER_NAME, COVER_PERKS, LoginCover } from './LoginCover'
import * as client from '../api/client'
import * as status from '../api/status'
@@ -17,37 +17,24 @@ describe('LoginCover', () => {
render(<LoginCover />)
const cover = screen.getByRole('complementary')
expect(within(cover).getByText(COVER_NAME)).toBeInTheDocument()
expect(within(cover).getByText(`v${__CONSOLE_VERSION__}`)).toBeInTheDocument()
expect(within(cover).getByText(`console v${__CONSOLE_VERSION__}`)).toBeInTheDocument()
expect(within(cover).getByText(COVER_HEADLINE)).toBeInTheDocument()
expect(within(cover).getByText(COVER_LEAD)).toBeInTheDocument()
})
it('it lists one record per offer, read without the record type or the quotes', () => {
it('it lists what the console covers, one item each', () => {
render(<LoginCover />)
const items = within(screen.getByRole('list')).getAllByRole('listitem')
expect(items).toHaveLength(COVER_RECORDS.length)
COVER_RECORDS.forEach((r, i) => {
// What is drawn: the dig line, quotes and `TXT` included.
expect(items[i]).toHaveTextContent(`${r.key}TXT"${r.value}"`)
// What is hidden from a screen reader: exactly the dig decoration.
const hidden = [...items[i].querySelectorAll('[aria-hidden="true"]')].map((e) => e.textContent)
expect(hidden).toEqual(['TXT', '"', '"'])
expect(items).toHaveLength(COVER_PERKS.length)
COVER_PERKS.forEach((p, i) => {
expect(items[i]).toHaveTextContent(`${p.title}${p.text}`)
})
})
/*
The texts must not age: the banner's "A new console" is the line this rule
exists for. And each value has to fit one line of the answer block at 1024 px,
where the cover is narrowest beside the form (measured on 2026-10-15: 34
characters of Plex Mono at 14 px leave the value column 286 px wide).
*/
it('it says nothing that ages and every value fits its line', () => {
const words = [COVER_HEADLINE, ...COVER_RECORDS.map((r) => r.value)].join(' ').toLowerCase()
/* The panel is shown before anyone has logged in: nothing may age or look like data. */
it('it says nothing that ages', () => {
const words = [COVER_HEADLINE, COVER_LEAD, ...COVER_PERKS.map((p) => `${p.title} ${p.text}`)].join(' ').toLowerCase()
expect(words).not.toMatch(/\b(new|newest|latest|now|soon|beta|20\d\d)\b/)
for (const r of COVER_RECORDS) {
expect(r.value.length).toBeLessThanOrEqual(34)
expect(r.key).toMatch(/^[a-z]+\.$/)
expect(r.key.length).toBeLessThanOrEqual(9)
}
})
})
+42 -49
View File
@@ -1,69 +1,62 @@
import type { CSSProperties } from 'react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Icon, type IconName } from '../ui/Icon'
import { publicUrl } from '../app/base'
import styles from './LoginCover.module.css'
/*
The login cover, OURS: a brand panel beside the stock login form. It is a
deliberate deviation from upstream (CONVENTIONS.md, "Deliberate deviations from
upstream behaviour", 2026-10-15), and it only changes the layout around the
form: every field, text, step and alert of `Login.tsx` is upstream's and stays so.
The login cover, OURS: a brand panel beside the stock login form, as the canvas draws
it. It is a deliberate deviation from upstream (CONVENTIONS.md, "Deliberate deviations
from upstream behaviour"), and it only changes the layout around the form: every field,
text, step and alert of `Login.tsx` is upstream's and stays so.
Every word in this file is OURS: the name `technitium-console`, the headline,
the `;; ANSWER SECTION:` line and the four TXT records (keys and values). They
are written so they do not age: nothing says "new", and each line is true of the
console as it ships today, which is why a line has to change the day it stops
being true. No screenshot and no figure is drawn here, because the panel is shown
before anyone has logged in and anything resembling data would be invented.
The look is the project's social banner: a thin amber rule, the name in Plex
Mono with an amber dot, an Archivo headline and a dig-style answer block.
The name and version, a headline, one sentence and three things the console covers,
each in an icon tile. Nothing says "new" and no figure is drawn: the panel is shown
before anyone has logged in, so anything resembling data would be invented.
*/
export const COVER_NAME = 'technitium-console'
export const COVER_HEADLINE = 'The same DNS server, in a rebuilt console.'
export const COVER_NAME = 'Technitium DNS'
export const COVER_HEADLINE = 'Administer your DNS Server'
export const COVER_LEAD = 'Zones, cache, blocking, DHCP and logs of your Technitium DNS Server in one console.'
/** The answer block: one TXT record per thing the console offers. */
export const COVER_RECORDS: readonly { key: string; value: string }[] = [
{ key: 'features.', value: 'every stock feature, same API' },
{ key: 'blocking.', value: 'rules and lists in one place' },
{ key: 'themes.', value: 'light, dark or system' },
{ key: 'phone.', value: 'works on a phone' },
/** What the console covers: one line each. */
export const COVER_PERKS: readonly { title: string; text: string; icon: IconName }[] = [
{ title: 'Zones and records', text: 'Primary, secondary, forwarder and stub zones', icon: 'zones' },
{ title: 'Blocking and cache', text: 'Block lists, allowed domains and the resolver cache', icon: 'blocked' },
{ title: 'DHCP and logs', text: 'Leases, scopes and query logs', icon: 'logs' },
]
export function LoginCover() {
return (
<aside className={styles.cover}>
<p className={styles.name}>
<span className={styles.dot} aria-hidden="true" />
<span>{COVER_NAME}</span>
{/* The version from package.json, injected at build time (vite.config.ts). */}
<span className={styles.version}>v{__CONSOLE_VERSION__}</span>
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={32} height={32} />
<span className={styles.who}>
<b>{COVER_NAME}</b>
{/* The version from package.json, injected at build time (vite.config.ts). */}
<small>console v{__CONSOLE_VERSION__}</small>
</span>
</p>
<p className={styles.headline}>{COVER_HEADLINE}</p>
<p className={styles.lead}>{COVER_LEAD}</p>
{/* The record type and the quotes are what makes it read as dig output;
to a screen reader they are noise, so each row reads as
"features. every stock feature, same API". */}
<div className={styles.answer} data-testid="cover-answer">
<span className={styles.section} aria-hidden="true">
;; ANSWER SECTION:
</span>
<ul className={styles.records}>
{COVER_RECORDS.map((r, i) => (
<li key={r.key} className={styles.record} style={{ '--row': i } as CSSProperties}>
<span className={styles.key}>{r.key}</span>
<span className={styles.type} aria-hidden="true">
TXT
</span>
<span className={styles.value}>
<span aria-hidden="true">"</span>
{r.value}
<span aria-hidden="true">"</span>
</span>
</li>
))}
</ul>
</div>
<Frame className={styles.perks}>
<FramePanel className="p-0">
<ul className={styles.list}>
{COVER_PERKS.map((p) => (
<li key={p.title} className={styles.perk}>
<span className={styles.ico} aria-hidden="true">
<Icon name={p.icon} size={16} />
</span>
<span>
<b>{p.title}</b>
<small>{p.text}</small>
</span>
</li>
))}
</ul>
</FramePanel>
</Frame>
</aside>
)
}
+3 -3
View File
@@ -64,7 +64,7 @@ About shows, right under the title.
export function About({ info }: { info?: Info }) {
return (
<>
<SectionHeader title="About" />
<SectionHeader title="About" subtitle="Technitium DNS Server and this console" />
<div className={styles.grid}>
<Panel>
<Body>
@@ -155,7 +155,7 @@ export function About({ info }: { info?: Info }) {
</Panel>
<div className={styles.col}>
<Panel title="Server">
<Panel title="Server" description="Version and uptime">
<Body>
<dl className={styles.kv}>
<dt>Version</dt><dd>{info?.version ?? '—'}</dd>
@@ -177,7 +177,7 @@ export function About({ info }: { info?: Info }) {
of `.github/FUNDING.yml` as buttons. The words are ours and follow the
README's `Support` and `Licence`.
*/}
<Panel title="Web Console">
<Panel title="Web Console" description="Version and license">
<Body>
<dl className={styles.kv}>
<dt>Version</dt><dd>{__CONSOLE_VERSION__}</dd>
+1
View File
@@ -175,6 +175,7 @@ export function Cluster({ tabs, token, cluster, node: controlledNode, onNodeChan
*/}
<SectionHeader
title="Administration"
subtitle="Sessions, users, groups, permissions, SSO, LDAP and cluster"
tabs={tabs}
labels={
initialised && ownType != null ? (
+4 -2
View File
@@ -1,3 +1,4 @@
import { Panel } from '../../ui/Panel'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '../../ui/Button'
@@ -94,6 +95,7 @@ export function Groups({ tabs, token, onNotice }: Props) {
<>
<SectionHeader
title="Administration"
subtitle="Sessions, users, groups, permissions, SSO, LDAP and cluster"
tabs={tabs}
actions={<><Button variant="primary" onClick={() => setAdd(true)}>
Add Group
@@ -103,7 +105,7 @@ export function Groups({ tabs, token, onNotice }: Props) {
{loading ? (
<Loading />
) : (
<>
<Panel title="Groups" description="Group names, descriptions and members">
<DataTable
data={visibleGroups}
getRowId={(g) => g.name}
@@ -149,7 +151,7 @@ export function Groups({ tabs, token, onNotice }: Props) {
<div className={styles.count}>
<span>{`Total Groups: ${groups.length}`}</span>
</div>
</>
</Panel>
)}
<Confirm
+1 -1
View File
@@ -279,7 +279,7 @@ export function Ldap({ tabs, token, onNotice }: Props) {
return (
<>
<SectionHeader title="Administration" tabs={tabs} />
<SectionHeader title="Administration" subtitle="Sessions, users, groups, permissions, SSO, LDAP and cluster" tabs={tabs} />
{/*
The dense form of `Sso.tsx`, block for block. Upstream puts its two
+1
View File
@@ -143,6 +143,7 @@ export function Permissions({ tabs, token, cluster, onNotice }: Props) {
<>
<SectionHeader
title="Administration"
subtitle="Sessions, users, groups, permissions, SSO, LDAP and cluster"
tabs={tabs}
/>
+4 -2
View File
@@ -1,3 +1,4 @@
import { Panel } from '../../ui/Panel'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '../../ui/Button'
@@ -130,6 +131,7 @@ export function Sessions({ tabs, token, cluster, node: controlledNode, onNodeCha
<>
<SectionHeader
title="Administration"
subtitle="Sessions, users, groups, permissions, SSO, LDAP and cluster"
tabs={tabs}
actions={<>{canCreateToken && (
<Button variant="primary" onClick={() => setCreate(true)}>
@@ -142,7 +144,7 @@ export function Sessions({ tabs, token, cluster, node: controlledNode, onNodeCha
{loading ? (
<Loading />
) : (
<>
<Panel title="Sessions" description="Signed-in sessions and API tokens">
{/* My own session's row, washed in `--acc-bg`. The `(current)` in
its token cell stays: the colour finds the row, the word says
which it is. Same treatment as this node's row in `Cluster`. */}
@@ -220,7 +222,7 @@ export function Sessions({ tabs, token, cluster, node: controlledNode, onNodeCha
<div className={styles.count}>
<span>{`Total Sessions: ${sessions.length}`}</span>
</div>
</>
</Panel>
)}
<Confirm
+1
View File
@@ -234,6 +234,7 @@ export function Sso({ tabs, token, onNotice }: Props) {
<>
<SectionHeader
title="Administration"
subtitle="Sessions, users, groups, permissions, SSO, LDAP and cluster"
tabs={tabs}
/>
+16 -2
View File
@@ -1,3 +1,5 @@
import { Panel } from '../../ui/Panel'
import { KpiStrip } from '../../ui/KpiStrip'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '../../ui/Button'
@@ -163,16 +165,28 @@ export function Users({ tabs, token, cluster, onNotice }: Props) {
<>
<SectionHeader
title="Administration"
subtitle="Sessions, users, groups, permissions, SSO, LDAP and cluster"
tabs={tabs}
actions={<><Button variant="primary" onClick={() => setAdd(true)}>
Add User
</Button></>}
/>
{!loading && (
<KpiStrip
label="User totals"
items={[
{ label: 'Users', value: users.length, icon: 'admin' },
{ label: 'Disabled', value: users.filter((u) => u.disabled).length, icon: 'power' },
{ label: 'With 2FA', value: users.filter((u) => u.totpEnabled === true).length, icon: 'lock', tone: 'ok' },
]}
/>
)}
{loading ? (
<Loading />
) : (
<>
<Panel title="Users" description="Username, display name, groups, 2FA, last seen and status">
<DataTable
data={visibleUsers}
getRowId={(u) => String(u.username)}
@@ -297,7 +311,7 @@ export function Users({ tabs, token, cluster, onNotice }: Props) {
<div className={styles.count}>
<span>{`Total Users: ${users.length}`}</span>
</div>
</>
</Panel>
)}
<Confirm
+45 -46
View File
@@ -2,7 +2,7 @@ import { dnsAppLabels, type InstalledApp } from '../../api/apps'
import { Button } from '../../ui/Button'
import { Menu } from '../../ui/Menu'
import { Details } from '../../ui/Details'
import { Chip } from '../../ui/Tag'
import { Chip, Tag } from '../../ui/Tag'
import styles from './Apps.module.css'
/*
@@ -32,55 +32,29 @@ export function AppCard({
const hasUpdate = app.updateAvailable === true && Boolean(app.updateUrl)
return (
<li className={styles.app} aria-label={app.name}>
<div className={styles.ah}>
<div>
<h2>{app.name}</h2>
{/*
The version, and the update when there is one. It used to read
`v11.1 · installed`, and "installed" is true of every card in this
list, so it was a word spending a line to say nothing. What replaces it
is nothing: the version alone, and the update beside it when it exists.
*/}
<div className={styles.view}>
v{app.version}
{hasUpdate && (
<>
{' · '}
<span className={styles.blank}>Update v{app.updateVersion}</span>
</>
)}
</div>
</div>
<li className={styles.gridRow} aria-label={app.name}>
<div className={styles.nameCell}>
<h2>{app.name}</h2>
{app.description && <p className={styles.desc}>{app.description}</p>}
</div>
{app.description && <p className={styles.desc}>{app.description}</p>}
<div className={styles.mono}>
v{app.version}
{hasUpdate && (
<>
{' · '}
<span className={styles.blank}>Update v{app.updateVersion}</span>
</>
)}
</div>
{app.dnsApps.length > 0 && (
<Details className={styles.details} summary="More Details">
{app.dnsApps.map((d) => (
<div key={d.classPath} className={styles.cls}>
<div className={styles.classPath}>{d.classPath}</div>
<div className={styles.caps}>
{dnsAppLabels(d).map((l) => (
<Chip key={l}>{l}</Chip>
))}
</div>
<p className={styles.classDesc}>{d.description}</p>
{/* apps.js:82. The template is only shown for the classes that
serve APP records, which are the ones that bring it. */}
{d.isAppRecordRequestHandler && d.recordDataTemplate != null && (
<>
<div className={styles.templateK}>Record Data Template</div>
<pre className={styles.template}>{d.recordDataTemplate}</pre>
</>
)}
</div>
))}
</Details>
)}
<div className={styles.mono}>{app.dnsApps.length}</div>
<div className={styles.foot}>
<div>
<Tag tone={hasUpdate ? 'warn' : 'ok'}>{hasUpdate ? 'Update available' : 'Up to date'}</Tag>
</div>
<div className={styles.rowActs}>
<Button disabled={busy} onClick={onConfig}>
Config
</Button>
@@ -120,6 +94,31 @@ export function AppCard({
)}
</Menu>
</div>
{app.dnsApps.length > 0 && (
<Details className={styles.rowDetails} summary="More Details">
{app.dnsApps.map((d) => (
<div key={d.classPath} className={styles.cls}>
<div className={styles.classPath}>{d.classPath}</div>
<div className={styles.caps}>
{dnsAppLabels(d).map((l) => (
<Chip key={l}>{l}</Chip>
))}
</div>
<p className={styles.classDesc}>{d.description}</p>
{/* apps.js:82. The template is only shown for the classes that
serve APP records, which are the ones that bring it. */}
{d.isAppRecordRequestHandler && d.recordDataTemplate != null && (
<>
<div className={styles.templateK}>Record Data Template</div>
<pre className={styles.template}>{d.recordDataTemplate}</pre>
</>
)}
</div>
))}
</Details>
)}
</li>
)
}
+22
View File
@@ -59,3 +59,25 @@ before.
.sortBy:focus-visible { outline: var(--focus); outline-offset: var(--focus-offset); }
/* The installed grid gets the same sort affordance as the store list. */
.sortRow + .apps { margin-top: var(--s-8); }
/* --- The canvas's grid: a header row and one row per app --- */
.gridHead, .gridRow { display:grid; grid-template-columns: minmax(220px, 2.2fr) 150px 90px 150px 320px; align-items:center; gap: 0 var(--s-8); padding: 0 var(--s-10); }
.gridHead { height: 40px; font: 500 12px/1 var(--font-ui); color: var(--mute); border-bottom:1px solid var(--line); background: color-mix(in oklch, var(--pan2) 30%, transparent); }
.rows { list-style:none; margin:0; padding:0; }
.gridRow { min-height: 56px; padding-block: var(--s-5); border-bottom:1px solid var(--line); }
.gridRow:last-child { border-bottom:0; }
.gridRow:hover { background: color-mix(in oklch, var(--pan2) 50%, transparent); }
.nameCell { min-width:0; }
.nameCell h2 { margin:0; font: 500 14px/1.3 var(--font-ui); color: var(--ink); }
.nameCell .desc { margin: 2px 0 0; font-size: 12px; line-height:1.4; color: var(--mute); white-space: normal; }
.mono { font-family: var(--font-mono); font-size: 13px; }
.rowActs { display:flex; gap: var(--s-3); justify-content:flex-end; flex-wrap:nowrap; }
.rowDetails { grid-column: 1 / -1; padding-top: var(--s-3); }
@media (max-width: 900px) {
.gridHead { display:none; }
.gridRow { grid-template-columns: 1fr; gap: var(--s-3); }
.rowActs { justify-content:flex-start; }
}
.store { padding: 0 var(--s-10); }
.sortRow { padding: var(--s-5) var(--s-10); }
.total { padding: var(--s-5) var(--s-10); }
+4 -4
View File
@@ -403,14 +403,14 @@ describe('Apps: the store', () => {
await userEvent.click(await screen.findByRole('button', { name: 'App Store' }))
const names = () =>
within(screen.getByRole('dialog'))
within(screen.getByRole('group', { name: 'DNS App Store' }))
.getAllByRole('listitem')
.map((li) => li.getAttribute('aria-label'))
await screen.findByText('Total Apps: 2')
expect(names()).toEqual(['Zone Alias', 'Auto PTR'])
const sort = within(screen.getByRole('dialog')).getByRole('button', { name: 'Sort by name' })
const sort = within(screen.getByRole('group', { name: 'DNS App Store' })).getByRole('button', { name: 'Sort by name' })
await userEvent.click(sort)
expect(names()).toEqual(['Auto PTR', 'Zone Alias'])
@@ -440,7 +440,7 @@ describe('Apps: the store', () => {
await openStore()
const row = within(
await within(screen.getByRole('dialog')).findByRole('listitem', { name: 'NO DATA' }),
await within(screen.getByRole('group', { name: 'DNS App Store' })).findByRole('listitem', { name: 'NO DATA' }),
)
expect(row.getByText('Version 5.0')).toBeInTheDocument()
expect(row.getByText('Update 6.0')).toBeInTheDocument()
@@ -479,7 +479,7 @@ describe('Apps: the store', () => {
await openStore()
const row = within(
await within(screen.getAllByRole('dialog')[0]).findByRole('listitem', { name: 'NO DATA' }),
await within(screen.getByRole('group', { name: 'DNS App Store' })).findByRole('listitem', { name: 'NO DATA' }),
)
await userEvent.click(row.getByRole('button', { name: 'Uninstall' }))
// The confirmation stacks over the store's dialog.
+61 -36
View File
@@ -1,3 +1,4 @@
import { KpiStrip } from '../../ui/KpiStrip'
import { useCallback, useEffect, useState } from 'react'
import {
downloadAndUpdate,
@@ -13,6 +14,8 @@ import { AppConfig } from './AppConfig'
import { AppCard } from './AppCard'
import { InstallApp } from './InstallApp'
import { StoreApps } from './StoreApps'
import { Panel } from '../../ui/Panel'
import { PageTabs } from '../../ui/PageTabs'
import { UpdateApp } from './UpdateApp'
import { Empty, Failure, Loading } from '../../ui/Empty'
import { Tag } from '../../ui/Tag'
@@ -45,7 +48,6 @@ adding functionality.
export interface AlertState { type: AlertType; title: string; text: string }
type Modal =
| { kind: 'store' }
| { kind: 'install' }
| { kind: 'update'; name: string }
| { kind: 'config'; name: string; config: string }
@@ -58,6 +60,7 @@ export function Apps({ token }: { token: string | null }) {
const [apps, setApps] = useState<InstalledApp[] | null>(null)
const [alert, setAlert] = useState<AlertState | null>(null)
const [modal, setModal] = useState<Modal | null>(null)
const [tab, setTab] = useState<'installed' | 'store'>('installed')
const [busy, setBusy] = useState<string | null>(null)
/** Whether the last load failed: the gap is not drawn as an empty one. */
const [failure, setFailure] = useState(false)
@@ -172,6 +175,7 @@ export function Apps({ token }: { token: string | null }) {
<SectionHeader
title="Apps"
subtitle="DNS apps installed on this server"
labels={
withUpdate > 0 ? (
<Tag tone="warn">
@@ -181,7 +185,7 @@ export function Apps({ token }: { token: string | null }) {
}
actions={
<>
<Button variant="primary" onClick={() => setModal({ kind: 'store' })}>
<Button variant="primary" onClick={() => setTab('store')}>
App Store
</Button>
<Button onClick={() => setModal({ kind: 'install' })}>Install from file</Button>
@@ -189,7 +193,30 @@ export function Apps({ token }: { token: string | null }) {
}
/>
{apps === null ? (
{apps != null && !failure && (
<KpiStrip
label="App totals"
items={[
{ label: 'Installed', value: apps.length, icon: 'apps' },
{ label: 'Updates available', value: withUpdate, icon: 'power', tone: withUpdate > 0 ? 'warn' : undefined },
{ label: 'DNS apps', value: apps.reduce((n, a) => n + a.dnsApps.length, 0), icon: 'dnsclient' },
]}
/>
)}
<PageTabs
label="Apps"
tabs={[
{ id: 'installed', label: 'Installed', count: apps?.length },
{ id: 'store', label: 'App Store' },
]}
active={tab}
onChoose={setTab}
/>
{tab === 'store' ? (
<StoreApps token={token} onChanged={() => void reload()} />
) : apps === null ? (
<Loading />
) : failure ? (
/* Solid and with no invitation: whether there are apps is not known, so
@@ -200,7 +227,7 @@ export function Apps({ token }: { token: string | null }) {
<Empty
title="No apps installed"
actions={
<Button variant="primary" onClick={() => setModal({ kind: 'store' })}>
<Button variant="primary" onClick={() => setTab('store')}>
Open App Store
</Button>
}
@@ -209,32 +236,36 @@ export function Apps({ token }: { token: string | null }) {
without touching the base configuration. Open the store to see what is available.
</Empty>
) : (
<>
<div className={styles.sortRow}>
<button
type="button"
className={styles.sortBy}
aria-label="Sort by name"
onClick={() => toggle('name')}
>
Installed Apps
<Icon name="sort" size={12} />
</button>
</div>
<ul className={styles.apps}>
{sortedApps.map((app) => (
<AppCard
key={app.name}
app={app}
busy={busy === app.name}
onConfig={() => void openConfig(app.name)}
onUpdate={() => setModal({ kind: 'update', name: app.name })}
onStoreUpdate={() => void updateFromStore(app)}
onUninstall={() => setPendingUninstall(app.name)}
/>
))}
</ul>
</>
<Panel title="Installed" description="Version, DNS apps and status">
<div className={styles.gridHead}>
<button
type="button"
className={styles.sortBy}
aria-label="Sort by name"
onClick={() => toggle('name')}
>
Installed Apps
<Icon name="sort" size={12} />
</button>
<span>Version</span>
<span>DNS apps</span>
<span>Status</span>
<span />
</div>
<ul className={styles.rows}>
{sortedApps.map((app) => (
<AppCard
key={app.name}
app={app}
busy={busy === app.name}
onConfig={() => void openConfig(app.name)}
onUpdate={() => setModal({ kind: 'update', name: app.name })}
onStoreUpdate={() => void updateFromStore(app)}
onUninstall={() => setPendingUninstall(app.name)}
/>
))}
</ul>
</Panel>
)}
<Confirm
@@ -246,12 +277,6 @@ export function Apps({ token }: { token: string | null }) {
onConfirm={() => pendingUninstall && uninstall(pendingUninstall)}
/>
<StoreApps
open={modal?.kind === 'store'}
onOpenChange={(o) => setModal(o ? { kind: 'store' } : null)}
token={token}
onChanged={() => void reload()}
/>
<InstallApp
open={modal?.kind === 'install'}
onOpenChange={(o) => setModal(o ? { kind: 'install' } : null)}
+4 -16
View File
@@ -4,7 +4,7 @@ import { Button } from '../../ui/Button'
import { useSort } from '../../ui/Table'
import { Icon } from '../../ui/Icon'
import { Details } from '../../ui/Details'
import { Dialog } from '../../ui/Dialog'
import { Panel } from '../../ui/Panel'
import { error, type AlertState } from './Apps'
import { Empty, Loading } from '../../ui/Empty'
import { Tag } from '../../ui/Tag'
@@ -28,13 +28,9 @@ it avoids upstream's lag, where `installedApp` comes without the update fields
and the freshly placed row is left without its "Store Update" button.
*/
export function StoreApps({
open,
onOpenChange,
token,
onChanged,
}: {
open: boolean
onOpenChange: (o: boolean) => void
token: string | null
onChanged: () => void
}) {
@@ -64,11 +60,8 @@ export function StoreApps({
}, [token])
useEffect(() => {
if (!open) return
setAlert(null)
setStoreApps(null)
void load()
}, [open, load])
}, [load])
async function runStoreAction(app: StoreApp, ok: AlertState, call: Promise<{ kind: string; message?: string }>) {
setBusy(app.name)
@@ -130,12 +123,7 @@ export function StoreApps({
const { rows: sorted, toggle } = useSort<StoreApp>({ name: (a) => a.name }, storeApps ?? [])
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
size="medium"
title="DNS App Store"
>
<Panel title="DNS App Store" description="Apps available from the Technitium store" groups2>
<Notifier notice={alert} onClose={() => setAlert(null)} />
<Confirm
@@ -241,6 +229,6 @@ export function StoreApps({
<div className={styles.total}>Total Apps: {storeApps.length}</div>
</>
)}
</Dialog>
</Panel>
)
}
+2 -2
View File
@@ -358,7 +358,7 @@ export function BlockLists({
if (viewNeed != null) {
return (
<>
<SectionHeader section="Blocking" title="Lists" subtitle={LISTS_SUBTITLE} tabs={tabs} />
<SectionHeader title="Blocking" subtitle={LISTS_SUBTITLE} tabs={tabs} />
<Locked title="Block Lists" need={viewNeed} />
</>
)
@@ -571,7 +571,7 @@ export function BlockLists({
return (
<>
<SectionHeader section="Blocking" title="Lists" subtitle={LISTS_SUBTITLE} tabs={tabs} />
<SectionHeader title="Blocking" subtitle={LISTS_SUBTITLE} tabs={tabs} />
<Notifier notice={notice} onClose={() => setNotice(null)} />
<div className={shared.stack}>
+4
View File
@@ -82,3 +82,7 @@ second frame, with rounded corners showing under the panel's header. Written und
.bad[aria-invalid],
.bad[aria-invalid]:focus { border-color: var(--dan); }
.invalid { color: var(--dan); font-size: var(--t-2); margin-top: var(--s-4); }
/* The canvas's state strip above the tabs. */
.banner { display:flex; flex-wrap:wrap; align-items:center; gap: var(--s-7); padding: var(--s-7) var(--s-9); margin-bottom: var(--s-9); border:1px solid var(--line); border-radius: var(--r-3); background: color-mix(in oklch, var(--pan2) 50%, transparent); font-size: 13px; color: var(--mute); }
.bannerAct { margin-left: auto; }
+68
View File
@@ -1,10 +1,17 @@
import { useEffect, useState } from 'react'
import { getSettings } from '../../api/settings'
import { getDashboardStats } from '../../api/dashboard'
import { BlockingMenu } from '../dashboard/BlockingMenu'
import { Tag } from '../../ui/Tag'
import { missing } from './permissions'
import { untilText, useBlockingState } from './blocking-state'
import { SubTabs } from '../../ui/SubTabs'
import { ruleSearch } from './rules-model'
import { Overview } from './Overview'
import { Rules } from './Rules'
import { BlockLists } from './BlockLists'
import type { Permissions } from './permissions'
import shared from './Blocking.module.css'
/*
The Blocking section, OURS: it replaces Allowed and Blocked with three tabs,
@@ -27,6 +34,61 @@ function dropRule(): void {
}
}
/* What the tabs count: the rules (blocked and allowed zones) and the lists. Each is read
once and only when the session may read it; a failed read leaves the tab without one. */
function useCounts(token: string | null, permissions: Permissions) {
const [counts, setCounts] = useState<{ Rules?: number; Lists?: number }>({})
useEffect(() => {
let alive = true
if (missing(permissions, 'Dashboard.canView') == null) {
void getDashboardStats(token).then((o) => {
if (alive && o.kind === 'ok') {
const s = o.data.stats
setCounts((c) => ({ ...c, Rules: s.blockedZones + s.allowedZones }))
}
})
}
if (missing(permissions, 'Settings.canView') == null) {
void getSettings(token).then((s) => {
if (alive && s != null) setCounts((c) => ({ ...c, Lists: s.blockListUrls?.length ?? 0 }))
})
}
return () => {
alive = false
}
// The permission object is read once: the counts are a hint, not a live figure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token])
return counts
}
/* The state strip of the canvas, above the tabs. Overview carries the full status block
itself, so the strip is for the other two. */
function Banner({ token, permissions }: { token: string | null; permissions: Permissions }) {
const { state } = useBlockingState(token, permissions)
if (state == null || state === 'failed') return null
const canModify = missing(permissions, 'Settings.canModify') == null
return (
<div className={shared.banner} role="status">
{state.kind === 'on' && <Tag tone="ok">Enabled</Tag>}
{state.kind === 'paused' && <Tag tone="acc">{untilText(state.till)}</Tag>}
{state.kind === 'off' && <Tag>Disabled</Tag>}
<span>
{state.kind === 'on'
? 'Blocking is applied to every query'
: state.kind === 'paused'
? 'Blocking is paused and resumes automatically'
: 'Nothing is being blocked'}
</span>
{canModify && (
<span className={shared.bannerAct}>
<BlockingMenu token={token} text={state.kind === 'on' ? 'Temporarily Disable Blocking' : 'Enable Blocking'} onNotice={() => {}} />
</span>
)}
</div>
)
}
export function Blocking({
token,
sub,
@@ -69,7 +131,11 @@ export function Blocking({
const [listsDirty, setListsDirty] = useState(false)
const counts = useCounts(token, permissions)
const tabs = (
<>
{active !== 'Overview' && <Banner token={token} permissions={permissions} />}
<SubTabs
label="Blocking sections"
section="blocking"
@@ -77,7 +143,9 @@ export function Blocking({
active={active}
onChoose={onSubChange}
marks={listsDirty ? { Lists: 'unsaved changes' } : undefined}
counts={counts}
/>
</>
)
if (active === 'Rules') {
+1 -2
View File
@@ -330,8 +330,7 @@ export function Overview({
</InChrome>
<SectionHeader
section="Blocking"
title="Overview"
title="Blocking"
subtitle={OVERVIEW_SUBTITLE}
tabs={tabs}
actions={
+1 -1
View File
@@ -375,7 +375,7 @@ export function Rules({
return (
<>
<SectionHeader section="Blocking" title="Rules" subtitle={RULES_SUBTITLE} tabs={tabs} />
<SectionHeader title="Blocking" subtitle={RULES_SUBTITLE} tabs={tabs} />
<Notifier notice={notice} onClose={() => setNotice(null)} />
<div className={shared.stack}>
+6 -4
View File
@@ -10,7 +10,7 @@ and its own pieces.
/* The title and the period on one line. `SectionHeader` brings its own space
under the title; on this line it is the line's. */
.head { display:flex; flex-wrap:wrap; align-items:center; gap: var(--s-5) var(--s-10); margin-bottom: var(--s-8); }
.head { display:flex; flex-direction:column; align-items:stretch; gap: var(--s-8); margin-bottom: var(--s-8); }
.head > :first-child { margin-bottom: 0; }
/* The picker scrolls inside its own strip when narrow; it may not push the line wider. */
.period { min-width: 0; max-width: 100%; }
@@ -27,8 +27,8 @@ and what fits (`--fit`, below), so a long figure never spills out.
*/
.tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
background: var(--pan); border: 1px solid var(--line); border-radius: var(--r-4); overflow: hidden;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
padding: 0;
}
.tile {
container-type: inline-size;
@@ -67,7 +67,7 @@ the whole card. Hover lifts the edge to the series' colour; focus draws the halo
.open:hover { border-color: var(--tc, var(--acc)); }
.open:focus-visible { outline: none; box-shadow: var(--halo-focus); }
.tile .k { font-size: var(--t-1); font-weight: 500; color:var(--mute); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.tile .k { font-size: 13px; font-weight: 500; color:var(--mute); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.line { display:flex; flex-wrap:wrap; align-items:baseline; column-gap: var(--s-3); min-width: 0; }
/*
@@ -310,3 +310,5 @@ each row's own columns, which are the same widths in all but the figures.
.legendTable .lg { display:grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items:center; }
.legendTable .lg b { text-align:right; }
.lgName { text-align:left; }
.groups { display:flex; flex-direction:column; gap: var(--s-10); }
+26 -22
View File
@@ -20,6 +20,7 @@ import { TopRowMenu } from './TopRowMenu'
import { useDomainAction } from '../../lib/allow-block'
import { SectionHeader } from '../../ui/SectionHeader'
import { Loading } from '../../ui/Empty'
import { Frame, FramePanel } from '@/components/reui/frame'
import styles from './Dashboard.module.css'
import { Body, Panel } from '../../ui/Panel'
import { Button } from '../../ui/Button'
@@ -73,6 +74,8 @@ export function Dashboard({
}, [node])
const [range, setRange] = useState<Range>('LastHour')
/* The header's Refresh: asks everything again, as a change of period would. */
const [again, setAgain] = useState(0)
const [data, setData] = useState<DashboardStats | null>(null)
const [loading, setLoading] = useState(true)
/*
@@ -215,7 +218,7 @@ export function Dashboard({
if (refreshTimer != null) window.clearTimeout(refreshTimer)
document.removeEventListener('visibilitychange', onVisibility)
}
}, [token, range, requested, node])
}, [token, range, requested, node, again])
/*
Last Hour in real time (`live.ts`, `useLive.ts`): between reloads the ten
@@ -312,11 +315,14 @@ export function Dashboard({
on this screen" (like `Add Zone`). It reframes every figure below.
*/}
<div className={styles.head}>
<SectionHeader title="Dashboard" />
<SectionHeader
title="Dashboard"
subtitle="Statistics of the DNS server, refreshed every minute"
actions={<Button onClick={() => setAgain((n) => n + 1)}>Refresh</Button>}
/>
<div className={styles.period}>
<Segmented
label="Period"
compact
options={RANGES.map((r) => ({ id: r, label: RANGE_LABEL[r] }))}
active={range}
onChoose={(r) => {
@@ -402,25 +408,23 @@ export function Dashboard({
with (`Dashboard.test.tsx`). Why Total Queries is larger and the order of the
other ten is in `Figures.tsx`.
*/}
<div className={styles.tiles} data-testid="metrics">
<StatCard
m={TOTAL}
s={s}
total={total}
changes={shift}
against={against}
onOpen={cardJump(TOTAL)}
/>
{FIGURES.map((m) => (
<StatCard
key={m.k}
m={m}
s={s}
total={total}
changes={shift}
against={against}
onOpen={cardJump(m)}
/>
<div className={styles.groups} data-testid="metrics">
{[[TOTAL, ...FIGURES.slice(0, 3)], FIGURES.slice(3, 7), FIGURES.slice(7)].map((group, g) => (
<Frame key={g}>
<FramePanel className={styles.tiles}>
{group.map((m) => (
<StatCard
key={m.k}
m={m}
s={s}
total={total}
changes={shift}
against={against}
onOpen={cardJump(m)}
/>
))}
</FramePanel>
</Frame>
))}
</div>
+9 -9
View File
@@ -96,15 +96,15 @@ export function StatCard({
<div className={styles.k}>{m.label}</div>
{/* With no data it is NOT a zero: it is a dash, and dimmed. A zero is a
true figure, and here it would be a lie. */}
<Figure className={`${styles.v}${s ? '' : ` ${styles.noData}`}`} text={s ? num2(s[m.k]) : '\u2014'} />
{/* The hint under the figure: its share of the total and its change. The gap is
a HARD space so a card with nothing to add keeps the line's height and its
neighbours' figures stay level. */}
<div className={styles.ctx}>
{share != null && <span className={styles.p}>{share}</span>}
{share != null && ratio != null && ' '}
<Change ratio={ratio} against={against} long={m.k === 'totalQueries'} />
{share == null && ratio == null && '\u00a0'}
<div className={styles.line}>
<Figure className={`${styles.v}${s ? '' : ` ${styles.noData}`}`} text={s ? num2(s[m.k]) : '—'} />
{/* The hint beside the figure: its share of the total and its change. */}
<div className={styles.ctx}>
{share != null && <span className={styles.p}>{share}</span>}
{share != null && ratio != null && ' '}
<Change ratio={ratio} against={against} long={m.k === 'totalQueries'} />
{share == null && ratio == null && ' '}
</div>
</div>
</div>
{/* Upstream's card is a link to Query Logs (v15.6.2). Here a button laid over
+2
View File
@@ -47,3 +47,5 @@ written in several places), except that here one of the copies was missing.
.ctlLine { composes: inline from '../../ui/PanelForm.module.css'; }
.suffix { composes: trailer from '../../ui/text.module.css'; }
.area { composes: area from '../../ui/PanelForm.module.css'; }
.sub { margin-top: 2px; font: 400 12px/1.3 var(--font-ui); color: var(--mute); }
+36 -17
View File
@@ -1,3 +1,4 @@
import { KpiStrip } from '../../ui/KpiStrip'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
@@ -16,6 +17,7 @@ import type { Notice } from './notices'
import { Failure, Loading } from '../../ui/Empty'
import { Tag } from '../../ui/Tag'
import tbl from '../../ui/Table.module.css'
import { Panel } from '../../ui/Panel'
import styles from './Dhcp.module.css'
import { DataTable } from '../../ui/DataTable'
import { RowAction, useSort, type Keys, head } from '../../ui/Table'
@@ -175,18 +177,36 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
<div className={styles.wrap}>
<SectionHeader
title="DHCP"
subtitle="Leases and scopes served by the built-in DHCP server"
tabs={tabs}
actions={<><Button onClick={() => void load()}>Refresh</Button></>}
/>
<Notifier notice={notice} onClose={() => setNotice(null)} />
{leases != null && !failure && (
<KpiStrip
label="Lease totals"
items={[
{ label: 'Leases', value: leases.length, icon: 'dhcp' },
{ label: 'Reserved', value: leases.filter((l) => l.type === 'Reserved').length, icon: 'lock' },
{ label: 'Scopes', value: new Set(leases.map((l) => l.scope)).size, icon: 'cache' },
{
label: 'Expiring in 10 min',
value: leases.filter((l) => l.type !== 'Reserved' && Date.parse(l.leaseExpires) - Date.now() < 600000 && Date.parse(l.leaseExpires) > Date.now()).length,
icon: 'power',
},
]}
/>
)}
{failure ? (
/* Solid, not dashed, and with no count: how many there are is not known.
The server's message goes in the notice at the top. */
<Failure>Could not load the list.</Failure>
) : (
<>
<Panel title="Leases" description="Scope, hardware address, IP address, host name, type and expiry">
<DataTable
data={visibleLeases}
getRowId={(l) => String(`${l.scope}/${l.clientIdentifier}`)}
@@ -194,10 +214,15 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
stack
columns={[
{
id: 'scope',
...head({ field: 'scope', sort, onSort: toggle, children: 'Scope' }),
td: () => ({ className: styles.mono }),
cell: (l) => l.scope,
id: 'host',
...head({ field: 'host', sort, onSort: toggle, children: 'Host Name' }),
td: () => ({ className: `${styles.mono} ${tbl.lead}` }),
cell: (l) => (
<>
{l.hostName}
<div className={styles.sub}>{l.scope}</div>
</>
),
},
{
id: 'mac',
@@ -220,23 +245,16 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
</Tag>
),
},
{
id: 'host',
...head({ field: 'host', sort, onSort: toggle, children: 'Host Name' }),
td: () => ({ className: `${styles.mono} ${tbl.lead}` }),
cell: (l) => l.hostName,
},
{
id: 'obtained',
...head({ field: 'obtained', sort, onSort: toggle, children: 'Lease Obtained' }),
td: () => ({ className: styles.date }),
cell: (l) => minuteStamp(l.leaseObtained),
},
{
id: 'expires',
...head({ field: 'expires', sort, onSort: toggle, children: 'Lease Expires' }),
td: () => ({ className: styles.date }),
cell: (l) => minuteStamp(l.leaseExpires),
cell: (l) => (
<>
{minuteStamp(l.leaseExpires)}
<div className={styles.sub}>Obtained {minuteStamp(l.leaseObtained)}</div>
</>
),
},
{
id: 'c7',
@@ -287,6 +305,7 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
left floating outside the panel, under a table with a blank body. */}
<span>{`Total Leases: ${leases.length}`}</span>
</div>
</Panel>
</>
)}
+16
View File
@@ -1,3 +1,5 @@
import { Panel } from '../../ui/Panel'
import { KpiStrip } from '../../ui/KpiStrip'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
@@ -197,6 +199,17 @@ export function Scopes({
return (
<div className={styles.wrap}>
<Notifier notice={notice} onClose={() => setNotice(null)} />
{scopes != null && !failure && (
<KpiStrip
label="Scope totals"
items={[
{ label: 'Scopes', value: scopes.length, icon: 'cache' },
{ label: 'Enabled', value: scopes.filter((s) => s.enabled).length, icon: 'check', tone: 'ok' },
{ label: 'Disabled', value: scopes.filter((s) => !s.enabled).length, icon: 'power' },
]}
/>
)}
<ScopeForm
key={editing.title + editing.form.oldName}
title={editing.title}
@@ -219,6 +232,7 @@ export function Scopes({
<div className={styles.wrap}>
<SectionHeader
title="DHCP"
subtitle="Leases and scopes served by the built-in DHCP server"
tabs={tabs}
actions={<>{canModify && (
<Button
@@ -241,6 +255,7 @@ export function Scopes({
<Failure>Could not load the list.</Failure>
) : (
<>
<Panel title="Scopes" description="Address pools and their options">
<DataTable
data={visibleScopes}
getRowId={(s) => String(s.name)}
@@ -346,6 +361,7 @@ export function Scopes({
left floating outside the panel, under a table with a blank body. */}
<span>{`Total Scopes: ${scopes.length}`}</span>
</div>
</Panel>
</>
)}
@@ -8,10 +8,6 @@ hand on each button, which is the symptom of a row that does not align.
align-items: flex-end;
gap: var(--s-8);
padding: var(--s-9);
background: var(--pan);
border: 1px solid var(--line);
border-radius: var(--r-4);
box-shadow: var(--e-1), var(--edge);
margin-bottom: var(--gap-block);
flex-wrap: wrap;
}
+4 -1
View File
@@ -146,7 +146,7 @@ export function DnsClient({
return (
<>
<SectionHeader title="DNS Client" />
<SectionHeader title="DNS Client" subtitle="Resolve a name through this server or another resolver" />
<ClusterNodeSelect
nodes={nodes}
@@ -164,6 +164,7 @@ export function DnsClient({
values off the scale on a single screen, and that is why the "Type" and
"DNS-over-" labels did not match the ones next to them.
*/}
<Panel title="Query" description="Name, type and server">
<div className={styles.flt}>
<div className={styles.width}>
{/*
@@ -226,12 +227,14 @@ export function DnsClient({
Import
</Button>
</div>
</Panel>
{output === null ? (
<Empty>Run a query to see the response.</Empty>
) : (
<Panel
title="Response"
description="Answer section"
actions={<span className={styles.meta}>{protocol} · {type}</span>}
className={styles.panel}
>
+12
View File
@@ -1,3 +1,4 @@
import { KpiStrip } from '../../ui/KpiStrip'
import { ClusterNodeSelect, primaryNodeName } from '../../ui/ClusterNodeSelect'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
@@ -470,6 +471,7 @@ export function Lists({
{!embedded && (
<SectionHeader
title={TITLE[list]}
subtitle={isCache ? 'Records cached by the recursive resolver' : undefined}
actions={
isCache ? (
<Button variant="danger" disabled={busy} onClick={askFlushCache}>
@@ -484,6 +486,16 @@ export function Lists({
{stale && <StaleData since={lastGood} onRetry={() => void load(node?.domain ?? '')} />}
{isCache && node != null && (
<KpiStrip
label="Cache totals"
items={[
{ label: 'Records at this node', value: records.length, icon: 'cache' },
{ label: 'Sub-zones', value: zones.length, icon: 'zones' },
]}
/>
)}
{(
<div className={styles.split}>
<div className={styles.tree}>
+4
View File
@@ -321,3 +321,7 @@ one pill per group.
padding-left: var(--s-8);
margin-left: calc(-1 * var(--s-8));
}
/* The status dot beside a domain, in the row's own colour (`--ql-tone`), as the canvas draws it. */
.dotTone { display:inline-flex; margin-right: var(--s-5); vertical-align: middle; }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background: var(--ql-tone, var(--mute)); }
+10 -3
View File
@@ -1,3 +1,4 @@
import { Panel } from '../../ui/Panel'
import type { CSSProperties, ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
@@ -604,6 +605,7 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
<div className={styles.wrap}>
<SectionHeader
title="Logs"
subtitle="Server log files and the query log of the installed logging app"
tabs={tabs}
labels={f.appName !== '' ? <Tag>app: {f.appName}</Tag> : undefined}
actions={<>
@@ -912,7 +914,7 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
</div>
{page != null && (
<>
<Panel title="Query Logs" description="Logging app, dates, client, domain and response type">
<div className={styles.count}>
<span>{statusText(page)}</span>
{/* logs.js:589. "Last" is asked for with -1; the server resolves it. */}
@@ -979,7 +981,12 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
id: 'c6',
header: 'Domain',
td: () => ({ className: `${styles.mono} ${tbl.lead}` }),
cell: (e) => (<Clip text={e.qname === '' ? '.' : (e.qname ?? '')} className={styles.clipDomain} />),
cell: (e) => (
<>
<span className={styles.dotTone} aria-hidden="true"><i className={styles.dot} /></span>
<Clip text={e.qname === '' ? '.' : (e.qname ?? '')} className={styles.clipDomain} />
</>
),
},
{
id: 'c7',
@@ -1045,7 +1052,7 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
<div className={styles.count}>
<span>{statusText(page)}</span>
</div>
</>
</Panel>
)}
</div>
)
+2 -1
View File
@@ -235,6 +235,7 @@ export function ViewLogs({
<div className={styles.viewLogs} data-fills-window="">
<SectionHeader
title="Logs"
subtitle="Server log files and the query log of the installed logging app"
tabs={tabs}
actions={<>
{/*
@@ -275,7 +276,7 @@ export function ViewLogs({
<Notifier notice={notice} onClose={() => setNotice(null)} />
<div className={styles.split}>
<Panel title="Log Files">
<Panel title="Log Files" description="Server log files by date">
<Body className={styles.pbList}>
{files.length === 0 ? (
<Empty>
+52 -70
View File
@@ -478,6 +478,7 @@ export function Settings({
*/}
<SectionHeader
title="Settings"
subtitle="Server configuration"
tabs={
<SubTabs
label="Settings sections"
@@ -489,14 +490,52 @@ export function Settings({
/>
}
actions={
<ClusterNodeSelect
nodes={nodes}
initialised={clusterInitialised}
aggregate
value={node}
onChange={setNode}
label="Cluster Node"
/>
<>
<ClusterNodeSelect
nodes={nodes}
initialised={clusterInitialised}
aggregate
value={node}
onChange={setNode}
label="Cluster Node"
/>
<PermissionButton
permission={canBackup ? undefined : 'Settings.canDelete'}
onClick={() => {
setSelection(initialBackupSelection())
setModalNotice(null)
setModal('backup')
}}
>
Backup Settings
</PermissionButton>
<PermissionButton
permission={canBackup ? undefined : 'Settings.canDelete'}
onClick={() => {
setSelection(initialBackupSelection())
setModalNotice(null)
setModal('restore')
}}
>
Restore Settings
</PermissionButton>
<PermissionButton
variant="primary"
disabled={busy}
permission={canModify ? undefined : 'Settings.canModify'}
onClick={() => void save()}
>
Save Settings
</PermissionButton>
<PermissionButton
variant="danger"
disabled={busy}
permission={canFlushCache ? undefined : 'Cache.canDelete'}
onClick={() => setConfirm('flush')}
>
Flush Cache
</PermissionButton>
</>
}
/>
@@ -536,68 +575,11 @@ export function Settings({
</div>
</BlockAnchors>
<div className={panelFormStyles.bar}>
{/*
The four verbs are still THERE without the permission, disabled and with a
padlock.
They used to disappear (`{canModify && …}`), and that breaks the console's
design rule *disabled, never hidden* twice over: the bar changed shape depending on who was looking, and
nobody could tell the action exists and a permission is missing.
The permission names come from upstream and **are not deduced from the
button**: these four verbs ask for THREE different permissions, and `Flush
Cache` asks for `Cache.canDelete` (another screen's), one of the three
controls, among the nine compared with upstream, that "does not ask for the
permission of the screen whose name it carries". Backup and restore ask for
`Settings.canDelete`, not `canModify`, and that has to be read off upstream
too: a backup asking for the DELETE permission is not what anyone would
assume.
*/}
<PermissionButton
variant="primary"
disabled={busy}
permission={canModify ? undefined : 'Settings.canModify'}
onClick={() => void save()}
>
Save Settings
</PermissionButton>
<PermissionButton
variant="danger"
disabled={busy}
permission={canFlushCache ? undefined : 'Cache.canDelete'}
onClick={() => setConfirm('flush')}
>
Flush Cache
</PermissionButton>
{/*
In the spacer's place, so that no button moves when it appears: wide, it is
the spacer; narrow, it takes the bar's first line and the spacer is back
(Settings.module.css).
*/}
{pendingWords !== '' && <span className={styles.pending}>Unsaved changes: {pendingWords}</span>}
<div className={[panelFormStyles.spacer, pendingWords !== '' ? styles.besidePending : null].filter(Boolean).join(' ')} />
<PermissionButton
permission={canBackup ? undefined : 'Settings.canDelete'}
onClick={() => {
setSelection(initialBackupSelection())
setModalNotice(null)
setModal('backup')
}}
>
Backup Settings
</PermissionButton>
<PermissionButton
permission={canBackup ? undefined : 'Settings.canDelete'}
onClick={() => {
setSelection(initialBackupSelection())
setModalNotice(null)
setModal('restore')
}}
>
Restore Settings
</PermissionButton>
</div>
{pendingWords !== '' && (
<div className={panelFormStyles.bar}>
<span className={styles.pending}>Unsaved changes: {pendingWords}</span>
</div>
)}
</div>
</IndexedForm>
+83 -25
View File
@@ -31,6 +31,9 @@ import { head, RowAction, useSort, type Keys } from '../../ui/Table'
import styles from './Zones.module.css'
import type { Notice, Confirmation } from './types'
import { Pagination } from '../../ui/Pagination'
import { Panel } from '../../ui/Panel'
import { KpiStrip } from '../../ui/KpiStrip'
import { Segmented } from '../../ui/Segmented'
import { noticeFromFailure } from '../../lib/notice'
/*
@@ -84,6 +87,22 @@ export interface ZoneListProps extends ZoneActions {
const ZONES_PER_PAGE_KEY = 'optZonesPerPage'
type View = 'all' | 'warnings' | 'enabled'
const VIEW_LABEL: Record<View, string> = { all: 'All', warnings: 'Warnings', enabled: 'Enabled' }
const signedOf = (z: Zone) => z.dnssecStatus === 'SignedWithNSEC' || z.dnssecStatus === 'SignedWithNSEC3'
/* A warning is any zone that is not disabled and not simply enabled: expired, failed to sync, to notify or to validate. */
const VIEWS: Record<Exclude<View, 'all'>, (z: Zone) => boolean> = {
warnings: (z) => zoneState(z) !== 'Enabled' && zoneState(z) !== 'Disabled',
enabled: (z) => zoneState(z) === 'Enabled',
}
function summary(zones: Zone[]) {
return {
enabled: zones.filter(VIEWS.enabled).length,
signed: zones.filter(signedOf).length,
pending: zones.filter((z) => z.type.startsWith('Secondary') && VIEWS.warnings(z)).length,
}
}
export function ZoneList({
token,
node = '',
@@ -129,6 +148,10 @@ export function ZoneList({
const [lastGood, setLastGood] = useState<string | null>(null)
const [stale, setStale] = useState(false)
const [checkedOnes, setChecked] = useState<string[]>([])
/* Every zone, read once per load for the strip and the view counts: the table itself
is paged by the server and cannot say how many zones are signed or in trouble. */
const [all, setAll] = useState<Zone[]>([])
const [view, setView] = useState<View>('all')
// The filters are form state: they do not apply until "Go" is pressed,
// just like upstream, where `refreshZones` reads them at that moment.
@@ -183,6 +206,13 @@ export function ZoneList({
setPageNumber(data.pageNumber)
setTotalPages(data.totalPages)
setTotalZones(data.totalZones)
/* A single unfiltered page already holds every zone; otherwise they are asked for once. */
if (data.totalPages <= 1 && nameFilter === '' && typeFilter === '') setAll(data.zones)
else {
void listZones(token, { pageNumber: 1, zonesPerPage: 5000, node }).then((every) => {
if (every.kind === 'ok') setAll(every.data.zones)
})
}
setPageField(String(data.pageNumber))
// `chkZonesTableCheckAll` is unchecked on every refresh (zone.js:938).
setChecked([])
@@ -361,7 +391,9 @@ export function ZoneList({
const firstRow = (pageNumber - 1) * perPage + 1
const state = statusText(firstRow, zones.length, totalZones, pageNumber, totalPages, 'zones')
const pg = pageWindow(pageNumber, totalPages)
const { rows: visibleZones, sort, toggle } = useSort(KEYS, zones)
const shown = view === 'all' ? zones : zones.filter(VIEWS[view])
const { rows: visibleZones, sort, toggle } = useSort(KEYS, shown)
const kpi = summary(all)
const allChecked = zones.length > 0 && checkedOnes.length === zones.length
@@ -397,12 +429,49 @@ export function ZoneList({
<>
<SectionHeader
title="Zones"
subtitle="Authoritative, forwarder, stub and secondary zones hosted by this server"
actions={<><Button variant="primary" disabled={!canModify || busy} onClick={onAdd}>
Add Zone
</Button></>}
/>
<div className={styles.flt}>
<KpiStrip
label="Zone totals"
items={[
{ label: 'Zones', value: all.length || totalZones, icon: 'zones' },
{ label: 'Enabled', value: kpi.enabled, icon: 'check', tone: 'ok' },
{ label: 'Signed with DNSSEC', value: kpi.signed, icon: 'lock' },
{ label: 'Secondary pending sync', value: kpi.pending, icon: 'convert', tone: kpi.pending > 0 ? 'warn' : undefined },
]}
/>
<div className={styles.pageTabs} role="tablist" aria-label="Zones">
<button type="button" role="tab" aria-selected="true" className={styles.pageTab}>
Zones <em>{all.length || totalZones}</em>
</button>
</div>
<Panel
title="Zones"
description="Filter by zone name, type, DNSSEC and expiry"
actions={
<Button variant="danger" disabled={!canDelete || busy} onClick={deleteChecked}>
Delete Zones
</Button>
}
>
<div className={styles.views}>
<Segmented
label="View"
options={(['all', 'warnings', 'enabled'] as View[]).map((v) => ({
id: v,
label: `${VIEW_LABEL[v]} · ${v === 'all' ? all.length || totalZones : all.filter(VIEWS[v]).length}`,
}))}
active={view}
onChoose={setView}
/>
</div>
<div className={`${styles.flt} ${styles.fltIn}`}>
<div className={styles.filterWide}>
<Field label="Name">
{(id) => (
@@ -509,9 +578,6 @@ export function ZoneList({
? state
: `${checkedOnes.length} of ${zones.length} zones selected`}
</span>
<Button variant="danger" disabled={!canDelete || busy} onClick={deleteChecked}>
Delete Zones
</Button>
</div>
<DataTable
@@ -542,13 +608,6 @@ export function ZoneList({
td: () => ({ className: tbl.checkCell }),
cell: (z, i) => zoneCells(rowProps(z, i)).check,
},
{
id: 'num',
th: { style: { width: 34 } },
header: '#',
td: () => ({ className: `${styles.num2} ${tbl.number}` }),
cell: (z, i) => zoneCells(rowProps(z, i)).num,
},
{
id: 'zone',
...head({ field: 'zone', sort, onSort: toggle, children: 'Zone' }),
@@ -571,24 +630,12 @@ export function ZoneList({
...head({ field: 'status', sort, onSort: toggle, style: { width: 120 }, children: 'Status' }),
cell: (z, i) => zoneCells(rowProps(z, i)).status,
},
{
id: 'serial',
...head({ field: 'serial', sort, onSort: toggle, style: { width: 110, textAlign: 'right' }, children: 'Serial' }),
td: () => ({ className: styles.mono }),
cell: (z, i) => zoneCells(rowProps(z, i)).serial,
},
{
id: 'expiry',
...head({ field: 'expiry', sort, onSort: toggle, style: { width: 110 }, children: 'Expiry' }),
td: () => ({ className: styles.mono }),
cell: (z, i) => zoneCells(rowProps(z, i)).expiry,
},
{
id: 'modified',
...head({ field: 'modified', sort, onSort: toggle, style: { width: 150 }, children: 'Last Modified' }),
td: () => ({ className: `${styles.mono} ${tbl.meta}` }),
cell: (z, i) => zoneCells(rowProps(z, i)).modified,
},
/* The actions column reserved 230 px with three labels inside; with icons
120 is enough and the 110 left over go to the data, which is what the
table is about. */
@@ -601,12 +648,13 @@ export function ZoneList({
]}
/>
<div className={`${styles.count} ${styles.countFoot}`}>
<div className={`${styles.count} ${styles.countFoot} ${styles.foot}`}>
<span>{state}</span>
{pagination}
</div>
</>
)}
</Panel>
</>
)
}
@@ -703,6 +751,11 @@ function zoneCells(p: RowProps) {
it on the button depended on which module was emitted last. */
zone: (
<>
<span
className={styles.dot}
data-tone={stateTone}
aria-hidden="true"
/>
<button
type="button"
className={`${styles.zoneLink} ${tbl.entity}`}
@@ -710,6 +763,11 @@ function zoneCells(p: RowProps) {
>
<DomainName name={zoneNameOf(z)} />
</button>
<div className={styles.sub}>
{z.soaSerial != null && <>Serial {z.soaSerial}</>}
{z.soaSerial != null && z.lastModified != null && ' · '}
{z.lastModified != null && <>Modified {date(z.lastModified)}</>}
</div>
{catalogLabel && (
<div className={styles.tags}>
<Tag tone={catalogLabel.tone}>{catalogLabel.text}</Tag>
+15
View File
@@ -110,3 +110,18 @@ the table.
font-size: var(--t-4);
color: var(--mute);
}
/* The canvas's page tab (an underline with the count) and the panel's own bars. */
.pageTabs { display:flex; gap:2px; border-bottom:1px solid var(--line); margin: 0 0 var(--s-10); }
.pageTab { display:inline-flex; gap:6px; align-items:center; padding:10px 16px; border:0; border-bottom:2px solid var(--ink); margin-bottom:-1px; background:transparent; color:var(--ink); font:500 14px/1.4 var(--font-ui); cursor:default; }
.pageTab em { font:400 10px/1 var(--font-mono); opacity:.6; font-style:normal; }
.views { padding: var(--s-7) var(--s-10) 0; }
.fltIn { padding: var(--s-8) var(--s-10); margin-bottom: 0; }
.foot { padding: var(--s-7) var(--s-10); }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right: var(--s-5); background: var(--mute); vertical-align: middle; }
.dot[data-tone='ok'] { background: var(--ok); }
.dot[data-tone='warn'] { background: var(--warn); }
.dot[data-tone='dan'] { background: var(--dan); }
.sub { margin-top: 2px; font: 400 12px/1.3 var(--font-mono); color: var(--mute); }
+1 -1
View File
@@ -44,7 +44,7 @@ describe('the zone selection bar', () => {
vi.spyOn(client, 'apiRequest').mockResolvedValue(zones)
render(<ZoneList {...props} />)
await screen.findByRole('button', { name: 'casa.test' })
const header = screen.getByRole('heading', { name: 'Zones' }).parentElement!.parentElement!
const header = screen.getByRole('heading', { level: 1, name: 'Zones' }).parentElement!.parentElement!
expect(header.textContent).toContain('Add Zone')
expect(header.textContent).not.toContain('Delete Zones')
})
+6 -6
View File
@@ -84,12 +84,12 @@ describe('zone list', () => {
draw()
await screen.findByRole('button', { name: 'casa.test' })
const initialCalls = spy.mock.calls.filter((c) => c[0] === 'zones/list').length
const initialCalls = spy.mock.calls.filter((c) => c[0] === 'zones/list' && c[1]?.body?.zonesPerPage !== '5000').length
await user.type(screen.getByLabelText('Name'), 'ca')
expect(spy.mock.calls.filter((c) => c[0] === 'zones/list')).toHaveLength(initialCalls)
expect(spy.mock.calls.filter((c) => c[0] === 'zones/list' && c[1]?.body?.zonesPerPage !== '5000')).toHaveLength(initialCalls)
await user.click(screen.getByRole('button', { name: 'Go' }))
const last = spy.mock.calls.filter((c) => c[0] === 'zones/list').at(-1)
const last = spy.mock.calls.filter((c) => c[0] === 'zones/list' && c[1]?.body?.zonesPerPage !== '5000').at(-1)
expect(last![1]?.body).toMatchObject({ filterName: 'ca' })
})
@@ -270,7 +270,7 @@ describe('records of a zone', () => {
// The "Zones" segment of the path IS the back button: before there was also a
// a loose "← Zones" above saying the same thing.
await user.click(within(screen.getByLabelText('Breadcrumb')).getByRole('button'))
expect(await screen.findByRole('heading', { name: 'Zones' })).toBeTruthy()
expect(await screen.findByRole('heading', { level: 1, name: 'Zones' })).toBeTruthy()
})
})
@@ -381,7 +381,7 @@ describe('zones: the cluster node', () => {
await userEvent.click(screen.getByRole('option', { name: 'b.example (secondary)' }))
await waitFor(() => {
const calls = spy.mock.calls.filter((c) => c[0] === 'zones/list')
const calls = spy.mock.calls.filter((c) => c[0] === 'zones/list' && c[1]?.body?.zonesPerPage !== '5000')
expect(calls.at(-1)![1]?.body).toMatchObject({ node: 'b.example' })
})
})
@@ -395,7 +395,7 @@ describe('zones: the cluster node', () => {
await userEvent.click(screen.getByRole('option', { name: 'b.example (secondary)' }))
await waitFor(() => {
const calls = spy.mock.calls.filter((c) => c[0] === 'zones/list')
const calls = spy.mock.calls.filter((c) => c[0] === 'zones/list' && c[1]?.body?.zonesPerPage !== '5000')
expect(calls.at(-1)![1]?.body).toMatchObject({ node: 'b.example', pageNumber: '2' })
})
})
+3 -3
View File
@@ -70,9 +70,9 @@ for whatever really rules its own line.
body {
margin: 0;
font-family: var(--font-ui);
font-size: 13px;
line-height: 1.55;
letter-spacing: 0.005em;
font-size: 14px;
line-height: 1.4;
letter-spacing: 0;
background: var(--bg);
color: var(--ink);
-webkit-font-smoothing: antialiased;
+15 -15
View File
@@ -44,9 +44,9 @@ before `data-theme` is set and with any value nobody declared.
color-scheme: dark;
/* ── Color ──────────────────────────────────────────────────────────── */
--bg: #06080c;
--pan: #121419;
--pan2: #202227;
--bg: oklch(0.133 0.011 264.376);
--pan: oklch(0.191 0.011 264.376);
--pan2: oklch(0.252 0.010 264.376);
/*
THE SURFACE RAMP. In a dark interface elevation is NOT told by the shadow
(black on near-black is invisible) but by the surface RISING in light. The old
@@ -72,8 +72,8 @@ before `data-theme` is set and with any value nobody declared.
Technitium's amber stays exactly as it is: it is THEIR brand, not ours, and over
this charcoal it gives 8.38:1.
*/
--ink: #fafafa;
--mute: #a2a5aa;
--ink: oklch(0.985 0 0);
--mute: oklch(0.648 0.008 264.376);
/*
`--faint` was `#5d6779` and gave 2.70-3.34:1: it met the 4.5:1 for normal text
on no surface at all, and it was being used for the `(4h)` of a TTL, the "2
@@ -87,9 +87,9 @@ before `data-theme` is set and with any value nobody declared.
ground enough to take `--faint` under 4.5:1, so light gives it a step of its own.
*/
--faint-on-tint: var(--faint);
--line: #292c30;
--line2: #202227;
--acc: #e5e5e5;
--line: oklch(1 0 0 / 10%);
--line2: oklch(1 0 0 / 6%);
--acc: oklch(0.922 0 0);
/*
AMBER AS INK, and AMBER AS A FILL, are two tokens since 2026-10-01. `--acc` is
the fill (the primary button, the active rail entry, a ticked checkbox, the
@@ -103,12 +103,12 @@ before `data-theme` is set and with any value nobody declared.
the 4.5 for text and the 3 for a line. Brand amber stays the fill in both
themes; light draws its ink darker (see the light block).
*/
--acc-ink: #e5e5e5;
--on-acc: #171717;
--ok: #00bc7d;
--warn: #f0b100;
--dan: #ff6467;
--info: #aa8dff;
--acc-ink: oklch(0.922 0 0);
--on-acc: oklch(0.205 0 0);
--ok: oklch(0.696 0.17 162.48);
--warn: oklch(0.795 0.184 86.047);
--dan: oklch(0.704 0.191 22.216);
--info: oklch(0.606 0.25 292.717);
/* The two the comp has and that had fallen out: the Query Logs series need
them so as not to go back to the loose `rgba()` inherited from upstream. */
--viol: #a78bfa;
@@ -342,7 +342,7 @@ before `data-theme` is set and with any value nobody declared.
--line-ctrl: #3a465b; /* a field's border under the pointer; a checkbox's at rest */
/* A field's border at rest. The value of `--line` here; light needs it apart,
because there the border is what tells a field from the panel (light block). */
--line-field: #35373c;
--line-field: oklch(1 0 0 / 15%);
--field-focus: #0e131b; /* a `ui/Field` input while it has the focus */
/* The veil behind what covers the page: the side drawer on a narrow screen,
and the dialog, which darkens more because it is modal. */
+19
View File
@@ -189,3 +189,22 @@ and the 39 rows stay aligned.
*/
.dependent { box-shadow: inset 2px 0 0 0 var(--acc-ink); }
.withPill { display: flex; align-items: center; gap: var(--s-4); flex-wrap: wrap; }
/*
The canvas's row (`.frow`): the label with its help under it on the left, the control
on the right, a hairline between rows. The help stays a sibling of the control, so
the grid puts it under the label without moving it in the DOM.
*/
.row {
grid-template-columns: minmax(0, 1fr) minmax(220px, 440px);
grid-template-areas: 'label ctl' 'help ctl';
gap: var(--s-1) var(--s-10);
padding: 14px var(--s-10);
align-items: center;
}
.row > .rowLabel { grid-area: label; padding-top: 0; font-weight: 500; font-size: 14px; }
.row > .rowCtl { grid-area: ctl; align-self: center; }
.row > .rowHelp { grid-area: help; grid-column: auto; max-width: 640px; font-size: 13px; color: var(--mute); }
@media (max-width: 900px) {
.row { grid-template-columns: minmax(0, 1fr); grid-template-areas: 'label' 'help' 'ctl'; }
}
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Icon, type IconName } from './Icon'
/*
The canvas's KPI strip: a Frame, one panel, and cells told apart by hairlines. Each
cell is a 40 px icon tile, the figure's name, the figure and, beside it, a small
badge with its hint. A tone colours the icon, the figure and the badge together.
*/
export type KpiTone = 'ok' | 'warn' | 'dan'
export interface Kpi {
label: string
value: ReactNode
icon: IconName
tone?: KpiTone
hint?: string
}
const TEXT: Record<KpiTone, string> = { ok: 'text-success', warn: 'text-warning', dan: 'text-destructive' }
const BADGE = { ok: 'success-light', warn: 'warning-light', dan: 'destructive-light' } as const
export function KpiStrip({ items, label }: { items: Kpi[]; label: string }) {
return (
<Frame role="group" aria-label={label}>
<FramePanel className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] -mb-px p-0">
{items.map((k) => (
<div
key={k.label}
className="flex min-w-0 items-center gap-3 border-r border-b border-border px-[18px] py-3.5 last:border-r-0"
>
<span
className={`flex size-10 flex-none items-center justify-center rounded-lg border border-border bg-muted shadow-sm ${k.tone ? TEXT[k.tone] : 'text-foreground'}`}
aria-hidden="true"
>
<Icon name={k.icon} size={18} />
</span>
<div className="flex min-w-0 flex-col gap-1">
<div className="text-[13px] text-muted-foreground">{k.label}</div>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className={`text-2xl font-semibold tabular-nums ${k.tone ? TEXT[k.tone] : ''}`}>{k.value}</span>
{k.hint != null && <Badge variant={k.tone ? BADGE[k.tone] : 'outline'}>{k.hint}</Badge>}
</div>
</div>
</div>
))}
</FramePanel>
</Frame>
)
}
+4
View File
@@ -55,6 +55,7 @@ export function Menu({
onOpen,
anchor = 'right',
asRow = false,
lead,
quiet = false,
icon = 'plus',
alignTo,
@@ -75,6 +76,8 @@ export function Menu({
anchor?: 'right' | 'left'
/** The trigger fills the width of its column, with the label on the left. */
asRow?: boolean
/** Before the label of a row trigger: the account's avatar. */
lead?: ReactNode
/**
* The compact `⋮` with no frame at rest, for a list where one menu repeats on
* every short row (the Dashboard's tops, Blocking's top tables): fifteen framed
@@ -207,6 +210,7 @@ export function Menu({
aria-expanded={open}
onKeyDown={onTriggerKeyDown}
>
{lead}
{text}
<Icon name="chevronDown" size={12} />
</button>
+5
View File
@@ -0,0 +1,5 @@
.tabs { display:flex; gap:2px; border-bottom:1px solid var(--line); margin: 0 0 var(--s-10); overflow-x:auto; }
.tab { display:inline-flex; gap:6px; align-items:center; padding:10px 16px; border:0; border-bottom:2px solid transparent; margin-bottom:-1px; background:transparent; color:var(--mute); font:500 14px/1.4 var(--font-ui); cursor:pointer; white-space:nowrap; }
.tab:hover { color:var(--ink); }
.tab[aria-selected='true'] { color:var(--ink); border-bottom-color:var(--ink); }
.tab em { font:400 10px/1 var(--font-mono); opacity:.6; font-style:normal; }
+36
View File
@@ -0,0 +1,36 @@
import styles from './PageTabs.module.css'
/*
The canvas's page tabs: an underline under the active one and a small count after
its name. They switch a panel on the same screen (an ARIA tablist), unlike
`SubTabs`, whose entries are routes.
*/
export function PageTabs<T extends string>({
label,
tabs,
active,
onChoose,
}: {
label: string
tabs: { id: T; label: string; count?: number }[]
active: T
onChoose: (id: T) => void
}) {
return (
<div className={styles.tabs} role="tablist" aria-label={label}>
{tabs.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={t.id === active}
className={styles.tab}
onClick={() => onChoose(t.id)}
>
{t.label}
{t.count != null && <em>{t.count}</em>}
</button>
))}
</div>
)
}
+19 -8
View File
@@ -21,6 +21,7 @@ is also what upstream does, using `div.panel` with its title and no `fieldset`.
*/
export function Panel({
title,
description,
actions,
groups2 = false,
children,
@@ -35,6 +36,8 @@ export function Panel({
exactly what this exists to avoid.
*/
title?: ReactNode
/** One muted line under the title, as the canvas draws it. */
description?: ReactNode
/** What goes on the right of the header. */
actions?: ReactNode
/*
@@ -53,11 +56,11 @@ export function Panel({
}) {
const id = useId()
/* ReUI's Frame: a tinted outer frame with the panel inside. `dense` removes the
frame's own padding, so what sits in the panel (tables, rows) keeps its edges. */
/* ReUI's Frame as the canvas draws it: a tinted outer frame, 3 px of ring and
the panel inside. */
if (title == null) {
return (
<Frame dense className={className}>
<Frame className={className}>
<FramePanel className="p-0">{children}</FramePanel>
</Frame>
)
@@ -65,15 +68,23 @@ export function Panel({
return (
<Frame
dense
className={className}
role={groups2 ? 'group' : undefined}
aria-labelledby={groups2 ? id : undefined}
>
<FrameHeader className="flex-row items-center justify-between gap-3 px-4 py-2.5">
<h2 className="m-0 text-sm leading-5 font-semibold text-foreground" id={id}>
{title}
</h2>
<FrameHeader className="flex-row items-center justify-between gap-3 px-4 py-3">
{description == null ? (
<h2 className="m-0 text-sm leading-5 font-semibold text-foreground" id={id}>
{title}
</h2>
) : (
<div className="min-w-0 flex-1">
<h2 className="m-0 text-sm leading-5 font-semibold text-foreground" id={id}>
{title}
</h2>
<p className="m-0 text-sm text-muted-foreground">{description}</p>
</div>
)}
{actions}
</FrameHeader>
<FramePanel className="p-0">{children}</FramePanel>
+1 -1
View File
@@ -40,7 +40,7 @@ export function Segmented<T extends string>({
type="button"
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',
'aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-xs aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-xs',
compact ? 'h-6 text-xs' : 'h-[30px]',
)}
role={asTabs ? 'tab' : undefined}
+2
View File
@@ -59,3 +59,5 @@ marked with an edge and the rest read as ordinary links.
border-radius: var(--r-full);
background: var(--acc-ink);
}
.count { margin-left: var(--s-3); font: 400 10px/1 var(--font-mono); font-style: normal; opacity: .6; }
+4
View File
@@ -45,6 +45,7 @@ export function SubTabs({
active,
onChoose,
marks,
counts,
}: {
/** The accessible name of the navigation: "Settings sections". */
label: string
@@ -55,6 +56,8 @@ export function SubTabs({
onChoose: (tab: string) => void
/** A tab's mark: the words a screen reader hears after its name. */
marks?: Partial<Record<string, string>>
/** A small count after a tab's name, as the canvas draws it (`Rules 42`). */
counts?: Partial<Record<string, number>>
}) {
return (
<nav className={styles.bar} aria-label={label}>
@@ -71,6 +74,7 @@ export function SubTabs({
}}
>
{t}
{counts?.[t] != null && <em className={styles.count}>{counts[t]}</em>}
{marks?.[t] != null && (
<>
<span className={text.sr}>, {marks[t]}</span>