From 87281a87d3e23c33384cf049af14b92bff6df1df Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 27 Oct 2026 12:47:27 +0700 Subject: [PATCH] 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 --- docs/compose.shnt.example.yaml | 181 +++++++++++++++++++++ src/app/Shell.module.css | 43 ++++- src/app/Shell.tsx | 41 +++-- src/screens/Login.module.css | 3 +- src/screens/LoginCover.module.css | 113 +++---------- src/screens/LoginCover.test.tsx | 33 ++-- src/screens/LoginCover.tsx | 91 +++++------ src/screens/about/About.tsx | 6 +- src/screens/admin/Cluster.tsx | 1 + src/screens/admin/Groups.tsx | 6 +- src/screens/admin/Ldap.tsx | 2 +- src/screens/admin/Permissions.tsx | 1 + src/screens/admin/Sessions.tsx | 6 +- src/screens/admin/Sso.tsx | 1 + src/screens/admin/Users.tsx | 18 +- src/screens/apps/AppCard.tsx | 91 +++++------ src/screens/apps/Apps.module.css | 22 +++ src/screens/apps/Apps.test.tsx | 8 +- src/screens/apps/Apps.tsx | 97 +++++++---- src/screens/apps/StoreApps.tsx | 20 +-- src/screens/blocking/BlockLists.tsx | 4 +- src/screens/blocking/Blocking.module.css | 4 + src/screens/blocking/Blocking.tsx | 68 ++++++++ src/screens/blocking/Overview.tsx | 3 +- src/screens/blocking/Rules.tsx | 2 +- src/screens/dashboard/Dashboard.module.css | 10 +- src/screens/dashboard/Dashboard.tsx | 48 +++--- src/screens/dashboard/Figures.tsx | 18 +- src/screens/dhcp/Dhcp.module.css | 2 + src/screens/dhcp/Leases.tsx | 53 ++++-- src/screens/dhcp/Scopes.tsx | 16 ++ src/screens/dnsclient/DnsClient.module.css | 4 - src/screens/dnsclient/DnsClient.tsx | 5 +- src/screens/lists/Lists.tsx | 12 ++ src/screens/logs/Logs.module.css | 4 + src/screens/logs/QueryLogs.tsx | 13 +- src/screens/logs/ViewLogs.tsx | 3 +- src/screens/settings/Settings.tsx | 122 ++++++-------- src/screens/zones/ZoneList.tsx | 108 +++++++++--- src/screens/zones/Zones.module.css | 15 ++ src/screens/zones/Zones.selection.test.tsx | 2 +- src/screens/zones/Zones.test.tsx | 12 +- src/theme/base.css | 6 +- src/theme/tokens.css | 30 ++-- src/ui/Form.module.css | 19 +++ src/ui/KpiStrip.tsx | 51 ++++++ src/ui/Menu.tsx | 4 + src/ui/PageTabs.module.css | 5 + src/ui/PageTabs.tsx | 36 ++++ src/ui/Panel.tsx | 27 ++- src/ui/Segmented.tsx | 2 +- src/ui/SubTabs.module.css | 2 + src/ui/SubTabs.tsx | 4 + 53 files changed, 1011 insertions(+), 487 deletions(-) create mode 100644 docs/compose.shnt.example.yaml create mode 100644 src/ui/KpiStrip.tsx create mode 100644 src/ui/PageTabs.module.css create mode 100644 src/ui/PageTabs.tsx diff --git a/docs/compose.shnt.example.yaml b/docs/compose.shnt.example.yaml new file mode 100644 index 0000000..2104db6 --- /dev/null +++ b/docs/compose.shnt.example.yaml @@ -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" + - "--certificatesresolvers.myresolver.acme.email=admin@shnt.top" + - "--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: diff --git a/src/app/Shell.module.css b/src/app/Shell.module.css index 9e4a2ca..81391c3 100644 --- a/src/app/Shell.module.css +++ b/src/app/Shell.module.css @@ -2,7 +2,7 @@ against 163 with tabs on top, and the second row of stacked tabs disappears. */ .shell { min-height:100vh; background:var(--bg); color:var(--ink); display:grid; grid-template-columns:240px 1fr; align-items:start; } .side { background:var(--pan); border-right:1px solid var(--line); align-self:stretch; position:sticky; top:0; height:100vh; overflow:auto; display:flex; flex-direction:column; } -.sbrand { display:flex; align-items:center; gap: var(--s-6); padding: 0 var(--s-10); height:48px; border-bottom:1px solid var(--line); font-weight: 600; letter-spacing:-.01em; color:var(--ink); } +.sbrand { display:flex; align-items:center; gap: var(--s-5); padding: 0 var(--s-10); height:56px; border-bottom:1px solid var(--line); font-weight: 600; letter-spacing:-.01em; color:var(--ink); } /* Technitium's logo, not a "T" in an amber square. @@ -11,7 +11,10 @@ The initial was our own invention: the logo exists, it travels in someone else's product's mark with a letter of our own is exactly what a fork must not do. */ -.mark { width:22px; height:22px; flex:none; } +.mark { width:32px; height:32px; flex:none; border-radius: var(--r-3); background: var(--acc); padding: 4px; object-fit: contain; } +.bname { display:flex; flex-direction:column; min-width:0; line-height:1.2; } +.bname b { font-weight:600; font-size:14px; } +.bname small { font-weight:400; font-size:12px; color: var(--mute); } /* The items sat 1 px from each other with 7 px of vertical padding: the list read as a block of text, not as twelve destinations. And there was no separation @@ -244,3 +247,39 @@ star, and the header is always there: toggle, breadcrumbs and the server's statu .shell[data-rail='true'] .s, .shell[data-rail='true'] .sbsearch, .shell[data-rail='true'] .sbrand { justify-content: flex-start; } .status .lbl { display: none; } } + +/* +The canvas's geometry, to the pixel (`reui.css` of the Technitium canvas): a 240 px +sidebar with 8 px of padding, a 56 px brand button, 32 px rows in the foreground +colour, 11 px uppercase group headings, and a 48 px header on the page colour. +*/ +.side { padding: var(--s-5); gap: 1px; background: var(--pan); } +.sbrand { height: auto; padding: var(--s-3); border: 0; border-radius: var(--r-3); gap: var(--s-5); } +.sbsearch { margin: var(--s-3) 2px var(--s-3); border-radius: var(--r-2); font-size: 13px; background: color-mix(in oklch, var(--pan2) 50%, transparent); } +.slist { padding: 0; gap: 1px; } +.group { gap: 1px; } +.group + .group { margin-top: 0; } +.grph { padding: 10px var(--s-5) var(--s-2); font: 600 11px/1.3 var(--font-ui); letter-spacing: .05em; color: color-mix(in oklch, var(--ink) 62%, transparent); } +.s { gap: var(--s-5); color: var(--ink); border-radius: var(--r-3); } +.s:hover { background: color-mix(in oklch, var(--pan2) 60%, transparent); } +.s[data-active='true'] { background: var(--pan2); } +.footSide { padding: var(--s-5) 0 0; border-top: 0; } +.rtop { height: 48px; padding: 6px var(--s-11); gap: 6px; background: var(--bg); box-shadow: none; } +.hamburger { border-color: transparent; color: var(--mute); border-radius: var(--r-3); } +.hamburger:hover { background: color-mix(in oklch, var(--pan2) 70%, transparent); color: var(--ink); } +.crumbs { font-size: 14px; } +.crumbs span { color: var(--mute); } +.crumbs span + span::before { color: var(--mute); } +.crumbs span[data-last='true'] { color: var(--ink); font-weight: 500; } +.status { border-radius: var(--r-3); font: 500 13px/1 var(--font-ui); } +.updown { margin-left: auto; color: var(--mute); display: inline-flex; } +.footSide :global(button), .footSide > button { text-align: left; } +.av { display:inline-flex; align-items:center; justify-content:center; flex:none; width:32px; height:32px; border-radius:50%; border:1px solid var(--line); background: var(--bg); color: var(--ink); font: 500 12px/1 var(--font-ui); } +.footSide { gap: 0; } +.footSide > div > button, .footSide > button { padding: var(--s-5); background: color-mix(in oklch, var(--pan2) 45%, transparent); border-radius: var(--r-3); font-weight: 600; gap: var(--s-5); justify-content: flex-start; } +.footSide > div > button > svg, .footSide > button > svg { margin-left: auto; } +.pop { width: 320px; } + +/* Blocking off or paused: the canvas's amber "Attention" button. */ +.status[data-attention='true'] { border-color: color-mix(in oklch, var(--warn) 40%, var(--line)); background: color-mix(in oklch, var(--warn) 12%, transparent); color: var(--ink); } +.status[data-attention='true'] .pdot { background: var(--warn); } diff --git a/src/app/Shell.tsx b/src/app/Shell.tsx index 43735e1..bbe845f 100644 --- a/src/app/Shell.tsx +++ b/src/app/Shell.tsx @@ -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 ( @@ -411,13 +412,16 @@ export function Shell({