Match the Technitium canvas: palette, shell, KPI strips, framed tables
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:
1 parent
ea98a33b25
commit
87281a87d3
53 files changed
+1011
-487
No files matched your search
@@ -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:
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
against 163 with tabs on top, and the second row of stacked tabs disappears. */
|
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; }
|
.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; }
|
.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.
|
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
|
someone else's product's mark with a letter of our own is exactly what a fork
|
||||||
must not do.
|
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
|
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
|
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; }
|
.shell[data-rail='true'] .s, .shell[data-rail='true'] .sbsearch, .shell[data-rail='true'] .sbrand { justify-content: flex-start; }
|
||||||
.status .lbl { display: none; }
|
.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
@@ -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 (
|
return (
|
||||||
<HandoffContext.Provider value={handoff}>
|
<HandoffContext.Provider value={handoff}>
|
||||||
@@ -411,13 +412,16 @@ export function Shell({
|
|||||||
|
|
||||||
<aside className={styles.side} data-open={drawer}>
|
<aside className={styles.side} data-open={drawer}>
|
||||||
<div className={styles.sbrand}>
|
<div className={styles.sbrand}>
|
||||||
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} />
|
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={32} height={32} />
|
||||||
<span className={styles.lbl}>Technitium</span>
|
<span className={`${styles.lbl} ${styles.bname}`}>
|
||||||
<span className={`${styles.lbl} ${styles.sbtag}`}>console</span>
|
<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>
|
</div>
|
||||||
<button type="button" className={styles.sbsearch} onClick={() => setPalette(true)} aria-label="Search">
|
<button type="button" className={styles.sbsearch} onClick={() => setPalette(true)} aria-label="Search">
|
||||||
<Icon name="menu" size={14} />
|
<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>
|
<kbd className={styles.lbl}>{'\u2318K'}</kbd>
|
||||||
</button>
|
</button>
|
||||||
<nav className={styles.slist} role="navigation" aria-label="Sections">
|
<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
|
domain heads it because the block is about that server, which is the
|
||||||
pairing upstream makes in its panel title.
|
pairing upstream makes in its panel title.
|
||||||
*/}
|
*/}
|
||||||
<Versions
|
<Menu
|
||||||
token={session.token}
|
label={displayName}
|
||||||
serverVersion={info?.version}
|
text={displayName}
|
||||||
domain={info?.dnsServerDomain}
|
anchor="left"
|
||||||
markHidden={markHidden}
|
asRow
|
||||||
canCheckConsole={permissions?.DnsClient?.canView === true}
|
lead={<span className={styles.av} aria-hidden="true">{displayName.slice(0, 2).toUpperCase()}</span>}
|
||||||
/>
|
>
|
||||||
<Menu label={displayName} text={displayName} anchor="left" asRow>
|
|
||||||
{(close) => (
|
{(close) => (
|
||||||
<>
|
<>
|
||||||
<button type="button" onClick={() => { close(); open('profile') }}>
|
<button type="button" onClick={() => { close(); open('profile') }}>
|
||||||
@@ -571,9 +574,9 @@ export function Shell({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Popover>
|
<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'} />
|
<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>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="end" className={styles.pop}>
|
<PopoverContent align="end" className={styles.pop}>
|
||||||
<dl>
|
<dl>
|
||||||
@@ -582,6 +585,14 @@ export function Shell({
|
|||||||
<dt>Up since</dt><dd>{info?.uptimestamp ? new Date(info.uptimestamp).toLocaleString() : '-'}</dd>
|
<dt>Up since</dt><dd>{info?.uptimestamp ? new Date(info.uptimestamp).toLocaleString() : '-'}</dd>
|
||||||
<dt>Blocking</dt><dd>{blockingNow ? blockingNow.label.replace(/^Blocking,?\s*/, '') : 'Enabled'}</dd>
|
<dt>Blocking</dt><dd>{blockingNow ? blockingNow.label.replace(/^Blocking,?\s*/, '') : 'Enabled'}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
<Versions
|
||||||
|
token={session.token}
|
||||||
|
serverVersion={info?.version}
|
||||||
|
domain={info?.dnsServerDomain}
|
||||||
|
markHidden={markHidden}
|
||||||
|
canCheckConsole={permissions?.DnsClient?.canView === true}
|
||||||
|
/>
|
||||||
|
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ above it below. The form's side keeps what it always had: the card centred on
|
|||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
composes: panel from '../ui/Panel.module.css';
|
composes: panel from '../ui/Panel.module.css';
|
||||||
width: 340px;
|
width: 420px;
|
||||||
|
max-width: 100%;
|
||||||
padding: var(--s-11) var(--s-11);
|
padding: var(--s-11) var(--s-11);
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1,101 +1,34 @@
|
|||||||
/*
|
/*
|
||||||
The login cover (OURS, see `LoginCover.tsx`). The social banner, redrawn with the
|
The login cover (OURS, see `LoginCover.tsx`): the canvas's left column. A name with
|
||||||
console's tokens so it follows the theme: `--field` is the deepest surface each
|
its version, a headline, one sentence and a framed list of three things. On the page
|
||||||
theme has (darker than the page in dark, lighter than it in light), which is what
|
colour, so it follows the theme; the card of the form sits beside it. Stacked above
|
||||||
separates the cover from the form's side in both. Every text token drawn on it is
|
the form below 960 px, where the list goes.
|
||||||
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
|
|
||||||
*/
|
*/
|
||||||
.cover {
|
.cover {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--s-12);
|
gap: var(--s-11);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: calc(var(--s-12) * 2);
|
padding: var(--s-12) calc(var(--s-12) * 1.5);
|
||||||
background: var(--field);
|
background: var(--bg);
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
|
.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) {
|
@media (max-width: 960px) {
|
||||||
.cover {
|
.cover { padding: var(--s-11) var(--s-10) 0; gap: var(--s-9); }
|
||||||
gap: var(--s-10);
|
.headline { font-size: 24px; }
|
||||||
padding: var(--s-12) var(--s-11) var(--s-11);
|
.lead, .perks { display: none; }
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|||||||
import { render, screen, within } from '@testing-library/react'
|
import { render, screen, within } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { Login } from './Login'
|
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 client from '../api/client'
|
||||||
import * as status from '../api/status'
|
import * as status from '../api/status'
|
||||||
|
|
||||||
@@ -17,37 +17,24 @@ describe('LoginCover', () => {
|
|||||||
render(<LoginCover />)
|
render(<LoginCover />)
|
||||||
const cover = screen.getByRole('complementary')
|
const cover = screen.getByRole('complementary')
|
||||||
expect(within(cover).getByText(COVER_NAME)).toBeInTheDocument()
|
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_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 />)
|
render(<LoginCover />)
|
||||||
const items = within(screen.getByRole('list')).getAllByRole('listitem')
|
const items = within(screen.getByRole('list')).getAllByRole('listitem')
|
||||||
expect(items).toHaveLength(COVER_RECORDS.length)
|
expect(items).toHaveLength(COVER_PERKS.length)
|
||||||
COVER_RECORDS.forEach((r, i) => {
|
COVER_PERKS.forEach((p, i) => {
|
||||||
// What is drawn: the dig line, quotes and `TXT` included.
|
expect(items[i]).toHaveTextContent(`${p.title}${p.text}`)
|
||||||
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', '"', '"'])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/*
|
/* The panel is shown before anyone has logged in: nothing may age or look like data. */
|
||||||
The texts must not age: the banner's "A new console" is the line this rule
|
it('it says nothing that ages', () => {
|
||||||
exists for. And each value has to fit one line of the answer block at 1024 px,
|
const words = [COVER_HEADLINE, COVER_LEAD, ...COVER_PERKS.map((p) => `${p.title} ${p.text}`)].join(' ').toLowerCase()
|
||||||
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()
|
|
||||||
expect(words).not.toMatch(/\b(new|newest|latest|now|soon|beta|20\d\d)\b/)
|
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
@@ -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'
|
import styles from './LoginCover.module.css'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The login cover, OURS: a brand panel beside the stock login form. It is a
|
The login cover, OURS: a brand panel beside the stock login form, as the canvas draws
|
||||||
deliberate deviation from upstream (CONVENTIONS.md, "Deliberate deviations from
|
it. It is a deliberate deviation from upstream (CONVENTIONS.md, "Deliberate deviations
|
||||||
upstream behaviour", 2026-10-15), and it only changes the layout around the
|
from upstream behaviour"), and it only changes the layout around the form: every field,
|
||||||
form: every field, text, step and alert of `Login.tsx` is upstream's and stays so.
|
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 name and version, a headline, one sentence and three things the console covers,
|
||||||
the `;; ANSWER SECTION:` line and the four TXT records (keys and values). They
|
each in an icon tile. Nothing says "new" and no figure is drawn: the panel is shown
|
||||||
are written so they do not age: nothing says "new", and each line is true of the
|
before anyone has logged in, so anything resembling data would be invented.
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const COVER_NAME = 'technitium-console'
|
export const COVER_NAME = 'Technitium DNS'
|
||||||
export const COVER_HEADLINE = 'The same DNS server, in a rebuilt console.'
|
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. */
|
/** What the console covers: one line each. */
|
||||||
export const COVER_RECORDS: readonly { key: string; value: string }[] = [
|
export const COVER_PERKS: readonly { title: string; text: string; icon: IconName }[] = [
|
||||||
{ key: 'features.', value: 'every stock feature, same API' },
|
{ title: 'Zones and records', text: 'Primary, secondary, forwarder and stub zones', icon: 'zones' },
|
||||||
{ key: 'blocking.', value: 'rules and lists in one place' },
|
{ title: 'Blocking and cache', text: 'Block lists, allowed domains and the resolver cache', icon: 'blocked' },
|
||||||
{ key: 'themes.', value: 'light, dark or system' },
|
{ title: 'DHCP and logs', text: 'Leases, scopes and query logs', icon: 'logs' },
|
||||||
{ key: 'phone.', value: 'works on a phone' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function LoginCover() {
|
export function LoginCover() {
|
||||||
return (
|
return (
|
||||||
<aside className={styles.cover}>
|
<aside className={styles.cover}>
|
||||||
<p className={styles.name}>
|
<p className={styles.name}>
|
||||||
<span className={styles.dot} aria-hidden="true" />
|
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={32} height={32} />
|
||||||
<span>{COVER_NAME}</span>
|
<span className={styles.who}>
|
||||||
{/* The version from package.json, injected at build time (vite.config.ts). */}
|
<b>{COVER_NAME}</b>
|
||||||
<span className={styles.version}>v{__CONSOLE_VERSION__}</span>
|
{/* The version from package.json, injected at build time (vite.config.ts). */}
|
||||||
|
<small>console v{__CONSOLE_VERSION__}</small>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className={styles.headline}>{COVER_HEADLINE}</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;
|
<Frame className={styles.perks}>
|
||||||
to a screen reader they are noise, so each row reads as
|
<FramePanel className="p-0">
|
||||||
"features. every stock feature, same API". */}
|
<ul className={styles.list}>
|
||||||
<div className={styles.answer} data-testid="cover-answer">
|
{COVER_PERKS.map((p) => (
|
||||||
<span className={styles.section} aria-hidden="true">
|
<li key={p.title} className={styles.perk}>
|
||||||
;; ANSWER SECTION:
|
<span className={styles.ico} aria-hidden="true">
|
||||||
</span>
|
<Icon name={p.icon} size={16} />
|
||||||
<ul className={styles.records}>
|
</span>
|
||||||
{COVER_RECORDS.map((r, i) => (
|
<span>
|
||||||
<li key={r.key} className={styles.record} style={{ '--row': i } as CSSProperties}>
|
<b>{p.title}</b>
|
||||||
<span className={styles.key}>{r.key}</span>
|
<small>{p.text}</small>
|
||||||
<span className={styles.type} aria-hidden="true">
|
</span>
|
||||||
TXT
|
</li>
|
||||||
</span>
|
))}
|
||||||
<span className={styles.value}>
|
</ul>
|
||||||
<span aria-hidden="true">"</span>
|
</FramePanel>
|
||||||
{r.value}
|
</Frame>
|
||||||
<span aria-hidden="true">"</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ About shows, right under the title.
|
|||||||
export function About({ info }: { info?: Info }) {
|
export function About({ info }: { info?: Info }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader title="About" />
|
<SectionHeader title="About" subtitle="Technitium DNS Server and this console" />
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
<Panel>
|
<Panel>
|
||||||
<Body>
|
<Body>
|
||||||
@@ -155,7 +155,7 @@ export function About({ info }: { info?: Info }) {
|
|||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<div className={styles.col}>
|
<div className={styles.col}>
|
||||||
<Panel title="Server">
|
<Panel title="Server" description="Version and uptime">
|
||||||
<Body>
|
<Body>
|
||||||
<dl className={styles.kv}>
|
<dl className={styles.kv}>
|
||||||
<dt>Version</dt><dd>{info?.version ?? '—'}</dd>
|
<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
|
of `.github/FUNDING.yml` as buttons. The words are ours and follow the
|
||||||
README's `Support` and `Licence`.
|
README's `Support` and `Licence`.
|
||||||
*/}
|
*/}
|
||||||
<Panel title="Web Console">
|
<Panel title="Web Console" description="Version and license">
|
||||||
<Body>
|
<Body>
|
||||||
<dl className={styles.kv}>
|
<dl className={styles.kv}>
|
||||||
<dt>Version</dt><dd>{__CONSOLE_VERSION__}</dd>
|
<dt>Version</dt><dd>{__CONSOLE_VERSION__}</dd>
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export function Cluster({ tabs, token, cluster, node: controlledNode, onNodeChan
|
|||||||
*/}
|
*/}
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Administration"
|
title="Administration"
|
||||||
|
subtitle="Sessions, users, groups, permissions, SSO, LDAP and cluster"
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
labels={
|
labels={
|
||||||
initialised && ownType != null ? (
|
initialised && ownType != null ? (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Panel } from '../../ui/Panel'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Button } from '../../ui/Button'
|
import { Button } from '../../ui/Button'
|
||||||
@@ -94,6 +95,7 @@ export function Groups({ tabs, token, onNotice }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Administration"
|
title="Administration"
|
||||||
|
subtitle="Sessions, users, groups, permissions, SSO, LDAP and cluster"
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
actions={<><Button variant="primary" onClick={() => setAdd(true)}>
|
actions={<><Button variant="primary" onClick={() => setAdd(true)}>
|
||||||
Add Group
|
Add Group
|
||||||
@@ -103,7 +105,7 @@ export function Groups({ tabs, token, onNotice }: Props) {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Panel title="Groups" description="Group names, descriptions and members">
|
||||||
<DataTable
|
<DataTable
|
||||||
data={visibleGroups}
|
data={visibleGroups}
|
||||||
getRowId={(g) => g.name}
|
getRowId={(g) => g.name}
|
||||||
@@ -149,7 +151,7 @@ export function Groups({ tabs, token, onNotice }: Props) {
|
|||||||
<div className={styles.count}>
|
<div className={styles.count}>
|
||||||
<span>{`Total Groups: ${groups.length}`}</span>
|
<span>{`Total Groups: ${groups.length}`}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Confirm
|
<Confirm
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ export function Ldap({ tabs, token, onNotice }: Props) {
|
|||||||
|
|
||||||
return (
|
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
|
The dense form of `Sso.tsx`, block for block. Upstream puts its two
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export function Permissions({ tabs, token, cluster, onNotice }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Administration"
|
title="Administration"
|
||||||
|
subtitle="Sessions, users, groups, permissions, SSO, LDAP and cluster"
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Panel } from '../../ui/Panel'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Button } from '../../ui/Button'
|
import { Button } from '../../ui/Button'
|
||||||
@@ -130,6 +131,7 @@ export function Sessions({ tabs, token, cluster, node: controlledNode, onNodeCha
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Administration"
|
title="Administration"
|
||||||
|
subtitle="Sessions, users, groups, permissions, SSO, LDAP and cluster"
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
actions={<>{canCreateToken && (
|
actions={<>{canCreateToken && (
|
||||||
<Button variant="primary" onClick={() => setCreate(true)}>
|
<Button variant="primary" onClick={() => setCreate(true)}>
|
||||||
@@ -142,7 +144,7 @@ export function Sessions({ tabs, token, cluster, node: controlledNode, onNodeCha
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Panel title="Sessions" description="Signed-in sessions and API tokens">
|
||||||
{/* My own session's row, washed in `--acc-bg`. The `(current)` in
|
{/* My own session's row, washed in `--acc-bg`. The `(current)` in
|
||||||
its token cell stays: the colour finds the row, the word says
|
its token cell stays: the colour finds the row, the word says
|
||||||
which it is. Same treatment as this node's row in `Cluster`. */}
|
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}>
|
<div className={styles.count}>
|
||||||
<span>{`Total Sessions: ${sessions.length}`}</span>
|
<span>{`Total Sessions: ${sessions.length}`}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Confirm
|
<Confirm
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export function Sso({ tabs, token, onNotice }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Administration"
|
title="Administration"
|
||||||
|
subtitle="Sessions, users, groups, permissions, SSO, LDAP and cluster"
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Panel } from '../../ui/Panel'
|
||||||
|
import { KpiStrip } from '../../ui/KpiStrip'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Button } from '../../ui/Button'
|
import { Button } from '../../ui/Button'
|
||||||
@@ -163,16 +165,28 @@ export function Users({ tabs, token, cluster, onNotice }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Administration"
|
title="Administration"
|
||||||
|
subtitle="Sessions, users, groups, permissions, SSO, LDAP and cluster"
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
actions={<><Button variant="primary" onClick={() => setAdd(true)}>
|
actions={<><Button variant="primary" onClick={() => setAdd(true)}>
|
||||||
Add User
|
Add User
|
||||||
</Button></>}
|
</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 ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Panel title="Users" description="Username, display name, groups, 2FA, last seen and status">
|
||||||
<DataTable
|
<DataTable
|
||||||
data={visibleUsers}
|
data={visibleUsers}
|
||||||
getRowId={(u) => String(u.username)}
|
getRowId={(u) => String(u.username)}
|
||||||
@@ -297,7 +311,7 @@ export function Users({ tabs, token, cluster, onNotice }: Props) {
|
|||||||
<div className={styles.count}>
|
<div className={styles.count}>
|
||||||
<span>{`Total Users: ${users.length}`}</span>
|
<span>{`Total Users: ${users.length}`}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Confirm
|
<Confirm
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { dnsAppLabels, type InstalledApp } from '../../api/apps'
|
|||||||
import { Button } from '../../ui/Button'
|
import { Button } from '../../ui/Button'
|
||||||
import { Menu } from '../../ui/Menu'
|
import { Menu } from '../../ui/Menu'
|
||||||
import { Details } from '../../ui/Details'
|
import { Details } from '../../ui/Details'
|
||||||
import { Chip } from '../../ui/Tag'
|
import { Chip, Tag } from '../../ui/Tag'
|
||||||
import styles from './Apps.module.css'
|
import styles from './Apps.module.css'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -32,55 +32,29 @@ export function AppCard({
|
|||||||
const hasUpdate = app.updateAvailable === true && Boolean(app.updateUrl)
|
const hasUpdate = app.updateAvailable === true && Boolean(app.updateUrl)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={styles.app} aria-label={app.name}>
|
<li className={styles.gridRow} aria-label={app.name}>
|
||||||
<div className={styles.ah}>
|
<div className={styles.nameCell}>
|
||||||
<div>
|
<h2>{app.name}</h2>
|
||||||
<h2>{app.name}</h2>
|
{app.description && <p className={styles.desc}>{app.description}</p>}
|
||||||
{/*
|
|
||||||
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>
|
|
||||||
</div>
|
</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 && (
|
<div className={styles.mono}>{app.dnsApps.length}</div>
|
||||||
<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.foot}>
|
<div>
|
||||||
|
<Tag tone={hasUpdate ? 'warn' : 'ok'}>{hasUpdate ? 'Update available' : 'Up to date'}</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.rowActs}>
|
||||||
<Button disabled={busy} onClick={onConfig}>
|
<Button disabled={busy} onClick={onConfig}>
|
||||||
Config
|
Config
|
||||||
</Button>
|
</Button>
|
||||||
@@ -120,6 +94,31 @@ export function AppCard({
|
|||||||
)}
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</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>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -59,3 +59,25 @@ before.
|
|||||||
.sortBy:focus-visible { outline: var(--focus); outline-offset: var(--focus-offset); }
|
.sortBy:focus-visible { outline: var(--focus); outline-offset: var(--focus-offset); }
|
||||||
/* The installed grid gets the same sort affordance as the store list. */
|
/* The installed grid gets the same sort affordance as the store list. */
|
||||||
.sortRow + .apps { margin-top: var(--s-8); }
|
.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); }
|
||||||
@@ -403,14 +403,14 @@ describe('Apps: the store', () => {
|
|||||||
await userEvent.click(await screen.findByRole('button', { name: 'App Store' }))
|
await userEvent.click(await screen.findByRole('button', { name: 'App Store' }))
|
||||||
|
|
||||||
const names = () =>
|
const names = () =>
|
||||||
within(screen.getByRole('dialog'))
|
within(screen.getByRole('group', { name: 'DNS App Store' }))
|
||||||
.getAllByRole('listitem')
|
.getAllByRole('listitem')
|
||||||
.map((li) => li.getAttribute('aria-label'))
|
.map((li) => li.getAttribute('aria-label'))
|
||||||
|
|
||||||
await screen.findByText('Total Apps: 2')
|
await screen.findByText('Total Apps: 2')
|
||||||
expect(names()).toEqual(['Zone Alias', 'Auto PTR'])
|
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)
|
await userEvent.click(sort)
|
||||||
expect(names()).toEqual(['Auto PTR', 'Zone Alias'])
|
expect(names()).toEqual(['Auto PTR', 'Zone Alias'])
|
||||||
|
|
||||||
@@ -440,7 +440,7 @@ describe('Apps: the store', () => {
|
|||||||
await openStore()
|
await openStore()
|
||||||
|
|
||||||
const row = within(
|
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('Version 5.0')).toBeInTheDocument()
|
||||||
expect(row.getByText('Update 6.0')).toBeInTheDocument()
|
expect(row.getByText('Update 6.0')).toBeInTheDocument()
|
||||||
@@ -479,7 +479,7 @@ describe('Apps: the store', () => {
|
|||||||
await openStore()
|
await openStore()
|
||||||
|
|
||||||
const row = within(
|
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' }))
|
await userEvent.click(row.getByRole('button', { name: 'Uninstall' }))
|
||||||
// The confirmation stacks over the store's dialog.
|
// The confirmation stacks over the store's dialog.
|
||||||
|
|||||||
+61
-36
@@ -1,3 +1,4 @@
|
|||||||
|
import { KpiStrip } from '../../ui/KpiStrip'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
downloadAndUpdate,
|
downloadAndUpdate,
|
||||||
@@ -13,6 +14,8 @@ import { AppConfig } from './AppConfig'
|
|||||||
import { AppCard } from './AppCard'
|
import { AppCard } from './AppCard'
|
||||||
import { InstallApp } from './InstallApp'
|
import { InstallApp } from './InstallApp'
|
||||||
import { StoreApps } from './StoreApps'
|
import { StoreApps } from './StoreApps'
|
||||||
|
import { Panel } from '../../ui/Panel'
|
||||||
|
import { PageTabs } from '../../ui/PageTabs'
|
||||||
import { UpdateApp } from './UpdateApp'
|
import { UpdateApp } from './UpdateApp'
|
||||||
import { Empty, Failure, Loading } from '../../ui/Empty'
|
import { Empty, Failure, Loading } from '../../ui/Empty'
|
||||||
import { Tag } from '../../ui/Tag'
|
import { Tag } from '../../ui/Tag'
|
||||||
@@ -45,7 +48,6 @@ adding functionality.
|
|||||||
export interface AlertState { type: AlertType; title: string; text: string }
|
export interface AlertState { type: AlertType; title: string; text: string }
|
||||||
|
|
||||||
type Modal =
|
type Modal =
|
||||||
| { kind: 'store' }
|
|
||||||
| { kind: 'install' }
|
| { kind: 'install' }
|
||||||
| { kind: 'update'; name: string }
|
| { kind: 'update'; name: string }
|
||||||
| { kind: 'config'; name: string; config: 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 [apps, setApps] = useState<InstalledApp[] | null>(null)
|
||||||
const [alert, setAlert] = useState<AlertState | null>(null)
|
const [alert, setAlert] = useState<AlertState | null>(null)
|
||||||
const [modal, setModal] = useState<Modal | null>(null)
|
const [modal, setModal] = useState<Modal | null>(null)
|
||||||
|
const [tab, setTab] = useState<'installed' | 'store'>('installed')
|
||||||
const [busy, setBusy] = useState<string | null>(null)
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
/** Whether the last load failed: the gap is not drawn as an empty one. */
|
/** Whether the last load failed: the gap is not drawn as an empty one. */
|
||||||
const [failure, setFailure] = useState(false)
|
const [failure, setFailure] = useState(false)
|
||||||
@@ -172,6 +175,7 @@ export function Apps({ token }: { token: string | null }) {
|
|||||||
|
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Apps"
|
title="Apps"
|
||||||
|
subtitle="DNS apps installed on this server"
|
||||||
labels={
|
labels={
|
||||||
withUpdate > 0 ? (
|
withUpdate > 0 ? (
|
||||||
<Tag tone="warn">
|
<Tag tone="warn">
|
||||||
@@ -181,7 +185,7 @@ export function Apps({ token }: { token: string | null }) {
|
|||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="primary" onClick={() => setModal({ kind: 'store' })}>
|
<Button variant="primary" onClick={() => setTab('store')}>
|
||||||
App Store
|
App Store
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setModal({ kind: 'install' })}>Install from file</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 />
|
<Loading />
|
||||||
) : failure ? (
|
) : failure ? (
|
||||||
/* Solid and with no invitation: whether there are apps is not known, so
|
/* 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
|
<Empty
|
||||||
title="No apps installed"
|
title="No apps installed"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="primary" onClick={() => setModal({ kind: 'store' })}>
|
<Button variant="primary" onClick={() => setTab('store')}>
|
||||||
Open App Store
|
Open App Store
|
||||||
</Button>
|
</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.
|
without touching the base configuration. Open the store to see what is available.
|
||||||
</Empty>
|
</Empty>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Panel title="Installed" description="Version, DNS apps and status">
|
||||||
<div className={styles.sortRow}>
|
<div className={styles.gridHead}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.sortBy}
|
className={styles.sortBy}
|
||||||
aria-label="Sort by name"
|
aria-label="Sort by name"
|
||||||
onClick={() => toggle('name')}
|
onClick={() => toggle('name')}
|
||||||
>
|
>
|
||||||
Installed Apps
|
Installed Apps
|
||||||
<Icon name="sort" size={12} />
|
<Icon name="sort" size={12} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<span>Version</span>
|
||||||
<ul className={styles.apps}>
|
<span>DNS apps</span>
|
||||||
{sortedApps.map((app) => (
|
<span>Status</span>
|
||||||
<AppCard
|
<span />
|
||||||
key={app.name}
|
</div>
|
||||||
app={app}
|
<ul className={styles.rows}>
|
||||||
busy={busy === app.name}
|
{sortedApps.map((app) => (
|
||||||
onConfig={() => void openConfig(app.name)}
|
<AppCard
|
||||||
onUpdate={() => setModal({ kind: 'update', name: app.name })}
|
key={app.name}
|
||||||
onStoreUpdate={() => void updateFromStore(app)}
|
app={app}
|
||||||
onUninstall={() => setPendingUninstall(app.name)}
|
busy={busy === app.name}
|
||||||
/>
|
onConfig={() => void openConfig(app.name)}
|
||||||
))}
|
onUpdate={() => setModal({ kind: 'update', name: app.name })}
|
||||||
</ul>
|
onStoreUpdate={() => void updateFromStore(app)}
|
||||||
</>
|
onUninstall={() => setPendingUninstall(app.name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Confirm
|
<Confirm
|
||||||
@@ -246,12 +277,6 @@ export function Apps({ token }: { token: string | null }) {
|
|||||||
onConfirm={() => pendingUninstall && uninstall(pendingUninstall)}
|
onConfirm={() => pendingUninstall && uninstall(pendingUninstall)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StoreApps
|
|
||||||
open={modal?.kind === 'store'}
|
|
||||||
onOpenChange={(o) => setModal(o ? { kind: 'store' } : null)}
|
|
||||||
token={token}
|
|
||||||
onChanged={() => void reload()}
|
|
||||||
/>
|
|
||||||
<InstallApp
|
<InstallApp
|
||||||
open={modal?.kind === 'install'}
|
open={modal?.kind === 'install'}
|
||||||
onOpenChange={(o) => setModal(o ? { kind: 'install' } : null)}
|
onOpenChange={(o) => setModal(o ? { kind: 'install' } : null)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Button } from '../../ui/Button'
|
|||||||
import { useSort } from '../../ui/Table'
|
import { useSort } from '../../ui/Table'
|
||||||
import { Icon } from '../../ui/Icon'
|
import { Icon } from '../../ui/Icon'
|
||||||
import { Details } from '../../ui/Details'
|
import { Details } from '../../ui/Details'
|
||||||
import { Dialog } from '../../ui/Dialog'
|
import { Panel } from '../../ui/Panel'
|
||||||
import { error, type AlertState } from './Apps'
|
import { error, type AlertState } from './Apps'
|
||||||
import { Empty, Loading } from '../../ui/Empty'
|
import { Empty, Loading } from '../../ui/Empty'
|
||||||
import { Tag } from '../../ui/Tag'
|
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.
|
and the freshly placed row is left without its "Store Update" button.
|
||||||
*/
|
*/
|
||||||
export function StoreApps({
|
export function StoreApps({
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
token,
|
token,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
|
||||||
onOpenChange: (o: boolean) => void
|
|
||||||
token: string | null
|
token: string | null
|
||||||
onChanged: () => void
|
onChanged: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -64,11 +60,8 @@ export function StoreApps({
|
|||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
|
||||||
setAlert(null)
|
|
||||||
setStoreApps(null)
|
|
||||||
void load()
|
void load()
|
||||||
}, [open, load])
|
}, [load])
|
||||||
|
|
||||||
async function runStoreAction(app: StoreApp, ok: AlertState, call: Promise<{ kind: string; message?: string }>) {
|
async function runStoreAction(app: StoreApp, ok: AlertState, call: Promise<{ kind: string; message?: string }>) {
|
||||||
setBusy(app.name)
|
setBusy(app.name)
|
||||||
@@ -130,12 +123,7 @@ export function StoreApps({
|
|||||||
const { rows: sorted, toggle } = useSort<StoreApp>({ name: (a) => a.name }, storeApps ?? [])
|
const { rows: sorted, toggle } = useSort<StoreApp>({ name: (a) => a.name }, storeApps ?? [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Panel title="DNS App Store" description="Apps available from the Technitium store" groups2>
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
size="medium"
|
|
||||||
title="DNS App Store"
|
|
||||||
>
|
|
||||||
<Notifier notice={alert} onClose={() => setAlert(null)} />
|
<Notifier notice={alert} onClose={() => setAlert(null)} />
|
||||||
|
|
||||||
<Confirm
|
<Confirm
|
||||||
@@ -241,6 +229,6 @@ export function StoreApps({
|
|||||||
<div className={styles.total}>Total Apps: {storeApps.length}</div>
|
<div className={styles.total}>Total Apps: {storeApps.length}</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Panel>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -358,7 +358,7 @@ export function BlockLists({
|
|||||||
if (viewNeed != null) {
|
if (viewNeed != null) {
|
||||||
return (
|
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} />
|
<Locked title="Block Lists" need={viewNeed} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -571,7 +571,7 @@ export function BlockLists({
|
|||||||
|
|
||||||
return (
|
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)} />
|
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||||
|
|
||||||
<div className={shared.stack}>
|
<div className={shared.stack}>
|
||||||
|
|||||||
@@ -82,3 +82,7 @@ second frame, with rounded corners showing under the panel's header. Written und
|
|||||||
.bad[aria-invalid],
|
.bad[aria-invalid],
|
||||||
.bad[aria-invalid]:focus { border-color: var(--dan); }
|
.bad[aria-invalid]:focus { border-color: var(--dan); }
|
||||||
.invalid { color: var(--dan); font-size: var(--t-2); margin-top: var(--s-4); }
|
.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; }
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { SubTabs } from '../../ui/SubTabs'
|
||||||
import { ruleSearch } from './rules-model'
|
import { ruleSearch } from './rules-model'
|
||||||
import { Overview } from './Overview'
|
import { Overview } from './Overview'
|
||||||
import { Rules } from './Rules'
|
import { Rules } from './Rules'
|
||||||
import { BlockLists } from './BlockLists'
|
import { BlockLists } from './BlockLists'
|
||||||
import type { Permissions } from './permissions'
|
import type { Permissions } from './permissions'
|
||||||
|
import shared from './Blocking.module.css'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The Blocking section, OURS: it replaces Allowed and Blocked with three tabs,
|
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({
|
export function Blocking({
|
||||||
token,
|
token,
|
||||||
sub,
|
sub,
|
||||||
@@ -69,7 +131,11 @@ export function Blocking({
|
|||||||
|
|
||||||
const [listsDirty, setListsDirty] = useState(false)
|
const [listsDirty, setListsDirty] = useState(false)
|
||||||
|
|
||||||
|
const counts = useCounts(token, permissions)
|
||||||
|
|
||||||
const tabs = (
|
const tabs = (
|
||||||
|
<>
|
||||||
|
{active !== 'Overview' && <Banner token={token} permissions={permissions} />}
|
||||||
<SubTabs
|
<SubTabs
|
||||||
label="Blocking sections"
|
label="Blocking sections"
|
||||||
section="blocking"
|
section="blocking"
|
||||||
@@ -77,7 +143,9 @@ export function Blocking({
|
|||||||
active={active}
|
active={active}
|
||||||
onChoose={onSubChange}
|
onChoose={onSubChange}
|
||||||
marks={listsDirty ? { Lists: 'unsaved changes' } : undefined}
|
marks={listsDirty ? { Lists: 'unsaved changes' } : undefined}
|
||||||
|
counts={counts}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (active === 'Rules') {
|
if (active === 'Rules') {
|
||||||
|
|||||||
@@ -330,8 +330,7 @@ export function Overview({
|
|||||||
</InChrome>
|
</InChrome>
|
||||||
|
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
section="Blocking"
|
title="Blocking"
|
||||||
title="Overview"
|
|
||||||
subtitle={OVERVIEW_SUBTITLE}
|
subtitle={OVERVIEW_SUBTITLE}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
actions={
|
actions={
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ export function Rules({
|
|||||||
|
|
||||||
return (
|
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)} />
|
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||||
|
|
||||||
<div className={shared.stack}>
|
<div className={shared.stack}>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ and its own pieces.
|
|||||||
|
|
||||||
/* The title and the period on one line. `SectionHeader` brings its own space
|
/* The title and the period on one line. `SectionHeader` brings its own space
|
||||||
under the title; on this line it is the line's. */
|
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; }
|
.head > :first-child { margin-bottom: 0; }
|
||||||
/* The picker scrolls inside its own strip when narrow; it may not push the line wider. */
|
/* The picker scrolls inside its own strip when narrow; it may not push the line wider. */
|
||||||
.period { min-width: 0; max-width: 100%; }
|
.period { min-width: 0; max-width: 100%; }
|
||||||
@@ -27,8 +27,8 @@ and what fits (`--fit`, below), so a long figure never spills out.
|
|||||||
*/
|
*/
|
||||||
.tiles {
|
.tiles {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
background: var(--pan); border: 1px solid var(--line); border-radius: var(--r-4); overflow: hidden;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.tile {
|
.tile {
|
||||||
container-type: inline-size;
|
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:hover { border-color: var(--tc, var(--acc)); }
|
||||||
.open:focus-visible { outline: none; box-shadow: var(--halo-focus); }
|
.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; }
|
.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 { display:grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items:center; }
|
||||||
.legendTable .lg b { text-align:right; }
|
.legendTable .lg b { text-align:right; }
|
||||||
.lgName { text-align:left; }
|
.lgName { text-align:left; }
|
||||||
|
|
||||||
|
.groups { display:flex; flex-direction:column; gap: var(--s-10); }
|
||||||
@@ -20,6 +20,7 @@ import { TopRowMenu } from './TopRowMenu'
|
|||||||
import { useDomainAction } from '../../lib/allow-block'
|
import { useDomainAction } from '../../lib/allow-block'
|
||||||
import { SectionHeader } from '../../ui/SectionHeader'
|
import { SectionHeader } from '../../ui/SectionHeader'
|
||||||
import { Loading } from '../../ui/Empty'
|
import { Loading } from '../../ui/Empty'
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import styles from './Dashboard.module.css'
|
import styles from './Dashboard.module.css'
|
||||||
import { Body, Panel } from '../../ui/Panel'
|
import { Body, Panel } from '../../ui/Panel'
|
||||||
import { Button } from '../../ui/Button'
|
import { Button } from '../../ui/Button'
|
||||||
@@ -73,6 +74,8 @@ export function Dashboard({
|
|||||||
}, [node])
|
}, [node])
|
||||||
|
|
||||||
const [range, setRange] = useState<Range>('LastHour')
|
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 [data, setData] = useState<DashboardStats | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
/*
|
/*
|
||||||
@@ -215,7 +218,7 @@ export function Dashboard({
|
|||||||
if (refreshTimer != null) window.clearTimeout(refreshTimer)
|
if (refreshTimer != null) window.clearTimeout(refreshTimer)
|
||||||
document.removeEventListener('visibilitychange', onVisibility)
|
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
|
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.
|
on this screen" (like `Add Zone`). It reframes every figure below.
|
||||||
*/}
|
*/}
|
||||||
<div className={styles.head}>
|
<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}>
|
<div className={styles.period}>
|
||||||
<Segmented
|
<Segmented
|
||||||
label="Period"
|
label="Period"
|
||||||
compact
|
|
||||||
options={RANGES.map((r) => ({ id: r, label: RANGE_LABEL[r] }))}
|
options={RANGES.map((r) => ({ id: r, label: RANGE_LABEL[r] }))}
|
||||||
active={range}
|
active={range}
|
||||||
onChoose={(r) => {
|
onChoose={(r) => {
|
||||||
@@ -402,25 +408,23 @@ export function Dashboard({
|
|||||||
with (`Dashboard.test.tsx`). Why Total Queries is larger and the order of the
|
with (`Dashboard.test.tsx`). Why Total Queries is larger and the order of the
|
||||||
other ten is in `Figures.tsx`.
|
other ten is in `Figures.tsx`.
|
||||||
*/}
|
*/}
|
||||||
<div className={styles.tiles} data-testid="metrics">
|
<div className={styles.groups} data-testid="metrics">
|
||||||
<StatCard
|
{[[TOTAL, ...FIGURES.slice(0, 3)], FIGURES.slice(3, 7), FIGURES.slice(7)].map((group, g) => (
|
||||||
m={TOTAL}
|
<Frame key={g}>
|
||||||
s={s}
|
<FramePanel className={styles.tiles}>
|
||||||
total={total}
|
{group.map((m) => (
|
||||||
changes={shift}
|
<StatCard
|
||||||
against={against}
|
key={m.k}
|
||||||
onOpen={cardJump(TOTAL)}
|
m={m}
|
||||||
/>
|
s={s}
|
||||||
{FIGURES.map((m) => (
|
total={total}
|
||||||
<StatCard
|
changes={shift}
|
||||||
key={m.k}
|
against={against}
|
||||||
m={m}
|
onOpen={cardJump(m)}
|
||||||
s={s}
|
/>
|
||||||
total={total}
|
))}
|
||||||
changes={shift}
|
</FramePanel>
|
||||||
against={against}
|
</Frame>
|
||||||
onOpen={cardJump(m)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -96,15 +96,15 @@ export function StatCard({
|
|||||||
<div className={styles.k}>{m.label}</div>
|
<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
|
{/* 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. */}
|
true figure, and here it would be a lie. */}
|
||||||
<Figure className={`${styles.v}${s ? '' : ` ${styles.noData}`}`} text={s ? num2(s[m.k]) : '\u2014'} />
|
<div className={styles.line}>
|
||||||
{/* The hint under the figure: its share of the total and its change. The gap is
|
<Figure className={`${styles.v}${s ? '' : ` ${styles.noData}`}`} text={s ? num2(s[m.k]) : '—'} />
|
||||||
a HARD space so a card with nothing to add keeps the line's height and its
|
{/* The hint beside the figure: its share of the total and its change. */}
|
||||||
neighbours' figures stay level. */}
|
<div className={styles.ctx}>
|
||||||
<div className={styles.ctx}>
|
{share != null && <span className={styles.p}>{share}</span>}
|
||||||
{share != null && <span className={styles.p}>{share}</span>}
|
{share != null && ratio != null && ' '}
|
||||||
{share != null && ratio != null && ' '}
|
<Change ratio={ratio} against={against} long={m.k === 'totalQueries'} />
|
||||||
<Change ratio={ratio} against={against} long={m.k === 'totalQueries'} />
|
{share == null && ratio == null && ' '}
|
||||||
{share == null && ratio == null && '\u00a0'}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Upstream's card is a link to Query Logs (v15.6.2). Here a button laid over
|
{/* Upstream's card is a link to Query Logs (v15.6.2). Here a button laid over
|
||||||
|
|||||||
@@ -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'; }
|
.ctlLine { composes: inline from '../../ui/PanelForm.module.css'; }
|
||||||
.suffix { composes: trailer from '../../ui/text.module.css'; }
|
.suffix { composes: trailer from '../../ui/text.module.css'; }
|
||||||
.area { composes: area from '../../ui/PanelForm.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
@@ -1,3 +1,4 @@
|
|||||||
|
import { KpiStrip } from '../../ui/KpiStrip'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +17,7 @@ import type { Notice } from './notices'
|
|||||||
import { Failure, Loading } from '../../ui/Empty'
|
import { Failure, Loading } from '../../ui/Empty'
|
||||||
import { Tag } from '../../ui/Tag'
|
import { Tag } from '../../ui/Tag'
|
||||||
import tbl from '../../ui/Table.module.css'
|
import tbl from '../../ui/Table.module.css'
|
||||||
|
import { Panel } from '../../ui/Panel'
|
||||||
import styles from './Dhcp.module.css'
|
import styles from './Dhcp.module.css'
|
||||||
import { DataTable } from '../../ui/DataTable'
|
import { DataTable } from '../../ui/DataTable'
|
||||||
import { RowAction, useSort, type Keys, head } from '../../ui/Table'
|
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}>
|
<div className={styles.wrap}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="DHCP"
|
title="DHCP"
|
||||||
|
subtitle="Leases and scopes served by the built-in DHCP server"
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
actions={<><Button onClick={() => void load()}>Refresh</Button></>}
|
actions={<><Button onClick={() => void load()}>Refresh</Button></>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
<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 ? (
|
{failure ? (
|
||||||
/* Solid, not dashed, and with no count: how many there are is not known.
|
/* 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. */
|
The server's message goes in the notice at the top. */
|
||||||
<Failure>Could not load the list.</Failure>
|
<Failure>Could not load the list.</Failure>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<Panel title="Leases" description="Scope, hardware address, IP address, host name, type and expiry">
|
||||||
<DataTable
|
<DataTable
|
||||||
data={visibleLeases}
|
data={visibleLeases}
|
||||||
getRowId={(l) => String(`${l.scope}/${l.clientIdentifier}`)}
|
getRowId={(l) => String(`${l.scope}/${l.clientIdentifier}`)}
|
||||||
@@ -194,10 +214,15 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
|
|||||||
stack
|
stack
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
id: 'scope',
|
id: 'host',
|
||||||
...head({ field: 'scope', sort, onSort: toggle, children: 'Scope' }),
|
...head({ field: 'host', sort, onSort: toggle, children: 'Host Name' }),
|
||||||
td: () => ({ className: styles.mono }),
|
td: () => ({ className: `${styles.mono} ${tbl.lead}` }),
|
||||||
cell: (l) => l.scope,
|
cell: (l) => (
|
||||||
|
<>
|
||||||
|
{l.hostName}
|
||||||
|
<div className={styles.sub}>{l.scope}</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mac',
|
id: 'mac',
|
||||||
@@ -220,23 +245,16 @@ export function Leases({ tabs, token, node = '', canModify = true, canDelete = t
|
|||||||
</Tag>
|
</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',
|
id: 'expires',
|
||||||
...head({ field: 'expires', sort, onSort: toggle, children: 'Lease Expires' }),
|
...head({ field: 'expires', sort, onSort: toggle, children: 'Lease Expires' }),
|
||||||
td: () => ({ className: styles.date }),
|
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',
|
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. */}
|
left floating outside the panel, under a table with a blank body. */}
|
||||||
<span>{`Total Leases: ${leases.length}`}</span>
|
<span>{`Total Leases: ${leases.length}`}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Panel } from '../../ui/Panel'
|
||||||
|
import { KpiStrip } from '../../ui/KpiStrip'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -197,6 +199,17 @@ export function Scopes({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
<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
|
<ScopeForm
|
||||||
key={editing.title + editing.form.oldName}
|
key={editing.title + editing.form.oldName}
|
||||||
title={editing.title}
|
title={editing.title}
|
||||||
@@ -219,6 +232,7 @@ export function Scopes({
|
|||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="DHCP"
|
title="DHCP"
|
||||||
|
subtitle="Leases and scopes served by the built-in DHCP server"
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
actions={<>{canModify && (
|
actions={<>{canModify && (
|
||||||
<Button
|
<Button
|
||||||
@@ -241,6 +255,7 @@ export function Scopes({
|
|||||||
<Failure>Could not load the list.</Failure>
|
<Failure>Could not load the list.</Failure>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<Panel title="Scopes" description="Address pools and their options">
|
||||||
<DataTable
|
<DataTable
|
||||||
data={visibleScopes}
|
data={visibleScopes}
|
||||||
getRowId={(s) => String(s.name)}
|
getRowId={(s) => String(s.name)}
|
||||||
@@ -346,6 +361,7 @@ export function Scopes({
|
|||||||
left floating outside the panel, under a table with a blank body. */}
|
left floating outside the panel, under a table with a blank body. */}
|
||||||
<span>{`Total Scopes: ${scopes.length}`}</span>
|
<span>{`Total Scopes: ${scopes.length}`}</span>
|
||||||
</div>
|
</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;
|
align-items: flex-end;
|
||||||
gap: var(--s-8);
|
gap: var(--s-8);
|
||||||
padding: var(--s-9);
|
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);
|
margin-bottom: var(--gap-block);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function DnsClient({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader title="DNS Client" />
|
<SectionHeader title="DNS Client" subtitle="Resolve a name through this server or another resolver" />
|
||||||
|
|
||||||
<ClusterNodeSelect
|
<ClusterNodeSelect
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
@@ -164,6 +164,7 @@ export function DnsClient({
|
|||||||
values off the scale on a single screen, and that is why the "Type" and
|
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.
|
"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.flt}>
|
||||||
<div className={styles.width}>
|
<div className={styles.width}>
|
||||||
{/*
|
{/*
|
||||||
@@ -226,12 +227,14 @@ export function DnsClient({
|
|||||||
Import
|
Import
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{output === null ? (
|
{output === null ? (
|
||||||
<Empty>Run a query to see the response.</Empty>
|
<Empty>Run a query to see the response.</Empty>
|
||||||
) : (
|
) : (
|
||||||
<Panel
|
<Panel
|
||||||
title="Response"
|
title="Response"
|
||||||
|
description="Answer section"
|
||||||
actions={<span className={styles.meta}>{protocol} · {type}</span>}
|
actions={<span className={styles.meta}>{protocol} · {type}</span>}
|
||||||
className={styles.panel}
|
className={styles.panel}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { KpiStrip } from '../../ui/KpiStrip'
|
||||||
import { ClusterNodeSelect, primaryNodeName } from '../../ui/ClusterNodeSelect'
|
import { ClusterNodeSelect, primaryNodeName } from '../../ui/ClusterNodeSelect'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -470,6 +471,7 @@ export function Lists({
|
|||||||
{!embedded && (
|
{!embedded && (
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={TITLE[list]}
|
title={TITLE[list]}
|
||||||
|
subtitle={isCache ? 'Records cached by the recursive resolver' : undefined}
|
||||||
actions={
|
actions={
|
||||||
isCache ? (
|
isCache ? (
|
||||||
<Button variant="danger" disabled={busy} onClick={askFlushCache}>
|
<Button variant="danger" disabled={busy} onClick={askFlushCache}>
|
||||||
@@ -484,6 +486,16 @@ export function Lists({
|
|||||||
|
|
||||||
{stale && <StaleData since={lastGood} onRetry={() => void load(node?.domain ?? '')} />}
|
{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.split}>
|
||||||
<div className={styles.tree}>
|
<div className={styles.tree}>
|
||||||
|
|||||||
@@ -321,3 +321,7 @@ one pill per group.
|
|||||||
padding-left: var(--s-8);
|
padding-left: var(--s-8);
|
||||||
margin-left: calc(-1 * 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)); }
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Panel } from '../../ui/Panel'
|
||||||
import type { CSSProperties, ReactNode } from 'react'
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { flushSync } from 'react-dom'
|
import { flushSync } from 'react-dom'
|
||||||
@@ -604,6 +605,7 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
|
|||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Logs"
|
title="Logs"
|
||||||
|
subtitle="Server log files and the query log of the installed logging app"
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
labels={f.appName !== '' ? <Tag>app: {f.appName}</Tag> : undefined}
|
labels={f.appName !== '' ? <Tag>app: {f.appName}</Tag> : undefined}
|
||||||
actions={<>
|
actions={<>
|
||||||
@@ -912,7 +914,7 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{page != null && (
|
{page != null && (
|
||||||
<>
|
<Panel title="Query Logs" description="Logging app, dates, client, domain and response type">
|
||||||
<div className={styles.count}>
|
<div className={styles.count}>
|
||||||
<span>{statusText(page)}</span>
|
<span>{statusText(page)}</span>
|
||||||
{/* logs.js:589. "Last" is asked for with -1; the server resolves it. */}
|
{/* 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',
|
id: 'c6',
|
||||||
header: 'Domain',
|
header: 'Domain',
|
||||||
td: () => ({ className: `${styles.mono} ${tbl.lead}` }),
|
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',
|
id: 'c7',
|
||||||
@@ -1045,7 +1052,7 @@ export function QueryLogs({ tabs, token, node = '', request }: QueryLogsProps) {
|
|||||||
<div className={styles.count}>
|
<div className={styles.count}>
|
||||||
<span>{statusText(page)}</span>
|
<span>{statusText(page)}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ export function ViewLogs({
|
|||||||
<div className={styles.viewLogs} data-fills-window="">
|
<div className={styles.viewLogs} data-fills-window="">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Logs"
|
title="Logs"
|
||||||
|
subtitle="Server log files and the query log of the installed logging app"
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
actions={<>
|
actions={<>
|
||||||
{/*
|
{/*
|
||||||
@@ -275,7 +276,7 @@ export function ViewLogs({
|
|||||||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||||||
|
|
||||||
<div className={styles.split}>
|
<div className={styles.split}>
|
||||||
<Panel title="Log Files">
|
<Panel title="Log Files" description="Server log files by date">
|
||||||
<Body className={styles.pbList}>
|
<Body className={styles.pbList}>
|
||||||
{files.length === 0 ? (
|
{files.length === 0 ? (
|
||||||
<Empty>
|
<Empty>
|
||||||
|
|||||||
@@ -478,6 +478,7 @@ export function Settings({
|
|||||||
*/}
|
*/}
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Settings"
|
title="Settings"
|
||||||
|
subtitle="Server configuration"
|
||||||
tabs={
|
tabs={
|
||||||
<SubTabs
|
<SubTabs
|
||||||
label="Settings sections"
|
label="Settings sections"
|
||||||
@@ -489,14 +490,52 @@ export function Settings({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<ClusterNodeSelect
|
<>
|
||||||
nodes={nodes}
|
<ClusterNodeSelect
|
||||||
initialised={clusterInitialised}
|
nodes={nodes}
|
||||||
aggregate
|
initialised={clusterInitialised}
|
||||||
value={node}
|
aggregate
|
||||||
onChange={setNode}
|
value={node}
|
||||||
label="Cluster 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>
|
</div>
|
||||||
</BlockAnchors>
|
</BlockAnchors>
|
||||||
|
|
||||||
<div className={panelFormStyles.bar}>
|
{pendingWords !== '' && (
|
||||||
{/*
|
<div className={panelFormStyles.bar}>
|
||||||
The four verbs are still THERE without the permission, disabled and with a
|
<span className={styles.pending}>Unsaved changes: {pendingWords}</span>
|
||||||
padlock.
|
</div>
|
||||||
|
)}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</IndexedForm>
|
</IndexedForm>
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ import { head, RowAction, useSort, type Keys } from '../../ui/Table'
|
|||||||
import styles from './Zones.module.css'
|
import styles from './Zones.module.css'
|
||||||
import type { Notice, Confirmation } from './types'
|
import type { Notice, Confirmation } from './types'
|
||||||
import { Pagination } from '../../ui/Pagination'
|
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'
|
import { noticeFromFailure } from '../../lib/notice'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -84,6 +87,22 @@ export interface ZoneListProps extends ZoneActions {
|
|||||||
|
|
||||||
const ZONES_PER_PAGE_KEY = 'optZonesPerPage'
|
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({
|
export function ZoneList({
|
||||||
token,
|
token,
|
||||||
node = '',
|
node = '',
|
||||||
@@ -129,6 +148,10 @@ export function ZoneList({
|
|||||||
const [lastGood, setLastGood] = useState<string | null>(null)
|
const [lastGood, setLastGood] = useState<string | null>(null)
|
||||||
const [stale, setStale] = useState(false)
|
const [stale, setStale] = useState(false)
|
||||||
const [checkedOnes, setChecked] = useState<string[]>([])
|
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,
|
// The filters are form state: they do not apply until "Go" is pressed,
|
||||||
// just like upstream, where `refreshZones` reads them at that moment.
|
// just like upstream, where `refreshZones` reads them at that moment.
|
||||||
@@ -183,6 +206,13 @@ export function ZoneList({
|
|||||||
setPageNumber(data.pageNumber)
|
setPageNumber(data.pageNumber)
|
||||||
setTotalPages(data.totalPages)
|
setTotalPages(data.totalPages)
|
||||||
setTotalZones(data.totalZones)
|
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))
|
setPageField(String(data.pageNumber))
|
||||||
// `chkZonesTableCheckAll` is unchecked on every refresh (zone.js:938).
|
// `chkZonesTableCheckAll` is unchecked on every refresh (zone.js:938).
|
||||||
setChecked([])
|
setChecked([])
|
||||||
@@ -361,7 +391,9 @@ export function ZoneList({
|
|||||||
const firstRow = (pageNumber - 1) * perPage + 1
|
const firstRow = (pageNumber - 1) * perPage + 1
|
||||||
const state = statusText(firstRow, zones.length, totalZones, pageNumber, totalPages, 'zones')
|
const state = statusText(firstRow, zones.length, totalZones, pageNumber, totalPages, 'zones')
|
||||||
const pg = pageWindow(pageNumber, totalPages)
|
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
|
const allChecked = zones.length > 0 && checkedOnes.length === zones.length
|
||||||
|
|
||||||
@@ -397,12 +429,49 @@ export function ZoneList({
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Zones"
|
title="Zones"
|
||||||
|
subtitle="Authoritative, forwarder, stub and secondary zones hosted by this server"
|
||||||
actions={<><Button variant="primary" disabled={!canModify || busy} onClick={onAdd}>
|
actions={<><Button variant="primary" disabled={!canModify || busy} onClick={onAdd}>
|
||||||
Add Zone
|
Add Zone
|
||||||
</Button></>}
|
</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}>
|
<div className={styles.filterWide}>
|
||||||
<Field label="Name">
|
<Field label="Name">
|
||||||
{(id) => (
|
{(id) => (
|
||||||
@@ -509,9 +578,6 @@ export function ZoneList({
|
|||||||
? state
|
? state
|
||||||
: `${checkedOnes.length} of ${zones.length} zones selected`}
|
: `${checkedOnes.length} of ${zones.length} zones selected`}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="danger" disabled={!canDelete || busy} onClick={deleteChecked}>
|
|
||||||
Delete Zones
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -542,13 +608,6 @@ export function ZoneList({
|
|||||||
td: () => ({ className: tbl.checkCell }),
|
td: () => ({ className: tbl.checkCell }),
|
||||||
cell: (z, i) => zoneCells(rowProps(z, i)).check,
|
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',
|
id: 'zone',
|
||||||
...head({ field: 'zone', sort, onSort: toggle, children: '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' }),
|
...head({ field: 'status', sort, onSort: toggle, style: { width: 120 }, children: 'Status' }),
|
||||||
cell: (z, i) => zoneCells(rowProps(z, i)).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',
|
id: 'expiry',
|
||||||
...head({ field: 'expiry', sort, onSort: toggle, style: { width: 110 }, children: 'Expiry' }),
|
...head({ field: 'expiry', sort, onSort: toggle, style: { width: 110 }, children: 'Expiry' }),
|
||||||
td: () => ({ className: styles.mono }),
|
td: () => ({ className: styles.mono }),
|
||||||
cell: (z, i) => zoneCells(rowProps(z, i)).expiry,
|
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
|
/* 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
|
120 is enough and the 110 left over go to the data, which is what the
|
||||||
table is about. */
|
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>
|
<span>{state}</span>
|
||||||
{pagination}
|
{pagination}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</Panel>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -703,6 +751,11 @@ function zoneCells(p: RowProps) {
|
|||||||
it on the button depended on which module was emitted last. */
|
it on the button depended on which module was emitted last. */
|
||||||
zone: (
|
zone: (
|
||||||
<>
|
<>
|
||||||
|
<span
|
||||||
|
className={styles.dot}
|
||||||
|
data-tone={stateTone}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.zoneLink} ${tbl.entity}`}
|
className={`${styles.zoneLink} ${tbl.entity}`}
|
||||||
@@ -710,6 +763,11 @@ function zoneCells(p: RowProps) {
|
|||||||
>
|
>
|
||||||
<DomainName name={zoneNameOf(z)} />
|
<DomainName name={zoneNameOf(z)} />
|
||||||
</button>
|
</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 && (
|
{catalogLabel && (
|
||||||
<div className={styles.tags}>
|
<div className={styles.tags}>
|
||||||
<Tag tone={catalogLabel.tone}>{catalogLabel.text}</Tag>
|
<Tag tone={catalogLabel.tone}>{catalogLabel.text}</Tag>
|
||||||
|
|||||||
@@ -110,3 +110,18 @@ the table.
|
|||||||
font-size: var(--t-4);
|
font-size: var(--t-4);
|
||||||
color: var(--mute);
|
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); }
|
||||||
@@ -44,7 +44,7 @@ describe('the zone selection bar', () => {
|
|||||||
vi.spyOn(client, 'apiRequest').mockResolvedValue(zones)
|
vi.spyOn(client, 'apiRequest').mockResolvedValue(zones)
|
||||||
render(<ZoneList {...props} />)
|
render(<ZoneList {...props} />)
|
||||||
await screen.findByRole('button', { name: 'casa.test' })
|
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).toContain('Add Zone')
|
||||||
expect(header.textContent).not.toContain('Delete Zones')
|
expect(header.textContent).not.toContain('Delete Zones')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -84,12 +84,12 @@ describe('zone list', () => {
|
|||||||
draw()
|
draw()
|
||||||
await screen.findByRole('button', { name: 'casa.test' })
|
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')
|
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' }))
|
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' })
|
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
|
// The "Zones" segment of the path IS the back button: before there was also a
|
||||||
// a loose "← Zones" above saying the same thing.
|
// a loose "← Zones" above saying the same thing.
|
||||||
await user.click(within(screen.getByLabelText('Breadcrumb')).getByRole('button'))
|
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 userEvent.click(screen.getByRole('option', { name: 'b.example (secondary)' }))
|
||||||
|
|
||||||
await waitFor(() => {
|
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' })
|
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 userEvent.click(screen.getByRole('option', { name: 'b.example (secondary)' }))
|
||||||
|
|
||||||
await waitFor(() => {
|
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' })
|
expect(calls.at(-1)![1]?.body).toMatchObject({ node: 'b.example', pageNumber: '2' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+3
-3
@@ -70,9 +70,9 @@ for whatever really rules its own line.
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
line-height: 1.55;
|
line-height: 1.4;
|
||||||
letter-spacing: 0.005em;
|
letter-spacing: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
|||||||
+15
-15
@@ -44,9 +44,9 @@ before `data-theme` is set and with any value nobody declared.
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
/* ── Color ──────────────────────────────────────────────────────────── */
|
/* ── Color ──────────────────────────────────────────────────────────── */
|
||||||
--bg: #06080c;
|
--bg: oklch(0.133 0.011 264.376);
|
||||||
--pan: #121419;
|
--pan: oklch(0.191 0.011 264.376);
|
||||||
--pan2: #202227;
|
--pan2: oklch(0.252 0.010 264.376);
|
||||||
/*
|
/*
|
||||||
THE SURFACE RAMP. In a dark interface elevation is NOT told by the shadow
|
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
|
(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
|
Technitium's amber stays exactly as it is: it is THEIR brand, not ours, and over
|
||||||
this charcoal it gives 8.38:1.
|
this charcoal it gives 8.38:1.
|
||||||
*/
|
*/
|
||||||
--ink: #fafafa;
|
--ink: oklch(0.985 0 0);
|
||||||
--mute: #a2a5aa;
|
--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
|
`--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
|
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.
|
ground enough to take `--faint` under 4.5:1, so light gives it a step of its own.
|
||||||
*/
|
*/
|
||||||
--faint-on-tint: var(--faint);
|
--faint-on-tint: var(--faint);
|
||||||
--line: #292c30;
|
--line: oklch(1 0 0 / 10%);
|
||||||
--line2: #202227;
|
--line2: oklch(1 0 0 / 6%);
|
||||||
--acc: #e5e5e5;
|
--acc: oklch(0.922 0 0);
|
||||||
/*
|
/*
|
||||||
AMBER AS INK, and AMBER AS A FILL, are two tokens since 2026-10-01. `--acc` is
|
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
|
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
|
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).
|
themes; light draws its ink darker (see the light block).
|
||||||
*/
|
*/
|
||||||
--acc-ink: #e5e5e5;
|
--acc-ink: oklch(0.922 0 0);
|
||||||
--on-acc: #171717;
|
--on-acc: oklch(0.205 0 0);
|
||||||
--ok: #00bc7d;
|
--ok: oklch(0.696 0.17 162.48);
|
||||||
--warn: #f0b100;
|
--warn: oklch(0.795 0.184 86.047);
|
||||||
--dan: #ff6467;
|
--dan: oklch(0.704 0.191 22.216);
|
||||||
--info: #aa8dff;
|
--info: oklch(0.606 0.25 292.717);
|
||||||
/* The two the comp has and that had fallen out: the Query Logs series need
|
/* 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. */
|
them so as not to go back to the loose `rgba()` inherited from upstream. */
|
||||||
--viol: #a78bfa;
|
--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 */
|
--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,
|
/* 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). */
|
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 */
|
--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,
|
/* The veil behind what covers the page: the side drawer on a narrow screen,
|
||||||
and the dialog, which darkens more because it is modal. */
|
and the dialog, which darkens more because it is modal. */
|
||||||
|
|||||||
@@ -189,3 +189,22 @@ and the 39 rows stay aligned.
|
|||||||
*/
|
*/
|
||||||
.dependent { box-shadow: inset 2px 0 0 0 var(--acc-ink); }
|
.dependent { box-shadow: inset 2px 0 0 0 var(--acc-ink); }
|
||||||
.withPill { display: flex; align-items: center; gap: var(--s-4); flex-wrap: wrap; }
|
.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'; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ export function Menu({
|
|||||||
onOpen,
|
onOpen,
|
||||||
anchor = 'right',
|
anchor = 'right',
|
||||||
asRow = false,
|
asRow = false,
|
||||||
|
lead,
|
||||||
quiet = false,
|
quiet = false,
|
||||||
icon = 'plus',
|
icon = 'plus',
|
||||||
alignTo,
|
alignTo,
|
||||||
@@ -75,6 +76,8 @@ export function Menu({
|
|||||||
anchor?: 'right' | 'left'
|
anchor?: 'right' | 'left'
|
||||||
/** The trigger fills the width of its column, with the label on the left. */
|
/** The trigger fills the width of its column, with the label on the left. */
|
||||||
asRow?: boolean
|
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
|
* 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
|
* every short row (the Dashboard's tops, Blocking's top tables): fifteen framed
|
||||||
@@ -207,6 +210,7 @@ export function Menu({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onKeyDown={onTriggerKeyDown}
|
onKeyDown={onTriggerKeyDown}
|
||||||
>
|
>
|
||||||
|
{lead}
|
||||||
{text}
|
{text}
|
||||||
<Icon name="chevronDown" size={12} />
|
<Icon name="chevronDown" size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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
@@ -21,6 +21,7 @@ is also what upstream does, using `div.panel` with its title and no `fieldset`.
|
|||||||
*/
|
*/
|
||||||
export function Panel({
|
export function Panel({
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
actions,
|
actions,
|
||||||
groups2 = false,
|
groups2 = false,
|
||||||
children,
|
children,
|
||||||
@@ -35,6 +36,8 @@ export function Panel({
|
|||||||
exactly what this exists to avoid.
|
exactly what this exists to avoid.
|
||||||
*/
|
*/
|
||||||
title?: ReactNode
|
title?: ReactNode
|
||||||
|
/** One muted line under the title, as the canvas draws it. */
|
||||||
|
description?: ReactNode
|
||||||
/** What goes on the right of the header. */
|
/** What goes on the right of the header. */
|
||||||
actions?: ReactNode
|
actions?: ReactNode
|
||||||
/*
|
/*
|
||||||
@@ -53,11 +56,11 @@ export function Panel({
|
|||||||
}) {
|
}) {
|
||||||
const id = useId()
|
const id = useId()
|
||||||
|
|
||||||
/* ReUI's Frame: a tinted outer frame with the panel inside. `dense` removes the
|
/* ReUI's Frame as the canvas draws it: a tinted outer frame, 3 px of ring and
|
||||||
frame's own padding, so what sits in the panel (tables, rows) keeps its edges. */
|
the panel inside. */
|
||||||
if (title == null) {
|
if (title == null) {
|
||||||
return (
|
return (
|
||||||
<Frame dense className={className}>
|
<Frame className={className}>
|
||||||
<FramePanel className="p-0">{children}</FramePanel>
|
<FramePanel className="p-0">{children}</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
@@ -65,15 +68,23 @@ export function Panel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame
|
<Frame
|
||||||
dense
|
|
||||||
className={className}
|
className={className}
|
||||||
role={groups2 ? 'group' : undefined}
|
role={groups2 ? 'group' : undefined}
|
||||||
aria-labelledby={groups2 ? id : undefined}
|
aria-labelledby={groups2 ? id : undefined}
|
||||||
>
|
>
|
||||||
<FrameHeader className="flex-row items-center justify-between gap-3 px-4 py-2.5">
|
<FrameHeader className="flex-row items-center justify-between gap-3 px-4 py-3">
|
||||||
<h2 className="m-0 text-sm leading-5 font-semibold text-foreground" id={id}>
|
{description == null ? (
|
||||||
{title}
|
<h2 className="m-0 text-sm leading-5 font-semibold text-foreground" id={id}>
|
||||||
</h2>
|
{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}
|
{actions}
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
<FramePanel className="p-0">{children}</FramePanel>
|
<FramePanel className="p-0">{children}</FramePanel>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function Segmented<T extends string>({
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-none rounded-md border-0 bg-transparent px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50',
|
'flex-none rounded-md border-0 bg-transparent px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50',
|
||||||
'aria-pressed:bg-card aria-pressed:text-foreground aria-pressed:shadow-xs aria-selected:bg-card aria-selected:text-foreground aria-selected:shadow-xs',
|
'aria-pressed:bg-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]',
|
compact ? 'h-6 text-xs' : 'h-[30px]',
|
||||||
)}
|
)}
|
||||||
role={asTabs ? 'tab' : undefined}
|
role={asTabs ? 'tab' : undefined}
|
||||||
|
|||||||
@@ -59,3 +59,5 @@ marked with an edge and the rest read as ordinary links.
|
|||||||
border-radius: var(--r-full);
|
border-radius: var(--r-full);
|
||||||
background: var(--acc-ink);
|
background: var(--acc-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.count { margin-left: var(--s-3); font: 400 10px/1 var(--font-mono); font-style: normal; opacity: .6; }
|
||||||
@@ -45,6 +45,7 @@ export function SubTabs({
|
|||||||
active,
|
active,
|
||||||
onChoose,
|
onChoose,
|
||||||
marks,
|
marks,
|
||||||
|
counts,
|
||||||
}: {
|
}: {
|
||||||
/** The accessible name of the navigation: "Settings sections". */
|
/** The accessible name of the navigation: "Settings sections". */
|
||||||
label: string
|
label: string
|
||||||
@@ -55,6 +56,8 @@ export function SubTabs({
|
|||||||
onChoose: (tab: string) => void
|
onChoose: (tab: string) => void
|
||||||
/** A tab's mark: the words a screen reader hears after its name. */
|
/** A tab's mark: the words a screen reader hears after its name. */
|
||||||
marks?: Partial<Record<string, string>>
|
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 (
|
return (
|
||||||
<nav className={styles.bar} aria-label={label}>
|
<nav className={styles.bar} aria-label={label}>
|
||||||
@@ -71,6 +74,7 @@ export function SubTabs({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t}
|
{t}
|
||||||
|
{counts?.[t] != null && <em className={styles.count}>{counts[t]}</em>}
|
||||||
{marks?.[t] != null && (
|
{marks?.[t] != null && (
|
||||||
<>
|
<>
|
||||||
<span className={text.sr}>, {marks[t]}</span>
|
<span className={text.sr}>, {marks[t]}</span>
|
||||||
|
|||||||
Reference in new issue
Block a user