Compare commits
36
Commits
e3529e4e61
...
13458a980e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13458a980e
|
||
|
|
e576704fad
|
||
|
|
4aaf6d3815
|
||
|
|
0ea2e2905a
|
||
|
|
9d5120cbad
|
||
|
|
65f76d4427
|
||
|
|
be12587b87
|
||
|
|
122044e6af
|
||
|
|
0e9433d53b
|
||
|
|
ae9f7fb9f3
|
||
|
|
1cd8f71a9b
|
||
|
|
15c895e180
|
||
|
|
3b33d7afee
|
||
|
|
f2b4582c0a
|
||
|
|
02bf1ca310
|
||
|
|
4379d7599b
|
||
|
|
1246791fe1
|
||
|
|
2f3115117f
|
||
|
|
e98896fc0a
|
||
|
|
1de608f9bc
|
||
|
|
38d34453bb
|
||
|
|
368149a358
|
||
|
|
ac0699faa1
|
||
|
|
d5b351d7fe
|
||
|
|
cddefa87e4
|
||
|
|
9e12683acf
|
||
|
|
0fdbe25f7a
|
||
|
|
7e7358b3c2
|
||
|
|
d5c4a8d978
|
||
|
|
73bc180c67
|
||
|
|
0f58966b31
|
||
|
|
bb39539fdf
|
||
|
|
01e455c8af
|
||
|
|
994bbf922c
|
||
|
|
7b0527d271
|
||
|
|
c5e6ab9180
|
@@ -104,7 +104,11 @@ jobs:
|
|||||||
run: zig build test-aarch64 -fqemu
|
run: zig build test-aarch64 -fqemu
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
runs-on: ubuntu-24.04
|
# The light runner, a second act_runner at capacity 1 that advertises only
|
||||||
|
# this label. This job peaks around 355 MB (tsc), well inside that runner's
|
||||||
|
# 1536Mi dind limit, and it runs no docker command — so it overlaps the
|
||||||
|
# heavy runner's zig and image work instead of queueing behind it.
|
||||||
|
runs-on: ubuntu-24.04-light
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ IPv4 + IPv6 full parity for: client identity, rate limiting, logging, group assi
|
|||||||
|
|
||||||
### 3.14 Frontend Stack (Decision I)
|
### 3.14 Frontend Stack (Decision I)
|
||||||
|
|
||||||
- Vite + React + TypeScript + Tailwind; TanStack Router + TanStack Query. SPA, no SSR.
|
- Vite + React + TypeScript + StyleX + React Aria; TanStack Router + TanStack Query. SPA, no SSR.
|
||||||
- **Built assets embedded in the binary** at compile time: single self-contained artifact, no asset-path config, no binary/UI skew. `zig build` accepts the dist path; the release pipeline runs `npm run build` first (CI always does).
|
- **Built assets embedded in the binary** at compile time: single self-contained artifact, no asset-path config, no binary/UI skew. `zig build` accepts the dist path; the release pipeline runs `npm run build` first (CI always does).
|
||||||
- Dev-mode flag serves assets from disk so UI iteration needs no Zig rebuild.
|
- Dev-mode flag serves assets from disk so UI iteration needs no Zig rebuild.
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ src/
|
|||||||
rules.zig local.zig lookup.zig pause.zig settings.zig
|
rules.zig local.zig lookup.zig pause.zig settings.zig
|
||||||
upstream_health.zig certs.zig health.zig version.zig
|
upstream_health.zig certs.zig health.zig version.zig
|
||||||
|
|
||||||
web/ # Vite + React + TS + Tailwind + TanStack
|
web/ # Vite + React + TS + StyleX + React Aria + TanStack
|
||||||
vendor/ # sqlite3 amalgamation, mbedtls (pinned)
|
vendor/ # sqlite3 amalgamation, mbedtls (pinned)
|
||||||
docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis)
|
docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis)
|
||||||
tests/ # dns/ integration/ fuzz/
|
tests/ # dns/ integration/ fuzz/
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
# nxdns
|
# nxdns
|
||||||
|
|
||||||
A self-hosted DNS sinkhole for a household LAN, written in Zig 0.16. One
|
nxdns is a DNS sinkhole for your LAN. It blocks the names you do not want, and
|
||||||
static musl binary, SQLite for state, a Raspberry Pi 5 as the reference
|
forwards the rest over an encrypted connection.
|
||||||
target. It answers your network's DNS, blocks what you tell it to, and shows
|
|
||||||
you what asked for what.
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
devices["Your devices"] -- "DNS query" --> nxdns["nxdns"]
|
||||||
|
nxdns -- "answer" --> devices
|
||||||
|
nxdns -- "allowed" --> upstream["Upstream resolvers<br/>DoH / DoT"]
|
||||||
|
upstream -- "answer" --> nxdns
|
||||||
|
nxdns -- "blocked" --> sink["0.0.0.0 / NXDOMAIN"]
|
||||||
|
```
|
||||||
|
|
||||||
|
One static Zig binary. SQLite holds the state and the query log, which the web
|
||||||
|
UI, the REST API and `/metrics` read.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Luke Edwards <luke.edwards05@gmail.com> (lukeed.com)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -39,6 +39,12 @@ mbedtls url=https://github.com/Mbed-TLS/mbedtls/archive/refs/tags/mbedtls-3.6.7.
|
|||||||
sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8AAGVtrgCcOcmjrOJnagmnRyMrcKaOo09KbU-vu8w8
|
sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8AAGVtrgCcOcmjrOJnagmnRyMrcKaOo09KbU-vu8w8
|
||||||
|
|
||||||
[npm runtime closure]
|
[npm runtime closure]
|
||||||
|
@internationalized/date 3.12.3 Apache-2.0
|
||||||
|
@internationalized/number 3.6.7 Apache-2.0
|
||||||
|
@internationalized/string 3.2.10 Apache-2.0
|
||||||
|
@react-types/shared 3.36.1 Apache-2.0
|
||||||
|
@stylexjs/stylex 0.19.0 MIT
|
||||||
|
@swc/helpers 0.5.23 Apache-2.0
|
||||||
@tanstack/history 1.162.0 MIT
|
@tanstack/history 1.162.0 MIT
|
||||||
@tanstack/query-core 5.101.4 MIT
|
@tanstack/query-core 5.101.4 MIT
|
||||||
@tanstack/react-query 5.101.4 MIT
|
@tanstack/react-query 5.101.4 MIT
|
||||||
@@ -46,24 +52,37 @@ sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8A
|
|||||||
@tanstack/react-store 0.9.3 MIT
|
@tanstack/react-store 0.9.3 MIT
|
||||||
@tanstack/router-core 1.171.15 MIT
|
@tanstack/router-core 1.171.15 MIT
|
||||||
@tanstack/store 0.9.3 MIT
|
@tanstack/store 0.9.3 MIT
|
||||||
|
aria-hidden 1.2.6 MIT
|
||||||
|
client-only 0.0.1 MIT
|
||||||
|
clsx 2.1.1 MIT
|
||||||
cookie-es 3.1.1 MIT
|
cookie-es 3.1.1 MIT
|
||||||
|
css-mediaquery 0.1.2 BSD
|
||||||
|
invariant 2.2.4 MIT
|
||||||
isbot 5.2.1 Unlicense
|
isbot 5.2.1 Unlicense
|
||||||
|
js-tokens 4.0.0 MIT
|
||||||
|
loose-envify 1.4.0 MIT
|
||||||
react 19.2.8 MIT
|
react 19.2.8 MIT
|
||||||
|
react-aria 3.51.0 Apache-2.0
|
||||||
|
react-aria-components 1.20.0 Apache-2.0
|
||||||
react-dom 19.2.8 MIT
|
react-dom 19.2.8 MIT
|
||||||
|
react-stately 3.49.0 Apache-2.0
|
||||||
scheduler 0.27.0 MIT
|
scheduler 0.27.0 MIT
|
||||||
seroval 1.5.6 MIT
|
seroval 1.5.6 MIT
|
||||||
seroval-plugins 1.5.6 MIT
|
seroval-plugins 1.5.6 MIT
|
||||||
|
styleq 0.2.1 MIT
|
||||||
|
tslib 2.8.1 0BSD
|
||||||
use-sync-external-store 1.6.0 MIT
|
use-sync-external-store 1.6.0 MIT
|
||||||
|
|
||||||
[npm build-time generators whose output ships]
|
[npm build-time generators whose output ships]
|
||||||
rolldown 1.1.5 MIT
|
rolldown 1.1.5 MIT
|
||||||
tailwindcss 4.3.3 MIT
|
|
||||||
vite 8.1.5 MIT
|
vite 8.1.5 MIT
|
||||||
|
|
||||||
[container base image]
|
[container base image]
|
||||||
alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
||||||
|
|
||||||
[npm packages bundled into web/dist]
|
[npm packages bundled into web/dist]
|
||||||
|
@internationalized/string
|
||||||
|
@stylexjs/stylex
|
||||||
@tanstack/history
|
@tanstack/history
|
||||||
@tanstack/query-core
|
@tanstack/query-core
|
||||||
@tanstack/react-query
|
@tanstack/react-query
|
||||||
@@ -71,7 +90,11 @@ alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695
|
|||||||
@tanstack/react-store
|
@tanstack/react-store
|
||||||
@tanstack/router-core
|
@tanstack/router-core
|
||||||
@tanstack/store
|
@tanstack/store
|
||||||
|
clsx
|
||||||
react
|
react
|
||||||
|
react-aria
|
||||||
|
react-aria-components
|
||||||
react-dom
|
react-dom
|
||||||
|
react-stately
|
||||||
scheduler
|
scheduler
|
||||||
use-sync-external-store
|
use-sync-external-store
|
||||||
|
|||||||
+59
-10
@@ -39,11 +39,42 @@
|
|||||||
// stay listed in licenses/dependency-identity.txt so that a future build which
|
// stay listed in licenses/dependency-identity.txt so that a future build which
|
||||||
// does pull them in trips the guard.
|
// does pull them in trips the guard.
|
||||||
//
|
//
|
||||||
|
// Four more joined that list with the StyleX build integration: css-mediaquery,
|
||||||
|
// invariant, and loose-envify with js-tokens under it. @stylexjs/stylex
|
||||||
|
// declares them, but its published runtime imports none of them — a sourcemap
|
||||||
|
// build of a component calling stylex.props shows no trace of any of the four,
|
||||||
|
// so they are dead entries of the published manifest rather than code with a
|
||||||
|
// job here. css-mediaquery is the one that matters: it is BSD, and it reaching
|
||||||
|
// web/dist would force a licence decision this project has not made.
|
||||||
|
//
|
||||||
|
// Seven more joined that list with React Aria, and the sourcemap build is again
|
||||||
|
// what settled which: @internationalized/date, @internationalized/number,
|
||||||
|
// @react-types/shared, @swc/helpers, aria-hidden, client-only and tslib are in
|
||||||
|
// the runtime closure but contribute no module to any chunk. The date and number
|
||||||
|
// packages serve the calendar and number-field components ruling 4 keeps out of
|
||||||
|
// scope; @react-types/shared is types only and emits no runtime code;
|
||||||
|
// client-only is a build-time marker for React Server Components; @swc/helpers
|
||||||
|
// and tslib are downlevelling helpers the published ESM builds never reach for;
|
||||||
|
// and aria-hidden sits behind a react-aria path the four adopted components do
|
||||||
|
// not take. Their licences differ and are recorded one by one in
|
||||||
|
// src/licenses_drift_test.zig rather than lumped together: @internationalized/
|
||||||
|
// date, @internationalized/number, @react-types/shared and @swc/helpers are
|
||||||
|
// Apache-2.0, tslib is 0BSD, aria-hidden and client-only are MIT. Keeping all
|
||||||
|
// seven out of the bundle is a fact worth re-checking rather than assuming, so
|
||||||
|
// the guard fails if any of them turns up in a `sources` list.
|
||||||
|
//
|
||||||
|
// StyleX now ships: the converted pages call stylex.props, and a sourcemap build
|
||||||
|
// puts @stylexjs/stylex in the bundle. What that build settles is not the
|
||||||
|
// obvious answer, though: only @stylexjs/stylex appears as a bundled package,
|
||||||
|
// and styleq arrives inside it. The published lib/es/stylex.mjs vendors styleq's source
|
||||||
|
// instead of importing it, so styleq's bytes ship while styleq never appears in
|
||||||
|
// a `sources` list — no guard would raise it, which is why it is written down
|
||||||
|
// here.
|
||||||
|
//
|
||||||
// Of the remaining devDependencies, none puts a byte in web/dist: @vitejs/
|
// Of the remaining devDependencies, none puts a byte in web/dist: @vitejs/
|
||||||
// plugin-react and typescript only transform our own sources (react-refresh is
|
// plugin-react and typescript only transform our own sources (react-refresh is
|
||||||
// dev-server only, and tslib is optional and unused), @tailwindcss/vite
|
// dev-server only, and tslib is optional and unused), lightningcss only
|
||||||
// orchestrates tailwindcss without contributing CSS of its own, lightningcss
|
// minifies, and @testing-library/*, jsdom, vitest, oxlint and prettier
|
||||||
// only minifies, and @testing-library/*, jsdom, vitest, oxlint and prettier
|
|
||||||
// never touch the build output.
|
// never touch the build output.
|
||||||
.{
|
.{
|
||||||
.{
|
.{
|
||||||
@@ -94,18 +125,36 @@
|
|||||||
.note = "Bundled into the admin UI JavaScript. Grouped because all five carry the identical MIT text and copyright line.",
|
.note = "Bundled into the admin UI JavaScript. Grouped because all five carry the identical MIT text and copyright line.",
|
||||||
.file = "tanstack-mit.txt",
|
.file = "tanstack-mit.txt",
|
||||||
},
|
},
|
||||||
|
.{
|
||||||
|
.component = "React Aria (react-aria-components, react-aria, react-stately, @internationalized/string)",
|
||||||
|
.version = "react-aria-components 1.20.0, react-aria 3.51.0, react-stately 3.49.0, @internationalized/string 3.2.10",
|
||||||
|
.note = "The dialog, alert dialog, tab and select behaviour of the admin UI, bundled into the JavaScript embedded in the binary. The first Apache-2.0 npm dependency this project has taken, and the first non-MIT one: Mokhtar Mial accepted Apache-2.0 inbound for nxdns on 2026-08-12, which is the decision that let these four ship. Apache-2.0 Section 4 attribution is satisfied by carrying the licence text in THIRD-PARTY-NOTICES, which the file below does; none of the four ships a NOTICE file, so 4(d) adds nothing. All four carry a byte-identical LICENSE. In the tarballs and in the image.",
|
||||||
|
.file = "react-aria-apache-2.0.txt",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.component = "clsx",
|
||||||
|
.version = "clsx 2.1.1",
|
||||||
|
.note = "The class-name joiner React Aria calls when it merges a render-prop class name with its own. Bundled into the admin UI JavaScript. Separate entry from the other MIT packages: same MIT text, different copyright line.",
|
||||||
|
.file = "clsx-mit.txt",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.component = "StyleX",
|
||||||
|
.version = "@stylexjs/stylex 0.19.0",
|
||||||
|
.note = "The style runtime for the admin UI. Its published dist vendors styleq's source, so the styleq entry below covers bytes that arrive inside this package. The npm tarball ships no licence file of its own; this text is the LICENSE of facebook/stylex at tag 0.19.0, which is byte-identical to the one on main.",
|
||||||
|
.file = "stylex-mit.txt",
|
||||||
|
},
|
||||||
|
.{
|
||||||
|
.component = "styleq",
|
||||||
|
.version = "styleq 0.2.1",
|
||||||
|
.note = "The class-name merger StyleX calls at runtime. Never a bundled package in its own right: @stylexjs/stylex vendors its source into lib/es/stylex.mjs, so it reaches web/dist without appearing in any sourcemap `sources` list. Separate entry from StyleX: same MIT text, different copyright line.",
|
||||||
|
.file = "styleq-mit.txt",
|
||||||
|
},
|
||||||
.{
|
.{
|
||||||
.component = "TanStack Store",
|
.component = "TanStack Store",
|
||||||
.version = "@tanstack/store 0.9.3, @tanstack/react-store 0.9.3",
|
.version = "@tanstack/store 0.9.3, @tanstack/react-store 0.9.3",
|
||||||
.note = "Bundled into the admin UI JavaScript. Separate entry from the other TanStack packages: same MIT text, different copyright line.",
|
.note = "Bundled into the admin UI JavaScript. Separate entry from the other TanStack packages: same MIT text, different copyright line.",
|
||||||
.file = "tanstack-store-mit.txt",
|
.file = "tanstack-store-mit.txt",
|
||||||
},
|
},
|
||||||
.{
|
|
||||||
.component = "Tailwind CSS",
|
|
||||||
.version = "tailwindcss 4.3.3",
|
|
||||||
.note = "A devDependency whose output ships: the admin UI stylesheet is generated from Tailwind's own CSS sources, so the generated CSS in the binary is a derivative of them.",
|
|
||||||
.file = "tailwindcss-mit.txt",
|
|
||||||
},
|
|
||||||
.{
|
.{
|
||||||
.component = "Vite",
|
.component = "Vite",
|
||||||
.version = "vite 8.1.5",
|
.version = "vite 8.1.5",
|
||||||
@@ -115,7 +164,7 @@
|
|||||||
.{
|
.{
|
||||||
.component = "Rolldown",
|
.component = "Rolldown",
|
||||||
.version = "rolldown 1.1.5",
|
.version = "rolldown 1.1.5",
|
||||||
.note = "A devDependency whose own runtime source ships: the bundler Vite 8 runs prepends its CommonJS interop helpers verbatim to web/dist/assets/classes-*.js, which is embedded in the binary.",
|
.note = "A devDependency whose own runtime source ships: the bundler Vite 8 runs prepends its CommonJS interop helpers verbatim to web/dist/assets/styles-*.js, which is embedded in the binary.",
|
||||||
.file = "rolldown-mit.txt",
|
.file = "rolldown-mit.txt",
|
||||||
},
|
},
|
||||||
.{
|
.{
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ pub const texts: []const Text = &.{
|
|||||||
.{ .name = "react-mit.txt", .body = @embedFile("react-mit.txt") },
|
.{ .name = "react-mit.txt", .body = @embedFile("react-mit.txt") },
|
||||||
.{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-mit.txt") },
|
.{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-mit.txt") },
|
||||||
.{ .name = "tanstack-store-mit.txt", .body = @embedFile("tanstack-store-mit.txt") },
|
.{ .name = "tanstack-store-mit.txt", .body = @embedFile("tanstack-store-mit.txt") },
|
||||||
.{ .name = "tailwindcss-mit.txt", .body = @embedFile("tailwindcss-mit.txt") },
|
.{ .name = "react-aria-apache-2.0.txt", .body = @embedFile("react-aria-apache-2.0.txt") },
|
||||||
|
.{ .name = "clsx-mit.txt", .body = @embedFile("clsx-mit.txt") },
|
||||||
|
.{ .name = "stylex-mit.txt", .body = @embedFile("stylex-mit.txt") },
|
||||||
|
.{ .name = "styleq-mit.txt", .body = @embedFile("styleq-mit.txt") },
|
||||||
.{ .name = "vite-mit.txt", .body = @embedFile("vite-mit.txt") },
|
.{ .name = "vite-mit.txt", .body = @embedFile("vite-mit.txt") },
|
||||||
.{ .name = "rolldown-mit.txt", .body = @embedFile("rolldown-mit.txt") },
|
.{ .name = "rolldown-mit.txt", .body = @embedFile("rolldown-mit.txt") },
|
||||||
.{ .name = "mozilla-ca-bundle-mpl-2.0.txt", .body = @embedFile("mozilla-ca-bundle-mpl-2.0.txt") },
|
.{ .name = "mozilla-ca-bundle-mpl-2.0.txt", .body = @embedFile("mozilla-ca-bundle-mpl-2.0.txt") },
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
React Aria: react-aria-components, react-aria, react-stately and
|
||||||
|
@internationalized/string, bundled into the admin UI JavaScript that is
|
||||||
|
embedded in every nxdns binary.
|
||||||
|
|
||||||
|
Copyright 2019 Adobe
|
||||||
|
SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||||
|
these files except in compliance with the License. You may obtain a copy of the
|
||||||
|
License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software distributed
|
||||||
|
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||||
|
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
|
The full text of the Apache License, Version 2.0 is reproduced above, in the
|
||||||
|
Mbed TLS section of this file. The LICENSE file each of these four packages
|
||||||
|
ships is that same document. The Mbed TLS copy carries an eight-line project
|
||||||
|
preamble of its own; setting that aside, the two differ on one line, the sample
|
||||||
|
copyright inside the "APPENDIX: How to apply the Apache License to your work"
|
||||||
|
boilerplate, which is not part of the licence terms. None of the four ships a
|
||||||
|
NOTICE file, so Section 4(d) attaches nothing further.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) Tailwind Labs, Inc.
|
Copyright (c) Nicolas Gallagher
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Milestone 22: TypeScript 7
|
||||||
|
|
||||||
|
Goal: move the web toolchain from TypeScript 6.0.3 to TypeScript 7.0.x (the
|
||||||
|
native compiler, GA 2026-07-08). One devDependency bump; every web gate and
|
||||||
|
the release pipeline stay green.
|
||||||
|
|
||||||
|
Design written 2026-08-12 against HEAD `91a0aa9`. TypeScript 7.0.2 is
|
||||||
|
`typescript@latest` on npm at spec time; the milestone pins whatever 7.0.x is
|
||||||
|
current when the session runs.
|
||||||
|
|
||||||
|
## Implementation contract (read first)
|
||||||
|
|
||||||
|
- Read `AGENTS.md`, then this spec whole, before session work starts.
|
||||||
|
- `web/src/lib/contractSamples.gen.ts` is generated; it is type-checked by
|
||||||
|
`tsc -b` but never hand-edited. If TS7 rejects it, the fix goes in the
|
||||||
|
generator (`AGENTS.md` regeneration procedure), not the file.
|
||||||
|
- No license work: `typescript` is a devDependency, absent from both the
|
||||||
|
runtime closure and the bundled set in `licenses/dependency-identity.txt`.
|
||||||
|
The Zig drift guard (`src/licenses_drift_test.zig`) must still pass —
|
||||||
|
it recomputes from `web/package-lock.json` and dev-only changes are
|
||||||
|
invisible to it.
|
||||||
|
|
||||||
|
## Rulings (binding)
|
||||||
|
|
||||||
|
### 1. The bump is one line plus the lockfile
|
||||||
|
|
||||||
|
`web/package.json:45` `"typescript": "6.0.3"` becomes the current 7.0.x,
|
||||||
|
exact pin (repo convention: no ranges, milestone-14 ruling 12 pins by exact
|
||||||
|
version everywhere). `npm install` updates `web/package-lock.json`. Nothing
|
||||||
|
else in `dependencies` or `devDependencies` moves in this milestone.
|
||||||
|
|
||||||
|
### 2. The tsconfigs are already TS7-clean; verify, do not churn
|
||||||
|
|
||||||
|
Checked against TS7's removed options (recorded here so the session does not
|
||||||
|
re-derive it): `tsconfig.app.json` and `tsconfig.node.json` use
|
||||||
|
`moduleResolution: "bundler"`, `module: "esnext"`, targets `es2022`/`es2023`,
|
||||||
|
`paths` without `baseUrl`, `verbatimModuleSyntax`, `isolatedModules`,
|
||||||
|
`noEmit`. None of TS7's removals (`target: es5`, `downlevelIteration`,
|
||||||
|
`moduleResolution: node10`/`classic`, `module: amd/umd/system/none`,
|
||||||
|
`baseUrl`-as-alias) are present. `tsconfig.json` is a two-entry project
|
||||||
|
reference; TS7 supports `tsc -b` and project references. The session changes
|
||||||
|
a tsconfig only if `tsc -b` errors demand it, and records any such change in
|
||||||
|
`## Recorded (implementation)`.
|
||||||
|
|
||||||
|
### 3. Known TS7 edge: `skipLibCheck` no longer hides parse-level .d.ts errors
|
||||||
|
|
||||||
|
Both tsconfigs set `skipLibCheck: true`. Under TS7 that still skips type
|
||||||
|
checking of `.d.ts` files but not parse-level errors in them. If a dependency
|
||||||
|
`.d.ts` fails to parse, the fix is a dependency patch bump or an upstream
|
||||||
|
issue reference recorded in the spec — never a copied-and-edited local
|
||||||
|
`.d.ts`.
|
||||||
|
|
||||||
|
### 4. Nothing else in the toolchain consumes the TS compiler
|
||||||
|
|
||||||
|
Verified at spec time: `oxlint` (own parser), `vite`/`vitest`
|
||||||
|
(esbuild transpile, no type checking), `prettier`, and
|
||||||
|
`@vitejs/plugin-react` run no `tsc` and load no `typescript` API. The only
|
||||||
|
consumer is the `typecheck` script (`web/package.json:12`, `tsc -b`), run in
|
||||||
|
CI at `.gitea/workflows/gates.yml:113-114`. TS7 has no stable programmatic
|
||||||
|
API until 7.1; nothing in this repo needs one.
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
One session.
|
||||||
|
|
||||||
|
### Session S1: the bump
|
||||||
|
|
||||||
|
Owns: `web/package.json`, `web/package-lock.json`, and — only if ruling 2
|
||||||
|
forces it — `web/tsconfig.app.json`, `web/tsconfig.node.json`,
|
||||||
|
`web/tsconfig.json`.
|
||||||
|
|
||||||
|
- S1.1 bump per ruling 1; `npm install`.
|
||||||
|
- S1.2 run, in order: `npm run format:check`, `npm run lint`,
|
||||||
|
`npm run typecheck`, `npm test`, `npm run build`, `npm run assert-bundled`
|
||||||
|
(all from `web/`).
|
||||||
|
- S1.3 `zig build test` (the licenses drift guard and the contract-samples
|
||||||
|
guard run inside it).
|
||||||
|
|
||||||
|
Acceptance (S1):
|
||||||
|
- [ ] `web/node_modules/.bin/tsc --version` reports 7.0.x.
|
||||||
|
- [ ] `npm run typecheck` exits 0 with no tsconfig change, or the change is
|
||||||
|
recorded with the TS7 error text that forced it.
|
||||||
|
- [ ] All six web gate commands exit 0.
|
||||||
|
- [ ] `zig build test` exits 0.
|
||||||
|
- [ ] `git diff` touches only the files S1 owns.
|
||||||
|
|
||||||
|
## Module layout
|
||||||
|
|
||||||
|
New files: none. Deleted surface: none.
|
||||||
|
|
||||||
|
## Acceptance (milestone complete)
|
||||||
|
|
||||||
|
- [ ] All S1 boxes.
|
||||||
|
- [ ] `licenses/dependency-identity.txt` is byte-identical to HEAD.
|
||||||
|
|
||||||
|
## Anti-requirements
|
||||||
|
|
||||||
|
- No other dependency bumps ride along — not oxlint, not vite, not vitest,
|
||||||
|
not `@types/*`.
|
||||||
|
- No tsconfig flag additions or "modernization" beyond what a TS7 error
|
||||||
|
forces.
|
||||||
|
- No typescript-eslint, ts-jest, or any tool needing the TS programmatic API
|
||||||
|
(unavailable until 7.1, and unwanted regardless — oxlint is the linter).
|
||||||
|
- No `tsgo` binary references anywhere: the binary is `tsc`, scripts stay as
|
||||||
|
they are.
|
||||||
|
- No edits to `web/src/**` — a source change to satisfy the new compiler is a
|
||||||
|
finding to record and fix, not silently absorb, unless `tsc -b` fails
|
||||||
|
without it; then it is recorded in `## Recorded (implementation)`.
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
# Milestone 23: StyleX and React Aria replace Tailwind
|
||||||
|
|
||||||
|
Goal: every styled element in `web/src` renders through StyleX
|
||||||
|
(`@stylexjs/unplugin` at build time); Dialog, AlertDialog, Tabs and Select
|
||||||
|
come from `react-aria-components`; Tailwind is removed. TanStack Router and
|
||||||
|
TanStack Query stay (decided 2026-08-12 after review; the router swap is out
|
||||||
|
of scope permanently, not deferred).
|
||||||
|
|
||||||
|
Design written 2026-08-12 against HEAD `91a0aa9`. Needs milestone 22 landed
|
||||||
|
(the StyleX and RAC `.d.ts` surfaces are checked by the TS7 `tsc -b`).
|
||||||
|
|
||||||
|
Inventory this spec rests on: 522 `className=` occurrences across 31
|
||||||
|
`web/src` files; 17 shared class constants in `web/src/ui/classes.ts`;
|
||||||
|
`web/src/styles.css` is the single line `@import "tailwindcss"` (so dark mode
|
||||||
|
is Tailwind 4's default `prefers-color-scheme` media strategy — no class
|
||||||
|
toggle exists); the Tailwind plugin sits at `web/vite.config.ts:3,8`;
|
||||||
|
`tailwindcss` and `@tailwindcss/vite` are devDependencies
|
||||||
|
(`web/package.json:34,44`) and appear in neither the runtime closure nor the
|
||||||
|
bundled set of `licenses/dependency-identity.txt`.
|
||||||
|
|
||||||
|
## Implementation contract (read first)
|
||||||
|
|
||||||
|
- Read `AGENTS.md`, then this spec whole, before session work starts.
|
||||||
|
- `web/src/lib/contractSamples.gen.ts` and `web/src/lib/types.ts` are API
|
||||||
|
contract surfaces; this milestone is styling-only and touches neither.
|
||||||
|
- Every session leaves all gates green: `npm run format:check`, `npm run
|
||||||
|
lint`, `npm run typecheck`, `npm test`, `npm run build`,
|
||||||
|
`npm run assert-bundled` (from `web/`), and `zig build test`. The release
|
||||||
|
gate (`zig build dist` + `verify-dist`, `.gitea/workflows/gates.yml`) keeps
|
||||||
|
its byte budgets; they are not tight — `web/dist` is 440,924 bytes raw
|
||||||
|
against the 15 MiB binary budget — but `verify-dist` remains the arbiter.
|
||||||
|
- `assert-bundled` fails whenever the bundled npm package set changes. That
|
||||||
|
failure is the license workflow trigger, not an obstacle: review each new
|
||||||
|
package into `licenses/inventory.zon`, then record the new set in
|
||||||
|
`licenses/dependency-identity.txt` (procedure in that file's header).
|
||||||
|
Never update the recorded list without the inventory review.
|
||||||
|
|
||||||
|
## Rulings (binding)
|
||||||
|
|
||||||
|
### 1. Build integration is `@stylexjs/unplugin`, proven before any page moves
|
||||||
|
|
||||||
|
`stylex.vite()` from `@stylexjs/unplugin` goes into `web/vite.config.ts`
|
||||||
|
alongside the existing plugins (Tailwind stays until S3). Configuration per
|
||||||
|
the official Vite guide (stylexjs.com/docs/learn/installation/vite):
|
||||||
|
`useCSSLayers: true`, `runtimeInjection: false`, `dev` keyed off the Vite
|
||||||
|
mode. The generated CSS injects into an existing CSS asset, so
|
||||||
|
`web/src/styles.css` (imported by `main.tsx:8`) remains the CSS entry for the
|
||||||
|
whole milestone; the dev/HMR virtual-module wiring from that guide is part of
|
||||||
|
S1. The unmaintained community `vite-plugin-stylex` is forbidden. S1's
|
||||||
|
acceptance proves dev server, `vitest run` and `vite build` all handle a
|
||||||
|
StyleX component before S2 or S3 convert anything — this is a gate, not an
|
||||||
|
assumption.
|
||||||
|
|
||||||
|
### 2. Tokens live in `web/src/ui/tokens.stylex.ts`
|
||||||
|
|
||||||
|
`stylex.defineVars` with role names, not raw shades: surface, surface-raised,
|
||||||
|
border, border-strong, text, text-muted (the zinc ramp today), primary and
|
||||||
|
primary-text (blue-600/white), danger, danger-surface, danger-border (the red
|
||||||
|
ramp), focus (blue-600). Each var carries its dark value under
|
||||||
|
`@media (prefers-color-scheme: dark)` inside `defineVars` — same media
|
||||||
|
strategy the app has now. No theme toggle, no `data-theme` attribute.
|
||||||
|
|
||||||
|
### 3. `web/src/ui/styles.ts` replaces `ui/classes.ts`
|
||||||
|
|
||||||
|
The 17 exports of `web/src/ui/classes.ts` (focusRing, insetFocusRing, input,
|
||||||
|
smallInput, button, smallButton, largeButton, primaryButton,
|
||||||
|
largePrimaryButton, rowButton, linkButton, dangerLinkButton, retryButton, th,
|
||||||
|
td, tableWrap, formCard) become `stylex.create` style objects in
|
||||||
|
`web/src/ui/styles.ts`, composed at call sites via
|
||||||
|
`{...stylex.props(styles.button, extra)}`. The milestone-9 accessibility
|
||||||
|
floor restated by milestone-18 ruling 10 carries over verbatim: every
|
||||||
|
interactive element renders the focus ring (`:focus-visible` outline, 2px,
|
||||||
|
offset 2, focus token; the inset variant for controls flush against panel
|
||||||
|
edges). `ui/classes.ts` is deleted in S3 when its last importer converts.
|
||||||
|
|
||||||
|
### 4. React Aria scope is exactly four components — end state, not a phase
|
||||||
|
|
||||||
|
From `react-aria-components`: Dialog + Modal (replacing the hand-rolled
|
||||||
|
`role="dialog"` overlay in `web/src/features/clients/ClientEditDialog.tsx:27-28`),
|
||||||
|
AlertDialog composition (ruling 5), Tabs (replacing the button-pair tab
|
||||||
|
switcher in `web/src/features/local/LocalDnsPage.tsx`), Select (replacing the
|
||||||
|
7 files carrying native `<select>`: LookupPage, QueryLogPage, RecordsTab,
|
||||||
|
RulesPage, SettingsPage, PrefixesEditor, ClientEditDialog). Everything else —
|
||||||
|
buttons, text inputs, tables, links, banners — stays native HTML styled with
|
||||||
|
StyleX. A native styled `<button>` is the finished design, not an interim
|
||||||
|
one. S2 wraps the four as shared components: `web/src/ui/Dialog.tsx`,
|
||||||
|
`web/src/ui/ConfirmDialog.tsx`, `web/src/ui/Tabs.tsx`,
|
||||||
|
`web/src/ui/Select.tsx`, each styled with StyleX through RAC's render-prop
|
||||||
|
state booleans (`stylex.props(isSelected && styles.selected)`) — never
|
||||||
|
data-attribute selectors, which StyleX cannot express.
|
||||||
|
|
||||||
|
### 5. `window.confirm` dies with the conversion
|
||||||
|
|
||||||
|
All call sites become `ConfirmDialog` (RAC AlertDialog: `role="alertdialog"`,
|
||||||
|
focus trapped, danger-styled confirm button):
|
||||||
|
`web/src/features/blocklists/BlocklistsPage.tsx:61`,
|
||||||
|
`web/src/features/upstreams/UpstreamsPage.tsx:43`,
|
||||||
|
`web/src/features/rules/RulesPage.tsx:34`, and
|
||||||
|
`web/src/ui/useCrudForm.ts:49` (which carries the confirm for ZonesTab and
|
||||||
|
RecordsTab). `useCrudForm`'s `CrudFormSpec.confirmDelete` keeps its signature;
|
||||||
|
the hook returns the dialog state instead of calling `window.confirm`. The
|
||||||
|
tests that stub confirm today
|
||||||
|
(`web/src/features/upstreams/UpstreamsPage.test.tsx:140,144,188`,
|
||||||
|
`web/src/ui/useCrudForm.test.tsx:102`) are rewritten to drive the real
|
||||||
|
dialog: open, assert `role="alertdialog"` and the entity name in the message,
|
||||||
|
confirm and cancel paths both asserted. BlocklistsPage and RulesPage gain the
|
||||||
|
same coverage for their delete paths.
|
||||||
|
|
||||||
|
### 6. Styling mechanics
|
||||||
|
|
||||||
|
`stylex.create`/`stylex.props` only. Conditions are boolean-guarded style
|
||||||
|
objects; variants are separate named styles; responsive behavior (the
|
||||||
|
`md:` sidebar grid in `web/src/shell/AppShell.tsx:90-98`, the mobile drawer)
|
||||||
|
becomes `@media` conditions inside style values. No dynamic class-name
|
||||||
|
strings, no `clsx`-style helpers, no `className` literals left anywhere in
|
||||||
|
`web/src` after S3 — `className`/`style` attributes appear only as the spread
|
||||||
|
of `stylex.props(...)` (RAC render-prop `className` functions included).
|
||||||
|
TanStack `Link` `activeProps`/`inactiveProps` (`AppShell.tsx:35-42`) pass
|
||||||
|
`stylex.props(...).className` outputs.
|
||||||
|
|
||||||
|
### 7. Visual contract: drift accepted, structure pinned
|
||||||
|
|
||||||
|
No pixel parity requirement and no visual-regression harness. Pinned instead,
|
||||||
|
checkable by reading the converted file against the original: same layout
|
||||||
|
structure (grid/flex relationships, table columns, heading levels), token
|
||||||
|
roles per ruling 2 (zinc surfaces, blue primary actions, red danger), the
|
||||||
|
focus floor of ruling 3 on every interactive element, dark mode via media
|
||||||
|
query, `overflow-x-auto` table wrappers preserved.
|
||||||
|
|
||||||
|
### 8. License inventory, named per step
|
||||||
|
|
||||||
|
- S1 adds `@stylexjs/stylex` (runtime dependency; `stylex.props` ships in the
|
||||||
|
bundle) to the runtime closure and the bundled set. `@stylexjs/unplugin` is
|
||||||
|
a devDependency — runtime closure and bundled set untouched by it.
|
||||||
|
- S2 adds `react-aria-components` and its transitive closure
|
||||||
|
(`@react-aria/*`, `@react-stately/*`, `@react-types/*`,
|
||||||
|
`@internationalized/*`, `@swc/helpers` — the exact bundled subset is
|
||||||
|
whatever `npm run assert-bundled` reports after S2's conversions; every
|
||||||
|
reported name gets a reviewed `licenses/inventory.zon` entry before the
|
||||||
|
recorded list moves). This is the milestone's largest review surface.
|
||||||
|
- S3 removes nothing from the license files: Tailwind was dev-only and never
|
||||||
|
recorded. The `@tanstack/*` entries stay untouched.
|
||||||
|
|
||||||
|
### 9. PLAN.md wording moves in S3
|
||||||
|
|
||||||
|
`PLAN.md:138` "Vite + React + TypeScript + Tailwind; TanStack Router +
|
||||||
|
TanStack Query" → "Vite + React + TypeScript + StyleX + React Aria; TanStack
|
||||||
|
Router + TanStack Query". `PLAN.md:235` directory comment updates the same
|
||||||
|
way. Line 562 names loaders and Query only — untouched. No other PLAN
|
||||||
|
amendment.
|
||||||
|
|
||||||
|
### 10. Conversion order is ascending `className` count, one commit per file
|
||||||
|
|
||||||
|
So a regression bisects to one page. Counts at spec time:
|
||||||
|
|
||||||
|
| file | count |
|
||||||
|
|---|---|
|
||||||
|
| lib/InlineError.tsx | 2 |
|
||||||
|
| features/settings/ReadOnlyConfigBanner.tsx | 2 |
|
||||||
|
| features/dashboard/HealthBanners.tsx | 2 |
|
||||||
|
| features/local/LocalDnsPage.tsx | 3 |
|
||||||
|
| features/settings/RestartBanner.tsx | 3 |
|
||||||
|
| features/dashboard/StatCards.tsx | 5 |
|
||||||
|
| routes.tsx | 6 |
|
||||||
|
| auth/LoginPage.tsx | 9 |
|
||||||
|
| features/dashboard/DashboardPage.tsx | 9 |
|
||||||
|
| features/groups/GroupSourcesEditor.tsx | 9 |
|
||||||
|
| features/pause/PauseWidget.tsx | 9 |
|
||||||
|
| features/blocklists/BlocklistForm.tsx | 11 |
|
||||||
|
| features/clients/ClientEditDialog.tsx | 11 |
|
||||||
|
| features/dashboard/DiskCard.tsx | 13 |
|
||||||
|
| features/upstreams/UpstreamForm.tsx | 14 |
|
||||||
|
| features/clients/PrefixesEditor.tsx | 15 |
|
||||||
|
| shell/AppShell.tsx | 17 |
|
||||||
|
| features/live/LiveLogPage.tsx | 18 |
|
||||||
|
| features/dashboard/TimeseriesChart.tsx | 21 |
|
||||||
|
| features/dashboard/UpstreamHealthTable.tsx | 21 |
|
||||||
|
| features/upstreams/UpstreamsPage.tsx | 21 |
|
||||||
|
| features/groups/GroupsPage.tsx | 22 |
|
||||||
|
| features/lookup/LookupPage.tsx | 25 |
|
||||||
|
| features/local/ZonesTab.tsx | 26 |
|
||||||
|
| features/blocklists/SourceStatusSection.tsx | 27 |
|
||||||
|
| features/clients/ClientsPage.tsx | 28 |
|
||||||
|
| features/rules/RulesPage.tsx | 31 |
|
||||||
|
| features/blocklists/BlocklistsPage.tsx | 31 |
|
||||||
|
| features/settings/SettingsPage.tsx | 31 |
|
||||||
|
| features/local/RecordsTab.tsx | 34 |
|
||||||
|
| features/queries/QueryLogPage.tsx | 46 |
|
||||||
|
|
||||||
|
S2 takes its owned cluster out of this order (converted together because the
|
||||||
|
RAC wrappers land there); S3 walks the remainder smallest-first.
|
||||||
|
|
||||||
|
### 11. Tests that must change, known in advance
|
||||||
|
|
||||||
|
RAC changes DOM and focus behavior; these edits are expected work, not
|
||||||
|
regressions to paper over:
|
||||||
|
- Dialog: `ClientsPage.test.tsx` (ClientEditDialog now portals into an RAC
|
||||||
|
Modal; queries move to `within(screen.getByRole("dialog"))`).
|
||||||
|
- Tabs: `LocalDnsPage.test.tsx` (tab switcher becomes `role="tablist"`/
|
||||||
|
`role="tab"`; `getByRole("button", { name: ... })` queries move to
|
||||||
|
`getByRole("tab", ...)`).
|
||||||
|
- AlertDialog: `UpstreamsPage.test.tsx`, `useCrudForm.test.tsx` per ruling 5.
|
||||||
|
- Select: RAC Select renders a button + listbox, not `<select>`, so
|
||||||
|
`getByLabelText(...)` + `fireEvent.change` patterns break in the tests for
|
||||||
|
the 7 Select files (LookupPage, QueryLogPage, LocalDnsPage/RecordsTab,
|
||||||
|
RulesPage, SettingsPage, ClientsPage). They move to
|
||||||
|
`userEvent`-style open-then-select interactions.
|
||||||
|
All other tests query by role/text and are expected to survive; a test that
|
||||||
|
asserted a Tailwind class is rewritten against role or accessible name.
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
Three sessions, strictly sequential: S1 → S2 → S3. (S2 and S3 both write
|
||||||
|
`web/package.json` and the license files; the RAC wrappers S3 consumes are
|
||||||
|
built in S2.)
|
||||||
|
|
||||||
|
### Session S1: StyleX foundation, proven end to end
|
||||||
|
|
||||||
|
Owns: `web/package.json`, `web/package-lock.json`, `web/vite.config.ts`,
|
||||||
|
`web/src/styles.css`, `web/src/ui/tokens.stylex.ts` (new),
|
||||||
|
`web/src/ui/styles.ts` (new), `web/src/ui/styles.test.tsx` (new),
|
||||||
|
`licenses/inventory.zon`, `licenses/dependency-identity.txt`.
|
||||||
|
|
||||||
|
- S1.1 add `@stylexjs/stylex` (dependency) and `@stylexjs/unplugin`
|
||||||
|
(devDependency), exact pins; wire `stylex.vite()` per ruling 1. Tailwind
|
||||||
|
plugin stays.
|
||||||
|
- S1.2 tokens per ruling 2; shared styles per ruling 3. `ui/classes.ts` is
|
||||||
|
not deleted yet — both vocabularies coexist until S3.
|
||||||
|
- S1.3 `styles.test.tsx`: render a probe component using
|
||||||
|
`stylex.props(styles.button)` under vitest; assert a non-empty
|
||||||
|
`className` lands on the element (proves the transform runs in the test
|
||||||
|
pipeline, since `runtimeInjection` is off).
|
||||||
|
- S1.4 license entries per ruling 8.
|
||||||
|
|
||||||
|
Acceptance (S1):
|
||||||
|
- [ ] `npm run dev` serves a probe page where a StyleX rule visibly applies
|
||||||
|
and edits hot-reload (checked by hand, result recorded).
|
||||||
|
- [ ] `npm test` passes including `styles.test.tsx`.
|
||||||
|
- [ ] `npm run build` emits StyleX CSS into the `web/dist` assets; the built
|
||||||
|
`index.html` page renders with the probe styled.
|
||||||
|
- [ ] `npm run assert-bundled` passes with `@stylexjs/stylex` in the recorded
|
||||||
|
set; `zig build test` passes (drift guard agrees).
|
||||||
|
- [ ] All remaining gates green.
|
||||||
|
|
||||||
|
### Session S2: React Aria primitives and their cluster
|
||||||
|
|
||||||
|
Owns: `web/package.json`, `web/package-lock.json`, `web/src/ui/Dialog.tsx`
|
||||||
|
(new), `web/src/ui/ConfirmDialog.tsx` (new), `web/src/ui/Tabs.tsx` (new),
|
||||||
|
`web/src/ui/Select.tsx` (new), `web/src/ui/useCrudForm.ts`,
|
||||||
|
`web/src/ui/useCrudForm.test.tsx`, `web/src/features/clients/*`,
|
||||||
|
`web/src/features/local/*`, `web/src/features/upstreams/*`,
|
||||||
|
`web/src/features/blocklists/BlocklistsPage.tsx` and its test,
|
||||||
|
`web/src/features/rules/RulesPage.tsx` and its test,
|
||||||
|
`licenses/inventory.zon`, `licenses/dependency-identity.txt`.
|
||||||
|
|
||||||
|
- S2.1 add `react-aria-components`, exact pin; build the four wrappers per
|
||||||
|
ruling 4, styled with tokens and `ui/styles.ts`.
|
||||||
|
- S2.2 convert the owned cluster fully to StyleX while adopting the wrappers:
|
||||||
|
ClientEditDialog + PrefixesEditor + ClientsPage (Dialog, Select);
|
||||||
|
LocalDnsPage + ZonesTab + RecordsTab (Tabs, Select, ConfirmDialog via
|
||||||
|
useCrudForm); UpstreamsPage + UpstreamForm, BlocklistsPage, RulesPage
|
||||||
|
(ConfirmDialog, Select). No `className` literal survives in an owned file.
|
||||||
|
- S2.3 `window.confirm` removal and test rewrites per rulings 5 and 11.
|
||||||
|
- S2.4 license review per ruling 8 — every package `assert-bundled` names.
|
||||||
|
|
||||||
|
Acceptance (S2):
|
||||||
|
- [ ] `grep -rn 'window\.confirm(' web/src` → zero matches. The bare name
|
||||||
|
still appears in three comments in `ui/` that record what replaced it,
|
||||||
|
which is why the check is for a call and not for the name.
|
||||||
|
- [ ] Delete flows on upstreams, blocklists, rules, zones and records drive a
|
||||||
|
`role="alertdialog"` element; confirm and cancel both covered by tests.
|
||||||
|
- [ ] Tabs on `/local-dns` expose `role="tablist"`; keyboard arrow keys move
|
||||||
|
between tabs (asserted in `LocalDnsPage.test.tsx`).
|
||||||
|
- [ ] No `className=` literal in S2-owned files
|
||||||
|
(`grep -c 'className="' <file>` = 0; `stylex.props` spreads excepted).
|
||||||
|
- [ ] `assert-bundled` passes with the RAC closure recorded;
|
||||||
|
`zig build test` passes.
|
||||||
|
- [ ] All remaining gates green.
|
||||||
|
|
||||||
|
### Session S3: the sweep and the removal
|
||||||
|
|
||||||
|
Owns: every `web/src` file not owned by S1/S2 that carries `className=`
|
||||||
|
(the ruling-10 remainder: InlineError, ReadOnlyConfigBanner, HealthBanners,
|
||||||
|
RestartBanner, StatCards, routes.tsx, LoginPage, DashboardPage,
|
||||||
|
GroupSourcesEditor, GroupsPage, PauseWidget, BlocklistForm,
|
||||||
|
SourceStatusSection, DiskCard, AppShell, LiveLogPage, TimeseriesChart,
|
||||||
|
UpstreamHealthTable, LookupPage, QueryLogPage, SettingsPage) plus their
|
||||||
|
tests, `web/src/ui/classes.ts` (delete), `web/package.json`,
|
||||||
|
`web/package-lock.json`, `web/vite.config.ts`, `web/src/styles.css`,
|
||||||
|
`PLAN.md`.
|
||||||
|
|
||||||
|
- S3.1 convert the remainder smallest-first per ruling 10, one commit per
|
||||||
|
file, gates green at every commit.
|
||||||
|
- S3.2 delete `ui/classes.ts` when its import count reaches zero.
|
||||||
|
- S3.3 remove Tailwind: drop `tailwindcss` and `@tailwindcss/vite` from
|
||||||
|
`package.json`, the plugin from `vite.config.ts:3,8`, and the
|
||||||
|
`@import "tailwindcss"` line from `styles.css` (the file itself stays as
|
||||||
|
the StyleX CSS entry per ruling 1).
|
||||||
|
- S3.4 PLAN.md per ruling 9.
|
||||||
|
|
||||||
|
Acceptance (S3):
|
||||||
|
- [ ] `grep -rn 'className="' web/src` returns zero matches. No Tailwind
|
||||||
|
dependency in `web/package.json`, no plugin in `web/vite.config.ts`, no
|
||||||
|
`@import "tailwindcss"` in `web/src/styles.css`. The word still appears
|
||||||
|
in seven comments in `web/src` that name the ramps the tokens were taken
|
||||||
|
from and the reset the preflight used to supply; those are the record of
|
||||||
|
where the values came from, so the check is not for the name.
|
||||||
|
- [ ] `ui/classes.ts` is gone; nothing imports it.
|
||||||
|
- [ ] Dark scheme still follows `prefers-color-scheme` (vitest: token var
|
||||||
|
resolves differently under a mocked dark media query, or a recorded
|
||||||
|
manual check in both schemes).
|
||||||
|
- [ ] `PLAN.md:138` and `:235` name StyleX + React Aria; `git diff PLAN.md`
|
||||||
|
touches nothing else.
|
||||||
|
- [ ] All gates green, including `zig build dist` + `zig build verify-dist`
|
||||||
|
with `-Dweb-dist=web/dist` (run once at milestone end).
|
||||||
|
|
||||||
|
### Orchestrator
|
||||||
|
|
||||||
|
Verify S1 acceptance (the ruling-1 gate) before S2 starts; S2 before S3.
|
||||||
|
After S3: full gate set, then a live smoke — `zig build` a server with the
|
||||||
|
fresh `web/dist`, click through every page in light and dark scheme, exercise
|
||||||
|
one delete confirm and the local-dns tabs. Record deviations in
|
||||||
|
`## Recorded (implementation)`.
|
||||||
|
|
||||||
|
## Module layout
|
||||||
|
|
||||||
|
New files:
|
||||||
|
- `web/src/ui/tokens.stylex.ts` — the design tokens (ruling 2).
|
||||||
|
- `web/src/ui/styles.ts` — the shared style vocabulary (ruling 3).
|
||||||
|
- `web/src/ui/styles.test.tsx` — the transform-pipeline probe (S1.3).
|
||||||
|
- `web/src/ui/Dialog.tsx`, `web/src/ui/ConfirmDialog.tsx`,
|
||||||
|
`web/src/ui/Tabs.tsx`, `web/src/ui/Select.tsx` — the RAC wrappers
|
||||||
|
(ruling 4).
|
||||||
|
|
||||||
|
Deleted surface: `web/src/ui/classes.ts`; the `window.confirm` calls; the
|
||||||
|
Tailwind dependency pair and its vite plugin line and CSS import.
|
||||||
|
|
||||||
|
## Acceptance (milestone complete)
|
||||||
|
|
||||||
|
- [ ] All session boxes above.
|
||||||
|
- [ ] Zero Tailwind: no dependency, no plugin, no `@import`, no utility
|
||||||
|
`className` literal in `web/src`.
|
||||||
|
- [ ] RAC appears in exactly four wrapper files plus their consumers; no
|
||||||
|
other `react-aria-components` import (`grep -rln 'react-aria-components'
|
||||||
|
web/src` ⊆ the four wrappers).
|
||||||
|
- [ ] Focus ring visible via keyboard on every interactive element of every
|
||||||
|
page (spot-checked live, recorded).
|
||||||
|
- [ ] `licenses/dependency-identity.txt`, `licenses/inventory.zon` and the
|
||||||
|
bundled reality agree (`assert-bundled` and `zig build test` both
|
||||||
|
green).
|
||||||
|
- [ ] `web/src/lib/types.ts` and `web/src/lib/contractSamples.gen.ts` are
|
||||||
|
byte-identical to HEAD.
|
||||||
|
|
||||||
|
## Recorded (implementation)
|
||||||
|
|
||||||
|
Written during S1 and the S2 hand-off. Each entry is a place the spec was wrong
|
||||||
|
or a decision the spec did not have.
|
||||||
|
|
||||||
|
### S1 could not put StyleX in the bundle, and its acceptance says otherwise
|
||||||
|
|
||||||
|
S1 owns no page file, so no production module imports StyleX and rollup emits
|
||||||
|
none of it. Two S1 boxes — "`npm run build` emits StyleX CSS into the `web/dist`
|
||||||
|
assets" and "`assert-bundled` passes with `@stylexjs/stylex` in the recorded
|
||||||
|
set" — are therefore unmeetable inside S1 as scoped, and the recorded bundled
|
||||||
|
set is unchanged at the S1 commit. Both were proven instead against a temporary
|
||||||
|
probe component, then deferred to S2, which converts real pages and does change
|
||||||
|
the bundled set. Ruling 8's "S1 adds `@stylexjs/stylex` ... to the runtime
|
||||||
|
closure and the bundled set" is half right: the closure moves in S1, the bundled
|
||||||
|
set in S2. The inventory entry for StyleX is anticipatory at the S1 commit,
|
||||||
|
which is deliberate — see the styleq entry for why it cannot wait.
|
||||||
|
|
||||||
|
### The probe results, recorded rather than left as a claim
|
||||||
|
|
||||||
|
Against the temporary probe: the built CSS carries `@layer priority1..N` and the
|
||||||
|
token variables, resolving to `#fafafa` light and `#09090b` dark with
|
||||||
|
lightningcss `lab()` fallbacks. The dev server needs no wiring of its own — the
|
||||||
|
plugin injects `<link rel="stylesheet" href="/virtual:stylex.css">` into
|
||||||
|
`index.html`, so the `DevStyleXInject` component in StyleX's Vite guide is for
|
||||||
|
frameworks that bypass Vite's HTML transform and is not used here. Editing a
|
||||||
|
token regenerates the virtual stylesheet and the dev server issues a page
|
||||||
|
reload.
|
||||||
|
|
||||||
|
### S1 wrote outside its owned files, and outside an anti-requirement
|
||||||
|
|
||||||
|
The licence guard forced it: `licenses/stylex-mit.txt`, `licenses/styleq-mit.txt`
|
||||||
|
and their registration in `licenses/licenses.zig`, plus the `npm_not_shipped`
|
||||||
|
array in `src/licenses_drift_test.zig`. The last contradicts the "no edits ...
|
||||||
|
under `src/`" anti-requirement below. The anti-requirement is amended, not
|
||||||
|
ignored: `src/licenses_drift_test.zig` is writable for its licence-fact arrays
|
||||||
|
and, from S2, for the per-package licence change described next. The rest of
|
||||||
|
`src/` stays closed.
|
||||||
|
|
||||||
|
### Apache-2.0 inbound: a decision, not a deduction
|
||||||
|
|
||||||
|
`react-aria-components` 1.20.0 brings eight Apache-2.0 packages
|
||||||
|
(react-aria-components, react-aria, react-stately, `@react-types/shared`, three
|
||||||
|
`@internationalized/*`, `@swc/helpers`) and `tslib` under 0BSD into an EUPL-1.2
|
||||||
|
project. The user accepted Apache-2.0 inbound on 2026-08-12 and authorised the
|
||||||
|
guard change it needs. This is recorded as their decision because nothing in the
|
||||||
|
licence settles it: EUPL-1.2's appendix does not list Apache-2.0 — it names GPL,
|
||||||
|
AGPL, OSL, EPL, CeCILL, MPL, LGPL, CC BY-SA, EUPL and LiLiQ — and that appendix
|
||||||
|
governs Article 5 outbound relicensing, not inbound consumption. Any claim that
|
||||||
|
the appendix permits this is false and must not be written into the inventory.
|
||||||
|
`src/licenses_drift_test.zig:122` hardcodes a single expected licence and
|
||||||
|
becomes per-package in S2; the guard stays strict, an unexpected licence still
|
||||||
|
fails.
|
||||||
|
|
||||||
|
### Ruling 8's picture of the React Aria closure is wrong
|
||||||
|
|
||||||
|
There are no `@react-aria/*` or `@react-stately/*` scoped packages. RAC 1.20.0
|
||||||
|
ships consolidated `react-aria` and `react-stately`. The runtime closure it adds
|
||||||
|
is the eleven packages named above plus `aria-hidden`, `clsx` and `client-only`
|
||||||
|
under MIT — not the "dozens" the ruling anticipated.
|
||||||
|
|
||||||
|
### Ruling 11's claim about the tab tests is wrong
|
||||||
|
|
||||||
|
`LocalDnsPage.tsx` already renders `role="tablist"` and `role="tab"` by hand, so
|
||||||
|
no test query moves from `getByRole("button")` to `getByRole("tab")`. What RAC
|
||||||
|
adds there is keyboard arrow-key navigation, which the hand-rolled switcher
|
||||||
|
lacks, and that is what the S2 acceptance box asserts.
|
||||||
|
|
||||||
|
### `formCard` is a layout change, not a port
|
||||||
|
|
||||||
|
`space-y-3` set sibling margins on a block container; the StyleX version is a
|
||||||
|
flex column with `gap`. StyleX cannot express the `> * + *` selector `space-y`
|
||||||
|
compiles to, so `gap` is the only mechanism available. Both call sites
|
||||||
|
(`RecordsTab`, `ZonesTab`) are plain vertical form stacks, where the difference
|
||||||
|
in child sizing and margin behaviour does not show. Recorded against ruling 7,
|
||||||
|
which pins layout structure.
|
||||||
|
|
||||||
|
### S2 needed two build-config changes the spec did not anticipate
|
||||||
|
|
||||||
|
Both are in files S1 and S3 own, both were forced, and neither is optional.
|
||||||
|
|
||||||
|
`web/vitest.setup.ts` (new) plus `setupFiles` in `web/vite.config.ts`: jsdom
|
||||||
|
implements no `CSS` interface at all, and React Aria's collection code calls
|
||||||
|
`CSS.escape` unguarded when it looks an item up by key
|
||||||
|
(`react-aria/dist/private/selection/utils.mjs:22`). Every RAC Select and Tabs
|
||||||
|
test threw `Cannot read properties of undefined (reading 'escape')` before this
|
||||||
|
landed. The setup file implements CSSOM's *serialize an identifier* algorithm
|
||||||
|
rather than approximating it, because a wrong escape would break the key lookups
|
||||||
|
silently instead of loudly.
|
||||||
|
|
||||||
|
The `@` alias passed to `stylex.vite()` in `web/vite.config.ts`: StyleX resolves
|
||||||
|
the `.stylex.ts` theme file itself and cannot see Vite's `resolve.alias`, so
|
||||||
|
`import { colors } from "@/ui/tokens.stylex"` in a feature file failed the build
|
||||||
|
with "Could not resolve the path to the imported file". Relative imports were the
|
||||||
|
alternative; passing the alias keeps the repo's `@/` convention instead.
|
||||||
|
|
||||||
|
### RAC Select changes the accessible name of a labelled control
|
||||||
|
|
||||||
|
`useSelect` labels the trigger button with `aria-labelledby="<value> <label>"`,
|
||||||
|
so a select labelled "Type" showing "A" has the accessible name "A Type", and
|
||||||
|
`getByLabelText("Type")` no longer finds it. Converted tests query
|
||||||
|
`getByRole("button", { name: /Type$/ })` and read the options by opening the
|
||||||
|
listbox. RAC also renders a hidden native `<select>` for form submission; it sits
|
||||||
|
inside an `aria-hidden` container, so role and label queries skip it.
|
||||||
|
|
||||||
|
### The RAC anti-requirement forbids parts of a component it requires
|
||||||
|
|
||||||
|
`Select.tsx` imports RAC `Button`, `Popover`, `Label`, `ListBox`, `ListBoxItem`
|
||||||
|
and `SelectValue`. The anti-requirement below names `Button` and `Popover`
|
||||||
|
among the components RAC must not bring. Those two are not adopted components
|
||||||
|
here — RAC composes a Select out of exactly these parts, and there is no RAC
|
||||||
|
Select without them. The anti-requirement is amended: it forbids adopting a RAC
|
||||||
|
component as an app-wide primitive, and the internal parts of the four adopted
|
||||||
|
components are permitted where no file outside their wrapper imports them.
|
||||||
|
That last clause is the check that keeps the scope honest, and it holds:
|
||||||
|
`react-aria-components` is imported only by the four `ui/` wrappers. A RAC
|
||||||
|
`Button` in a feature file would still be a breach.
|
||||||
|
|
||||||
|
### The focus-ring floor was briefly amended on a false premise
|
||||||
|
|
||||||
|
S2 left the Select's options with their outline removed, showing keyboard focus
|
||||||
|
as a background inversion alone. This section previously amended ruling 3 to
|
||||||
|
excuse that, arguing RAC drives option focus through an `isFocused` render prop
|
||||||
|
so a `:focus-visible` rule could never match. That argument is wrong.
|
||||||
|
`useSelectableItem` gives options a roving `tabIndex` and focuses the option's
|
||||||
|
own DOM node (`react-aria/dist/private/selection/useSelectableItem.mjs:64`),
|
||||||
|
which `ListBoxItem` spreads onto its element — so `:focus-visible` matches and
|
||||||
|
the ring was simply missing. The amendment is withdrawn and ruling 3 stands
|
||||||
|
unchanged. `Select.tsx` now composes the shared inset ring onto every option:
|
||||||
|
inset because an option flush against a scrolling popover clips an outset
|
||||||
|
outline, and recoloured to `primaryText` on the highlighted row because the
|
||||||
|
focus token is the same blue that row paints behind it. The inversion stays as
|
||||||
|
the listbox convention; it is no longer the only indicator.
|
||||||
|
|
||||||
|
### Two notes for S3
|
||||||
|
|
||||||
|
`textMuted` resolves to zinc-400 in dark mode. Several current `text-zinc-500`
|
||||||
|
call sites carry no dark override and stay zinc-500, so the token cannot replace
|
||||||
|
those without a shade shift — decide per call site rather than sweeping.
|
||||||
|
|
||||||
|
Vitest began reporting "Tests closed successfully but something prevents Vite
|
||||||
|
server from exiting" with the StyleX plugin in the pipeline. The suite passes
|
||||||
|
and exits 0. It should not be allowed to become the accepted baseline.
|
||||||
|
|
||||||
|
### Both S3 notes are now settled
|
||||||
|
|
||||||
|
**The vitest warning is fixed, and it was not our code.** Bisecting a trivial
|
||||||
|
one-assertion test against a bare config showed `react()` exits clean and
|
||||||
|
`stylex.vite()` does not. `@stylexjs/unplugin@0.19.0`'s
|
||||||
|
`lib/es/vite.mjs:59` starts a 150ms `setInterval` in `configureServer` to poll
|
||||||
|
its CSS store for HMR, and clears it from the `close` event of
|
||||||
|
`server.httpServer`. A probe plugin confirmed vitest resolves `httpServer` to
|
||||||
|
`null`, because it runs the dev server in middleware mode — so the timer is
|
||||||
|
never cleared and holds the event loop open until vitest kills the worker on a
|
||||||
|
ten-second timeout. `web/vite.config.ts` now strips that one hook when
|
||||||
|
`process.env.VITEST` is set; the transform hook, which the tests do need, is
|
||||||
|
untouched, and `ui/styles.test.tsx` still proves it runs. `npm test` reports 31
|
||||||
|
files and 183 tests passed, with no warning, and exits 0.
|
||||||
|
|
||||||
|
**`textMuted` was swept; here is the per-call-site judgement.** Measuring the
|
||||||
|
two shifts against their grounds settles it in opposite directions:
|
||||||
|
|
||||||
|
- 39 call sites were a flat `text-zinc-500` with no dark override. On the dark
|
||||||
|
ground that measures **4.12:1**, which fails WCAG AA. The token's zinc-400
|
||||||
|
dark value measures **7.56:1**. The sweep repaired a real accessibility
|
||||||
|
defect there, so it stands, and the token comment now records why.
|
||||||
|
- 4 call sites were `text-zinc-600 dark:text-zinc-400` — already adaptive. The
|
||||||
|
sweep moved their light value to zinc-500, from **7.40:1** to **4.62:1**.
|
||||||
|
That clears AA by a hair on small text: the query-log table header, the
|
||||||
|
dashboard period button, the inactive nav item and the chart legend. It buys
|
||||||
|
nothing, so a second token `textSecondary` (zinc-600 light, zinc-400 dark)
|
||||||
|
restores the original pair at exactly those four.
|
||||||
|
|
||||||
|
Contrast was computed from the tokens' own oklch values through linear sRGB to
|
||||||
|
WCAG relative luminance, not read off a chart.
|
||||||
|
|
||||||
|
### The reset's font stack is not Tailwind's, deliberately
|
||||||
|
|
||||||
|
Tailwind is uninstalled, so its default stack cannot be reproduced from the
|
||||||
|
tree, and ruling 7 accepts the metric drift. One part is not drift: `Select.tsx`
|
||||||
|
renders U+25BE for its chevron, and Segoe UI does not carry that glyph, so on
|
||||||
|
Windows the stack has to reach a font that does. `"Segoe UI Symbol"` and
|
||||||
|
`"Noto Sans Symbols 2"` are appended for that glyph. No emoji families are
|
||||||
|
listed, because the app renders no emoji.
|
||||||
|
|
||||||
|
### Two review findings closed by decision, not by a change
|
||||||
|
|
||||||
|
Both were put to Mokhtar Mial on 2026-08-12 and both are closed. Neither is to
|
||||||
|
be re-opened by a later review reading the history and inferring an oversight.
|
||||||
|
|
||||||
|
`0f58966` carries a `README.md` edit that was already in the working tree and
|
||||||
|
is not part of the `srOnly` work its message describes. The content is intact
|
||||||
|
and only the attribution is wrong; rewriting published history to correct that
|
||||||
|
was declined.
|
||||||
|
|
||||||
|
Ruling 10 asks for one commit per file in ascending `className` count. S3 did
|
||||||
|
not hold to the order strictly. Bisectability does not depend on it and the
|
||||||
|
commits were already published, so the order stands as it is.
|
||||||
|
|
||||||
|
## Anti-requirements
|
||||||
|
|
||||||
|
- No router or query changes: TanStack Router and TanStack Query stay,
|
||||||
|
untouched. That decision is closed.
|
||||||
|
- No RAC beyond Dialog, AlertDialog, Tabs, Select — no RAC Button, TextField,
|
||||||
|
Table, Menu, DatePicker adopted as an app primitive. Native elements styled
|
||||||
|
with StyleX are the end state. The internal parts of the four adopted
|
||||||
|
components (Button, Popover, Label, ListBox, ListBoxItem, SelectValue inside
|
||||||
|
Select) are permitted, and only inside their own wrapper file — see the
|
||||||
|
recorded amendment above.
|
||||||
|
- No runtime style injection (`runtimeInjection: false` stays), no CSS-in-JS
|
||||||
|
at runtime, no styled-components-like patterns.
|
||||||
|
- No dynamic class-name assembly, no `clsx`/`classnames` dependency, no
|
||||||
|
Tailwind-compatibility shim.
|
||||||
|
- No theme toggle, no `data-theme`, no stored preference — media-query dark
|
||||||
|
mode only, as today.
|
||||||
|
- No redesign: layout structure is pinned by ruling 7. Drift in shades and
|
||||||
|
spacing is accepted; new page structures are not.
|
||||||
|
- No visual-regression harness, no screenshot tooling.
|
||||||
|
- No edits to `web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts`,
|
||||||
|
`web/src/lib/api.ts`, or anything under `src/` (the Zig tree).
|
||||||
|
- No `vite-plugin-stylex` (unmaintained community plugin).
|
||||||
+106
-20
@@ -47,7 +47,7 @@ const licenses = @import("licenses_files");
|
|||||||
/// rolldown for the CommonJS interop helpers it prepends to any chunk pulling in
|
/// rolldown for the CommonJS interop helpers it prepends to any chunk pulling in
|
||||||
/// a CommonJS module. Every other devDependency left `web/dist` untouched.
|
/// a CommonJS module. Every other devDependency left `web/dist` untouched.
|
||||||
/// Anything added here must also be inventoried.
|
/// Anything added here must also be inventoried.
|
||||||
const npm_generators = [_][]const u8{ "rolldown", "tailwindcss", "vite" };
|
const npm_generators = [_][]const u8{ "rolldown", "vite" };
|
||||||
|
|
||||||
/// Packages of the recorded runtime closure that put no byte in `web/dist`, so
|
/// Packages of the recorded runtime closure that put no byte in `web/dist`, so
|
||||||
/// the inventory names them in a note rather than carrying their licence. The
|
/// the inventory names them in a note rather than carrying their licence. The
|
||||||
@@ -57,7 +57,23 @@ const npm_generators = [_][]const u8{ "rolldown", "tailwindcss", "vite" };
|
|||||||
/// This list is a claim about the build, and the build is what settles it: a
|
/// This list is a claim about the build, and the build is what settles it: a
|
||||||
/// name here that turns up in the recorded `web/dist` bundle fails, so a
|
/// name here that turns up in the recorded `web/dist` bundle fails, so a
|
||||||
/// package that starts shipping cannot stay on this list quietly.
|
/// package that starts shipping cannot stay on this list quietly.
|
||||||
const npm_not_shipped = [_][]const u8{ "cookie-es", "isbot", "seroval", "seroval-plugins" };
|
const npm_not_shipped = [_][]const u8{
|
||||||
|
"cookie-es",
|
||||||
|
"isbot",
|
||||||
|
"seroval",
|
||||||
|
"seroval-plugins",
|
||||||
|
"css-mediaquery",
|
||||||
|
"invariant",
|
||||||
|
"js-tokens",
|
||||||
|
"loose-envify",
|
||||||
|
"@internationalized/date",
|
||||||
|
"@internationalized/number",
|
||||||
|
"@react-types/shared",
|
||||||
|
"@swc/helpers",
|
||||||
|
"aria-hidden",
|
||||||
|
"client-only",
|
||||||
|
"tslib",
|
||||||
|
};
|
||||||
|
|
||||||
/// Components the shipped artifacts contain that no dependency file mentions,
|
/// Components the shipped artifacts contain that no dependency file mentions,
|
||||||
/// so no automatic check would ever notice their disappearance from the
|
/// so no automatic check would ever notice their disappearance from the
|
||||||
@@ -71,7 +87,6 @@ const required_components = [_][]const u8{
|
|||||||
"p256-m",
|
"p256-m",
|
||||||
"React",
|
"React",
|
||||||
"TanStack",
|
"TanStack",
|
||||||
"Tailwind",
|
|
||||||
"Vite",
|
"Vite",
|
||||||
"Rolldown",
|
"Rolldown",
|
||||||
"Mozilla CA certificate bundle",
|
"Mozilla CA certificate bundle",
|
||||||
@@ -103,15 +118,61 @@ const ZigDependencyVersion = struct {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The licence every *shipped* npm package carries today, and the licence of
|
/// The licence each npm package of the recorded sets carries today. The identity
|
||||||
/// every npm text under `licenses/`. The identity file records a licence token
|
/// file records a licence token per package; nothing used to read it, so a
|
||||||
/// per package; nothing used to read it, so a package that relicensed passed as
|
/// package that relicensed passed as long as its version had not moved.
|
||||||
/// long as its version had not moved. A token other than this one needs its own
|
///
|
||||||
/// licence text and a decision about EUPL-1.2 compatibility, which is a human
|
/// MIT is the default because almost every package here is MIT, so only the
|
||||||
/// review, not a paste. The `npm_not_shipped` packages are exempt — they
|
/// exceptions are written down. A package whose recorded token stops matching
|
||||||
/// redistribute nothing, and one of them is Unlicense.
|
/// its expectation fails — including the packages below, which is the point of
|
||||||
|
/// naming them rather than waving them through: a new licence on any of them is
|
||||||
|
/// a new decision, not a fact to absorb quietly.
|
||||||
|
///
|
||||||
|
/// Adding a name here is a human review, not a paste. A licence this project has
|
||||||
|
/// not taken before needs its own text under `licenses/`, an inventory entry
|
||||||
|
/// that says who accepted it and when, and whatever that licence's attribution
|
||||||
|
/// terms require. Apache-2.0 arrived that way: Mokhtar Mial accepted it inbound
|
||||||
|
/// on 2026-08-12, and `licenses/react-aria-apache-2.0.txt` carries the text
|
||||||
|
/// Section 4 asks for.
|
||||||
|
///
|
||||||
|
/// Packages that put no byte in `web/dist` are here too. They redistribute
|
||||||
|
/// nothing today, so their licence carries no obligation today — but "not
|
||||||
|
/// shipped" is a claim about the build that a future build can falsify, and
|
||||||
|
/// recording the licence now means the answer is already reviewed when it does.
|
||||||
const npm_expected_licence = "MIT";
|
const npm_expected_licence = "MIT";
|
||||||
|
|
||||||
|
const npm_licence_exceptions = [_]NpmLicence{
|
||||||
|
// Shipped: React Aria and the one @internationalized package it pulls into
|
||||||
|
// the bundle.
|
||||||
|
.{ .name = "react-aria-components", .licence = "Apache-2.0" },
|
||||||
|
.{ .name = "react-aria", .licence = "Apache-2.0" },
|
||||||
|
.{ .name = "react-stately", .licence = "Apache-2.0" },
|
||||||
|
.{ .name = "@internationalized/string", .licence = "Apache-2.0" },
|
||||||
|
// Not shipped: the rest of the React Aria closure.
|
||||||
|
.{ .name = "@internationalized/date", .licence = "Apache-2.0" },
|
||||||
|
.{ .name = "@internationalized/number", .licence = "Apache-2.0" },
|
||||||
|
.{ .name = "@react-types/shared", .licence = "Apache-2.0" },
|
||||||
|
.{ .name = "@swc/helpers", .licence = "Apache-2.0" },
|
||||||
|
.{ .name = "tslib", .licence = "0BSD" },
|
||||||
|
// Not shipped: reached only through @tanstack/router-core's server paths
|
||||||
|
// and @stylexjs/stylex's compiler.
|
||||||
|
.{ .name = "isbot", .licence = "Unlicense" },
|
||||||
|
.{ .name = "css-mediaquery", .licence = "BSD" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const NpmLicence = struct {
|
||||||
|
name: []const u8,
|
||||||
|
licence: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The reviewed licence for one package: its exception, or MIT.
|
||||||
|
fn expectedLicence(name: []const u8) []const u8 {
|
||||||
|
for (npm_licence_exceptions) |entry| {
|
||||||
|
if (std.mem.eql(u8, entry.name, name)) return entry.licence;
|
||||||
|
}
|
||||||
|
return npm_expected_licence;
|
||||||
|
}
|
||||||
|
|
||||||
/// The two licence texts that must be reproduced in full, pinned by content.
|
/// The two licence texts that must be reproduced in full, pinned by content.
|
||||||
/// The marker-string probes below prove the right *document* is present; they
|
/// The marker-string probes below prove the right *document* is present; they
|
||||||
/// cannot tell a complete Apache-2.0 from one with its middle sections deleted,
|
/// cannot tell a complete Apache-2.0 from one with its middle sections deleted,
|
||||||
@@ -481,22 +542,21 @@ test "the CA bundle entry names the Alpine release the Dockerfile pins" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test "every npm package in the recorded closure ships under the licence the inventory assumes" {
|
test "every npm package in the recorded closure carries the licence the inventory expects" {
|
||||||
for ([_][]const u8{ npm_section, generator_section }) |header| {
|
for ([_][]const u8{ npm_section, generator_section }) |header| {
|
||||||
const recorded = try recordedSection(header);
|
const recorded = try recordedSection(header);
|
||||||
var lines = std.mem.tokenizeScalar(u8, recorded, '\n');
|
var lines = std.mem.tokenizeScalar(u8, recorded, '\n');
|
||||||
while (lines.next()) |line| {
|
while (lines.next()) |line| {
|
||||||
const pkg = parseRecordedPackage(line) orelse return error.MalformedIdentityLine;
|
const pkg = parseRecordedPackage(line) orelse return error.MalformedIdentityLine;
|
||||||
// A package that puts no byte in web/dist redistributes nothing, so
|
const expected = expectedLicence(pkg.name);
|
||||||
// its licence carries no obligation. isbot is Unlicense and is one
|
if (!std.mem.eql(u8, pkg.licence, expected)) {
|
||||||
// of these.
|
|
||||||
if (isNotShipped(pkg.name)) continue;
|
|
||||||
if (!std.mem.eql(u8, pkg.licence, npm_expected_licence)) {
|
|
||||||
std.debug.print(
|
std.debug.print(
|
||||||
"npm package '{s}' is recorded under '{s}', not {s}. Every npm licence text under" ++
|
"npm package '{s}' is recorded under '{s}', but the reviewed expectation is" ++
|
||||||
" licenses/ is an {s} text, so this package needs one of its own and a" ++
|
" '{s}'. Work out what the new licence means for the shipped artifacts, give" ++
|
||||||
" decision about EUPL-1.2 compatibility.\n",
|
" it a text under licenses/ and an inventory entry naming who accepted it if" ++
|
||||||
.{ pkg.name, pkg.licence, npm_expected_licence, npm_expected_licence },
|
" this project has not taken it before, then record the new expectation in" ++
|
||||||
|
" npm_licence_exceptions.\n",
|
||||||
|
.{ pkg.name, pkg.licence, expected },
|
||||||
);
|
);
|
||||||
return error.NpmLicenceChanged;
|
return error.NpmLicenceChanged;
|
||||||
}
|
}
|
||||||
@@ -504,6 +564,32 @@ test "every npm package in the recorded closure ships under the licence the inve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "every npm licence exception names a package the identity file records" {
|
||||||
|
const closure = try recordedSection(npm_section);
|
||||||
|
const generators = try recordedSection(generator_section);
|
||||||
|
for (npm_licence_exceptions) |entry| {
|
||||||
|
// A stale exception is how a package quietly regains the MIT default
|
||||||
|
// after it leaves the tree and comes back under a different licence.
|
||||||
|
if (recordsPackage(closure, entry.name) or recordsPackage(generators, entry.name)) continue;
|
||||||
|
std.debug.print(
|
||||||
|
"npm_licence_exceptions names '{s}', which licenses/dependency-identity.txt no longer" ++
|
||||||
|
" records. Drop the exception when the package leaves the closure.\n",
|
||||||
|
.{entry.name},
|
||||||
|
);
|
||||||
|
return error.StaleLicenceException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a recorded `<package> <version> <licence>` section names a package.
|
||||||
|
fn recordsPackage(section: []const u8, name: []const u8) bool {
|
||||||
|
var lines = std.mem.tokenizeScalar(u8, section, '\n');
|
||||||
|
while (lines.next()) |line| {
|
||||||
|
const pkg = parseRecordedPackage(line) orelse continue;
|
||||||
|
if (std.mem.eql(u8, pkg.name, name)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
test "the licence texts that must be reproduced in full are unmodified" {
|
test "the licence texts that must be reproduced in full are unmodified" {
|
||||||
for (pinned_texts) |pinned| {
|
for (pinned_texts) |pinned| {
|
||||||
const body = textBody(pinned.file) orelse {
|
const body = textBody(pinned.file) orelse {
|
||||||
|
|||||||
Generated
+1111
-328
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build && node scripts/stamp-dist.mjs",
|
"build": "vite build && node scripts/assert-css-layers.mjs && node scripts/stamp-dist.mjs",
|
||||||
"typecheck": "tsc -b",
|
"typecheck": "tsc -b",
|
||||||
"lint": "oxlint src vite.config.ts",
|
"lint": "oxlint src vite.config.ts",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
@@ -25,13 +25,15 @@
|
|||||||
"trailingComma": "all"
|
"trailingComma": "all"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@stylexjs/stylex": "0.19.0",
|
||||||
"@tanstack/react-query": "5.101.4",
|
"@tanstack/react-query": "5.101.4",
|
||||||
"@tanstack/react-router": "1.170.18",
|
"@tanstack/react-router": "1.170.18",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
|
"react-aria-components": "1.20.0",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "4.3.3",
|
"@stylexjs/unplugin": "0.19.0",
|
||||||
"@testing-library/dom": "10.4.1",
|
"@testing-library/dom": "10.4.1",
|
||||||
"@testing-library/react": "16.3.2",
|
"@testing-library/react": "16.3.2",
|
||||||
"@types/node": "26.1.1",
|
"@types/node": "26.1.1",
|
||||||
@@ -41,8 +43,7 @@
|
|||||||
"jsdom": "29.1.1",
|
"jsdom": "29.1.1",
|
||||||
"oxlint": "1.75.0",
|
"oxlint": "1.75.0",
|
||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"tailwindcss": "4.3.3",
|
"typescript": "7.0.2",
|
||||||
"typescript": "6.0.3",
|
|
||||||
"vite": "8.1.5",
|
"vite": "8.1.5",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Every rule in the built stylesheet must sit inside a cascade layer
|
||||||
|
// (milestone 23). Unlayered author CSS outranks every layer whatever its
|
||||||
|
// selector says, so a single unlayered rule silently beats the StyleX atomic
|
||||||
|
// rules it was written to sit under. That failure renders wrong and passes
|
||||||
|
// every other gate: no test asserts computed style, and the bundler is happy.
|
||||||
|
// It also checks the layer ORDER, which is the invariant that actually matters:
|
||||||
|
// a later layer beats an earlier one, so `reset` has to be declared first.
|
||||||
|
//
|
||||||
|
// What it does not catch, so nobody reads more into a pass than is there: an
|
||||||
|
// unlayered rule that sets only custom properties is allowed, because StyleX
|
||||||
|
// emits its token `:root` block exactly that way and this cannot tell that
|
||||||
|
// block from an override of it; a declaration value containing `@layer` or a
|
||||||
|
// brace inside a string blinds the stripper; and with several stylesheets it
|
||||||
|
// judges each alone, not their load order in the document.
|
||||||
|
//
|
||||||
|
// This check runs from web/ as part of `npm run build`.
|
||||||
|
|
||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
|
||||||
|
|
||||||
|
const sheets = readdirSync(distDir).filter((name) => name.endsWith(".css"));
|
||||||
|
if (sheets.length === 0) {
|
||||||
|
console.error("assert-css-layers: no stylesheet in dist/assets — did the build emit one?");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// At-rules that describe a resource or a name rather than styling an element.
|
||||||
|
// They carry no cascade priority against a layer, so being outside one is
|
||||||
|
// correct, and StyleX emits `@property` for its custom properties.
|
||||||
|
const unlayerable = String.raw`@(?:layer|property|keyframes|font-face|counter-style|charset|import)`;
|
||||||
|
|
||||||
|
/** Strip comments, then every balanced block and statement the rule above allows. */
|
||||||
|
function outsideLayers(css) {
|
||||||
|
let rest = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||||
|
for (;;) {
|
||||||
|
const at = rest.search(new RegExp(`${unlayerable}[^{;]*\\{`));
|
||||||
|
if (at === -1) break;
|
||||||
|
let depth = 0;
|
||||||
|
let end = rest.indexOf("{", at);
|
||||||
|
for (let i = end; i < rest.length; i += 1) {
|
||||||
|
if (rest[i] === "{") depth += 1;
|
||||||
|
else if (rest[i] === "}") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
end = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest = rest.slice(0, at) + rest.slice(end + 1);
|
||||||
|
}
|
||||||
|
return rest.replace(new RegExp(`${unlayerable}[^;{}]*;`, "g"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rule that only sets custom properties styles nothing on its own — StyleX
|
||||||
|
* emits its token `:root` block that way, ahead of its layers, and a variable
|
||||||
|
* is consumed through `var()` rather than competing with a layered rule.
|
||||||
|
*/
|
||||||
|
function stylesSomething(body) {
|
||||||
|
return body
|
||||||
|
.split(";")
|
||||||
|
.map((declaration) => declaration.trim())
|
||||||
|
.some((declaration) => declaration.length > 0 && !declaration.startsWith("--"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer names in the order their position is fixed, which is where each name is
|
||||||
|
* first mentioned — a later block under an already-named layer does not move it.
|
||||||
|
*/
|
||||||
|
function layerOrder(css) {
|
||||||
|
const seen = [];
|
||||||
|
for (const [, names] of css.replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/@layer\s+([^{;]+)[{;]/g)) {
|
||||||
|
for (const name of names.split(",")) {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (trimmed.length > 0 && !seen.includes(trimmed)) seen.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
for (const sheet of sheets) {
|
||||||
|
const css = readFileSync(join(distDir, sheet), "utf8");
|
||||||
|
|
||||||
|
// Order is the whole point: a later layer wins, so the reset has to be first.
|
||||||
|
const order = layerOrder(css);
|
||||||
|
if (order.length > 0 && order[0] !== "reset") {
|
||||||
|
console.error(
|
||||||
|
`assert-css-layers: ${sheet} declares layers in the order ${order.join(", ")} — ` +
|
||||||
|
`'reset' must come first or it outranks the StyleX rules written against it.`,
|
||||||
|
);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftover = outsideLayers(css);
|
||||||
|
for (const [, selector, body] of leftover.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||||
|
if (!stylesSomething(body)) continue;
|
||||||
|
console.error(
|
||||||
|
`assert-css-layers: ${sheet} styles elements outside every @layer:\n` +
|
||||||
|
` ${selector.trim().slice(0, 80)} { ${body.trim().slice(0, 60)} … }`,
|
||||||
|
);
|
||||||
|
failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
console.error("Wrap it in a layer declared before StyleX's, as web/src/styles.css does.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`every rule in ${sheets.length === 1 ? "the stylesheet" : `${sheets.length} stylesheets`} sits inside a cascade layer`,
|
||||||
|
);
|
||||||
+59
-10
@@ -1,8 +1,57 @@
|
|||||||
import { useEffect, useState, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
import { useRouter, useSearch } from "@tanstack/react-router";
|
import { useRouter, useSearch } from "@tanstack/react-router";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
import { useAuth } from "@/auth/store";
|
import { useAuth } from "@/auth/store";
|
||||||
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
/** Login renders outside AppShell, so it paints the page ground itself. */
|
||||||
|
page: {
|
||||||
|
display: "flex",
|
||||||
|
minHeight: "100dvh",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
color: colors.text,
|
||||||
|
padding: "1rem",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "24rem",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
probing: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
},
|
||||||
|
fieldLabel: {
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export function safeRedirect(raw: string | undefined): string {
|
export function safeRedirect(raw: string | undefined): string {
|
||||||
if (raw === undefined) return "/";
|
if (raw === undefined) return "/";
|
||||||
@@ -76,15 +125,15 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-dvh items-center justify-center bg-zinc-50 p-4 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
|
<main {...stylex.props(styles.page)}>
|
||||||
<section className="w-full max-w-sm">
|
<section {...stylex.props(styles.card)}>
|
||||||
<h1 className="text-2xl font-semibold">nxdns</h1>
|
<h1 {...stylex.props(styles.heading)}>nxdns</h1>
|
||||||
{authRequired !== true ? (
|
{authRequired !== true ? (
|
||||||
<p className="mt-4 text-zinc-500">Checking whether a password is required…</p>
|
<p {...stylex.props(styles.probing)}>Checking whether a password is required…</p>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={onSubmit} className="mt-6 space-y-4">
|
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium">
|
<label htmlFor="password" {...stylex.props(styles.fieldLabel)}>
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -95,20 +144,20 @@ export default function LoginPage() {
|
|||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy || lockedOut}
|
disabled={busy || lockedOut}
|
||||||
className={`w-full ${largePrimaryButtonClass}`}
|
{...stylex.props(shared.largePrimaryButton, styles.submit, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{busy ? "Logging in…" : "Log in"}
|
{busy ? "Logging in…" : "Log in"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
{error !== null && (
|
{error !== null && (
|
||||||
<p role="alert" className="mt-4 text-sm text-red-600 dark:text-red-400">
|
<p role="alert" {...stylex.props(styles.error)}>
|
||||||
{errorMessage(error, remaining)}
|
{errorMessage(error, remaining)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,48 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||||
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { READ_ONLY_HINT } from "@/features/settings/authority";
|
import { READ_ONLY_HINT } from "@/features/settings/authority";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
form: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.75rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
maxWidth: "36rem",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
fieldLabel: {
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
checkboxLabel: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drops the rejection the page already renders inline below the form. Anything
|
* Drops the rejection the page already renders inline below the form. Anything
|
||||||
* else is a bug in this component and must reach the console instead of dying
|
* else is a bug in this component and must reach the console instead of dying
|
||||||
@@ -46,10 +84,10 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3">
|
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
|
||||||
<h2 className="text-lg font-medium">{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2>
|
<h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="blocklist-url" className="block text-sm font-medium">
|
<label htmlFor="blocklist-url" {...stylex.props(styles.fieldLabel)}>
|
||||||
URL
|
URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -58,11 +96,11 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
|
|||||||
required
|
required
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(event) => setUrl(event.target.value)}
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="blocklist-name" className="block text-sm font-medium">
|
<label htmlFor="blocklist-name" {...stylex.props(styles.fieldLabel)}>
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -71,29 +109,33 @@ export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit
|
|||||||
required
|
required
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm font-medium">
|
<label {...stylex.props(styles.checkboxLabel)}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
onChange={(event) => setEnabled(event.target.checked)}
|
onChange={(event) => setEnabled(event.target.checked)}
|
||||||
className={focusRing}
|
{...stylex.props(shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
Enabled
|
Enabled
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div {...stylex.props(styles.buttonRow)}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy || readOnly}
|
disabled={busy || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={primaryButtonClass}
|
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{initial === undefined ? "Add source" : "Save changes"}
|
{initial === undefined ? "Add source" : "Save changes"}
|
||||||
</button>
|
</button>
|
||||||
{onCancel !== undefined && (
|
{onCancel !== undefined && (
|
||||||
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
{...stylex.props(shared.button, styles.cancel, shared.focusRing)}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -41,14 +41,20 @@ const RESPONSES: Record<string, unknown> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let resolveUpdate: ((response: Response) => void) | null;
|
let resolveUpdate: ((response: Response) => void) | null;
|
||||||
|
let deleted: string[];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearRefreshStatus();
|
clearRefreshStatus();
|
||||||
resolveUpdate = null;
|
resolveUpdate = null;
|
||||||
|
deleted = [];
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
|
if (init?.method === "DELETE") {
|
||||||
|
deleted.push(url);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
if (url === "/api/blocklists/update" && init?.method === "POST") {
|
if (url === "/api/blocklists/update" && init?.method === "POST") {
|
||||||
return new Promise<Response>((resolve) => {
|
return new Promise<Response>((resolve) => {
|
||||||
resolveUpdate = resolve;
|
resolveUpdate = resolve;
|
||||||
@@ -227,3 +233,27 @@ test("the refresh snapshot outlives the query cache's gcTime", async () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("delete asks for confirmation, and cancelling sends no request", async () => {
|
||||||
|
renderBlocklistsRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Blocklists" });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
expect(dialog.textContent).toContain('Delete blocklist "StevenBlack"? Its domains stop being blocked.');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
|
expect(deleted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("confirming the delete dialog issues the DELETE for that source", async () => {
|
||||||
|
renderBlocklistsRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Blocklists" });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
|
||||||
|
await screen.findByRole("alertdialog");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(deleted).toEqual(["/api/blocklists/2"]));
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { formatTime } from "@/lib/format";
|
import { formatTime } from "@/lib/format";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import {
|
import {
|
||||||
@@ -13,21 +14,75 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
|
|||||||
import BlocklistForm from "./BlocklistForm";
|
import BlocklistForm from "./BlocklistForm";
|
||||||
import { useRefreshStatus } from "./refreshStore";
|
import { useRefreshStatus } from "./refreshStore";
|
||||||
import SourceStatusSection from "./SourceStatusSection";
|
import SourceStatusSection from "./SourceStatusSection";
|
||||||
import {
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
dangerLinkButtonClass,
|
import { styles as shared } from "@/ui/styles";
|
||||||
focusRing,
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
linkButtonClass,
|
|
||||||
primaryButtonClass,
|
|
||||||
tableWrapClass,
|
|
||||||
tdClass,
|
|
||||||
thClass,
|
|
||||||
} from "@/ui/classes";
|
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
header: {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
minWidth: "max-content",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
paddingInline: "0.375rem",
|
||||||
|
paddingBlock: "0.125rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
display: "block",
|
||||||
|
maxWidth: "18rem",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
dimWhenDisabled: {
|
||||||
|
opacity: { default: 1, ":disabled": 0.5 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function BlocklistsPage() {
|
export default function BlocklistsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
|
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
|
||||||
const [editing, setEditing] = useState<Blocklist | null>(null);
|
const [editing, setEditing] = useState<Blocklist | null>(null);
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
|
||||||
|
|
||||||
const create = useMutation(blocklistCreateMutation(queryClient));
|
const create = useMutation(blocklistCreateMutation(queryClient));
|
||||||
const save = useMutation(blocklistUpdateMutation(queryClient));
|
const save = useMutation(blocklistUpdateMutation(queryClient));
|
||||||
@@ -57,10 +112,10 @@ export default function BlocklistsPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteBlocklist(b: Blocklist) {
|
function confirmDelete() {
|
||||||
if (window.confirm(`Delete blocklist "${b.name}"? Its domains stop being blocked.`)) {
|
if (pendingDelete === null) return;
|
||||||
remove.mutate(b.id);
|
remove.mutate(pendingDelete.id);
|
||||||
}
|
setPendingDelete(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formError = editing === null ? create.error : save.error;
|
const formError = editing === null ? create.error : save.error;
|
||||||
@@ -68,60 +123,56 @@ export default function BlocklistsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div {...stylex.props(styles.header)}>
|
||||||
<h1 className="text-2xl font-semibold">Blocklists</h1>
|
<h1 {...stylex.props(styles.heading)}>Blocklists</h1>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateNow.mutate()}
|
onClick={() => updateNow.mutate()}
|
||||||
disabled={updateNow.isPending}
|
disabled={updateNow.isPending}
|
||||||
className={primaryButtonClass}
|
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{updateNow.isPending ? "Updating…" : "Update now"}
|
{updateNow.isPending ? "Updating…" : "Update now"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{updateNow.isSuccess && !updateNow.isPending && (
|
{updateNow.isSuccess && !updateNow.isPending && (
|
||||||
<p className="mt-2 text-sm text-green-700 dark:text-green-400" role="status">
|
<p {...stylex.props(styles.done)} role="status">
|
||||||
Update completed; source status refreshed below.
|
Update completed; source status refreshed below.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<InlineError error={updateNow.error} />
|
<InlineError error={updateNow.error} />
|
||||||
|
|
||||||
{blocklists.length === 0 ? (
|
{blocklists.length === 0 ? (
|
||||||
<p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p>
|
<p {...stylex.props(styles.empty)}>No blocklist sources yet. Add one below.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={tableWrapClass}>
|
<div {...stylex.props(shared.tableWrap)}>
|
||||||
<table className="w-full min-w-max border-collapse text-sm">
|
<table {...stylex.props(styles.table)}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={thClass}>Name</th>
|
<th {...stylex.props(shared.th)}>Name</th>
|
||||||
<th className={thClass}>URL</th>
|
<th {...stylex.props(shared.th)}>URL</th>
|
||||||
<th className={thClass}>Enabled</th>
|
<th {...stylex.props(shared.th)}>Enabled</th>
|
||||||
<th className={thClass}>Domains</th>
|
<th {...stylex.props(shared.th)}>Domains</th>
|
||||||
<th className={thClass}>Wildcards</th>
|
<th {...stylex.props(shared.th)}>Wildcards</th>
|
||||||
<th className={thClass}>Skipped regex</th>
|
<th {...stylex.props(shared.th)}>Skipped regex</th>
|
||||||
<th className={thClass}>Last updated</th>
|
<th {...stylex.props(shared.th)}>Last updated</th>
|
||||||
<th className={thClass}>
|
<th {...stylex.props(shared.th)}>
|
||||||
<span className="sr-only">Actions</span>
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{blocklists.map((b) => (
|
{blocklists.map((b) => (
|
||||||
<tr key={b.id}>
|
<tr key={b.id}>
|
||||||
<td className={tdClass}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<span className="font-medium">{b.name}</span>
|
<span {...stylex.props(styles.name)}>{b.name}</span>
|
||||||
{b.is_suggested && (
|
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
|
||||||
<span className="ml-2 rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
|
|
||||||
Suggested
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className={tdClass}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<span className="block max-w-72 truncate" title={b.url}>
|
<span {...stylex.props(styles.url)} title={b.url}>
|
||||||
{b.url}
|
{b.url}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={tdClass}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label={`${b.name} enabled`}
|
aria-label={`${b.name} enabled`}
|
||||||
@@ -129,32 +180,36 @@ export default function BlocklistsPage() {
|
|||||||
disabled={toggle.isPending || readOnly}
|
disabled={toggle.isPending || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
onChange={() => toggleEnabled(b)}
|
onChange={() => toggleEnabled(b)}
|
||||||
className={focusRing}
|
{...stylex.props(shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className={`${tdClass} tabular-nums`}>{b.domain_count}</td>
|
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
|
||||||
<td className={`${tdClass} tabular-nums`}>{b.wildcard_count}</td>
|
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.wildcard_count}</td>
|
||||||
<td className={`${tdClass} tabular-nums`}>{b.skipped_regex_count}</td>
|
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.skipped_regex_count}</td>
|
||||||
<td className={tdClass}>
|
<td {...stylex.props(shared.td)}>
|
||||||
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||||
</td>
|
</td>
|
||||||
<td className={tdClass}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<div className="flex gap-3">
|
<div {...stylex.props(styles.actions)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditing(b)}
|
onClick={() => setEditing(b)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={`${linkButtonClass} disabled:opacity-50`}
|
{...stylex.props(
|
||||||
|
shared.linkButton,
|
||||||
|
styles.dimWhenDisabled,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => deleteBlocklist(b)}
|
onClick={() => setPendingDelete(b)}
|
||||||
disabled={remove.isPending || readOnly}
|
disabled={remove.isPending || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={dangerLinkButtonClass}
|
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -179,6 +234,19 @@ export default function BlocklistsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SourceStatusSection sources={sources} namesById={namesById} />
|
<SourceStatusSection sources={sources} namesById={namesById} />
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={pendingDelete !== null}
|
||||||
|
title="Delete blocklist"
|
||||||
|
message={
|
||||||
|
pendingDelete === null
|
||||||
|
? ""
|
||||||
|
: `Delete blocklist "${pendingDelete.name}"? Its domains stop being blocked.`
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setPendingDelete(null)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { formatTime } from "@/lib/format";
|
import { formatTime } from "@/lib/format";
|
||||||
import type { SourceStatus } from "@/lib/types";
|
import type { SourceStatus } from "@/lib/types";
|
||||||
import { tdClass, thClass } from "@/ui/classes";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
function formatAttempt(unixSeconds: number): string {
|
function formatAttempt(unixSeconds: number): string {
|
||||||
return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
|
return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
|
||||||
@@ -11,61 +13,108 @@ interface SourceStatusSectionProps {
|
|||||||
namesById: ReadonlyMap<number, string>;
|
namesById: ReadonlyMap<number, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
section: {
|
||||||
|
marginTop: "2rem",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
tableWrap: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
overflowX: "auto",
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
minWidth: "max-content",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
marginTop: "0.125rem",
|
||||||
|
display: "block",
|
||||||
|
maxWidth: "16rem",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
/** Green has no token: a loaded source is the only success state in the app. */
|
||||||
|
loaded: {
|
||||||
|
color: {
|
||||||
|
default: "oklch(52.7% 0.154 150.069)",
|
||||||
|
"@media (prefers-color-scheme: dark)": "oklch(79.2% 0.209 151.711)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
absent: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function SourceStatusSection({ sources, namesById }: SourceStatusSectionProps) {
|
export default function SourceStatusSection({ sources, namesById }: SourceStatusSectionProps) {
|
||||||
return (
|
return (
|
||||||
<section className="mt-8">
|
<section {...stylex.props(styles.section)}>
|
||||||
<h2 className="text-lg font-medium">Source status</h2>
|
<h2 {...stylex.props(styles.heading)}>Source status</h2>
|
||||||
{sources === null ? (
|
{sources === null ? (
|
||||||
<p className="mt-2 text-zinc-500">
|
<p {...stylex.props(styles.note)}>
|
||||||
No status snapshot yet — run “Update now” to fetch status for every enabled source.
|
No status snapshot yet — run “Update now” to fetch status for every enabled source.
|
||||||
</p>
|
</p>
|
||||||
) : sources.length === 0 ? (
|
) : sources.length === 0 ? (
|
||||||
<p className="mt-2 text-zinc-500">The last update ran against no enabled sources.</p>
|
<p {...stylex.props(styles.note)}>The last update ran against no enabled sources.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 overflow-x-auto">
|
<div {...stylex.props(styles.tableWrap)}>
|
||||||
<table className="w-full min-w-max border-collapse text-sm">
|
<table {...stylex.props(styles.table)}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={thClass}>Source</th>
|
<th {...stylex.props(shared.th)}>Source</th>
|
||||||
<th className={thClass}>State</th>
|
<th {...stylex.props(shared.th)}>State</th>
|
||||||
<th className={thClass}>Last attempt</th>
|
<th {...stylex.props(shared.th)}>Last attempt</th>
|
||||||
<th className={thClass}>Last success</th>
|
<th {...stylex.props(shared.th)}>Last success</th>
|
||||||
<th className={thClass}>Domains</th>
|
<th {...stylex.props(shared.th)}>Domains</th>
|
||||||
<th className={thClass}>Wildcards</th>
|
<th {...stylex.props(shared.th)}>Wildcards</th>
|
||||||
<th className={thClass}>Skipped regex</th>
|
<th {...stylex.props(shared.th)}>Skipped regex</th>
|
||||||
<th className={thClass}>Last error</th>
|
<th {...stylex.props(shared.th)}>Last error</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sources.map((source) => (
|
{sources.map((source) => (
|
||||||
<tr key={source.id}>
|
<tr key={source.id}>
|
||||||
<td className={tdClass}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<span className="font-medium">{namesById.get(source.id) ?? source.url}</span>
|
<span {...stylex.props(styles.name)}>
|
||||||
<span className="mt-0.5 block max-w-64 truncate text-xs text-zinc-500">
|
{namesById.get(source.id) ?? source.url}
|
||||||
{source.url}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span {...stylex.props(styles.url)}>{source.url}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={tdClass}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<span
|
<span {...stylex.props(source.loaded ? styles.loaded : styles.failed)}>
|
||||||
className={
|
|
||||||
source.loaded
|
|
||||||
? "text-green-700 dark:text-green-400"
|
|
||||||
: "text-red-600 dark:text-red-400"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{source.state}
|
{source.state}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={tdClass}>{formatAttempt(source.last_attempt)}</td>
|
<td {...stylex.props(shared.td)}>{formatAttempt(source.last_attempt)}</td>
|
||||||
<td className={tdClass}>{formatAttempt(source.last_success)}</td>
|
<td {...stylex.props(shared.td)}>{formatAttempt(source.last_success)}</td>
|
||||||
<td className={`${tdClass} tabular-nums`}>{source.domains}</td>
|
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.domains}</td>
|
||||||
<td className={`${tdClass} tabular-nums`}>{source.wildcards}</td>
|
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.wildcards}</td>
|
||||||
<td className={`${tdClass} tabular-nums`}>{source.skipped_regex}</td>
|
<td {...stylex.props(shared.td, shared.tabularNums)}>{source.skipped_regex}</td>
|
||||||
<td className={tdClass}>
|
<td {...stylex.props(shared.td)}>
|
||||||
{source.last_error === "" ? (
|
{source.last_error === "" ? (
|
||||||
<span className="text-zinc-400">—</span>
|
<span {...stylex.props(styles.absent)}>—</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-red-600 dark:text-red-400">{source.last_error}</span>
|
<span {...stylex.props(styles.failed)}>{source.last_error}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { clientUpdateMutation } from "@/lib/queries";
|
import { clientUpdateMutation } from "@/lib/queries";
|
||||||
import type { Client, Group } from "@/lib/types";
|
import type { Client, Group } from "@/lib/types";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes";
|
import Dialog from "@/ui/Dialog";
|
||||||
|
import Select from "@/ui/Select";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,7 +15,34 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialogInputClass = `mt-1 w-full ${smallInputClass}`;
|
const styles = stylex.create({
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
},
|
||||||
|
fieldLabel: {
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
dialogInput: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -22,64 +52,50 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
|||||||
const readOnly = useReadOnlyConfig();
|
const readOnly = useReadOnlyConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
||||||
<div
|
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
|
||||||
role="dialog"
|
<form
|
||||||
aria-modal="true"
|
{...stylex.props(styles.form)}
|
||||||
aria-label={`Edit client ${client.ip}`}
|
onSubmit={(event) => {
|
||||||
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
|
event.preventDefault();
|
||||||
|
mutation.mutate(
|
||||||
|
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
|
||||||
|
{ onSuccess: onClose },
|
||||||
|
);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold">Edit {client.ip}</h2>
|
<label {...stylex.props(styles.fieldLabel)}>
|
||||||
<form
|
Name
|
||||||
className="mt-4 space-y-4"
|
<input
|
||||||
onSubmit={(event) => {
|
type="text"
|
||||||
event.preventDefault();
|
value={name}
|
||||||
mutation.mutate(
|
onChange={(event) => setName(event.target.value)}
|
||||||
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
|
autoFocus
|
||||||
{ onSuccess: onClose },
|
{...stylex.props(shared.smallInput, styles.dialogInput, shared.focusRing)}
|
||||||
);
|
/>
|
||||||
}}
|
</label>
|
||||||
>
|
<Select
|
||||||
<label className="block text-sm font-medium">
|
label="Group"
|
||||||
Name
|
variant="compactField"
|
||||||
<input
|
value={String(groupId)}
|
||||||
type="text"
|
onChange={(value) => setGroupId(Number(value))}
|
||||||
value={name}
|
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
|
||||||
onChange={(event) => setName(event.target.value)}
|
/>
|
||||||
className={dialogInputClass}
|
<InlineError error={mutation.error} />
|
||||||
autoFocus
|
<div {...stylex.props(styles.actions)}>
|
||||||
/>
|
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
|
||||||
</label>
|
Cancel
|
||||||
<label className="block text-sm font-medium">
|
</button>
|
||||||
Group
|
<button
|
||||||
<select
|
type="submit"
|
||||||
value={String(groupId)}
|
disabled={mutation.isPending || readOnly}
|
||||||
onChange={(event) => setGroupId(Number(event.target.value))}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={dialogInputClass}
|
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{groups.map((group) => (
|
Save
|
||||||
<option key={group.id} value={String(group.id)}>
|
</button>
|
||||||
{group.name}
|
</div>
|
||||||
</option>
|
</form>
|
||||||
))}
|
</Dialog>
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<InlineError error={mutation.error} />
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button type="button" onClick={onClose} className={buttonClass}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={mutation.isPending || readOnly}
|
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
className={primaryButtonClass}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
import { AuthProvider } from "@/auth/store";
|
import { AuthProvider } from "@/auth/store";
|
||||||
@@ -106,10 +106,25 @@ test("edit opens a dialog seeded with the client's name and group", async () =>
|
|||||||
await renderClientsPage(BASE);
|
await renderClientsPage(BASE);
|
||||||
|
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
|
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
|
||||||
const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" });
|
// The dialog portals out of the table, so every field query is scoped to it.
|
||||||
expect(dialog).toBeTruthy();
|
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
|
||||||
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
|
expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
|
||||||
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("1");
|
// The RAC Select names its trigger with the value and then the label.
|
||||||
|
expect(dialog.getByRole("button", { name: /Group$/ }).textContent).toContain("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the group picker offers every group and reports the choice", async () => {
|
||||||
|
await renderClientsPage(BASE);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
|
||||||
|
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
|
||||||
|
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
|
||||||
|
|
||||||
|
const options = await screen.findAllByRole("option");
|
||||||
|
expect(options.map((option) => option.textContent)).toEqual(["default", "kids"]);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: "kids" }));
|
||||||
|
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("prefix editor starts clean and dirties on add", async () => {
|
test("prefix editor starts clean and dirties on add", async () => {
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
|
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
|
||||||
import { formatTime } from "@/lib/format";
|
import { formatTime } from "@/lib/format";
|
||||||
import type { Client } from "@/lib/types";
|
import type { Client } from "@/lib/types";
|
||||||
import ClientEditDialog from "./ClientEditDialog";
|
import ClientEditDialog from "./ClientEditDialog";
|
||||||
import PrefixesEditor from "./PrefixesEditor";
|
import PrefixesEditor from "./PrefixesEditor";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { smallButtonClass, tableWrapClass } from "@/ui/classes";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||||
|
|
||||||
const cellClass = "px-3 py-2";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deleting an observed row discards runtime state the file never declared, so
|
* Deleting an observed row discards runtime state the file never declared, so
|
||||||
* it stays live under file authority; deleting a hand-edited row contradicts
|
* it stays live under file authority; deleting a hand-edited row contradicts
|
||||||
@@ -18,6 +18,83 @@ const cellClass = "px-3 py-2";
|
|||||||
*/
|
*/
|
||||||
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
|
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
minWidth: "48rem",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
headRow: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
bodyRow: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
cell: {
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
textAlign: "right",
|
||||||
|
},
|
||||||
|
dash: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingInline: "0.375rem",
|
||||||
|
paddingBlock: "0.125rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: colors.primaryText,
|
||||||
|
},
|
||||||
|
confirmGroup: {
|
||||||
|
display: "inline-flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
actionGroup: {
|
||||||
|
display: "inline-flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
dangerText: {
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
dimWhenDisabled: {
|
||||||
|
opacity: { default: 1, ":disabled": 0.5 },
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The accessible name of the actions column, kept out of the visual table
|
||||||
|
* without leaving the accessibility tree.
|
||||||
|
*/
|
||||||
|
});
|
||||||
|
|
||||||
export default function ClientsPage() {
|
export default function ClientsPage() {
|
||||||
const { data: clients } = useSuspenseQuery(clientsQuery());
|
const { data: clients } = useSuspenseQuery(clientsQuery());
|
||||||
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
|
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
|
||||||
@@ -30,46 +107,46 @@ export default function ClientsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1 className="text-2xl font-semibold">Clients</h1>
|
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
||||||
{clients.length === 0 ? (
|
{clients.length === 0 ? (
|
||||||
<p className="mt-4 text-zinc-500">
|
<p {...stylex.props(styles.empty)}>
|
||||||
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
||||||
nothing to create by hand.
|
nothing to create by hand.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={tableWrapClass}>
|
<div {...stylex.props(shared.tableWrap)}>
|
||||||
<table className="w-full min-w-[48rem] text-left text-sm">
|
<table {...stylex.props(styles.table)}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700">
|
<tr {...stylex.props(styles.headRow)}>
|
||||||
<th className={cellClass}>IP</th>
|
<th {...stylex.props(styles.cell)}>IP</th>
|
||||||
<th className={cellClass}>Name</th>
|
<th {...stylex.props(styles.cell)}>Name</th>
|
||||||
<th className={cellClass}>Group</th>
|
<th {...stylex.props(styles.cell)}>Group</th>
|
||||||
<th className={cellClass}>First seen</th>
|
<th {...stylex.props(styles.cell)}>First seen</th>
|
||||||
<th className={cellClass}>Last seen</th>
|
<th {...stylex.props(styles.cell)}>Last seen</th>
|
||||||
<th className={cellClass}>
|
<th {...stylex.props(styles.cell)}>
|
||||||
<span className="sr-only">Actions</span>
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{clients.map((client) => (
|
{clients.map((client) => (
|
||||||
<tr key={client.id} className="border-b border-zinc-100 dark:border-zinc-800">
|
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
|
||||||
<td className={`${cellClass} font-mono`}>{client.ip}</td>
|
<td {...stylex.props(styles.cell, shared.mono)}>{client.ip}</td>
|
||||||
<td className={cellClass}>
|
<td {...stylex.props(styles.cell)}>
|
||||||
{client.name === "" ? <span className="text-zinc-400">—</span> : client.name}
|
{client.name === "" ? (
|
||||||
{client.hand_edited && (
|
<span {...stylex.props(styles.dash)}>—</span>
|
||||||
<span className="ml-2 rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
) : (
|
||||||
edited
|
client.name
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className={cellClass}>{client.group}</td>
|
<td {...stylex.props(styles.cell)}>{client.group}</td>
|
||||||
<td className={cellClass}>{formatTime(client.first_seen)}</td>
|
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
||||||
<td className={cellClass}>{formatTime(client.last_seen)}</td>
|
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
||||||
<td className={`${cellClass} text-right`}>
|
<td {...stylex.props(styles.cell, styles.right)}>
|
||||||
{confirmingId === client.id ? (
|
{confirmingId === client.id ? (
|
||||||
<span className="inline-flex flex-wrap items-center justify-end gap-2">
|
<span {...stylex.props(styles.confirmGroup)}>
|
||||||
<span className="text-xs text-zinc-500">
|
<span {...stylex.props(styles.note)}>
|
||||||
Deleted clients re-materialize on their next DNS query.
|
Deleted clients re-materialize on their next DNS query.
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -78,26 +155,34 @@ export default function ClientsPage() {
|
|||||||
setConfirmingId(null);
|
setConfirmingId(null);
|
||||||
deleteMutation.mutate(client.id);
|
deleteMutation.mutate(client.id);
|
||||||
}}
|
}}
|
||||||
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
|
{...stylex.props(
|
||||||
|
shared.smallButton,
|
||||||
|
styles.dangerText,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Confirm delete
|
Confirm delete
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmingId(null)}
|
onClick={() => setConfirmingId(null)}
|
||||||
className={smallButtonClass}
|
{...stylex.props(shared.smallButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex gap-2">
|
<span {...stylex.props(styles.actionGroup)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditing(client)}
|
onClick={() => setEditing(client)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={`${smallButtonClass} disabled:opacity-50`}
|
{...stylex.props(
|
||||||
|
shared.smallButton,
|
||||||
|
styles.dimWhenDisabled,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -110,7 +195,12 @@ export default function ClientsPage() {
|
|||||||
? DECLARED_CLIENT_NOTE
|
? DECLARED_CLIENT_NOTE
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className={`${smallButtonClass} text-red-700 disabled:opacity-50 dark:text-red-400`}
|
{...stylex.props(
|
||||||
|
shared.smallButton,
|
||||||
|
styles.dangerText,
|
||||||
|
styles.dimWhenDisabled,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useReducer, useState } from "react";
|
import { useReducer, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { clientPrefixesPutMutation } from "@/lib/queries";
|
import { clientPrefixesPutMutation } from "@/lib/queries";
|
||||||
import type { ClientPrefix, Group } from "@/lib/types";
|
import type { ClientPrefix, Group } from "@/lib/types";
|
||||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||||
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
|
import Select from "@/ui/Select";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,6 +16,72 @@ interface Props {
|
|||||||
groups: Group[];
|
groups: Group[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
section: {
|
||||||
|
marginTop: "2.5rem",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
intro: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
listStyleType: "none",
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
prefixInput: {
|
||||||
|
width: "13rem",
|
||||||
|
},
|
||||||
|
priorityInput: {
|
||||||
|
width: "5rem",
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.borderStrong,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
paddingInline: "0.5rem",
|
||||||
|
paddingBlock: "0.375rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function PrefixesEditor({ prefixes, groups }: Props) {
|
export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
||||||
@@ -21,6 +90,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
|||||||
const dirty = isDirty(state);
|
const dirty = isDirty(state);
|
||||||
const fallbackGroupId = defaultGroupId(groups);
|
const fallbackGroupId = defaultGroupId(groups);
|
||||||
const readOnly = useReadOnlyConfig();
|
const readOnly = useReadOnlyConfig();
|
||||||
|
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
const problem = firstProblem(state.rows);
|
const problem = firstProblem(state.rows);
|
||||||
@@ -32,18 +102,18 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mt-10">
|
<section {...stylex.props(styles.section)}>
|
||||||
<h2 className="text-xl font-semibold">Client prefixes</h2>
|
<h2 {...stylex.props(styles.heading)}>Client prefixes</h2>
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
<p {...stylex.props(styles.intro)}>
|
||||||
Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
|
Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
|
||||||
match wins.
|
match wins.
|
||||||
</p>
|
</p>
|
||||||
{state.rows.length === 0 ? (
|
{state.rows.length === 0 ? (
|
||||||
<p className="mt-4 text-sm text-zinc-500">No prefixes configured.</p>
|
<p {...stylex.props(styles.empty)}>No prefixes configured.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-4 space-y-2">
|
<ul {...stylex.props(styles.rows)}>
|
||||||
{state.rows.map((row, index) => (
|
{state.rows.map((row, index) => (
|
||||||
<li key={index} className="flex flex-wrap items-center gap-2">
|
<li key={index} {...stylex.props(styles.row)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
aria-label={`Prefix ${index + 1}`}
|
aria-label={`Prefix ${index + 1}`}
|
||||||
@@ -52,22 +122,17 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
|
dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
|
||||||
}
|
}
|
||||||
className={`${smallInputClass} w-52`}
|
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
aria-label={`Group for prefix ${index + 1}`}
|
aria-label={`Group for prefix ${index + 1}`}
|
||||||
|
variant="inline"
|
||||||
value={String(row.group_id)}
|
value={String(row.group_id)}
|
||||||
onChange={(event) =>
|
onChange={(value) =>
|
||||||
dispatch({ type: "edit", index, patch: { group_id: Number(event.target.value) } })
|
dispatch({ type: "edit", index, patch: { group_id: Number(value) } })
|
||||||
}
|
}
|
||||||
className={smallInputClass}
|
options={groupOptions}
|
||||||
>
|
/>
|
||||||
{groups.map((group) => (
|
|
||||||
<option key={group.id} value={String(group.id)}>
|
|
||||||
{group.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
@@ -77,12 +142,12 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
dispatch({ type: "edit", index, patch: { priority: event.target.value } })
|
dispatch({ type: "edit", index, patch: { priority: event.target.value } })
|
||||||
}
|
}
|
||||||
className={`${smallInputClass} w-20`}
|
{...stylex.props(shared.smallInput, styles.priorityInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => dispatch({ type: "remove", index })}
|
onClick={() => dispatch({ type: "remove", index })}
|
||||||
className={`rounded border border-zinc-300 px-2 py-1.5 text-sm text-red-700 ${focusRing} dark:border-zinc-700 dark:text-red-400`}
|
{...stylex.props(styles.removeButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
@@ -91,16 +156,16 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{validation !== null && (
|
{validation !== null && (
|
||||||
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-400">
|
<p role="alert" {...stylex.props(styles.validation)}>
|
||||||
{validation}
|
{validation}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<InlineError error={mutation.error} />
|
<InlineError error={mutation.error} />
|
||||||
<div className="mt-4 flex gap-2">
|
<div {...stylex.props(styles.actions)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
|
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
|
||||||
className={buttonClass}
|
{...stylex.props(shared.button, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Add prefix
|
Add prefix
|
||||||
</button>
|
</button>
|
||||||
@@ -109,7 +174,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
|||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={!dirty || mutation.isPending || readOnly}
|
disabled={!dirty || mutation.isPending || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={primaryButtonClass}
|
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Save prefixes
|
Save prefixes
|
||||||
</button>
|
</button>
|
||||||
@@ -120,7 +185,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
|
|||||||
setValidation(null);
|
setValidation(null);
|
||||||
dispatch({ type: "reset", prefixes });
|
dispatch({ type: "reset", prefixes });
|
||||||
}}
|
}}
|
||||||
className={buttonClass}
|
{...stylex.props(shared.button, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Discard changes
|
Discard changes
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
|
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
|
||||||
import type { Period } from "@/lib/types";
|
import type { Period } from "@/lib/types";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { focusRing } from "@/ui/classes";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import DiskCard from "./DiskCard";
|
import DiskCard from "./DiskCard";
|
||||||
import HealthBanners from "./HealthBanners";
|
import HealthBanners from "./HealthBanners";
|
||||||
import StatCards from "./StatCards";
|
import StatCards from "./StatCards";
|
||||||
@@ -12,20 +14,98 @@ import UpstreamHealthTable from "./UpstreamHealthTable";
|
|||||||
|
|
||||||
const PERIODS: Period[] = ["1h", "24h", "7d", "30d"];
|
const PERIODS: Period[] = ["1h", "24h", "7d", "30d"];
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
page: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
periodGroup: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.25rem",
|
||||||
|
},
|
||||||
|
period: {
|
||||||
|
borderStyle: "none",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
paddingInline: "0.625rem",
|
||||||
|
paddingBlock: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
|
||||||
|
periodSelected: {
|
||||||
|
backgroundColor: {
|
||||||
|
default: "oklch(92% 0.004 286.32)",
|
||||||
|
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
||||||
|
},
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
periodIdle: {
|
||||||
|
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
/** Dynamic: the caller sizes the placeholder to the widget it stands in for. */
|
||||||
|
skeletonHeight: (height: number) => ({ height }),
|
||||||
|
skeleton: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
backgroundColor: {
|
||||||
|
default: "oklch(92% 0.004 286.32)",
|
||||||
|
"@media (prefers-color-scheme: dark)": "oklch(27.4% 0.006 286.033)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/** The chart takes two thirds beside the disk card from `lg`, one column below. */
|
||||||
|
panelGrid: {
|
||||||
|
display: "grid",
|
||||||
|
gap: "1rem",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
default: "repeat(1, minmax(0, 1fr))",
|
||||||
|
"@media (min-width: 1024px)": "2fr 1fr",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.75rem",
|
||||||
|
},
|
||||||
|
panelHeading: {
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
|
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
|
||||||
return (
|
return (
|
||||||
<div role="group" aria-label="Period" className="flex gap-1">
|
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
|
||||||
{PERIODS.map((option) => (
|
{PERIODS.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option}
|
key={option}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={option === period}
|
aria-pressed={option === period}
|
||||||
onClick={() => onChange(option)}
|
onClick={() => onChange(option)}
|
||||||
className={`rounded px-2.5 py-1 text-sm ${focusRing} ${
|
{...stylex.props(
|
||||||
option === period
|
styles.period,
|
||||||
? "bg-zinc-200 font-medium text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
|
option === period ? styles.periodSelected : styles.periodIdle,
|
||||||
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800"
|
shared.focusRing,
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
</button>
|
</button>
|
||||||
@@ -35,7 +115,7 @@ function PeriodPicker({ period, onChange }: { period: Period; onChange: (period:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Skeleton({ height }: { height: number }) {
|
function Skeleton({ height }: { height: number }) {
|
||||||
return <div aria-hidden="true" className="animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" style={{ height }} />;
|
return <div aria-hidden="true" {...stylex.props(styles.skeleton, styles.skeletonHeight(height), shared.pulse)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
@@ -46,9 +126,9 @@ export default function DashboardPage() {
|
|||||||
const upstreamHealth = useQuery(upstreamHealthQuery());
|
const upstreamHealth = useQuery(upstreamHealthQuery());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section {...stylex.props(styles.page)}>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div {...stylex.props(styles.titleRow)}>
|
||||||
<h1 className="text-2xl font-semibold">Dashboard</h1>
|
<h1 {...stylex.props(styles.heading)}>Dashboard</h1>
|
||||||
<PeriodPicker period={period} onChange={setPeriod} />
|
<PeriodPicker period={period} onChange={setPeriod} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,9 +142,9 @@ export default function DashboardPage() {
|
|||||||
<StatCards stats={stats.data} />
|
<StatCards stats={stats.data} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
<div {...stylex.props(styles.panelGrid)}>
|
||||||
<section className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
|
<section {...stylex.props(styles.panel)}>
|
||||||
<h2 className="mb-3 text-sm font-semibold">Queries over time</h2>
|
<h2 {...stylex.props(styles.panelHeading)}>Queries over time</h2>
|
||||||
{timeseries.isError ? (
|
{timeseries.isError ? (
|
||||||
<InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} />
|
<InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} />
|
||||||
) : timeseries.data === undefined ? (
|
) : timeseries.data === undefined ? (
|
||||||
|
|||||||
@@ -1,33 +1,95 @@
|
|||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { formatBytes } from "@/lib/format";
|
import { formatBytes } from "@/lib/format";
|
||||||
import type { Health } from "@/lib/types";
|
import type { Health } from "@/lib/types";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
const STATE_CLASSES: Record<Health["disk"]["state"], string> = {
|
const DARK = "@media (prefers-color-scheme: dark)";
|
||||||
ok: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
|
|
||||||
warn: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
const styles = stylex.create({
|
||||||
critical: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
|
card: {
|
||||||
};
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.75rem",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
paddingInline: "0.5rem",
|
||||||
|
paddingBlock: "0.125rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The badge fills are their own three-step scale, not the `danger`/`warn`
|
||||||
|
* banner tokens: they read as a tinted chip against a raised card, where a
|
||||||
|
* banner fill would be too heavy.
|
||||||
|
*/
|
||||||
|
ok: {
|
||||||
|
backgroundColor: { default: "oklch(95% 0.052 163.051)", [DARK]: "oklch(26.2% 0.051 172.552)" },
|
||||||
|
color: { default: "oklch(43.2% 0.095 166.913)", [DARK]: "oklch(84.5% 0.143 164.978)" },
|
||||||
|
},
|
||||||
|
warn: {
|
||||||
|
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
|
||||||
|
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
|
||||||
|
},
|
||||||
|
critical: {
|
||||||
|
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
|
||||||
|
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
term: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function stateStyle(state: Health["disk"]["state"]) {
|
||||||
|
if (state === "critical") return styles.critical;
|
||||||
|
return state === "warn" ? styles.warn : styles.ok;
|
||||||
|
}
|
||||||
|
|
||||||
export default function DiskCard({ disk }: { disk: Health["disk"] }) {
|
export default function DiskCard({ disk }: { disk: Health["disk"] }) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
|
<section {...stylex.props(styles.card)}>
|
||||||
<h2 className="flex items-center justify-between text-sm font-semibold">
|
<h2 {...stylex.props(styles.heading)}>
|
||||||
Disk
|
Disk
|
||||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${STATE_CLASSES[disk.state]}`}>
|
<span {...stylex.props(styles.badge, stateStyle(disk.state))}>{disk.state}</span>
|
||||||
{disk.state}
|
|
||||||
</span>
|
|
||||||
</h2>
|
</h2>
|
||||||
<dl className="mt-3 space-y-2 text-sm">
|
<dl {...stylex.props(styles.list)}>
|
||||||
<div className="flex justify-between">
|
<div {...stylex.props(styles.row)}>
|
||||||
<dt className="text-zinc-500">Free</dt>
|
<dt {...stylex.props(styles.term)}>Free</dt>
|
||||||
<dd className="tabular-nums">{formatBytes(disk.free_bytes)}</dd>
|
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.free_bytes)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div {...stylex.props(styles.row)}>
|
||||||
<dt className="text-zinc-500">Database</dt>
|
<dt {...stylex.props(styles.term)}>Database</dt>
|
||||||
<dd className="tabular-nums">{formatBytes(disk.db_bytes)}</dd>
|
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.db_bytes)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div {...stylex.props(styles.row)}>
|
||||||
<dt className="text-zinc-500">Logs</dt>
|
<dt {...stylex.props(styles.term)}>Logs</dt>
|
||||||
<dd className="tabular-nums">{formatBytes(disk.log_bytes)}</dd>
|
<dd {...stylex.props(shared.tabularNums)}>{formatBytes(disk.log_bytes)}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { formatBytes } from "@/lib/format";
|
import { formatBytes } from "@/lib/format";
|
||||||
import type { Health } from "@/lib/types";
|
import type { Health } from "@/lib/types";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
stack: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
warn: {
|
||||||
|
borderColor: colors.warnBorder,
|
||||||
|
backgroundColor: colors.warnSurface,
|
||||||
|
color: colors.warnText,
|
||||||
|
},
|
||||||
|
critical: {
|
||||||
|
borderColor: colors.dangerBorder,
|
||||||
|
backgroundColor: colors.dangerSurface,
|
||||||
|
color: colors.dangerText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function Banner({ tone, children }: { tone: "warn" | "critical"; children: React.ReactNode }) {
|
function Banner({ tone, children }: { tone: "warn" | "critical"; children: React.ReactNode }) {
|
||||||
const classes =
|
|
||||||
tone === "critical"
|
|
||||||
? "border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-200"
|
|
||||||
: "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200";
|
|
||||||
return (
|
return (
|
||||||
<p role="alert" className={`rounded border px-4 py-2 text-sm ${classes}`}>
|
<p role="alert" {...stylex.props(styles.banner, tone === "critical" ? styles.critical : styles.warn)}>
|
||||||
{children}
|
{children}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -39,5 +64,5 @@ export default function HealthBanners({ health }: { health: Health }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (banners.length === 0) return null;
|
if (banners.length === 0) return null;
|
||||||
return <div className="space-y-2">{banners}</div>;
|
return <div {...stylex.props(styles.stack)}>{banners}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,49 @@
|
|||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { formatMicros } from "@/lib/format";
|
import { formatMicros } from "@/lib/format";
|
||||||
import type { StatsTotals } from "@/lib/types";
|
import type { StatsTotals } from "@/lib/types";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
const numberFormat = new Intl.NumberFormat();
|
const numberFormat = new Intl.NumberFormat();
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
/** Two columns on a phone, three from `md`, five from `xl`, as before. */
|
||||||
|
grid: {
|
||||||
|
display: "grid",
|
||||||
|
gap: "0.75rem",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
default: "repeat(2, minmax(0, 1fr))",
|
||||||
|
"@media (min-width: 768px)": "repeat(3, minmax(0, 1fr))",
|
||||||
|
"@media (min-width: 1280px)": "repeat(5, minmax(0, 1fr))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.75rem",
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function percentOf(part: number, total: number): string | null {
|
function percentOf(part: number, total: number): string | null {
|
||||||
if (total === 0) return null;
|
if (total === 0) return null;
|
||||||
return `${((part / total) * 100).toFixed(1)}%`;
|
return `${((part / total) * 100).toFixed(1)}%`;
|
||||||
@@ -10,11 +51,11 @@ function percentOf(part: number, total: number): string | null {
|
|||||||
|
|
||||||
function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) {
|
function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
|
<div {...stylex.props(styles.card)}>
|
||||||
<dt className="text-sm text-zinc-500">{label}</dt>
|
<dt {...stylex.props(styles.label)}>{label}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<span className="text-2xl font-semibold tabular-nums">{value}</span>
|
<span {...stylex.props(styles.value, shared.tabularNums)}>{value}</span>
|
||||||
{detail != null && <span className="ml-2 text-sm text-zinc-500 tabular-nums">{detail}</span>}
|
{detail != null && <span {...stylex.props(styles.detail, shared.tabularNums)}>{detail}</span>}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -22,7 +63,7 @@ function Card({ label, value, detail }: { label: string; value: string; detail?:
|
|||||||
|
|
||||||
export default function StatCards({ stats }: { stats: StatsTotals }) {
|
export default function StatCards({ stats }: { stats: StatsTotals }) {
|
||||||
return (
|
return (
|
||||||
<dl className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5">
|
<dl {...stylex.props(styles.grid)}>
|
||||||
<Card label="Queries" value={numberFormat.format(stats.queries)} />
|
<Card label="Queries" value={numberFormat.format(stats.queries)} />
|
||||||
<Card
|
<Card
|
||||||
label="Blocked"
|
label="Blocked"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { formatTime } from "@/lib/format";
|
import { formatTime } from "@/lib/format";
|
||||||
import type { StatsTimeseries } from "@/lib/types";
|
import type { StatsTimeseries } from "@/lib/types";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout";
|
import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout";
|
||||||
|
|
||||||
// Series colors validated for CVD separation and 3:1 surface contrast in both
|
// Series colors validated for CVD separation and 3:1 surface contrast in both
|
||||||
@@ -14,6 +17,108 @@ const SERIES = [
|
|||||||
const CHART_HEIGHT = 240;
|
const CHART_HEIGHT = 240;
|
||||||
const FALLBACK_WIDTH = 640;
|
const FALLBACK_WIDTH = 640;
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
empty: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: CHART_HEIGHT,
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "dashed",
|
||||||
|
borderColor: colors.borderStrong,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
chartRoot: {
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
pointerEvents: "none",
|
||||||
|
position: "absolute",
|
||||||
|
top: "0.5rem",
|
||||||
|
zIndex: 10,
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.borderStrong,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||||
|
},
|
||||||
|
/** Dynamic: the tooltip flips to whichever side of the bar has room. */
|
||||||
|
tooltipLeft: (left: number) => ({ left, right: null }),
|
||||||
|
tooltipRight: (right: number) => ({ left: null, right }),
|
||||||
|
tooltipTitle: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
tooltipList: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.125rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
},
|
||||||
|
tooltipRow: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "1rem",
|
||||||
|
},
|
||||||
|
tooltipTerm: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
swatch: {
|
||||||
|
display: "inline-block",
|
||||||
|
borderRadius: "0.125rem",
|
||||||
|
},
|
||||||
|
/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
|
||||||
|
swatchColor: (color: string) => ({ backgroundColor: color }),
|
||||||
|
swatchSmall: {
|
||||||
|
width: "0.5rem",
|
||||||
|
height: "0.5rem",
|
||||||
|
},
|
||||||
|
swatchLarge: {
|
||||||
|
width: "0.625rem",
|
||||||
|
height: "0.625rem",
|
||||||
|
},
|
||||||
|
gridLine: {
|
||||||
|
stroke: colors.border,
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
stroke: colors.borderStrong,
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
fill: colors.textMuted,
|
||||||
|
fontSize: "10px",
|
||||||
|
},
|
||||||
|
/** The hairline separating touching segments is the page ground, not a colour. */
|
||||||
|
segment: {
|
||||||
|
stroke: colors.surface,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
columnGap: "1rem",
|
||||||
|
rowGap: "0.25rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
legendItem: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
@@ -46,28 +151,29 @@ function barSummary(bar: BarLayout): string {
|
|||||||
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
|
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
|
||||||
const centerX = bar.slot.x + bar.slot.width / 2;
|
const centerX = bar.slot.x + bar.slot.width / 2;
|
||||||
const leftHalf = centerX < chartWidth / 2;
|
const leftHalf = centerX < chartWidth / 2;
|
||||||
|
const side = leftHalf
|
||||||
|
? styles.tooltipLeft(Math.min(centerX + 8, chartWidth - 160))
|
||||||
|
: styles.tooltipRight(chartWidth - centerX + 8);
|
||||||
return (
|
return (
|
||||||
<div
|
<div {...stylex.props(styles.tooltip, side)}>
|
||||||
className="pointer-events-none absolute top-2 z-10 rounded border border-zinc-200 bg-white px-3 py-2 text-xs shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
|
<div {...stylex.props(styles.tooltipTitle)}>{formatTime(bar.bucket.ts)}</div>
|
||||||
style={leftHalf ? { left: Math.min(centerX + 8, chartWidth - 160) } : { right: chartWidth - centerX + 8 }}
|
<dl {...stylex.props(styles.tooltipList)}>
|
||||||
>
|
<div {...stylex.props(styles.tooltipRow)}>
|
||||||
<div className="font-medium">{formatTime(bar.bucket.ts)}</div>
|
<dt {...stylex.props(styles.tooltipTerm)}>Queries</dt>
|
||||||
<dl className="mt-1 space-y-0.5">
|
<dd {...stylex.props(shared.tabularNums)}>{bar.bucket.queries}</dd>
|
||||||
<div className="flex justify-between gap-4">
|
|
||||||
<dt className="text-zinc-500">Queries</dt>
|
|
||||||
<dd className="tabular-nums">{bar.bucket.queries}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
{SERIES.map((series) => (
|
{SERIES.map((series) => (
|
||||||
<div key={series.key} className="flex items-center justify-between gap-4">
|
<div key={series.key} {...stylex.props(styles.tooltipRow)}>
|
||||||
<dt className="flex items-center gap-1.5 text-zinc-500">
|
<dt {...stylex.props(styles.tooltipTerm)}>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="inline-block size-2 rounded-xs"
|
{...stylex.props(styles.swatch, styles.swatchSmall, styles.swatchColor(series.color))}
|
||||||
style={{ backgroundColor: series.color }}
|
|
||||||
/>
|
/>
|
||||||
{series.label}
|
{series.label}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="tabular-nums">{series.key === "other" ? bar.other : bar.bucket[series.key]}</dd>
|
<dd {...stylex.props(shared.tabularNums)}>
|
||||||
|
{series.key === "other" ? bar.other : bar.bucket[series.key]}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
@@ -82,11 +188,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
|
|
||||||
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
|
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={containerRef} {...stylex.props(styles.empty)}>
|
||||||
ref={containerRef}
|
|
||||||
className="flex items-center justify-center rounded border border-dashed border-zinc-300 text-sm text-zinc-500 dark:border-zinc-700"
|
|
||||||
style={{ height: CHART_HEIGHT }}
|
|
||||||
>
|
|
||||||
No queries in this period.
|
No queries in this period.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -97,7 +199,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
|
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} {...stylex.props(styles.chartRoot)}>
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`}
|
aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`}
|
||||||
@@ -113,14 +215,14 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
x2={layout.plot.x + layout.plot.width}
|
x2={layout.plot.x + layout.plot.width}
|
||||||
y1={tick.y}
|
y1={tick.y}
|
||||||
y2={tick.y}
|
y2={tick.y}
|
||||||
className="stroke-zinc-200 dark:stroke-zinc-800"
|
{...stylex.props(styles.gridLine)}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={layout.plot.x - 6}
|
x={layout.plot.x - 6}
|
||||||
y={tick.y}
|
y={tick.y}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
dominantBaseline="middle"
|
dominantBaseline="middle"
|
||||||
className="fill-zinc-500 text-[10px] tabular-nums"
|
{...stylex.props(styles.axisLabel, shared.tabularNums)}
|
||||||
>
|
>
|
||||||
{compact.format(tick.value)}
|
{compact.format(tick.value)}
|
||||||
</text>
|
</text>
|
||||||
@@ -131,7 +233,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
x2={layout.plot.x + layout.plot.width}
|
x2={layout.plot.x + layout.plot.width}
|
||||||
y1={baseline}
|
y1={baseline}
|
||||||
y2={baseline}
|
y2={baseline}
|
||||||
className="stroke-zinc-300 dark:stroke-zinc-700"
|
{...stylex.props(styles.axisLine)}
|
||||||
/>
|
/>
|
||||||
{layout.xTicks.map((tick) => (
|
{layout.xTicks.map((tick) => (
|
||||||
<text
|
<text
|
||||||
@@ -139,7 +241,7 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
x={tick.x}
|
x={tick.x}
|
||||||
y={baseline + 14}
|
y={baseline + 14}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="fill-zinc-500 text-[10px]"
|
{...stylex.props(styles.axisLabel)}
|
||||||
>
|
>
|
||||||
{formatTick(tick.ts, data.bucket_seconds)}
|
{formatTick(tick.ts, data.bucket_seconds)}
|
||||||
</text>
|
</text>
|
||||||
@@ -157,8 +259,8 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
width={rect.width}
|
width={rect.width}
|
||||||
height={rect.height}
|
height={rect.height}
|
||||||
fill={series.color}
|
fill={series.color}
|
||||||
className="stroke-zinc-50 dark:stroke-zinc-950"
|
|
||||||
strokeWidth={rect.width > 3 ? 1 : 0}
|
strokeWidth={rect.width > 3 ? 1 : 0}
|
||||||
|
{...stylex.props(styles.segment)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -179,19 +281,18 @@ export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
|||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
|
{hoveredBar !== undefined && <Tooltip bar={hoveredBar} chartWidth={width} />}
|
||||||
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-600 dark:text-zinc-400">
|
<ul {...stylex.props(styles.legend)}>
|
||||||
{SERIES.map((series) => (
|
{SERIES.map((series) => (
|
||||||
<li key={series.key} className="flex items-center gap-1.5">
|
<li key={series.key} {...stylex.props(styles.legendItem)}>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="inline-block size-2.5 rounded-xs"
|
{...stylex.props(styles.swatch, styles.swatchLarge, styles.swatchColor(series.color))}
|
||||||
style={{ backgroundColor: series.color }}
|
|
||||||
/>
|
/>
|
||||||
{series.label}
|
{series.label}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<table className="sr-only">
|
<table {...stylex.props(shared.srOnly)}>
|
||||||
<caption>Queries per time bucket</caption>
|
<caption>Queries per time bucket</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -1,64 +1,150 @@
|
|||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import type { UpstreamHealth } from "@/lib/types";
|
import type { UpstreamHealth } from "@/lib/types";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
card: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingTop: "0.75rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
fontWeight: 400,
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.75rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
tableWrap: {
|
||||||
|
overflowX: "auto",
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
width: "100%",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
headRow: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
th: {
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
thRight: {
|
||||||
|
textAlign: "right",
|
||||||
|
},
|
||||||
|
/** No hairline under the last row: the card border already closes the table. */
|
||||||
|
row: {
|
||||||
|
borderBottomWidth: { default: 1, ":last-child": 0 },
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
cell: {
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
},
|
||||||
|
cellRight: {
|
||||||
|
textAlign: "right",
|
||||||
|
},
|
||||||
|
small: {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
bad: {
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function YesNo({ value, badValue }: { value: boolean; badValue: boolean }) {
|
function YesNo({ value, badValue }: { value: boolean; badValue: boolean }) {
|
||||||
const bad = value === badValue;
|
const bad = value === badValue;
|
||||||
return <span className={bad ? "text-red-700 dark:text-red-400" : ""}>{value ? "yes" : "no"}</span>;
|
return <span {...stylex.props(bad && styles.bad)}>{value ? "yes" : "no"}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
|
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
|
<section {...stylex.props(styles.card)}>
|
||||||
<h2 className="flex items-baseline justify-between px-4 pt-3 text-sm font-semibold">
|
<h2 {...stylex.props(styles.heading)}>
|
||||||
Upstreams
|
Upstreams
|
||||||
<span className="text-xs font-normal text-zinc-500 tabular-nums">
|
<span {...stylex.props(styles.count, shared.tabularNums)}>
|
||||||
{health.available}/{health.total} available
|
{health.available}/{health.total} available
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
{health.upstreams.length === 0 ? (
|
{health.upstreams.length === 0 ? (
|
||||||
<p className="px-4 py-3 text-sm text-zinc-500">No upstreams configured.</p>
|
<p {...stylex.props(styles.empty)}>No upstreams configured.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div {...stylex.props(styles.tableWrap)}>
|
||||||
<table className="mt-2 w-full text-sm">
|
<table {...stylex.props(styles.table)}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-200 text-left text-xs text-zinc-500 dark:border-zinc-800">
|
<tr {...stylex.props(styles.headRow)}>
|
||||||
<th scope="col" className="px-4 py-2 font-medium">
|
<th scope="col" {...stylex.props(styles.th)}>
|
||||||
URL
|
URL
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-4 py-2 font-medium">
|
<th scope="col" {...stylex.props(styles.th)}>
|
||||||
Enabled
|
Enabled
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-4 py-2 font-medium">
|
<th scope="col" {...stylex.props(styles.th)}>
|
||||||
Available
|
Available
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-4 py-2 text-right font-medium">
|
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
|
||||||
Failures
|
Failures
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-4 py-2 text-right font-medium">
|
<th scope="col" {...stylex.props(styles.th, styles.thRight)}>
|
||||||
Success rate
|
Success rate
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-4 py-2 font-medium">
|
<th scope="col" {...stylex.props(styles.th)}>
|
||||||
Last error
|
Last error
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{health.upstreams.map((upstream) => (
|
{health.upstreams.map((upstream) => (
|
||||||
<tr
|
<tr key={upstream.url} {...stylex.props(styles.row)}>
|
||||||
key={upstream.url}
|
<td {...stylex.props(styles.cell, styles.small, shared.mono)}>{upstream.url}</td>
|
||||||
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800/50"
|
<td {...stylex.props(styles.cell)}>
|
||||||
>
|
|
||||||
<td className="px-4 py-2 font-mono text-xs">{upstream.url}</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<YesNo value={upstream.enabled} badValue={false} />
|
<YesNo value={upstream.enabled} badValue={false} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td {...stylex.props(styles.cell)}>
|
||||||
<YesNo value={upstream.available} badValue={false} />
|
<YesNo value={upstream.available} badValue={false} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-right tabular-nums">{upstream.total_failures}</td>
|
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
||||||
<td className="px-4 py-2 text-right tabular-nums">
|
{upstream.total_failures}
|
||||||
|
</td>
|
||||||
|
<td {...stylex.props(styles.cell, styles.cellRight, shared.tabularNums)}>
|
||||||
{(upstream.success_rate * 100).toFixed(1)}%
|
{(upstream.success_rate * 100).toFixed(1)}%
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-xs text-zinc-500">{upstream.last_error || "—"}</td>
|
<td {...stylex.props(styles.cell, styles.small, styles.muted)}>
|
||||||
|
{upstream.last_error || "—"}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
|
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
|
||||||
import type { Blocklist } from "@/lib/types";
|
import type { Blocklist } from "@/lib/types";
|
||||||
import { sameSet, toggleSource } from "./sourceSet";
|
import { sameSet, toggleSource } from "./sourceSet";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,6 +14,35 @@ interface Props {
|
|||||||
blocklists: Blocklist[];
|
blocklists: Blocklist[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
note: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.25rem",
|
||||||
|
},
|
||||||
|
checkboxLabel: {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const sources = useQuery(groupSourcesQuery(groupId));
|
const sources = useQuery(groupSourcesQuery(groupId));
|
||||||
@@ -21,7 +52,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
|||||||
|
|
||||||
if (sources.isPending) {
|
if (sources.isPending) {
|
||||||
return (
|
return (
|
||||||
<p role="status" className="mt-3 text-sm text-zinc-500">
|
<p role="status" {...stylex.props(styles.note)}>
|
||||||
Loading sources…
|
Loading sources…
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -29,27 +60,23 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
|||||||
if (sources.isError) return <InlineError error={sources.error} />;
|
if (sources.isError) return <InlineError error={sources.error} />;
|
||||||
|
|
||||||
if (blocklists.length === 0) {
|
if (blocklists.length === 0) {
|
||||||
return (
|
return <p {...stylex.props(styles.note)}>No blocklist sources exist yet — add them on the Blocklists page.</p>;
|
||||||
<p className="mt-3 text-sm text-zinc-500">
|
|
||||||
No blocklist sources exist yet — add them on the Blocklists page.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = selected ?? sources.data;
|
const current = selected ?? sources.data;
|
||||||
const dirty = !sameSet(current, sources.data);
|
const dirty = !sameSet(current, sources.data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3">
|
<div {...stylex.props(styles.root)}>
|
||||||
<ul className="space-y-1">
|
<ul {...stylex.props(styles.list)}>
|
||||||
{blocklists.map((blocklist) => (
|
{blocklists.map((blocklist) => (
|
||||||
<li key={blocklist.id}>
|
<li key={blocklist.id}>
|
||||||
<label className="inline-flex items-center gap-2 text-sm">
|
<label {...stylex.props(styles.checkboxLabel)}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={current.includes(blocklist.id)}
|
checked={current.includes(blocklist.id)}
|
||||||
onChange={() => setSelected(toggleSource(current, blocklist.id))}
|
onChange={() => setSelected(toggleSource(current, blocklist.id))}
|
||||||
className={focusRing}
|
{...stylex.props(shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
{blocklist.name}
|
{blocklist.name}
|
||||||
</label>
|
</label>
|
||||||
@@ -57,7 +84,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<InlineError error={mutation.error} />
|
<InlineError error={mutation.error} />
|
||||||
<div className="mt-3 flex gap-2">
|
<div {...stylex.props(styles.buttonRow)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!dirty || mutation.isPending || readOnly}
|
disabled={!dirty || mutation.isPending || readOnly}
|
||||||
@@ -65,12 +92,16 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
|
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
|
||||||
}
|
}
|
||||||
className={primaryButtonClass}
|
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Save sources
|
Save sources
|
||||||
</button>
|
</button>
|
||||||
{dirty && (
|
{dirty && (
|
||||||
<button type="button" onClick={() => setSelected(null)} className={buttonClass}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
{...stylex.props(shared.button, shared.focusRing)}
|
||||||
|
>
|
||||||
Discard
|
Discard
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import {
|
import {
|
||||||
blocklistsQuery,
|
blocklistsQuery,
|
||||||
groupCreateMutation,
|
groupCreateMutation,
|
||||||
@@ -10,13 +11,85 @@ import {
|
|||||||
import type { Blocklist, Group } from "@/lib/types";
|
import type { Blocklist, Group } from "@/lib/types";
|
||||||
import GroupSourcesEditor from "./GroupSourcesEditor";
|
import GroupSourcesEditor from "./GroupSourcesEditor";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
|
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||||
|
|
||||||
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
|
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
|
||||||
|
|
||||||
const groupButtonClass = `${smallButtonClass} disabled:opacity-50`;
|
const styles = stylex.create({
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
createForm: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
fieldLabel: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
padding: "1rem",
|
||||||
|
},
|
||||||
|
rowControls: {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
renameForm: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
checkboxLabel: {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
marginLeft: "auto",
|
||||||
|
display: "inline-flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
groupButton: {
|
||||||
|
opacity: { default: 1, ":disabled": 0.5 },
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
lockNote: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function GroupsPage() {
|
export default function GroupsPage() {
|
||||||
const { data: groups } = useSuspenseQuery(groupsQuery());
|
const { data: groups } = useSuspenseQuery(groupsQuery());
|
||||||
@@ -28,9 +101,9 @@ export default function GroupsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1 className="text-2xl font-semibold">Groups</h1>
|
<h1 {...stylex.props(styles.heading)}>Groups</h1>
|
||||||
<form
|
<form
|
||||||
className="mt-4 flex flex-wrap items-center gap-2"
|
{...stylex.props(styles.createForm)}
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const name = newName.trim();
|
const name = newName.trim();
|
||||||
@@ -38,7 +111,7 @@ export default function GroupsPage() {
|
|||||||
createMutation.mutate({ name }, { onSuccess: () => setNewName("") });
|
createMutation.mutate({ name }, { onSuccess: () => setNewName("") });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label className="text-sm font-medium" htmlFor="new-group-name">
|
<label {...stylex.props(styles.fieldLabel)} htmlFor="new-group-name">
|
||||||
New group
|
New group
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -47,19 +120,19 @@ export default function GroupsPage() {
|
|||||||
value={newName}
|
value={newName}
|
||||||
onChange={(event) => setNewName(event.target.value)}
|
onChange={(event) => setNewName(event.target.value)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
className={smallInputClass}
|
{...stylex.props(shared.smallInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createMutation.isPending || readOnly}
|
disabled={createMutation.isPending || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={primaryButtonClass}
|
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<InlineError error={createMutation.error} />
|
<InlineError error={createMutation.error} />
|
||||||
<ul className="mt-6 space-y-4">
|
<ul {...stylex.props(styles.list)}>
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<GroupRow key={group.id} group={group} blocklists={blocklists} />
|
<GroupRow key={group.id} group={group} blocklists={blocklists} />
|
||||||
))}
|
))}
|
||||||
@@ -81,11 +154,11 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
|||||||
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
|
const lockNote = isDefault ? DEFAULT_GROUP_NOTE : readOnly ? READ_ONLY_HINT : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="rounded border border-zinc-200 p-4 dark:border-zinc-700">
|
<li {...stylex.props(styles.row)}>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div {...stylex.props(styles.rowControls)}>
|
||||||
{renaming ? (
|
{renaming ? (
|
||||||
<form
|
<form
|
||||||
className="flex items-center gap-2"
|
{...stylex.props(styles.renameForm)}
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
@@ -101,14 +174,14 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
|||||||
aria-label={`New name for ${group.name}`}
|
aria-label={`New name for ${group.name}`}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
className={smallInputClass}
|
{...stylex.props(shared.smallInput, shared.focusRing)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={updateMutation.isPending || readOnly}
|
disabled={updateMutation.isPending || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={groupButtonClass}
|
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
@@ -118,21 +191,21 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
|||||||
setName(group.name);
|
setName(group.name);
|
||||||
setRenaming(false);
|
setRenaming(false);
|
||||||
}}
|
}}
|
||||||
className={groupButtonClass}
|
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-medium">{group.name}</span>
|
<span {...stylex.props(styles.name)}>{group.name}</span>
|
||||||
)}
|
)}
|
||||||
<label className="inline-flex items-center gap-2 text-sm">
|
<label {...stylex.props(styles.checkboxLabel)}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={group.safe_search}
|
checked={group.safe_search}
|
||||||
disabled={updateMutation.isPending || readOnly}
|
disabled={updateMutation.isPending || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={focusRing}
|
{...stylex.props(shared.focusRing)}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
@@ -142,12 +215,12 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
|||||||
/>
|
/>
|
||||||
Safe search
|
Safe search
|
||||||
</label>
|
</label>
|
||||||
<span className="ml-auto inline-flex flex-wrap items-center gap-2">
|
<span {...stylex.props(styles.actions)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
onClick={() => setExpanded((open) => !open)}
|
onClick={() => setExpanded((open) => !open)}
|
||||||
className={groupButtonClass}
|
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Sources
|
Sources
|
||||||
</button>
|
</button>
|
||||||
@@ -160,7 +233,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
|||||||
setName(group.name);
|
setName(group.name);
|
||||||
setRenaming(true);
|
setRenaming(true);
|
||||||
}}
|
}}
|
||||||
className={groupButtonClass}
|
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</button>
|
||||||
@@ -173,11 +246,20 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
|||||||
setConfirming(false);
|
setConfirming(false);
|
||||||
deleteMutation.mutate(group.id);
|
deleteMutation.mutate(group.id);
|
||||||
}}
|
}}
|
||||||
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
|
{...stylex.props(
|
||||||
|
shared.smallButton,
|
||||||
|
styles.groupButton,
|
||||||
|
styles.destructive,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Confirm delete
|
Confirm delete
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setConfirming(false)} className={groupButtonClass}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
{...stylex.props(shared.smallButton, styles.groupButton, shared.focusRing)}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -187,14 +269,19 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
|
|||||||
disabled={isDefault || readOnly}
|
disabled={isDefault || readOnly}
|
||||||
title={lockNote}
|
title={lockNote}
|
||||||
onClick={() => setConfirming(true)}
|
onClick={() => setConfirming(true)}
|
||||||
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
|
{...stylex.props(
|
||||||
|
shared.smallButton,
|
||||||
|
styles.groupButton,
|
||||||
|
styles.destructive,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isDefault && <p className="mt-2 text-xs text-zinc-500">{DEFAULT_GROUP_NOTE}</p>}
|
{isDefault && <p {...stylex.props(styles.lockNote)}>{DEFAULT_GROUP_NOTE}</p>}
|
||||||
<InlineError error={updateMutation.error ?? deleteMutation.error} />
|
<InlineError error={updateMutation.error ?? deleteMutation.error} />
|
||||||
{expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />}
|
{expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -50,8 +50,14 @@ test("streams rows, flags blocked ones, and freezes the display", () => {
|
|||||||
expect(screen.getByText("Blocked")).toBeTruthy();
|
expect(screen.getByText("Blocked")).toBeTruthy();
|
||||||
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
|
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
|
||||||
expect(screen.getByText("AAAA")).toBeTruthy();
|
expect(screen.getByText("AAAA")).toBeTruthy();
|
||||||
|
// StyleX compiles to opaque class names, so the check is structural: a blocked
|
||||||
|
// row carries every class a plain row does, plus the ones the flag adds.
|
||||||
const blockedRow = screen.getByText("ads.example").closest("tr");
|
const blockedRow = screen.getByText("ads.example").closest("tr");
|
||||||
expect(blockedRow?.className).toContain("bg-red-50");
|
const plainRow = screen.getByText("ok.example").closest("tr");
|
||||||
|
const blockedClasses = new Set(blockedRow?.className.split(" "));
|
||||||
|
const plainClasses = plainRow?.className.split(" ") ?? [];
|
||||||
|
expect(plainClasses.every((name) => blockedClasses.has(name))).toBe(true);
|
||||||
|
expect(blockedClasses.size).toBeGreaterThan(plainClasses.length);
|
||||||
|
|
||||||
const freeze = screen.getByRole("button", { name: "Freeze" });
|
const freeze = screen.getByRole("button", { name: "Freeze" });
|
||||||
fireEvent.click(freeze);
|
fireEvent.click(freeze);
|
||||||
|
|||||||
@@ -1,20 +1,164 @@
|
|||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
|
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
|
||||||
import { RING_CAPACITY } from "./ringBuffer";
|
import { RING_CAPACITY } from "./ringBuffer";
|
||||||
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
|
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
|
||||||
import { buttonClass, retryButtonClass, tableWrapClass } from "@/ui/classes";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
const toolbarButtonClass = `${buttonClass} font-medium`;
|
const DARK = "@media (prefers-color-scheme: dark)";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
toolbar: {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
toolbarButton: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
pill: {
|
||||||
|
borderRadius: "9999px",
|
||||||
|
paddingInline: "0.625rem",
|
||||||
|
paddingBlock: "0.125rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
/** Four stream states need four tints; only two of them map onto a token role. */
|
||||||
|
pillConnecting: {
|
||||||
|
backgroundColor: { default: "oklch(96.7% 0.001 286.375)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
||||||
|
color: { default: "oklch(37% 0.013 285.805)", [DARK]: "oklch(87.1% 0.006 286.286)" },
|
||||||
|
},
|
||||||
|
pillOpen: {
|
||||||
|
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(39.3% 0.095 152.535)" },
|
||||||
|
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(92.5% 0.084 155.995)" },
|
||||||
|
},
|
||||||
|
pillRetrying: {
|
||||||
|
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(41.4% 0.112 45.904)" },
|
||||||
|
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(92.4% 0.12 95.746)" },
|
||||||
|
},
|
||||||
|
pillCapped: {
|
||||||
|
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
|
||||||
|
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
/** Informational, neither a warning nor a failure, so the blue ramp stands alone. */
|
||||||
|
resumed: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: { default: "oklch(80.9% 0.105 251.813)", [DARK]: "oklch(37.9% 0.146 265.522)" },
|
||||||
|
backgroundColor: { default: "oklch(97% 0.014 254.604)", [DARK]: "oklch(28.2% 0.091 267.935)" },
|
||||||
|
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(88.2% 0.059 254.128)" },
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
dismiss: {
|
||||||
|
borderStyle: "none",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
color: "inherit",
|
||||||
|
fontSize: "inherit",
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
},
|
||||||
|
failureNote: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.dangerText,
|
||||||
|
},
|
||||||
|
cappedBox: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.dangerBorder,
|
||||||
|
backgroundColor: colors.dangerSurface,
|
||||||
|
padding: "1rem",
|
||||||
|
},
|
||||||
|
cappedHeading: {
|
||||||
|
fontWeight: 600,
|
||||||
|
color: colors.dangerText,
|
||||||
|
},
|
||||||
|
cappedDetail: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.dangerText,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
tableWrap: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
overflowX: "auto",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||||
|
row: {
|
||||||
|
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||||
|
borderTopStyle: "solid",
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
rowBlocked: {
|
||||||
|
backgroundColor: {
|
||||||
|
default: "oklch(97.1% 0.013 17.38)",
|
||||||
|
[DARK]: "oklch(25.8% 0.092 26.042 / 0.4)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
footnote: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const PILL_LABELS: Record<StreamStatus, string> = {
|
||||||
|
connecting: "Connecting…",
|
||||||
|
open: "Live",
|
||||||
|
retrying: "Reconnecting…",
|
||||||
|
capped: "Disconnected",
|
||||||
|
};
|
||||||
|
|
||||||
|
function pillStyle(status: StreamStatus) {
|
||||||
|
if (status === "open") return styles.pillOpen;
|
||||||
|
if (status === "retrying") return styles.pillRetrying;
|
||||||
|
if (status === "capped") return styles.pillCapped;
|
||||||
|
return styles.pillConnecting;
|
||||||
|
}
|
||||||
|
|
||||||
function StatusPill({ status }: { status: StreamStatus }) {
|
function StatusPill({ status }: { status: StreamStatus }) {
|
||||||
const styles: Record<StreamStatus, [string, string]> = {
|
const label = PILL_LABELS[status];
|
||||||
connecting: ["Connecting…", "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"],
|
|
||||||
open: ["Live", "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"],
|
|
||||||
retrying: ["Reconnecting…", "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200"],
|
|
||||||
capped: ["Disconnected", "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"],
|
|
||||||
};
|
|
||||||
const [label, tone] = styles[status];
|
|
||||||
return (
|
return (
|
||||||
<span role="status" aria-label={label} className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${tone}`}>
|
<span role="status" aria-label={label} {...stylex.props(styles.pill, pillStyle(status))}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -27,58 +171,56 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div {...stylex.props(styles.toolbar)}>
|
||||||
<h1 className="text-2xl font-semibold">Live</h1>
|
<h1 {...stylex.props(styles.heading)}>Live</h1>
|
||||||
<StatusPill status={live.status} />
|
<StatusPill status={live.status} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={live.toggleFreeze}
|
onClick={live.toggleFreeze}
|
||||||
aria-pressed={live.frozen}
|
aria-pressed={live.frozen}
|
||||||
className={toolbarButtonClass}
|
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{live.frozen ? "Resume" : "Freeze"}
|
{live.frozen ? "Resume" : "Freeze"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{live.frozen && (
|
{live.frozen && (
|
||||||
<p className="mt-2 text-sm text-zinc-500" role="status">
|
<p {...stylex.props(styles.note)} role="status">
|
||||||
Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
|
Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
|
||||||
kept).
|
kept).
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{live.missed !== null && (
|
{live.missed !== null && (
|
||||||
<div
|
<div role="status" {...stylex.props(styles.resumed)}>
|
||||||
role="status"
|
|
||||||
className="mt-3 flex items-center gap-3 rounded border border-blue-300 bg-blue-50 px-3 py-2 text-sm text-blue-800 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-200"
|
|
||||||
>
|
|
||||||
<span>
|
<span>
|
||||||
Stream resumed —{" "}
|
Stream resumed —{" "}
|
||||||
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
|
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
|
||||||
</span>
|
</span>
|
||||||
<button type="button" onClick={live.dismissMissed} className="font-medium underline">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={live.dismissMissed}
|
||||||
|
{...stylex.props(styles.dismiss, shared.focusRing)}
|
||||||
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{live.resyncFailed && (
|
{live.resyncFailed && (
|
||||||
<p role="alert" className="mt-3 text-sm text-red-700 dark:text-red-300">
|
<p role="alert" {...stylex.props(styles.failureNote)}>
|
||||||
Stream resumed, but re-syncing the gap failed — some queries may be missing here.
|
Stream resumed, but re-syncing the gap failed — some queries may be missing here.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{live.status === "capped" && (
|
{live.status === "capped" && (
|
||||||
<div
|
<div role="alert" {...stylex.props(styles.cappedBox)}>
|
||||||
role="alert"
|
<h2 {...stylex.props(styles.cappedHeading)}>Live stream unavailable</h2>
|
||||||
className="mt-4 rounded border border-red-300 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950"
|
<p {...stylex.props(styles.cappedDetail)}>
|
||||||
>
|
|
||||||
<h2 className="font-semibold text-red-800 dark:text-red-200">Live stream unavailable</h2>
|
|
||||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
|
||||||
The connection failed repeatedly — possibly too many live viewers (the server caps streams per
|
The connection failed repeatedly — possibly too many live viewers (the server caps streams per
|
||||||
address), or the server is unreachable.
|
address), or the server is unreachable.
|
||||||
</p>
|
</p>
|
||||||
<button type="button" onClick={live.retry} className={retryButtonClass}>
|
<button type="button" onClick={live.retry} {...stylex.props(shared.retryButton, shared.focusRing)}>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,25 +228,25 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
|||||||
|
|
||||||
{live.rows.length === 0 ? (
|
{live.rows.length === 0 ? (
|
||||||
live.status !== "capped" && (
|
live.status !== "capped" && (
|
||||||
<p className="mt-6 text-zinc-500">
|
<p {...stylex.props(styles.empty)}>
|
||||||
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
|
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}>
|
<div {...stylex.props(styles.tableWrap)}>
|
||||||
<table className="w-full text-sm">
|
<table {...stylex.props(styles.table)}>
|
||||||
<QueryTableHead />
|
<QueryTableHead />
|
||||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
<tbody>
|
||||||
{live.rows.map((row) => (
|
{live.rows.map((row) => (
|
||||||
<tr key={row.key} className={row.blocked ? "bg-red-50 dark:bg-red-950/40" : ""}>
|
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
|
||||||
<QueryCells row={row} />
|
<QueryCells row={row} />
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm text-zinc-500">
|
<p {...stylex.props(styles.footnote)}>
|
||||||
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
|
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
|
||||||
{RING_CAPACITY} kept).
|
{RING_CAPACITY} kept).
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
import { AuthProvider } from "@/auth/store";
|
import { AuthProvider } from "@/auth/store";
|
||||||
@@ -24,6 +24,12 @@ function createFetchMock() {
|
|||||||
records = [...records, created];
|
records = [...records, created];
|
||||||
return json(created, 201);
|
return json(created, 201);
|
||||||
}
|
}
|
||||||
|
if (url.startsWith("/api/local-records/") && method === "DELETE") {
|
||||||
|
const id = Number(url.slice("/api/local-records/".length));
|
||||||
|
records = records.filter((record) => record.id !== id);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
if (url.startsWith("/api/forward-zones/") && method === "DELETE") return new Response(null, { status: 204 });
|
||||||
if (url === "/api/forward-zones" && method === "GET") {
|
if (url === "/api/forward-zones" && method === "GET") {
|
||||||
return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] });
|
return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] });
|
||||||
}
|
}
|
||||||
@@ -65,13 +71,34 @@ test("renders the records table and switches to the forward zones tab", async ()
|
|||||||
expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy();
|
expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the arrow keys move between tabs", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("nas.lan.home");
|
||||||
|
|
||||||
|
const tablist = screen.getByRole("tablist", { name: "Local DNS" });
|
||||||
|
const records = screen.getByRole("tab", { name: "Records" });
|
||||||
|
expect(records.getAttribute("aria-selected")).toBe("true");
|
||||||
|
|
||||||
|
fireEvent.keyDown(tablist, { key: "ArrowRight" });
|
||||||
|
const zones = screen.getByRole("tab", { name: "Forward zones" });
|
||||||
|
expect(zones.getAttribute("aria-selected")).toBe("true");
|
||||||
|
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("false");
|
||||||
|
await screen.findByText("lan.home");
|
||||||
|
|
||||||
|
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
|
||||||
|
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("true");
|
||||||
|
await screen.findByText("nas.lan.home");
|
||||||
|
});
|
||||||
|
|
||||||
test("creates a record: POST body per LocalRecordInput, list refreshes", async () => {
|
test("creates a record: POST body per LocalRecordInput, list refreshes", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText("nas.lan.home");
|
await screen.findByText("nas.lan.home");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Add record" }));
|
fireEvent.click(screen.getByRole("button", { name: "Add record" }));
|
||||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
|
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
|
||||||
fireEvent.change(screen.getByLabelText("Type"), { target: { value: "AAAA" } });
|
// The record type is a RAC Select now: open the listbox, then pick.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Type$/ }));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "AAAA" }));
|
||||||
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
|
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
@@ -83,3 +110,61 @@ test("creates a record: POST body per LocalRecordInput, list refreshes", async (
|
|||||||
expect(post).toBeTruthy();
|
expect(post).toBeTruthy();
|
||||||
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" });
|
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("cancelling the record delete dialog sends no request", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("nas.lan.home");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
expect(dialog.textContent).toContain('Delete record "nas.lan.home"?');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
|
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
|
||||||
|
expect(screen.getByText("nas.lan.home")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("confirming the record delete dialog issues the DELETE", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("nas.lan.home");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
await screen.findByRole("alertdialog");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.some(
|
||||||
|
([input, init]) => init?.method === "DELETE" && String(input) === "/api/local-records/1",
|
||||||
|
),
|
||||||
|
).toBe(true),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.queryByText("nas.lan.home")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the forward zone delete dialog names the zone and confirms", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("nas.lan.home");
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
|
||||||
|
await screen.findByText("lan.home");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
expect(dialog.textContent).toContain('Delete forward zone "lan.home"?');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
|
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
await screen.findByRole("alertdialog");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.some(
|
||||||
|
([input, init]) => init?.method === "DELETE" && String(input) === "/api/forward-zones/7",
|
||||||
|
),
|
||||||
|
).toBe(true),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,79 +1,27 @@
|
|||||||
import { useState } from "react";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import RecordsTab from "@/features/local/RecordsTab";
|
import RecordsTab from "@/features/local/RecordsTab";
|
||||||
import ZonesTab from "@/features/local/ZonesTab";
|
import ZonesTab from "@/features/local/ZonesTab";
|
||||||
import { focusRing } from "@/ui/classes";
|
import Tabs from "@/ui/Tabs";
|
||||||
|
|
||||||
type Tab = "records" | "zones";
|
const styles = stylex.create({
|
||||||
|
heading: {
|
||||||
function TabButton({
|
fontSize: "1.5rem",
|
||||||
id,
|
lineHeight: "2rem",
|
||||||
controls,
|
fontWeight: 600,
|
||||||
selected,
|
},
|
||||||
onClick,
|
});
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
controls: string;
|
|
||||||
selected: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
children: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
id={id}
|
|
||||||
aria-controls={controls}
|
|
||||||
aria-selected={selected}
|
|
||||||
onClick={onClick}
|
|
||||||
className={`-mb-px border-b-2 px-3 py-2 font-medium ${focusRing} ${
|
|
||||||
selected
|
|
||||||
? "border-blue-600 text-blue-600 dark:text-blue-400"
|
|
||||||
: "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LocalDnsPage() {
|
export default function LocalDnsPage() {
|
||||||
const [tab, setTab] = useState<Tab>("records");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1 className="text-2xl font-semibold">Local DNS</h1>
|
<h1 {...stylex.props(styles.heading)}>Local DNS</h1>
|
||||||
<div
|
<Tabs
|
||||||
role="tablist"
|
label="Local DNS"
|
||||||
aria-label="Local DNS"
|
tabs={[
|
||||||
className="mt-4 flex gap-2 border-b border-zinc-200 dark:border-zinc-800"
|
{ id: "records", label: "Records", content: <RecordsTab /> },
|
||||||
>
|
{ id: "zones", label: "Forward zones", content: <ZonesTab /> },
|
||||||
<TabButton
|
]}
|
||||||
id="tab-records"
|
/>
|
||||||
controls="panel-records"
|
|
||||||
selected={tab === "records"}
|
|
||||||
onClick={() => setTab("records")}
|
|
||||||
>
|
|
||||||
Records
|
|
||||||
</TabButton>
|
|
||||||
<TabButton
|
|
||||||
id="tab-zones"
|
|
||||||
controls="panel-zones"
|
|
||||||
selected={tab === "zones"}
|
|
||||||
onClick={() => setTab("zones")}
|
|
||||||
>
|
|
||||||
Forward zones
|
|
||||||
</TabButton>
|
|
||||||
</div>
|
|
||||||
{tab === "records" ? (
|
|
||||||
<div role="tabpanel" id="panel-records" aria-labelledby="tab-records">
|
|
||||||
<RecordsTab />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div role="tabpanel" id="panel-zones" aria-labelledby="tab-zones">
|
|
||||||
<ZonesTab />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useId, useState, type FormEvent } from "react";
|
import { useId, useState, type FormEvent } from "react";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import {
|
import {
|
||||||
localRecordCreateMutation,
|
localRecordCreateMutation,
|
||||||
localRecordDeleteMutation,
|
localRecordDeleteMutation,
|
||||||
@@ -8,18 +9,86 @@ import {
|
|||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
|
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
|
import Select from "@/ui/Select";
|
||||||
import { useCrudForm } from "@/ui/useCrudForm";
|
import { useCrudForm } from "@/ui/useCrudForm";
|
||||||
import {
|
import { styles as shared } from "@/ui/styles";
|
||||||
formCardClass,
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
inputClass,
|
|
||||||
largeButtonClass,
|
|
||||||
largePrimaryButtonClass,
|
|
||||||
rowButtonClass,
|
|
||||||
tableWrapClass,
|
|
||||||
} from "@/ui/classes";
|
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||||
|
|
||||||
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
|
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
|
||||||
|
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
formHeading: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
fieldLabel: {
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
toolbar: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginTop: "1rem",
|
||||||
|
},
|
||||||
|
intro: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
headRow: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
headCell: {
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
paddingRight: "1rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
headCellLast: {
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
},
|
||||||
|
bodyRow: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
cell: {
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
paddingRight: "1rem",
|
||||||
|
},
|
||||||
|
emptyCell: {
|
||||||
|
paddingBlock: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
actionCell: {
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
textAlign: "right",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
dangerText: {
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
dimWhenDisabled: {
|
||||||
|
opacity: { default: 1, ":disabled": 0.5 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function RecordForm({
|
function RecordForm({
|
||||||
initial,
|
initial,
|
||||||
@@ -50,10 +119,12 @@ function RecordForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit} className={formCardClass}>
|
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
|
||||||
<h3 className="font-medium">{initial === undefined ? "New record" : `Edit ${initial.name}`}</h3>
|
<h3 {...stylex.props(styles.formHeading)}>
|
||||||
|
{initial === undefined ? "New record" : `Edit ${initial.name}`}
|
||||||
|
</h3>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={`${id}-name`} className="block text-sm font-medium">
|
<label htmlFor={`${id}-name`} {...stylex.props(styles.fieldLabel)}>
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -62,28 +133,19 @@ function RecordForm({
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
placeholder="nas.lan.home"
|
placeholder="nas.lan.home"
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={`${id}-rtype`} className="block text-sm font-medium">
|
<Select
|
||||||
Type
|
label="Type"
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id={`${id}-rtype`}
|
|
||||||
value={rtype}
|
value={rtype}
|
||||||
onChange={(event) => setRtype(event.target.value as LocalRecordType)}
|
onChange={(next) => setRtype(next as LocalRecordType)}
|
||||||
className={inputClass}
|
options={RTYPE_OPTIONS}
|
||||||
>
|
/>
|
||||||
{RTYPES.map((option) => (
|
|
||||||
<option key={option} value={option}>
|
|
||||||
{option}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={`${id}-value`} className="block text-sm font-medium">
|
<label htmlFor={`${id}-value`} {...stylex.props(styles.fieldLabel)}>
|
||||||
Value
|
Value
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -94,11 +156,11 @@ function RecordForm({
|
|||||||
placeholder={
|
placeholder={
|
||||||
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
|
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
|
||||||
}
|
}
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={`${id}-ttl`} className="block text-sm font-medium">
|
<label htmlFor={`${id}-ttl`} {...stylex.props(styles.fieldLabel)}>
|
||||||
TTL (seconds)
|
TTL (seconds)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -108,19 +170,19 @@ function RecordForm({
|
|||||||
value={ttl}
|
value={ttl}
|
||||||
onChange={(event) => setTtl(event.target.value)}
|
onChange={(event) => setTtl(event.target.value)}
|
||||||
placeholder="300"
|
placeholder="300"
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div {...stylex.props(styles.buttonRow)}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy || readOnly}
|
disabled={busy || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={largePrimaryButtonClass}
|
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{busy ? "Saving…" : "Save"}
|
{busy ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onCancel} className={largeButtonClass}>
|
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,10 +193,19 @@ function RecordForm({
|
|||||||
|
|
||||||
export default function RecordsTab() {
|
export default function RecordsTab() {
|
||||||
const records = useSuspenseQuery(localRecordsQuery()).data;
|
const records = useSuspenseQuery(localRecordsQuery()).data;
|
||||||
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
|
const {
|
||||||
LocalRecord,
|
create,
|
||||||
LocalRecordInput
|
update,
|
||||||
>({
|
remove,
|
||||||
|
form,
|
||||||
|
openForm,
|
||||||
|
closeForm,
|
||||||
|
onSubmit,
|
||||||
|
onDelete,
|
||||||
|
pendingDelete,
|
||||||
|
confirmPendingDelete,
|
||||||
|
cancelPendingDelete,
|
||||||
|
} = useCrudForm<LocalRecord, LocalRecordInput>({
|
||||||
create: localRecordCreateMutation,
|
create: localRecordCreateMutation,
|
||||||
update: localRecordUpdateMutation,
|
update: localRecordUpdateMutation,
|
||||||
remove: localRecordDeleteMutation,
|
remove: localRecordDeleteMutation,
|
||||||
@@ -144,14 +215,14 @@ export default function RecordsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div {...stylex.props(styles.toolbar)}>
|
||||||
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
|
<p {...stylex.props(styles.intro)}>Answers served directly for LAN names. Changes apply live.</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openForm({ mode: "create" })}
|
onClick={() => openForm({ mode: "create" })}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={largePrimaryButtonClass}
|
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Add record
|
Add record
|
||||||
</button>
|
</button>
|
||||||
@@ -166,37 +237,37 @@ export default function RecordsTab() {
|
|||||||
onCancel={closeForm}
|
onCancel={closeForm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={tableWrapClass}>
|
<div {...stylex.props(shared.tableWrap)}>
|
||||||
<table className="w-full text-left text-sm">
|
<table {...stylex.props(styles.table)}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
|
<tr {...stylex.props(styles.headRow)}>
|
||||||
<th scope="col" className="py-2 pr-4 font-medium">
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
Name
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="py-2 pr-4 font-medium">
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
Type
|
Type
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="py-2 pr-4 font-medium">
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
Value
|
Value
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="py-2 pr-4 font-medium">
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
TTL
|
TTL
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="py-2">
|
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
||||||
<span className="sr-only">Actions</span>
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{records.length === 0 && (
|
{records.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="py-4 text-zinc-500">
|
<td colSpan={5} {...stylex.props(styles.emptyCell)}>
|
||||||
No local records yet.
|
No local records yet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{records.map((record) => (
|
{records.map((record) => (
|
||||||
<tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900">
|
<tr key={record.id} {...stylex.props(styles.bodyRow)}>
|
||||||
{form?.mode === "edit" && form.entity.id === record.id ? (
|
{form?.mode === "edit" && form.entity.id === record.id ? (
|
||||||
<td colSpan={5}>
|
<td colSpan={5}>
|
||||||
<RecordForm
|
<RecordForm
|
||||||
@@ -210,17 +281,21 @@ export default function RecordsTab() {
|
|||||||
</td>
|
</td>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<td className="py-2 pr-4 font-mono">{record.name}</td>
|
<td {...stylex.props(styles.cell, shared.mono)}>{record.name}</td>
|
||||||
<td className="py-2 pr-4">{record.rtype}</td>
|
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
|
||||||
<td className="py-2 pr-4 font-mono">{record.value}</td>
|
<td {...stylex.props(styles.cell, shared.mono)}>{record.value}</td>
|
||||||
<td className="py-2 pr-4">{record.ttl}</td>
|
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
|
||||||
<td className="py-2 text-right whitespace-nowrap">
|
<td {...stylex.props(styles.actionCell)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openForm({ mode: "edit", entity: record })}
|
onClick={() => openForm({ mode: "edit", entity: record })}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={`${rowButtonClass} disabled:opacity-50`}
|
{...stylex.props(
|
||||||
|
shared.rowButton,
|
||||||
|
styles.dimWhenDisabled,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -229,7 +304,12 @@ export default function RecordsTab() {
|
|||||||
onClick={() => onDelete(record)}
|
onClick={() => onDelete(record)}
|
||||||
disabled={remove.isPending || readOnly}
|
disabled={remove.isPending || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
|
{...stylex.props(
|
||||||
|
shared.rowButton,
|
||||||
|
styles.dangerText,
|
||||||
|
styles.dimWhenDisabled,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -241,6 +321,14 @@ export default function RecordsTab() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={pendingDelete !== null}
|
||||||
|
title="Delete record"
|
||||||
|
message={pendingDelete?.message ?? ""}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={confirmPendingDelete}
|
||||||
|
onCancel={cancelPendingDelete}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useId, useState, type FormEvent } from "react";
|
import { useId, useState, type FormEvent } from "react";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import {
|
import {
|
||||||
forwardZoneCreateMutation,
|
forwardZoneCreateMutation,
|
||||||
forwardZoneDeleteMutation,
|
forwardZoneDeleteMutation,
|
||||||
@@ -8,17 +9,83 @@ import {
|
|||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
|
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import { useCrudForm } from "@/ui/useCrudForm";
|
import { useCrudForm } from "@/ui/useCrudForm";
|
||||||
import {
|
import { styles as shared } from "@/ui/styles";
|
||||||
formCardClass,
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
inputClass,
|
|
||||||
largeButtonClass,
|
|
||||||
largePrimaryButtonClass,
|
|
||||||
rowButtonClass,
|
|
||||||
tableWrapClass,
|
|
||||||
} from "@/ui/classes";
|
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
formHeading: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
fieldLabel: {
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
toolbar: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginTop: "1rem",
|
||||||
|
},
|
||||||
|
intro: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
headRow: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
headCell: {
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
paddingRight: "1rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
headCellLast: {
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
},
|
||||||
|
bodyRow: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
cell: {
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
paddingRight: "1rem",
|
||||||
|
},
|
||||||
|
emptyCell: {
|
||||||
|
paddingBlock: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
actionCell: {
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
textAlign: "right",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
dangerText: {
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
dimWhenDisabled: {
|
||||||
|
opacity: { default: 1, ":disabled": 0.5 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function ZoneForm({
|
function ZoneForm({
|
||||||
initial,
|
initial,
|
||||||
busy,
|
busy,
|
||||||
@@ -44,10 +111,12 @@ function ZoneForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit} className={formCardClass}>
|
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
|
||||||
<h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3>
|
<h3 {...stylex.props(styles.formHeading)}>
|
||||||
|
{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}
|
||||||
|
</h3>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={`${id}-zone`} className="block text-sm font-medium">
|
<label htmlFor={`${id}-zone`} {...stylex.props(styles.fieldLabel)}>
|
||||||
Zone
|
Zone
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -56,11 +125,11 @@ function ZoneForm({
|
|||||||
value={zone}
|
value={zone}
|
||||||
onChange={(event) => setZone(event.target.value)}
|
onChange={(event) => setZone(event.target.value)}
|
||||||
placeholder="lan.home"
|
placeholder="lan.home"
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={`${id}-resolver`} className="block text-sm font-medium">
|
<label htmlFor={`${id}-resolver`} {...stylex.props(styles.fieldLabel)}>
|
||||||
Resolver
|
Resolver
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -69,19 +138,19 @@ function ZoneForm({
|
|||||||
value={resolver}
|
value={resolver}
|
||||||
onChange={(event) => setResolver(event.target.value)}
|
onChange={(event) => setResolver(event.target.value)}
|
||||||
placeholder="udp://192.168.1.1:53"
|
placeholder="udp://192.168.1.1:53"
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div {...stylex.props(styles.buttonRow)}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy || readOnly}
|
disabled={busy || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={largePrimaryButtonClass}
|
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{busy ? "Saving…" : "Save"}
|
{busy ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onCancel} className={largeButtonClass}>
|
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,10 +161,19 @@ function ZoneForm({
|
|||||||
|
|
||||||
export default function ZonesTab() {
|
export default function ZonesTab() {
|
||||||
const zones = useSuspenseQuery(forwardZonesQuery()).data;
|
const zones = useSuspenseQuery(forwardZonesQuery()).data;
|
||||||
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
|
const {
|
||||||
ForwardZone,
|
create,
|
||||||
ForwardZoneInput
|
update,
|
||||||
>({
|
remove,
|
||||||
|
form,
|
||||||
|
openForm,
|
||||||
|
closeForm,
|
||||||
|
onSubmit,
|
||||||
|
onDelete,
|
||||||
|
pendingDelete,
|
||||||
|
confirmPendingDelete,
|
||||||
|
cancelPendingDelete,
|
||||||
|
} = useCrudForm<ForwardZone, ForwardZoneInput>({
|
||||||
create: forwardZoneCreateMutation,
|
create: forwardZoneCreateMutation,
|
||||||
update: forwardZoneUpdateMutation,
|
update: forwardZoneUpdateMutation,
|
||||||
remove: forwardZoneDeleteMutation,
|
remove: forwardZoneDeleteMutation,
|
||||||
@@ -105,8 +183,8 @@ export default function ZonesTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div {...stylex.props(styles.toolbar)}>
|
||||||
<p className="text-sm text-zinc-500">
|
<p {...stylex.props(styles.intro)}>
|
||||||
Names under these zones go to their own resolver. Changes apply live.
|
Names under these zones go to their own resolver. Changes apply live.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -114,7 +192,7 @@ export default function ZonesTab() {
|
|||||||
onClick={() => openForm({ mode: "create" })}
|
onClick={() => openForm({ mode: "create" })}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={largePrimaryButtonClass}
|
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Add zone
|
Add zone
|
||||||
</button>
|
</button>
|
||||||
@@ -129,31 +207,31 @@ export default function ZonesTab() {
|
|||||||
onCancel={closeForm}
|
onCancel={closeForm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={tableWrapClass}>
|
<div {...stylex.props(shared.tableWrap)}>
|
||||||
<table className="w-full text-left text-sm">
|
<table {...stylex.props(styles.table)}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
|
<tr {...stylex.props(styles.headRow)}>
|
||||||
<th scope="col" className="py-2 pr-4 font-medium">
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
Zone
|
Zone
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="py-2 pr-4 font-medium">
|
<th scope="col" {...stylex.props(styles.headCell)}>
|
||||||
Resolver
|
Resolver
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="py-2">
|
<th scope="col" {...stylex.props(styles.headCellLast)}>
|
||||||
<span className="sr-only">Actions</span>
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{zones.length === 0 && (
|
{zones.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={3} className="py-4 text-zinc-500">
|
<td colSpan={3} {...stylex.props(styles.emptyCell)}>
|
||||||
No forward zones yet.
|
No forward zones yet.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{zones.map((zone) => (
|
{zones.map((zone) => (
|
||||||
<tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900">
|
<tr key={zone.id} {...stylex.props(styles.bodyRow)}>
|
||||||
{form?.mode === "edit" && form.entity.id === zone.id ? (
|
{form?.mode === "edit" && form.entity.id === zone.id ? (
|
||||||
<td colSpan={3}>
|
<td colSpan={3}>
|
||||||
<ZoneForm
|
<ZoneForm
|
||||||
@@ -167,15 +245,19 @@ export default function ZonesTab() {
|
|||||||
</td>
|
</td>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<td className="py-2 pr-4 font-mono">{zone.zone}</td>
|
<td {...stylex.props(styles.cell, shared.mono)}>{zone.zone}</td>
|
||||||
<td className="py-2 pr-4 font-mono">{zone.resolver}</td>
|
<td {...stylex.props(styles.cell, shared.mono)}>{zone.resolver}</td>
|
||||||
<td className="py-2 text-right whitespace-nowrap">
|
<td {...stylex.props(styles.actionCell)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openForm({ mode: "edit", entity: zone })}
|
onClick={() => openForm({ mode: "edit", entity: zone })}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={`${rowButtonClass} disabled:opacity-50`}
|
{...stylex.props(
|
||||||
|
shared.rowButton,
|
||||||
|
styles.dimWhenDisabled,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -184,7 +266,12 @@ export default function ZonesTab() {
|
|||||||
onClick={() => onDelete(zone)}
|
onClick={() => onDelete(zone)}
|
||||||
disabled={remove.isPending || readOnly}
|
disabled={remove.isPending || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
|
{...stylex.props(
|
||||||
|
shared.rowButton,
|
||||||
|
styles.dangerText,
|
||||||
|
styles.dimWhenDisabled,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -196,6 +283,14 @@ export default function ZonesTab() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={pendingDelete !== null}
|
||||||
|
title="Delete forward zone"
|
||||||
|
message={pendingDelete?.message ?? ""}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={confirmPendingDelete}
|
||||||
|
onCancel={cancelPendingDelete}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ test("fetches nothing until submit, then renders the blocked verdict", async ()
|
|||||||
|
|
||||||
test("defaults the group select to the default group (id 1)", async () => {
|
test("defaults the group select to the default group (id 1)", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
const select = (await screen.findByLabelText("Group")) as HTMLSelectElement;
|
// A RAC Select names its trigger with the current value and then the label, so
|
||||||
expect(select.value).toBe("1");
|
// the selected group's name is the only thing the trigger shows.
|
||||||
|
const trigger = await screen.findByRole("button", { name: /Group$/ });
|
||||||
|
expect(trigger.textContent).toContain("default");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useState, type FormEvent, type ReactNode } from "react";
|
import { useState, type FormEvent, type ReactNode } from "react";
|
||||||
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
import { groupsQuery, lookupQuery } from "@/lib/queries";
|
import { groupsQuery, lookupQuery } from "@/lib/queries";
|
||||||
import type { Group, LookupResult } from "@/lib/types";
|
import type { Group, LookupResult } from "@/lib/types";
|
||||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||||
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
|
import Select from "@/ui/Select";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
|
const DARK = "@media (prefers-color-scheme: dark)";
|
||||||
|
|
||||||
interface Submitted {
|
interface Submitted {
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -13,10 +18,123 @@ interface Submitted {
|
|||||||
|
|
||||||
interface Verdict {
|
interface Verdict {
|
||||||
label: string;
|
label: string;
|
||||||
className: string;
|
tone: "local" | "blocked" | "forwarded" | "allowed";
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
intro: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
display: "flex",
|
||||||
|
maxWidth: "42rem",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
domainField: {
|
||||||
|
minWidth: "14rem",
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
fieldLabel: {
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
borderStartStartRadius: "0.25rem",
|
||||||
|
borderStartEndRadius: "0.25rem",
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.75rem",
|
||||||
|
},
|
||||||
|
/** Four verdicts need four tints; only "blocked" maps onto a token role. */
|
||||||
|
local: {
|
||||||
|
backgroundColor: { default: "oklch(93.2% 0.032 255.585)", [DARK]: "oklch(28.2% 0.091 267.935)" },
|
||||||
|
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(80.9% 0.105 251.813)" },
|
||||||
|
},
|
||||||
|
blocked: {
|
||||||
|
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
|
||||||
|
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
|
||||||
|
},
|
||||||
|
forwarded: {
|
||||||
|
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
|
||||||
|
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
|
||||||
|
},
|
||||||
|
allowed: {
|
||||||
|
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(26.6% 0.065 152.934)" },
|
||||||
|
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(87.1% 0.15 154.449)" },
|
||||||
|
},
|
||||||
|
verdictLabel: {
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
verdictDescription: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||||
|
detailRow: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "1rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||||
|
borderTopStyle: "solid",
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
detailTerm: {
|
||||||
|
width: "10rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
detailValue: {
|
||||||
|
minWidth: 0,
|
||||||
|
overflowWrap: "break-word",
|
||||||
|
},
|
||||||
|
sourceLink: {
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function toneStyle(tone: Verdict["tone"]) {
|
||||||
|
if (tone === "local") return styles.local;
|
||||||
|
if (tone === "blocked") return styles.blocked;
|
||||||
|
return tone === "forwarded" ? styles.forwarded : styles.allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header priority follows the pipeline order the lookup handler documents
|
* Header priority follows the pipeline order the lookup handler documents
|
||||||
* (PLAN §6): local records answer first, then the block decision, then
|
* (PLAN §6): local records answer first, then the block decision, then
|
||||||
@@ -26,27 +144,27 @@ export function verdictOf(result: LookupResult): Verdict {
|
|||||||
if (result.local_records) {
|
if (result.local_records) {
|
||||||
return {
|
return {
|
||||||
label: "Local answer",
|
label: "Local answer",
|
||||||
className: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300",
|
tone: "local",
|
||||||
description: "A local record answers this name directly.",
|
description: "A local record answers this name directly.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (result.blocked) {
|
if (result.blocked) {
|
||||||
return {
|
return {
|
||||||
label: "Blocked",
|
label: "Blocked",
|
||||||
className: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
|
tone: "blocked",
|
||||||
description: "Queries for this name get a blocked response.",
|
description: "Queries for this name get a blocked response.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (result.forward_zone !== null) {
|
if (result.forward_zone !== null) {
|
||||||
return {
|
return {
|
||||||
label: "Forwarded",
|
label: "Forwarded",
|
||||||
className: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
tone: "forwarded",
|
||||||
description: `Queries go to the resolver for zone ${result.forward_zone}.`,
|
description: `Queries go to the resolver for zone ${result.forward_zone}.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
label: "Allowed",
|
label: "Allowed",
|
||||||
className: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300",
|
tone: "allowed",
|
||||||
description: "Queries resolve through the upstream pool.",
|
description: "Queries resolve through the upstream pool.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -68,9 +186,9 @@ function errorMessage(error: unknown): string {
|
|||||||
|
|
||||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 py-2">
|
<div {...stylex.props(styles.detailRow)}>
|
||||||
<dt className="w-40 shrink-0 text-zinc-500">{label}</dt>
|
<dt {...stylex.props(styles.detailTerm)}>{label}</dt>
|
||||||
<dd className="min-w-0 break-words">{children}</dd>
|
<dd {...stylex.props(styles.detailValue)}>{children}</dd>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,26 +198,30 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
|
|||||||
const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`;
|
const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 rounded border border-zinc-200 dark:border-zinc-800">
|
<div {...stylex.props(styles.card)}>
|
||||||
<div className={`rounded-t px-4 py-3 ${verdict.className}`}>
|
<div {...stylex.props(styles.banner, toneStyle(verdict.tone))}>
|
||||||
<h2 className="text-lg font-semibold">{verdict.label}</h2>
|
<h2 {...stylex.props(styles.verdictLabel)}>{verdict.label}</h2>
|
||||||
<p className="text-sm">{verdict.description}</p>
|
<p {...stylex.props(styles.verdictDescription)}>{verdict.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<dl className="divide-y divide-zinc-100 px-4 py-2 text-sm dark:divide-zinc-900">
|
<dl {...stylex.props(styles.details)}>
|
||||||
<DetailRow label="Domain">
|
<DetailRow label="Domain">
|
||||||
<span className="font-mono">{result.domain}</span>
|
<span {...stylex.props(shared.mono)}>{result.domain}</span>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label="Group">{groupName}</DetailRow>
|
<DetailRow label="Group">{groupName}</DetailRow>
|
||||||
<DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow>
|
<DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow>
|
||||||
<DetailRow label="Forward zone">
|
<DetailRow label="Forward zone">
|
||||||
{result.forward_zone !== null ? <span className="font-mono">{result.forward_zone}</span> : "—"}
|
{result.forward_zone !== null ? (
|
||||||
|
<span {...stylex.props(shared.mono)}>{result.forward_zone}</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow>
|
<DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow>
|
||||||
<DetailRow label="Reason">
|
<DetailRow label="Reason">
|
||||||
<span className="font-mono">{result.reason}</span>
|
<span {...stylex.props(shared.mono)}>{result.reason}</span>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label="Matched pattern">
|
<DetailRow label="Matched pattern">
|
||||||
{result.matched !== "" ? <span className="font-mono">{result.matched}</span> : "—"}
|
{result.matched !== "" ? <span {...stylex.props(shared.mono)}>{result.matched}</span> : "—"}
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label="Blocklist source">
|
<DetailRow label="Blocklist source">
|
||||||
{result.source_url !== null ? (
|
{result.source_url !== null ? (
|
||||||
@@ -107,7 +229,7 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
|
|||||||
href={result.source_url}
|
href={result.source_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-blue-600 underline dark:text-blue-400"
|
{...stylex.props(styles.sourceLink, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{result.source_url}
|
{result.source_url}
|
||||||
</a>
|
</a>
|
||||||
@@ -117,7 +239,7 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
|
|||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label="Safe search rewrite">
|
<DetailRow label="Safe search rewrite">
|
||||||
{result.safe_search_rewrite !== null ? (
|
{result.safe_search_rewrite !== null ? (
|
||||||
<span className="font-mono">{result.safe_search_rewrite}</span>
|
<span {...stylex.props(shared.mono)}>{result.safe_search_rewrite}</span>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
@@ -153,13 +275,13 @@ export default function LookupPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1 className="text-2xl font-semibold">Lookup</h1>
|
<h1 {...stylex.props(styles.heading)}>Lookup</h1>
|
||||||
<p className="mt-2 text-zinc-500">
|
<p {...stylex.props(styles.intro)}>
|
||||||
What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
|
What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={onSubmit} className="mt-6 flex max-w-2xl flex-wrap items-end gap-3">
|
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
||||||
<div className="min-w-56 grow">
|
<div {...stylex.props(styles.domainField)}>
|
||||||
<label htmlFor="lookup-domain" className="block text-sm font-medium">
|
<label htmlFor="lookup-domain" {...stylex.props(styles.fieldLabel)}>
|
||||||
Domain
|
Domain
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -168,33 +290,28 @@ export default function LookupPage() {
|
|||||||
value={domain}
|
value={domain}
|
||||||
onChange={(event) => setDomain(event.target.value)}
|
onChange={(event) => setDomain(event.target.value)}
|
||||||
placeholder="ads.example.com"
|
placeholder="ads.example.com"
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="lookup-group" className="block text-sm font-medium">
|
<Select
|
||||||
Group
|
label="Group"
|
||||||
</label>
|
value={String(groupId)}
|
||||||
<select
|
onChange={(value) => setGroupId(Number(value))}
|
||||||
id="lookup-group"
|
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
|
||||||
value={groupId}
|
/>
|
||||||
onChange={(event) => setGroupId(Number(event.target.value))}
|
|
||||||
className={inputClass}
|
|
||||||
>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<option key={group.id} value={group.id}>
|
|
||||||
{group.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className={largePrimaryButtonClass} disabled={lookup.isFetching}>
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={lookup.isFetching}
|
||||||
|
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||||
|
>
|
||||||
Look up
|
Look up
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{lookup.isFetching && <p className="mt-6 text-zinc-500">Looking up…</p>}
|
{lookup.isFetching && <p {...stylex.props(styles.note)}>Looking up…</p>}
|
||||||
{!lookup.isFetching && lookup.isError && (
|
{!lookup.isFetching && lookup.isError && (
|
||||||
<p role="alert" className="mt-6 text-sm text-red-600 dark:text-red-400">
|
<p role="alert" {...stylex.props(styles.error)}>
|
||||||
{errorMessage(lookup.error)}
|
{errorMessage(lookup.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { pauseMutation, pauseQuery } from "@/lib/queries";
|
import { pauseMutation, pauseQuery } from "@/lib/queries";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { buttonClass, insetFocusRing } from "@/ui/classes";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
const DURATIONS = [
|
const DURATIONS = [
|
||||||
{ label: "60 seconds", seconds: 60 },
|
{ label: "60 seconds", seconds: 60 },
|
||||||
@@ -11,6 +13,72 @@ const DURATIONS = [
|
|||||||
{ label: "Indefinitely", seconds: null },
|
{ label: "Indefinitely", seconds: null },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
/**
|
||||||
|
* Disabled text darkens in light scheme and lightens in dark, the opposite
|
||||||
|
* direction from `textMuted`, so the token cannot express it.
|
||||||
|
*/
|
||||||
|
trigger: {
|
||||||
|
color: {
|
||||||
|
default: null,
|
||||||
|
":disabled": "oklch(70.5% 0.015 286.067)",
|
||||||
|
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pausedRow: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
},
|
||||||
|
pausedControls: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Amber as standalone text on the app ground, not inside a warning banner, so
|
||||||
|
* the `warn*` tokens — tuned against `warnSurface` — do not apply here.
|
||||||
|
*/
|
||||||
|
pausedLabel: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: {
|
||||||
|
default: "oklch(55.5% 0.163 48.998)",
|
||||||
|
"@media (prefers-color-scheme: dark)": "oklch(82.8% 0.189 84.429)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
anchor: {
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
position: "absolute",
|
||||||
|
right: 0,
|
||||||
|
top: "100%",
|
||||||
|
zIndex: 10,
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
display: "flex",
|
||||||
|
width: "9rem",
|
||||||
|
flexDirection: "column",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
paddingBlock: "0.25rem",
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||||
|
},
|
||||||
|
menuItem: {
|
||||||
|
borderStyle: "none",
|
||||||
|
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||||
|
color: "inherit",
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.375rem",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export function formatRemaining(totalSeconds: number): string {
|
export function formatRemaining(totalSeconds: number): string {
|
||||||
const clamped = Math.max(0, totalSeconds);
|
const clamped = Math.max(0, totalSeconds);
|
||||||
const hours = Math.floor(clamped / 3600);
|
const hours = Math.floor(clamped / 3600);
|
||||||
@@ -35,8 +103,6 @@ function useNowSeconds(active: boolean): number {
|
|||||||
return now;
|
return now;
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerButtonClass = `${buttonClass} disabled:text-zinc-400 dark:disabled:text-zinc-600`;
|
|
||||||
|
|
||||||
export default function PauseWidget() {
|
export default function PauseWidget() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
@@ -52,7 +118,7 @@ export default function PauseWidget() {
|
|||||||
|
|
||||||
if (data === undefined) {
|
if (data === undefined) {
|
||||||
return (
|
return (
|
||||||
<button type="button" disabled className={triggerButtonClass}>
|
<button type="button" disabled {...stylex.props(shared.button, styles.trigger, shared.focusRing)}>
|
||||||
Pause
|
Pause
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -60,16 +126,16 @@ export default function PauseWidget() {
|
|||||||
|
|
||||||
if (data.paused) {
|
if (data.paused) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-end">
|
<div {...stylex.props(styles.pausedRow)}>
|
||||||
<div className="flex items-center gap-2">
|
<div {...stylex.props(styles.pausedControls)}>
|
||||||
<span className="text-sm text-amber-700 dark:text-amber-400">
|
<span {...stylex.props(styles.pausedLabel)}>
|
||||||
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
|
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => mutation.mutate({ paused: false })}
|
onClick={() => mutation.mutate({ paused: false })}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
className={triggerButtonClass}
|
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Resume
|
Resume
|
||||||
</button>
|
</button>
|
||||||
@@ -81,7 +147,7 @@ export default function PauseWidget() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative"
|
{...stylex.props(styles.anchor)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") setMenuOpen(false);
|
if (e.key === "Escape") setMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
@@ -92,15 +158,12 @@ export default function PauseWidget() {
|
|||||||
aria-controls="pause-menu"
|
aria-controls="pause-menu"
|
||||||
onClick={() => setMenuOpen((open) => !open)}
|
onClick={() => setMenuOpen((open) => !open)}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
className={triggerButtonClass}
|
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Pause
|
Pause
|
||||||
</button>
|
</button>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div
|
<div id="pause-menu" {...stylex.props(styles.menu)}>
|
||||||
id="pause-menu"
|
|
||||||
className="absolute right-0 top-full z-10 mt-1 flex w-36 flex-col rounded border border-zinc-200 bg-white py-1 shadow dark:border-zinc-800 dark:bg-zinc-900"
|
|
||||||
>
|
|
||||||
{DURATIONS.map(({ label, seconds }) => (
|
{DURATIONS.map(({ label, seconds }) => (
|
||||||
<button
|
<button
|
||||||
key={label}
|
key={label}
|
||||||
@@ -111,7 +174,7 @@ export default function PauseWidget() {
|
|||||||
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className={`px-3 py-1.5 text-left text-sm hover:bg-zinc-100 ${insetFocusRing} dark:hover:bg-zinc-800`}
|
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,14 +1,146 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import * as api from "@/lib/api";
|
import * as api from "@/lib/api";
|
||||||
import { formatMicros, formatTime } from "@/lib/format";
|
import { formatMicros, formatTime } from "@/lib/format";
|
||||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||||
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
||||||
import { qtypeName } from "./qtype";
|
import { qtypeName } from "./qtype";
|
||||||
import { buttonClass, smallInputClass, tableWrapClass } from "@/ui/classes";
|
import Select from "@/ui/Select";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
const filterInputClass = `mt-1 w-full ${smallInputClass}`;
|
const DARK = "@media (prefers-color-scheme: dark)";
|
||||||
const toolbarButtonClass = `${buttonClass} font-medium`;
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: "any", label: "All" },
|
||||||
|
{ value: "blocked", label: "Blocked only" },
|
||||||
|
{ value: "allowed", label: "Allowed only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
/** One column on a phone, two from `sm`, five from `lg`, as before. */
|
||||||
|
filterGrid: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
display: "grid",
|
||||||
|
gap: "0.75rem",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
default: "repeat(1, minmax(0, 1fr))",
|
||||||
|
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
||||||
|
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
filterLabel: {
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
filterInput: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: "0.5rem",
|
||||||
|
gridColumn: {
|
||||||
|
default: null,
|
||||||
|
"@media (min-width: 640px)": "span 2 / span 2",
|
||||||
|
"@media (min-width: 1024px)": "span 5 / span 5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toolbarButton: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
tableWrap: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
overflowX: "auto",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
/** The header tint is a shade off the ground in each scheme, not a token role. */
|
||||||
|
head: {
|
||||||
|
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
th: {
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||||
|
row: {
|
||||||
|
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||||
|
borderTopStyle: "solid",
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
cell: {
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
},
|
||||||
|
nowrap: {
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
breakAll: {
|
||||||
|
wordBreak: "break-all",
|
||||||
|
},
|
||||||
|
small: {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
blockedWrap: {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
},
|
||||||
|
blockedBadge: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
paddingInline: "0.375rem",
|
||||||
|
paddingBlock: "0.125rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
|
||||||
|
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
moreError: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.dangerText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
@@ -21,13 +153,11 @@ function datetimeLocalToUnix(value: string): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_reason"> }) {
|
export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_reason"> }) {
|
||||||
if (!row.blocked) return <span className="text-zinc-400">—</span>;
|
if (!row.blocked) return <span {...stylex.props(styles.muted)}>—</span>;
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span {...stylex.props(styles.blockedWrap)}>
|
||||||
<span className="rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900 dark:text-red-200">
|
<span {...stylex.props(styles.blockedBadge)}>Blocked</span>
|
||||||
Blocked
|
{row.block_reason !== "" && <span {...stylex.props(styles.small, styles.muted)}>{row.block_reason}</span>}
|
||||||
</span>
|
|
||||||
{row.block_reason !== "" && <span className="text-xs text-zinc-500">{row.block_reason}</span>}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -35,37 +165,38 @@ export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_re
|
|||||||
export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) {
|
export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td className="px-3 py-2 whitespace-nowrap text-zinc-500">{formatTime(row.ts)}</td>
|
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||||
<td className="px-3 py-2 font-mono text-xs break-all">{row.domain}</td>
|
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td>
|
||||||
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{row.client_ip}</td>
|
<td {...stylex.props(styles.cell, styles.small, styles.nowrap, shared.mono)}>{row.client_ip}</td>
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{qtypeName(row.qtype)}</td>
|
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
||||||
<td className="px-3 py-2">
|
<td {...stylex.props(styles.cell)}>
|
||||||
<BlockedCell row={row} />
|
<BlockedCell row={row} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 whitespace-nowrap tabular-nums">
|
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||||
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 whitespace-nowrap">
|
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||||
{row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"}
|
{row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 font-mono text-xs break-all">{row.upstream === "" ? "—" : row.upstream}</td>
|
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||||
|
{row.upstream === "" ? "—" : row.upstream}
|
||||||
|
</td>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueryTableHead() {
|
export function QueryTableHead() {
|
||||||
const th = "px-3 py-2 font-medium text-zinc-600 dark:text-zinc-400";
|
|
||||||
return (
|
return (
|
||||||
<thead className="bg-zinc-50 text-left dark:bg-zinc-900">
|
<thead {...stylex.props(styles.head)}>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={th}>Time</th>
|
<th {...stylex.props(styles.th)}>Time</th>
|
||||||
<th className={th}>Domain</th>
|
<th {...stylex.props(styles.th)}>Domain</th>
|
||||||
<th className={th}>Client</th>
|
<th {...stylex.props(styles.th)}>Client</th>
|
||||||
<th className={th}>Type</th>
|
<th {...stylex.props(styles.th)}>Type</th>
|
||||||
<th className={th}>Status</th>
|
<th {...stylex.props(styles.th)}>Status</th>
|
||||||
<th className={th}>Response</th>
|
<th {...stylex.props(styles.th)}>Response</th>
|
||||||
<th className={th}>Cache</th>
|
<th {...stylex.props(styles.th)}>Cache</th>
|
||||||
<th className={th}>Upstream</th>
|
<th {...stylex.props(styles.th)}>Upstream</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
);
|
);
|
||||||
@@ -124,66 +255,65 @@ export default function QueryLogPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1 className="text-2xl font-semibold">Query Log</h1>
|
<h1 {...stylex.props(styles.heading)}>Query Log</h1>
|
||||||
|
|
||||||
<form onSubmit={applyFilters} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
<form onSubmit={applyFilters} {...stylex.props(styles.filterGrid)}>
|
||||||
<label className="block text-sm">
|
<label {...stylex.props(styles.filterLabel)}>
|
||||||
Domain contains
|
Domain contains
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={domain}
|
value={domain}
|
||||||
onChange={(event) => setDomain(event.target.value)}
|
onChange={(event) => setDomain(event.target.value)}
|
||||||
className={filterInputClass}
|
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block text-sm">
|
<label {...stylex.props(styles.filterLabel)}>
|
||||||
Client (exact)
|
Client (exact)
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={client}
|
value={client}
|
||||||
onChange={(event) => setClient(event.target.value)}
|
onChange={(event) => setClient(event.target.value)}
|
||||||
className={filterInputClass}
|
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block text-sm">
|
<Select
|
||||||
Status
|
variant="compactField"
|
||||||
<select
|
label="Status"
|
||||||
value={blocked}
|
value={blocked}
|
||||||
onChange={(event) => setBlocked(event.target.value)}
|
onChange={setBlocked}
|
||||||
className={filterInputClass}
|
options={STATUS_OPTIONS}
|
||||||
>
|
/>
|
||||||
<option value="any">All</option>
|
<label {...stylex.props(styles.filterLabel)}>
|
||||||
<option value="blocked">Blocked only</option>
|
|
||||||
<option value="allowed">Allowed only</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="block text-sm">
|
|
||||||
Since
|
Since
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={since}
|
value={since}
|
||||||
onChange={(event) => setSince(event.target.value)}
|
onChange={(event) => setSince(event.target.value)}
|
||||||
className={filterInputClass}
|
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block text-sm">
|
<label {...stylex.props(styles.filterLabel)}>
|
||||||
Until
|
Until
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={until}
|
value={until}
|
||||||
onChange={(event) => setUntil(event.target.value)}
|
onChange={(event) => setUntil(event.target.value)}
|
||||||
className={filterInputClass}
|
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5">
|
<div {...stylex.props(styles.buttonRow)}>
|
||||||
<button type="submit" className={toolbarButtonClass}>
|
<button type="submit" {...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}>
|
||||||
Apply filters
|
Apply filters
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={clearFilters} className={toolbarButtonClass}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearFilters}
|
||||||
|
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||||
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
{base.isFetching && (
|
{base.isFetching && (
|
||||||
<span className="text-sm text-zinc-500" role="status">
|
<span {...stylex.props(styles.note)} role="status">
|
||||||
Loading…
|
Loading…
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -191,29 +321,29 @@ export default function QueryLogPage() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{base.data === undefined ? (
|
{base.data === undefined ? (
|
||||||
<p className="mt-6 animate-pulse text-zinc-500" role="status">
|
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||||
Loading query log…
|
Loading query log…
|
||||||
</p>
|
</p>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<p className="mt-6 text-zinc-500">
|
<p {...stylex.props(styles.empty)}>
|
||||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}>
|
<div {...stylex.props(styles.tableWrap)}>
|
||||||
<table className="w-full text-sm">
|
<table {...stylex.props(styles.table)}>
|
||||||
<QueryTableHead />
|
<QueryTableHead />
|
||||||
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.id}>
|
<tr key={row.id} {...stylex.props(styles.row)}>
|
||||||
<QueryCells row={row} />
|
<QueryCells row={row} />
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center gap-3">
|
<div {...stylex.props(styles.footer)}>
|
||||||
<p className="text-sm text-zinc-500">
|
<p {...stylex.props(styles.note)}>
|
||||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||||
{hasMore ? "" : " — end of log"}
|
{hasMore ? "" : " — end of log"}
|
||||||
</p>
|
</p>
|
||||||
@@ -222,14 +352,14 @@ export default function QueryLogPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={loadMore}
|
onClick={loadMore}
|
||||||
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
||||||
className={toolbarButtonClass}
|
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{moreError !== null && (
|
{moreError !== null && (
|
||||||
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-300">
|
<p role="alert" {...stylex.props(styles.moreError)}>
|
||||||
Failed to load more: {moreError}
|
Failed to load more: {moreError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
import { AuthProvider } from "@/auth/store";
|
import { AuthProvider } from "@/auth/store";
|
||||||
@@ -33,16 +33,26 @@ const RESPONSES: Record<string, unknown> = {
|
|||||||
|
|
||||||
// The API orders groups by name, so the id-1 default is not always first.
|
// The API orders groups by name, so the id-1 default is not always first.
|
||||||
let groups: { id: number; name: string; safe_search: boolean }[];
|
let groups: { id: number; name: string; safe_search: boolean }[];
|
||||||
|
let deleted: string[];
|
||||||
|
|
||||||
|
function deleteCalls(): string[] {
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
groups = [
|
groups = [
|
||||||
{ id: 1, name: "Default", safe_search: false },
|
{ id: 1, name: "Default", safe_search: false },
|
||||||
{ id: 2, name: "Kids", safe_search: true },
|
{ id: 2, name: "Kids", safe_search: true },
|
||||||
];
|
];
|
||||||
|
deleted = [];
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
|
if (init?.method === "DELETE") {
|
||||||
|
deleted.push(url);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
if (url === "/api/rules" && init?.method === "POST") {
|
if (url === "/api/rules" && init?.method === "POST") {
|
||||||
return new Response(JSON.stringify({ error: "rate limited" }), {
|
return new Response(JSON.stringify({ error: "rate limited" }), {
|
||||||
status: 429,
|
status: 429,
|
||||||
@@ -75,6 +85,25 @@ function renderRulesRoute() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A RAC Select names its trigger with the current value and then the label, so
|
||||||
|
* the label alone is a suffix match. Opening it is the only way to read the
|
||||||
|
* options: there is no `<select>` carrying them any more.
|
||||||
|
*/
|
||||||
|
function trigger(label: string): HTMLElement {
|
||||||
|
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optionsOf(label: string): Promise<(string | null)[]> {
|
||||||
|
fireEvent.click(trigger(label));
|
||||||
|
const options = await screen.findAllByRole("option");
|
||||||
|
const labels = options.map((option) => option.textContent);
|
||||||
|
// Re-picking the current value closes the listbox and changes nothing.
|
||||||
|
fireEvent.click(options.find((option) => option.getAttribute("aria-selected") === "true") ?? options[0]!);
|
||||||
|
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
test("renders the rule table and the create form with contract enums", async () => {
|
test("renders the rule table and the create form with contract enums", async () => {
|
||||||
renderRulesRoute();
|
renderRulesRoute();
|
||||||
await screen.findByRole("heading", { name: "Rules" });
|
await screen.findByRole("heading", { name: "Rules" });
|
||||||
@@ -87,14 +116,9 @@ test("renders the rule table and the create form with contract enums", async ()
|
|||||||
expect(table.getByText("Kids")).toBeTruthy();
|
expect(table.getByText("Kids")).toBeTruthy();
|
||||||
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
|
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
|
||||||
|
|
||||||
const kindSelect = screen.getByLabelText("Kind") as HTMLSelectElement;
|
expect(await optionsOf("Kind")).toEqual(["exact", "wildcard"]);
|
||||||
expect(Array.from(kindSelect.options).map((o) => o.value)).toEqual(["exact", "wildcard"]);
|
expect(await optionsOf("Action")).toEqual(["allow", "block"]);
|
||||||
|
expect(await optionsOf("Group")).toEqual(["Default", "Kids"]);
|
||||||
const actionSelect = screen.getByLabelText("Action") as HTMLSelectElement;
|
|
||||||
expect(Array.from(actionSelect.options).map((o) => o.value)).toEqual(["allow", "block"]);
|
|
||||||
|
|
||||||
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
|
|
||||||
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Default", "Kids"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rule create shows a countdown when rate limited with Retry-After", async () => {
|
test("rule create shows a countdown when rate limited with Retry-After", async () => {
|
||||||
@@ -116,9 +140,8 @@ test("the group select preselects the id-1 default, not the alphabetically first
|
|||||||
renderRulesRoute();
|
renderRulesRoute();
|
||||||
await screen.findByRole("heading", { name: "Rules" });
|
await screen.findByRole("heading", { name: "Rules" });
|
||||||
|
|
||||||
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
|
expect(await optionsOf("Group")).toEqual(["Attic", "Default"]);
|
||||||
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Attic", "Default"]);
|
expect(trigger("Group").textContent).toContain("Default");
|
||||||
expect(groupSelect.value).toBe("1");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the group select falls back to the first group when the default is absent", async () => {
|
test("the group select falls back to the first group when the default is absent", async () => {
|
||||||
@@ -129,5 +152,29 @@ test("the group select falls back to the first group when the default is absent"
|
|||||||
renderRulesRoute();
|
renderRulesRoute();
|
||||||
await screen.findByRole("heading", { name: "Rules" });
|
await screen.findByRole("heading", { name: "Rules" });
|
||||||
|
|
||||||
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("5");
|
expect(trigger("Group").textContent).toContain("Attic");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete asks for confirmation, and cancelling sends no request", async () => {
|
||||||
|
renderRulesRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Rules" });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
expect(dialog.textContent).toContain('Delete the block rule for "ads.example.com"?');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
|
expect(deleteCalls()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("confirming the delete dialog issues the DELETE for that rule", async () => {
|
||||||
|
renderRulesRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Rules" });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
|
||||||
|
await screen.findByRole("alertdialog");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(deleteCalls()).toEqual(["/api/rules/2"]));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,85 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { formatTime } from "@/lib/format";
|
import { formatTime } from "@/lib/format";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
|
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
|
||||||
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
|
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
|
||||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||||
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
|
import Select from "@/ui/Select";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||||
|
|
||||||
|
const KIND_OPTIONS = [
|
||||||
|
{ value: "exact", label: "exact" },
|
||||||
|
{ value: "wildcard", label: "wildcard" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACTION_OPTIONS = [
|
||||||
|
{ value: "allow", label: "allow" },
|
||||||
|
{ value: "block", label: "block" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
minWidth: "max-content",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
pattern: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
allow: {
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
},
|
||||||
|
block: {
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.75rem",
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
maxWidth: "36rem",
|
||||||
|
},
|
||||||
|
formHeading: {
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
fieldLabel: {
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
/** One column on a phone, three from the `sm` breakpoint, as before. */
|
||||||
|
fieldGrid: {
|
||||||
|
display: "grid",
|
||||||
|
gap: "0.75rem",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
default: "repeat(1, minmax(0, 1fr))",
|
||||||
|
"@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
submitRow: {
|
||||||
|
display: "flex",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function RulesPage() {
|
export default function RulesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: rules } = useSuspenseQuery(rulesQuery());
|
const { data: rules } = useSuspenseQuery(rulesQuery());
|
||||||
@@ -20,6 +92,7 @@ export default function RulesPage() {
|
|||||||
const [kind, setKind] = useState<RuleKind>("exact");
|
const [kind, setKind] = useState<RuleKind>("exact");
|
||||||
const [action, setAction] = useState<RuleAction>("block");
|
const [action, setAction] = useState<RuleAction>("block");
|
||||||
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
|
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
|
||||||
const readOnly = useReadOnlyConfig();
|
const readOnly = useReadOnlyConfig();
|
||||||
|
|
||||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
@@ -30,58 +103,52 @@ export default function RulesPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteRule(rule: Rule) {
|
function confirmDelete() {
|
||||||
if (window.confirm(`Delete the ${rule.action} rule for "${rule.pattern}"?`)) {
|
if (pendingDelete === null) return;
|
||||||
remove.mutate(rule.id);
|
remove.mutate(pendingDelete.id);
|
||||||
}
|
setPendingDelete(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1 className="text-2xl font-semibold">Rules</h1>
|
<h1 {...stylex.props(styles.heading)}>Rules</h1>
|
||||||
|
|
||||||
{rules.length === 0 ? (
|
{rules.length === 0 ? (
|
||||||
<p className="mt-4 text-zinc-500">No allow or block rules yet. Create one below.</p>
|
<p {...stylex.props(styles.empty)}>No allow or block rules yet. Create one below.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={tableWrapClass}>
|
<div {...stylex.props(shared.tableWrap)}>
|
||||||
<table className="w-full min-w-max border-collapse text-sm">
|
<table {...stylex.props(styles.table)}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={thClass}>Pattern</th>
|
<th {...stylex.props(shared.th)}>Pattern</th>
|
||||||
<th className={thClass}>Kind</th>
|
<th {...stylex.props(shared.th)}>Kind</th>
|
||||||
<th className={thClass}>Action</th>
|
<th {...stylex.props(shared.th)}>Action</th>
|
||||||
<th className={thClass}>Group</th>
|
<th {...stylex.props(shared.th)}>Group</th>
|
||||||
<th className={thClass}>Created</th>
|
<th {...stylex.props(shared.th)}>Created</th>
|
||||||
<th className={thClass}>
|
<th {...stylex.props(shared.th)}>
|
||||||
<span className="sr-only">Actions</span>
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
<tr key={rule.id}>
|
<tr key={rule.id}>
|
||||||
<td className={`${tdClass} font-medium`}>{rule.pattern}</td>
|
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
|
||||||
<td className={tdClass}>{rule.kind}</td>
|
<td {...stylex.props(shared.td)}>{rule.kind}</td>
|
||||||
<td className={tdClass}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<span
|
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
|
||||||
className={
|
|
||||||
rule.action === "allow"
|
|
||||||
? "text-green-700 dark:text-green-400"
|
|
||||||
: "text-red-600 dark:text-red-400"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{rule.action}
|
{rule.action}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={tdClass}>{rule.group}</td>
|
<td {...stylex.props(shared.td)}>{rule.group}</td>
|
||||||
<td className={tdClass}>{formatTime(rule.created_at)}</td>
|
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
|
||||||
<td className={tdClass}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => deleteRule(rule)}
|
onClick={() => setPendingDelete(rule)}
|
||||||
disabled={remove.isPending || readOnly}
|
disabled={remove.isPending || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={dangerLinkButtonClass}
|
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -94,10 +161,10 @@ export default function RulesPage() {
|
|||||||
)}
|
)}
|
||||||
<InlineError error={remove.error} />
|
<InlineError error={remove.error} />
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="mt-6 max-w-xl space-y-3">
|
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
||||||
<h2 className="text-lg font-medium">Create rule</h2>
|
<h2 {...stylex.props(styles.formHeading)}>Create rule</h2>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="rule-pattern" className="block text-sm font-medium">
|
<label htmlFor="rule-pattern" {...stylex.props(styles.fieldLabel)}>
|
||||||
Pattern
|
Pattern
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -107,66 +174,54 @@ export default function RulesPage() {
|
|||||||
value={pattern}
|
value={pattern}
|
||||||
onChange={(event) => setPattern(event.target.value)}
|
onChange={(event) => setPattern(event.target.value)}
|
||||||
placeholder="ads.example.com or *.example.com"
|
placeholder="ads.example.com or *.example.com"
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div {...stylex.props(styles.fieldGrid)}>
|
||||||
<div>
|
<Select
|
||||||
<label htmlFor="rule-kind" className="block text-sm font-medium">
|
label="Kind"
|
||||||
Kind
|
value={kind}
|
||||||
</label>
|
onChange={(value) => setKind(value as RuleKind)}
|
||||||
<select
|
options={KIND_OPTIONS}
|
||||||
id="rule-kind"
|
/>
|
||||||
value={kind}
|
<Select
|
||||||
onChange={(event) => setKind(event.target.value as RuleKind)}
|
label="Action"
|
||||||
className={inputClass}
|
value={action}
|
||||||
>
|
onChange={(value) => setAction(value as RuleAction)}
|
||||||
<option value="exact">exact</option>
|
options={ACTION_OPTIONS}
|
||||||
<option value="wildcard">wildcard</option>
|
/>
|
||||||
</select>
|
<Select
|
||||||
</div>
|
label="Group"
|
||||||
<div>
|
value={String(groupId)}
|
||||||
<label htmlFor="rule-action" className="block text-sm font-medium">
|
onChange={(value) => setGroupId(Number(value))}
|
||||||
Action
|
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
|
||||||
</label>
|
/>
|
||||||
<select
|
</div>
|
||||||
id="rule-action"
|
<div {...stylex.props(styles.submitRow)}>
|
||||||
value={action}
|
<button
|
||||||
onChange={(event) => setAction(event.target.value as RuleAction)}
|
type="submit"
|
||||||
className={inputClass}
|
disabled={create.isPending || readOnly}
|
||||||
>
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
<option value="allow">allow</option>
|
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||||
<option value="block">block</option>
|
>
|
||||||
</select>
|
{create.isPending ? "Creating…" : "Create rule"}
|
||||||
</div>
|
</button>
|
||||||
<div>
|
|
||||||
<label htmlFor="rule-group" className="block text-sm font-medium">
|
|
||||||
Group
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="rule-group"
|
|
||||||
value={groupId}
|
|
||||||
onChange={(event) => setGroupId(Number(event.target.value))}
|
|
||||||
className={inputClass}
|
|
||||||
>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<option key={group.id} value={group.id}>
|
|
||||||
{group.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={create.isPending || readOnly}
|
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
|
||||||
className={primaryButtonClass}
|
|
||||||
>
|
|
||||||
{create.isPending ? "Creating…" : "Create rule"}
|
|
||||||
</button>
|
|
||||||
<InlineError error={create.error} />
|
<InlineError error={create.error} />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={pendingDelete !== null}
|
||||||
|
title="Delete rule"
|
||||||
|
message={
|
||||||
|
pendingDelete === null
|
||||||
|
? ""
|
||||||
|
: `Delete the ${pendingDelete.action} rule for "${pendingDelete.pattern}"?`
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setPendingDelete(null)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { useAuthority } from "./authority";
|
import { useAuthority } from "./authority";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
banner: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.warnBorder,
|
||||||
|
backgroundColor: colors.warnSurface,
|
||||||
|
color: colors.warnText,
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File authority is a standing condition, not an event, so this banner has no
|
* File authority is a standing condition, not an event, so this banner has no
|
||||||
* dismiss button: it stays up for as long as the process runs from a file.
|
* dismiss button: it stays up for as long as the process runs from a file.
|
||||||
@@ -8,12 +25,9 @@ export default function ReadOnlyConfigBanner() {
|
|||||||
const authority = useAuthority();
|
const authority = useAuthority();
|
||||||
if (authority?.mode !== "managed_file") return null;
|
if (authority?.mode !== "managed_file") return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div role="status" {...stylex.props(styles.banner)}>
|
||||||
role="status"
|
Configuration is managed by <code {...stylex.props(shared.mono)}>{authority.path}</code>. Edit the file and
|
||||||
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
|
restart nxdns to change it; the server rejects edits made here.
|
||||||
>
|
|
||||||
Configuration is managed by <code className="font-mono">{authority.path}</code>. Edit the file and restart
|
|
||||||
nxdns to change it; the server rejects edits made here.
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,47 @@
|
|||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { dismissRestartBanner, useRestartBanner } from "./restartBanner";
|
import { dismissRestartBanner, useRestartBanner } from "./restartBanner";
|
||||||
import { focusRing } from "@/ui/classes";
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
banner: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.warnBorder,
|
||||||
|
backgroundColor: colors.warnSurface,
|
||||||
|
color: colors.warnText,
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
dismiss: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.warnBorderStrong,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "inherit",
|
||||||
|
paddingInline: "0.5rem",
|
||||||
|
paddingBlock: "0.25rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function RestartBanner() {
|
export default function RestartBanner() {
|
||||||
const raised = useRestartBanner();
|
const raised = useRestartBanner();
|
||||||
if (!raised) return null;
|
if (!raised) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div role="status" {...stylex.props(styles.banner)}>
|
||||||
role="status"
|
<span {...stylex.props(styles.message)}>Changes saved. Restart nxdns to apply.</span>
|
||||||
className="flex items-center gap-3 border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
|
<button type="button" onClick={dismissRestartBanner} {...stylex.props(styles.dismiss, shared.focusRing)}>
|
||||||
>
|
|
||||||
<span className="flex-1">Changes saved. Restart nxdns to apply.</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={dismissRestartBanner}
|
|
||||||
className={`rounded border border-amber-400 px-2 py-1 text-xs ${focusRing} dark:border-amber-700`}
|
|
||||||
>
|
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -130,7 +130,11 @@ test("a changed field enables Save and the PUT body is exactly the diff", async
|
|||||||
test("enum and boolean fields diff as their own types", async () => {
|
test("enum and boolean fields diff as their own types", async () => {
|
||||||
await renderPage();
|
await renderPage();
|
||||||
const logging = screen.getByRole("group", { name: "Logging" });
|
const logging = screen.getByRole("group", { name: "Logging" });
|
||||||
fireEvent.change(within(logging).getByLabelText("level"), { target: { value: "debug" } });
|
// A RAC Select names its trigger with the current value and then the label, and
|
||||||
|
// carries the options only while the listbox is open.
|
||||||
|
fireEvent.click(within(logging).getByRole("button", { name: /level$/ }));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "debug" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||||
fireEvent.click(within(logging).getByLabelText("hide_domains"));
|
fireEvent.click(within(logging).getByLabelText("hide_domains"));
|
||||||
fireEvent.click(saveButton());
|
fireEvent.click(saveButton());
|
||||||
await waitFor(() => expect(putBodies).toHaveLength(1));
|
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { settingsPutMutation, settingsQuery } from "@/lib/queries";
|
import { settingsPutMutation, settingsQuery } from "@/lib/queries";
|
||||||
import { buildSettingsPatch } from "@/lib/settingsDiff";
|
import { buildSettingsPatch } from "@/lib/settingsDiff";
|
||||||
import type { Settings, SettingsPatch } from "@/lib/types";
|
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||||
import { raiseRestartBanner } from "./restartBanner";
|
import { raiseRestartBanner } from "./restartBanner";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
|
import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority";
|
||||||
import { focusRing } from "@/ui/classes";
|
import Select from "@/ui/Select";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
|
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
|
||||||
export function patchRequiresRestart(patch: SettingsPatch): boolean {
|
export function patchRequiresRestart(patch: SettingsPatch): boolean {
|
||||||
@@ -139,8 +142,121 @@ const SECTIONS: readonly AnySectionDef[] = [
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300";
|
const DARK = "@media (prefers-color-scheme: dark)";
|
||||||
const fieldInputClass = `rounded border border-zinc-300 bg-white px-2 py-1 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
intro: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
maxWidth: "48rem",
|
||||||
|
},
|
||||||
|
/** A `fieldset` has a browser default border and padding; the layout wants neither. */
|
||||||
|
sections: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1.5rem",
|
||||||
|
borderStyle: "none",
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
padding: "1rem",
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
paddingInline: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
/** One column on a phone, two from `sm`, as before. */
|
||||||
|
fieldGrid: {
|
||||||
|
display: "grid",
|
||||||
|
gap: "0.75rem",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
default: "repeat(1, minmax(0, 1fr))",
|
||||||
|
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: {
|
||||||
|
default: "oklch(37% 0.013 285.805)",
|
||||||
|
[DARK]: "oklch(87.1% 0.006 286.286)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
checkboxRow: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
field: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.25rem",
|
||||||
|
},
|
||||||
|
fieldInput: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.borderStrong,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
color: colors.text,
|
||||||
|
paddingInline: "0.5rem",
|
||||||
|
paddingBlock: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
derived: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
/** Both notices span the whole grid so the wrapped sentence stays readable. */
|
||||||
|
spanRow: {
|
||||||
|
gridColumn: { default: null, "@media (min-width: 640px)": "span 2 / span 2" },
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
passwordNotice: {
|
||||||
|
color: { default: "oklch(55.5% 0.163 48.998)", [DARK]: "oklch(82.8% 0.189 84.429)" },
|
||||||
|
},
|
||||||
|
mismatchNotice: {
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
submitRow: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
save: {
|
||||||
|
borderStyle: "none",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.375rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
backgroundColor: {
|
||||||
|
default: colors.primary,
|
||||||
|
":disabled": "oklch(87.1% 0.006 286.286)",
|
||||||
|
[DARK]: { default: colors.primary, ":disabled": "oklch(27.4% 0.006 286.033)" },
|
||||||
|
},
|
||||||
|
color: { default: colors.primaryText, ":disabled": "oklch(55.2% 0.016 285.938)" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function FieldRow({
|
function FieldRow({
|
||||||
section,
|
section,
|
||||||
@@ -156,15 +272,15 @@ function FieldRow({
|
|||||||
const id = `${section}.${def.key}`;
|
const id = `${section}.${def.key}`;
|
||||||
if (def.kind === "boolean") {
|
if (def.kind === "boolean") {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div {...stylex.props(styles.checkboxRow)}>
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={value as boolean}
|
checked={value as boolean}
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
className={focusRing}
|
{...stylex.props(shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={id} className={LABEL_CLASS}>
|
<label htmlFor={id} {...stylex.props(styles.label)}>
|
||||||
{def.key}
|
{def.key}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,30 +288,20 @@ function FieldRow({
|
|||||||
}
|
}
|
||||||
if (Array.isArray(def.kind)) {
|
if (Array.isArray(def.kind)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<Select
|
||||||
<label htmlFor={id} className={LABEL_CLASS}>
|
variant="inline"
|
||||||
{def.key}
|
label={def.key}
|
||||||
</label>
|
value={value as string}
|
||||||
<select
|
onChange={onChange}
|
||||||
id={id}
|
options={def.kind.map((option) => ({ value: option, label: option }))}
|
||||||
value={value as string}
|
/>
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className={fieldInputClass}
|
|
||||||
>
|
|
||||||
{def.kind.map((option) => (
|
|
||||||
<option key={option} value={option}>
|
|
||||||
{option}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (def.kind === "number") {
|
if (def.kind === "number") {
|
||||||
const numeric = value as number;
|
const numeric = value as number;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div {...stylex.props(styles.field)}>
|
||||||
<label htmlFor={id} className={LABEL_CLASS}>
|
<label htmlFor={id} {...stylex.props(styles.label)}>
|
||||||
{def.key}
|
{def.key}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -203,14 +309,14 @@ function FieldRow({
|
|||||||
type="number"
|
type="number"
|
||||||
value={Number.isNaN(numeric) ? "" : numeric}
|
value={Number.isNaN(numeric) ? "" : numeric}
|
||||||
onChange={(e) => onChange(e.target.valueAsNumber)}
|
onChange={(e) => onChange(e.target.valueAsNumber)}
|
||||||
className={fieldInputClass}
|
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div {...stylex.props(styles.field)}>
|
||||||
<label htmlFor={id} className={LABEL_CLASS}>
|
<label htmlFor={id} {...stylex.props(styles.label)}>
|
||||||
{def.key}
|
{def.key}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -218,7 +324,7 @@ function FieldRow({
|
|||||||
type="text"
|
type="text"
|
||||||
value={value as string}
|
value={value as string}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
className={fieldInputClass}
|
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -270,16 +376,16 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
<h1 {...stylex.props(styles.heading)}>Settings</h1>
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
<p {...stylex.props(styles.intro)}>
|
||||||
Changes are validated as a whole; every setting requires a restart to take effect.
|
Changes are validated as a whole; every setting requires a restart to take effect.
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={handleSubmit} className="mt-4 max-w-3xl">
|
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
|
||||||
<fieldset disabled={mutation.isPending || readOnly} className="space-y-6">
|
<fieldset disabled={mutation.isPending || readOnly} {...stylex.props(styles.sections)}>
|
||||||
{SECTIONS.map(({ section, title, fields }) => (
|
{SECTIONS.map(({ section, title, fields }) => (
|
||||||
<fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800">
|
<fieldset key={section} {...stylex.props(styles.section)}>
|
||||||
<legend className="px-1 text-sm font-semibold">{title}</legend>
|
<legend {...stylex.props(styles.legend)}>{title}</legend>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div {...stylex.props(styles.fieldGrid)}>
|
||||||
{(fields as readonly AnyFieldDef[]).map((def) => (
|
{(fields as readonly AnyFieldDef[]).map((def) => (
|
||||||
<FieldRow
|
<FieldRow
|
||||||
key={def.key}
|
key={def.key}
|
||||||
@@ -291,12 +397,12 @@ export default function SettingsPage() {
|
|||||||
))}
|
))}
|
||||||
{section === "web" && (
|
{section === "web" && (
|
||||||
<>
|
<>
|
||||||
<p className={LABEL_CLASS}>
|
<p {...stylex.props(styles.label)}>
|
||||||
auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "}
|
auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "}
|
||||||
<span className="text-zinc-500">(derived, read-only)</span>
|
<span {...stylex.props(styles.derived)}>(derived, read-only)</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-1">
|
<div {...stylex.props(styles.field)}>
|
||||||
<label htmlFor="web.password" className={LABEL_CLASS}>
|
<label htmlFor="web.password" {...stylex.props(styles.label)}>
|
||||||
password
|
password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -305,11 +411,11 @@ export default function SettingsPage() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className={fieldInputClass}
|
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div {...stylex.props(styles.field)}>
|
||||||
<label htmlFor="web.password_confirm" className={LABEL_CLASS}>
|
<label htmlFor="web.password_confirm" {...stylex.props(styles.label)}>
|
||||||
confirm password
|
confirm password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -318,17 +424,17 @@ export default function SettingsPage() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={confirm}
|
value={confirm}
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
className={fieldInputClass}
|
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{password !== "" && (
|
{password !== "" && (
|
||||||
<p className="text-sm text-amber-700 sm:col-span-2 dark:text-amber-400">
|
<p {...stylex.props(styles.spanRow, styles.passwordNotice)}>
|
||||||
Changing the password signs out every session; you will be asked to log
|
Changing the password signs out every session; you will be asked to log
|
||||||
in again.
|
in again.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{passwordsMismatch && (
|
{passwordsMismatch && (
|
||||||
<p className="text-sm text-red-700 sm:col-span-2 dark:text-red-400">
|
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
||||||
Passwords do not match.
|
Passwords do not match.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -337,12 +443,12 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-3">
|
<div {...stylex.props(styles.submitRow)}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saveDisabled}
|
disabled={saveDisabled}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={`rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white ${focusRing} disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800`}
|
{...stylex.props(styles.save, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{mutation.isPending ? "Saving…" : "Save"}
|
{mutation.isPending ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import type { Upstream, UpstreamInput } from "@/lib/types";
|
import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||||
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { READ_ONLY_HINT } from "@/features/settings/authority";
|
import { READ_ONLY_HINT } from "@/features/settings/authority";
|
||||||
|
|
||||||
const DEFAULT_PRIORITY = "100";
|
const DEFAULT_PRIORITY = "100";
|
||||||
@@ -16,6 +18,49 @@ interface UpstreamFormProps {
|
|||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
form: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.75rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
maxWidth: "36rem",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
fieldLabel: {
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
checkboxLabel: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) {
|
export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) {
|
||||||
const [url, setUrl] = useState(initial?.url ?? "");
|
const [url, setUrl] = useState(initial?.url ?? "");
|
||||||
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
|
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
|
||||||
@@ -45,10 +90,10 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3">
|
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
|
||||||
<h2 className="text-lg font-medium">{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2>
|
<h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="upstream-url" className="block text-sm font-medium">
|
<label htmlFor="upstream-url" {...stylex.props(styles.fieldLabel)}>
|
||||||
URL
|
URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -58,11 +103,11 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
|
|||||||
value={url}
|
value={url}
|
||||||
onChange={(event) => setUrl(event.target.value)}
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
placeholder="udp://1.1.1.1:53"
|
placeholder="udp://1.1.1.1:53"
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="upstream-priority" className="block text-sm font-medium">
|
<label htmlFor="upstream-priority" {...stylex.props(styles.fieldLabel)}>
|
||||||
Priority
|
Priority
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -71,11 +116,11 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
|
|||||||
min={0}
|
min={0}
|
||||||
value={priority}
|
value={priority}
|
||||||
onChange={(event) => setPriority(event.target.value)}
|
onChange={(event) => setPriority(event.target.value)}
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="upstream-tls-name" className="block text-sm font-medium">
|
<label htmlFor="upstream-tls-name" {...stylex.props(styles.fieldLabel)}>
|
||||||
TLS name
|
TLS name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -84,32 +129,36 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
|
|||||||
value={tlsName}
|
value={tlsName}
|
||||||
onChange={(event) => setTlsName(event.target.value)}
|
onChange={(event) => setTlsName(event.target.value)}
|
||||||
placeholder="one.one.one.one"
|
placeholder="one.one.one.one"
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-zinc-500">
|
<p {...stylex.props(styles.hint)}>
|
||||||
The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme.
|
The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm font-medium">
|
<label {...stylex.props(styles.checkboxLabel)}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
onChange={(event) => setEnabled(event.target.checked)}
|
onChange={(event) => setEnabled(event.target.checked)}
|
||||||
className={focusRing}
|
{...stylex.props(shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
Enabled
|
Enabled
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div {...stylex.props(styles.actions)}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy || readOnly}
|
disabled={busy || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={primaryButtonClass}
|
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{initial === undefined ? "Add upstream" : "Save changes"}
|
{initial === undefined ? "Add upstream" : "Save changes"}
|
||||||
</button>
|
</button>
|
||||||
{onCancel !== undefined && (
|
{onCancel !== undefined && (
|
||||||
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
{...stylex.props(shared.button, styles.cancelButton, shared.focusRing)}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -134,15 +134,29 @@ test("toggling enabled resends the whole row", async () => {
|
|||||||
await screen.findByRole("status");
|
await screen.findByRole("status");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("delete asks for confirmation and skips the request when refused", async () => {
|
/** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
|
||||||
|
async function openDeleteDialog() {
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
|
||||||
|
return await screen.findByRole("alertdialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("delete asks for confirmation and skips the request when cancelled", async () => {
|
||||||
await renderUpstreamsRoute();
|
await renderUpstreamsRoute();
|
||||||
|
|
||||||
vi.spyOn(window, "confirm").mockReturnValue(false);
|
const dialog = await openDeleteDialog();
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
|
expect(dialog.textContent).toContain('Delete upstream "udp://1.1.1.1:53"?');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
expect(calls).toHaveLength(0);
|
expect(calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("confirming the delete dialog issues the DELETE and raises the restart banner", async () => {
|
||||||
|
await renderUpstreamsRoute();
|
||||||
|
|
||||||
|
await openDeleteDialog();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
|
||||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
|
|
||||||
await waitFor(() => expect(calls).toHaveLength(1));
|
await waitFor(() => expect(calls).toHaveLength(1));
|
||||||
expect(calls[0]!.method).toBe("DELETE");
|
expect(calls[0]!.method).toBe("DELETE");
|
||||||
expect(calls[0]!.url).toBe("/api/upstreams/1");
|
expect(calls[0]!.url).toBe("/api/upstreams/1");
|
||||||
@@ -185,14 +199,14 @@ test("a 409 on toggle renders the last-enabled conflict above the form", async (
|
|||||||
test("a 409 on delete renders the last-enabled conflict", async () => {
|
test("a 409 on delete renders the last-enabled conflict", async () => {
|
||||||
await renderUpstreamsRoute();
|
await renderUpstreamsRoute();
|
||||||
|
|
||||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
|
||||||
writeResponse = () =>
|
writeResponse = () =>
|
||||||
new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), {
|
new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), {
|
||||||
status: 409,
|
status: 409,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
|
await openDeleteDialog();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
|
||||||
const alert = await screen.findByRole("alert");
|
const alert = await screen.findByRole("alert");
|
||||||
expect(alert.textContent).toBe("the last enabled upstream cannot be removed");
|
expect(alert.textContent).toBe("the last enabled upstream cannot be removed");
|
||||||
|
|||||||
@@ -1,17 +1,62 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
|
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
|
||||||
import type { Upstream, UpstreamInput } from "@/lib/types";
|
import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||||
import { raiseRestartBanner } from "../settings/restartBanner";
|
import { raiseRestartBanner } from "../settings/restartBanner";
|
||||||
import UpstreamForm from "./UpstreamForm";
|
import UpstreamForm from "./UpstreamForm";
|
||||||
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
intro: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
maxWidth: "42rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
minWidth: "max-content",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
display: "block",
|
||||||
|
maxWidth: "18rem",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
dimWhenDisabled: {
|
||||||
|
opacity: { default: 1, ":disabled": 0.5 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function UpstreamsPage() {
|
export default function UpstreamsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: upstreams } = useSuspenseQuery(upstreamsQuery());
|
const { data: upstreams } = useSuspenseQuery(upstreamsQuery());
|
||||||
const [editing, setEditing] = useState<Upstream | null>(null);
|
const [editing, setEditing] = useState<Upstream | null>(null);
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<Upstream | null>(null);
|
||||||
|
|
||||||
const create = useMutation(upstreamCreateMutation(queryClient));
|
const create = useMutation(upstreamCreateMutation(queryClient));
|
||||||
const save = useMutation(upstreamUpdateMutation(queryClient));
|
const save = useMutation(upstreamUpdateMutation(queryClient));
|
||||||
@@ -39,10 +84,10 @@ export default function UpstreamsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteUpstream(u: Upstream) {
|
function confirmDelete() {
|
||||||
if (window.confirm(`Delete upstream "${u.url}"? Queries stop being forwarded to it.`)) {
|
if (pendingDelete === null) return;
|
||||||
remove.mutate(u.id, { onSuccess: () => raiseRestartBanner() });
|
remove.mutate(pendingDelete.id, { onSuccess: () => raiseRestartBanner() });
|
||||||
}
|
setPendingDelete(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formError = editing === null ? create.error : save.error;
|
const formError = editing === null ? create.error : save.error;
|
||||||
@@ -50,38 +95,38 @@ export default function UpstreamsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1 className="text-2xl font-semibold">Upstreams</h1>
|
<h1 {...stylex.props(styles.heading)}>Upstreams</h1>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-zinc-500">
|
<p {...stylex.props(styles.intro)}>
|
||||||
The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream
|
The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream
|
||||||
health table on the Dashboard reflects the running pool, not this list.
|
health table on the Dashboard reflects the running pool, not this list.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{upstreams.length === 0 ? (
|
{upstreams.length === 0 ? (
|
||||||
<p className="mt-4 text-zinc-500">No upstreams yet. Add one below.</p>
|
<p {...stylex.props(styles.empty)}>No upstreams yet. Add one below.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className={tableWrapClass}>
|
<div {...stylex.props(shared.tableWrap)}>
|
||||||
<table className="w-full min-w-max border-collapse text-sm">
|
<table {...stylex.props(styles.table)}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className={thClass}>URL</th>
|
<th {...stylex.props(shared.th)}>URL</th>
|
||||||
<th className={thClass}>Priority</th>
|
<th {...stylex.props(shared.th)}>Priority</th>
|
||||||
<th className={thClass}>Enabled</th>
|
<th {...stylex.props(shared.th)}>Enabled</th>
|
||||||
<th className={thClass}>TLS name</th>
|
<th {...stylex.props(shared.th)}>TLS name</th>
|
||||||
<th className={thClass}>
|
<th {...stylex.props(shared.th)}>
|
||||||
<span className="sr-only">Actions</span>
|
<span {...stylex.props(shared.srOnly)}>Actions</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{upstreams.map((u) => (
|
{upstreams.map((u) => (
|
||||||
<tr key={u.id}>
|
<tr key={u.id}>
|
||||||
<td className={tdClass}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<span className="block max-w-72 truncate font-medium" title={u.url}>
|
<span {...stylex.props(styles.url)} title={u.url}>
|
||||||
{u.url}
|
{u.url}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={`${tdClass} tabular-nums`}>{u.priority}</td>
|
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
|
||||||
<td className={tdClass}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label={`${u.url} enabled`}
|
aria-label={`${u.url} enabled`}
|
||||||
@@ -89,27 +134,31 @@ export default function UpstreamsPage() {
|
|||||||
disabled={toggle.isPending || readOnly}
|
disabled={toggle.isPending || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
onChange={() => toggleEnabled(u)}
|
onChange={() => toggleEnabled(u)}
|
||||||
className={focusRing}
|
{...stylex.props(shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className={tdClass}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
||||||
<td className={tdClass}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<div className="flex gap-3">
|
<div {...stylex.props(styles.actions)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditing(u)}
|
onClick={() => setEditing(u)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={`${linkButtonClass} disabled:opacity-50`}
|
{...stylex.props(
|
||||||
|
shared.linkButton,
|
||||||
|
styles.dimWhenDisabled,
|
||||||
|
shared.focusRing,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => deleteUpstream(u)}
|
onClick={() => setPendingDelete(u)}
|
||||||
disabled={remove.isPending || readOnly}
|
disabled={remove.isPending || readOnly}
|
||||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||||
className={dangerLinkButtonClass}
|
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -132,6 +181,19 @@ export default function UpstreamsPage() {
|
|||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onCancel={editing === null ? undefined : () => setEditing(null)}
|
onCancel={editing === null ? undefined : () => setEditing(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={pendingDelete !== null}
|
||||||
|
title="Delete upstream"
|
||||||
|
message={
|
||||||
|
pendingDelete === null
|
||||||
|
? ""
|
||||||
|
: `Delete upstream "${pendingDelete.url}"? Queries stop being forwarded to it.`
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setPendingDelete(null)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
import InlineError from "./InlineError";
|
import InlineError from "./InlineError";
|
||||||
|
|
||||||
test("no retry button without onRetry", () => {
|
test("no retry button without onRetry", () => {
|
||||||
@@ -13,7 +15,10 @@ test("onRetry renders a focusable retry button that calls back", () => {
|
|||||||
render(<InlineError error={new ApiError(500, "internal")} onRetry={onRetry} />);
|
render(<InlineError error={new ApiError(500, "internal")} onRetry={onRetry} />);
|
||||||
|
|
||||||
const button = screen.getByRole("button", { name: "Retry" });
|
const button = screen.getByRole("button", { name: "Retry" });
|
||||||
expect(button.className).toContain("focus-visible:outline-2");
|
// The accessibility floor: StyleX compiles the ring to opaque class names, so
|
||||||
|
// the check is that every class `focusRing` produces landed on the button.
|
||||||
|
const ring = (stylex.props(shared.focusRing).className ?? "").split(" ");
|
||||||
|
expect(button.className.split(" ")).toEqual(expect.arrayContaining(ring));
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
import { focusRing } from "@/ui/classes";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
message: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
retry: {
|
||||||
|
borderStyle: "none",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
color: "inherit",
|
||||||
|
fontSize: "inherit",
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with
|
* Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with
|
||||||
@@ -36,12 +56,12 @@ export default function InlineError({ error, onRetry }: { error: unknown; onRetr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p role="alert" className="mt-2 text-sm text-red-600 dark:text-red-400">
|
<p role="alert" {...stylex.props(styles.message)}>
|
||||||
{message}
|
{message}
|
||||||
{onRetry !== undefined && (
|
{onRetry !== undefined && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<button type="button" onClick={onRetry} className={`font-medium underline ${focusRing}`}>
|
<button type="button" onClick={onRetry} {...stylex.props(styles.retry, shared.focusRing)}>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+40
-10
@@ -1,4 +1,5 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import {
|
import {
|
||||||
createRootRouteWithContext,
|
createRootRouteWithContext,
|
||||||
createRoute,
|
createRoute,
|
||||||
@@ -27,16 +28,44 @@ import {
|
|||||||
upstreamHealthQuery,
|
upstreamHealthQuery,
|
||||||
upstreamsQuery,
|
upstreamsQuery,
|
||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
import { retryButtonClass } from "@/ui/classes";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
pending: {
|
||||||
|
padding: "2rem",
|
||||||
|
textAlign: "center",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
margin: "1rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.dangerBorder,
|
||||||
|
backgroundColor: colors.dangerSurface,
|
||||||
|
padding: "1rem",
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontWeight: 600,
|
||||||
|
color: colors.dangerText,
|
||||||
|
},
|
||||||
|
errorDetail: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.dangerText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function RoutePending() {
|
function RoutePending() {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-zinc-500" role="status">
|
<div {...stylex.props(styles.pending)} role="status">
|
||||||
<span className="animate-pulse">Loading…</span>
|
<span {...stylex.props(shared.pulse)}>Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,13 +88,14 @@ function RouteError({ error }: ErrorComponentProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div role="alert" {...stylex.props(styles.errorBox)}>
|
||||||
role="alert"
|
<h2 {...stylex.props(styles.errorTitle)}>{title}</h2>
|
||||||
className="m-4 rounded border border-red-300 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950"
|
<p {...stylex.props(styles.errorDetail)}>{detail}</p>
|
||||||
>
|
<button
|
||||||
<h2 className="font-semibold text-red-800 dark:text-red-200">{title}</h2>
|
type="button"
|
||||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">{detail}</p>
|
onClick={() => void router.invalidate()}
|
||||||
<button type="button" onClick={() => void router.invalidate()} className={retryButtonClass}>
|
{...stylex.props(shared.retryButton, shared.focusRing)}
|
||||||
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+132
-23
@@ -1,13 +1,19 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
|
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { useAuth } from "@/auth/store";
|
import { useAuth } from "@/auth/store";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { versionQuery } from "@/lib/queries";
|
import { versionQuery } from "@/lib/queries";
|
||||||
import PauseWidget from "../features/pause/PauseWidget";
|
import PauseWidget from "../features/pause/PauseWidget";
|
||||||
import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner";
|
import ReadOnlyConfigBanner from "../features/settings/ReadOnlyConfigBanner";
|
||||||
import RestartBanner from "../features/settings/RestartBanner";
|
import RestartBanner from "../features/settings/RestartBanner";
|
||||||
import { buttonClass, focusRing } from "@/ui/classes";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
|
/** The one breakpoint the shell has: below it the sidebar becomes a drawer. */
|
||||||
|
const WIDE = "@media (min-width: 768px)";
|
||||||
|
const DARK = "@media (prefers-color-scheme: dark)";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: "/", label: "Dashboard" },
|
{ to: "/", label: "Dashboard" },
|
||||||
@@ -23,9 +29,115 @@ const NAV_ITEMS = [
|
|||||||
{ to: "/settings", label: "Settings" },
|
{ to: "/settings", label: "Settings" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
navList: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.25rem",
|
||||||
|
},
|
||||||
|
navLink: {
|
||||||
|
display: "block",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.375rem",
|
||||||
|
textDecorationLine: "none",
|
||||||
|
},
|
||||||
|
/** The current page reads as a filled chip, heavier than the hover fill. */
|
||||||
|
navActive: {
|
||||||
|
backgroundColor: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
navIdle: {
|
||||||
|
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||||
|
color: { default: colors.textSecondary, ":hover": colors.text },
|
||||||
|
},
|
||||||
|
versionFooter: {
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.75rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
logoutRow: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
minHeight: "100dvh",
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
color: colors.text,
|
||||||
|
display: { default: "block", [WIDE]: "grid" },
|
||||||
|
gridTemplateColumns: { default: null, [WIDE]: "14rem 1fr" },
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
display: { default: "none", [WIDE]: "flex" },
|
||||||
|
flexDirection: { default: null, [WIDE]: "column" },
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightStyle: "solid",
|
||||||
|
borderRightColor: colors.border,
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "1rem",
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
sidebarNav: {
|
||||||
|
flex: 1,
|
||||||
|
paddingInline: "0.5rem",
|
||||||
|
},
|
||||||
|
column: {
|
||||||
|
display: "flex",
|
||||||
|
minHeight: "100dvh",
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
},
|
||||||
|
narrowOnly: {
|
||||||
|
display: { default: null, [WIDE]: "none" },
|
||||||
|
},
|
||||||
|
narrowBrand: {
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
display: { default: null, [WIDE]: "none" },
|
||||||
|
},
|
||||||
|
headerRight: {
|
||||||
|
marginLeft: "auto",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
drawer: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
display: { default: null, [WIDE]: "none" },
|
||||||
|
},
|
||||||
|
drawerNav: {
|
||||||
|
paddingInline: "0.5rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
},
|
||||||
|
main: {
|
||||||
|
flex: 1,
|
||||||
|
padding: "1rem",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-1">
|
<ul {...stylex.props(styles.navList)}>
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_ITEMS.map((item) => (
|
||||||
<li key={item.to}>
|
<li key={item.to}>
|
||||||
<Link
|
<Link
|
||||||
@@ -34,13 +146,10 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
activeOptions={{ exact: item.to === "/" }}
|
activeOptions={{ exact: item.to === "/" }}
|
||||||
activeProps={{
|
activeProps={{
|
||||||
"aria-current": "page",
|
"aria-current": "page",
|
||||||
className: "bg-zinc-200 font-medium text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50",
|
className: stylex.props(styles.navActive).className,
|
||||||
}}
|
}}
|
||||||
inactiveProps={{
|
inactiveProps={{ className: stylex.props(styles.navIdle).className }}
|
||||||
className:
|
{...stylex.props(styles.navLink, shared.focusRing)}
|
||||||
"text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100",
|
|
||||||
}}
|
|
||||||
className={`block rounded px-3 py-1.5 ${focusRing}`}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -53,7 +162,7 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
function VersionFooter() {
|
function VersionFooter() {
|
||||||
const { data } = useQuery(versionQuery());
|
const { data } = useQuery(versionQuery());
|
||||||
return (
|
return (
|
||||||
<footer className="px-4 py-3 text-xs text-zinc-500">
|
<footer {...stylex.props(styles.versionFooter)}>
|
||||||
{data === undefined ? "nxdns" : `nxdns v${data.version} (${data.git_commit.slice(0, 7)})`}
|
{data === undefined ? "nxdns" : `nxdns v${data.version} (${data.git_commit.slice(0, 7)})`}
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
@@ -65,7 +174,7 @@ function LogoutButton() {
|
|||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
if (authRequired !== true) return null;
|
if (authRequired !== true) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div {...stylex.props(styles.logoutRow)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -75,7 +184,7 @@ function LogoutButton() {
|
|||||||
(logoutError: unknown) => setError(logoutError),
|
(logoutError: unknown) => setError(logoutError),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className={buttonClass}
|
{...stylex.props(shared.button, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Log out
|
Log out
|
||||||
</button>
|
</button>
|
||||||
@@ -87,27 +196,27 @@ function LogoutButton() {
|
|||||||
export default function AppShell() {
|
export default function AppShell() {
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-zinc-50 text-zinc-900 md:grid md:grid-cols-[14rem_1fr] dark:bg-zinc-950 dark:text-zinc-100">
|
<div {...stylex.props(styles.shell)}>
|
||||||
<aside className="hidden border-r border-zinc-200 md:flex md:flex-col dark:border-zinc-800">
|
<aside {...stylex.props(styles.sidebar)}>
|
||||||
<div className="px-4 py-4 text-lg font-semibold">nxdns</div>
|
<div {...stylex.props(styles.brand)}>nxdns</div>
|
||||||
<nav aria-label="Main" className="flex-1 px-2">
|
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
|
||||||
<NavLinks />
|
<NavLinks />
|
||||||
</nav>
|
</nav>
|
||||||
<VersionFooter />
|
<VersionFooter />
|
||||||
</aside>
|
</aside>
|
||||||
<div className="flex min-h-dvh flex-col">
|
<div {...stylex.props(styles.column)}>
|
||||||
<header className="flex items-center gap-3 border-b border-zinc-200 px-4 py-2 dark:border-zinc-800">
|
<header {...stylex.props(styles.header)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${buttonClass} md:hidden`}
|
|
||||||
aria-expanded={drawerOpen}
|
aria-expanded={drawerOpen}
|
||||||
aria-controls="mobile-nav"
|
aria-controls="mobile-nav"
|
||||||
onClick={() => setDrawerOpen((open) => !open)}
|
onClick={() => setDrawerOpen((open) => !open)}
|
||||||
|
{...stylex.props(shared.button, styles.narrowOnly, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Menu
|
Menu
|
||||||
</button>
|
</button>
|
||||||
<span className="text-lg font-semibold md:hidden">nxdns</span>
|
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div {...stylex.props(styles.headerRight)}>
|
||||||
<PauseWidget />
|
<PauseWidget />
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
</div>
|
</div>
|
||||||
@@ -115,14 +224,14 @@ export default function AppShell() {
|
|||||||
<RestartBanner />
|
<RestartBanner />
|
||||||
<ReadOnlyConfigBanner />
|
<ReadOnlyConfigBanner />
|
||||||
{drawerOpen && (
|
{drawerOpen && (
|
||||||
<div id="mobile-nav" className="border-b border-zinc-200 md:hidden dark:border-zinc-800">
|
<div id="mobile-nav" {...stylex.props(styles.drawer)}>
|
||||||
<nav aria-label="Main" className="px-2 py-2">
|
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
|
||||||
<NavLinks onNavigate={() => setDrawerOpen(false)} />
|
<NavLinks onNavigate={() => setDrawerOpen(false)} />
|
||||||
</nav>
|
</nav>
|
||||||
<VersionFooter />
|
<VersionFooter />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<main className="flex-1 p-4">
|
<main {...stylex.props(styles.main)}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+193
-1
@@ -1 +1,193 @@
|
|||||||
@import "tailwindcss";
|
/*
|
||||||
|
* The CSS entry (milestone 23, ruling 1). The StyleX plugin emits the compiled
|
||||||
|
* atomic rules into their own layers; what is left here is the element reset
|
||||||
|
* those rules are written against.
|
||||||
|
*
|
||||||
|
* The reset is not optional and not cosmetic. Tailwind's preflight used to
|
||||||
|
* supply it, so every StyleX style in `web/src` is written assuming border-box
|
||||||
|
* sizing, no default margins, unstyled lists and form controls that inherit
|
||||||
|
* their font. Deleting this block does not restore browser defaults — it
|
||||||
|
* silently changes the meaning of every size and spacing value in the app.
|
||||||
|
* Rules are limited to what this app renders; it is not a general reset.
|
||||||
|
*
|
||||||
|
* The reset lives in its own cascade layer, declared here before StyleX emits
|
||||||
|
* its own. Layer order is priority order, and unlayered author CSS outranks
|
||||||
|
* every layer: leaving these rules unlayered silently beat every StyleX rule in
|
||||||
|
* the app, whatever the selector said.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@layer reset {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This is not a reproduction of Tailwind's default stack, which is gone with
|
||||||
|
* the package; text metrics drift here, which ruling 7 accepts. The symbol
|
||||||
|
* families at the end are not decoration: `Select.tsx` renders U+25BE for its
|
||||||
|
* chevron, and Segoe UI does not carry that glyph, so on Windows the stack
|
||||||
|
* has to reach a font that does before it falls back to a substitute box.
|
||||||
|
*/
|
||||||
|
html {
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
"Segoe UI",
|
||||||
|
Roboto,
|
||||||
|
"Helvetica Neue",
|
||||||
|
Arial,
|
||||||
|
sans-serif,
|
||||||
|
"Segoe UI Symbol",
|
||||||
|
"Noto Sans Symbols 2";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings carry their scale from StyleX, not from the user agent. */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
menu {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links opt into colour and underline; the shell's nav wants neither. */
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp,
|
||||||
|
pre {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Without this the spinner stretches a number input taller than its row. */
|
||||||
|
::-webkit-inner-spin-button,
|
||||||
|
::-webkit-outer-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox draws a red glow on an invalid field; the form shows its own error. */
|
||||||
|
:-moz-ui-invalid {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-indent: 0;
|
||||||
|
border-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Form controls: inherit type and colour, drop the user-agent chrome, and keep
|
||||||
|
* `appearance: button` so iOS Safari honours a button's border radius.
|
||||||
|
*/
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
optgroup,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 0 solid;
|
||||||
|
border-radius: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input[type="button"],
|
||||||
|
input[type="reset"],
|
||||||
|
input[type="submit"] {
|
||||||
|
appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A checkbox keeps its native chrome; the rules above would erase it. */
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
appearance: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* WebKit lays a date/time input out from its own pseudo-elements, and an
|
||||||
|
* empty one comes out shorter than a filled one without these. The query log
|
||||||
|
* filters are two `datetime-local` inputs sitting in a row of controls, so
|
||||||
|
* the height has to hold whether or not a value is set.
|
||||||
|
*/
|
||||||
|
::-webkit-date-and-time-value {
|
||||||
|
min-height: 1lh;
|
||||||
|
text-align: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-datetime-edit {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-datetime-edit-fields-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-datetime-edit,
|
||||||
|
::-webkit-datetime-edit-year-field,
|
||||||
|
::-webkit-datetime-edit-month-field,
|
||||||
|
::-webkit-datetime-edit-day-field,
|
||||||
|
::-webkit-datetime-edit-hour-field,
|
||||||
|
::-webkit-datetime-edit-minute-field,
|
||||||
|
::-webkit-datetime-edit-second-field,
|
||||||
|
::-webkit-datetime-edit-millisecond-field,
|
||||||
|
::-webkit-datetime-edit-meridiem-field {
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::placeholder {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Safari below 16.4 resolves `color-mix` against the wrong colour here and
|
||||||
|
* renders the placeholder invisible. The guard admits every engine that
|
||||||
|
* supports either a non-WebKit feature or one Safari only gained afterwards.
|
||||||
|
*/
|
||||||
|
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
|
||||||
|
::placeholder {
|
||||||
|
color: color-mix(in oklab, currentcolor 50%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An inline SVG leaves a baseline gap under a full-width chart. */
|
||||||
|
svg,
|
||||||
|
img,
|
||||||
|
video,
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden]:not([hidden="until-found"]) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* The destructive-action confirmation (milestone 23, ruling 5), which replaces
|
||||||
|
* every `window.confirm` call in the app.
|
||||||
|
*
|
||||||
|
* `role="alertdialog"` rather than `dialog`: the message is the reason the
|
||||||
|
* dialog exists, so it is announced with the dialog instead of after it. The
|
||||||
|
* overlay is deliberately not dismissable — a delete needs an explicit answer,
|
||||||
|
* and a stray outside click is not one. Escape still cancels.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
||||||
|
import { colors } from "./tokens.stylex";
|
||||||
|
import { styles as shared } from "./styles";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
/** The full sentence the operator reads before confirming; names the entity. */
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
overlay: {
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "1rem",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "26rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.dangerBorder,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
color: colors.text,
|
||||||
|
padding: "1.5rem",
|
||||||
|
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
outlineStyle: "none",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
},
|
||||||
|
dangerButton: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderStyle: "none",
|
||||||
|
backgroundColor: colors.danger,
|
||||||
|
color: colors.primaryText,
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.375rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, onConfirm, onCancel }: Props) {
|
||||||
|
return (
|
||||||
|
<ModalOverlay
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onCancel();
|
||||||
|
}}
|
||||||
|
className={() => stylex.props(styles.overlay).className ?? ""}
|
||||||
|
>
|
||||||
|
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
|
||||||
|
<AriaDialog role="alertdialog" {...stylex.props(styles.body)}>
|
||||||
|
<Heading slot="title" level={2} {...stylex.props(styles.title)}>
|
||||||
|
{title}
|
||||||
|
</Heading>
|
||||||
|
<p {...stylex.props(styles.message)}>{message}</p>
|
||||||
|
<div {...stylex.props(styles.actions)}>
|
||||||
|
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
{...stylex.props(styles.dangerButton, shared.focusRing)}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AriaDialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* The modal dialog (milestone 23, ruling 4).
|
||||||
|
*
|
||||||
|
* React Aria owns the focus trap, the Escape handler and the `aria-modal`
|
||||||
|
* wiring that the hand-rolled overlay only approximated. State is controlled by
|
||||||
|
* the caller because the trigger is a table row button, not a `DialogTrigger`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { Dialog as AriaDialog, Modal, ModalOverlay } from "react-aria-components";
|
||||||
|
import { colors } from "./tokens.stylex";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The dialog's accessible name. */
|
||||||
|
label: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
overlay: {
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "1rem",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "28rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
color: colors.text,
|
||||||
|
padding: "1.5rem",
|
||||||
|
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||||
|
},
|
||||||
|
/** The panel already draws the boundary; the dialog's own ring would double it. */
|
||||||
|
body: {
|
||||||
|
outlineStyle: "none",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Dialog({ label, isOpen, onClose, children }: Props) {
|
||||||
|
return (
|
||||||
|
<ModalOverlay
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onClose();
|
||||||
|
}}
|
||||||
|
isDismissable
|
||||||
|
className={() => stylex.props(styles.overlay).className ?? ""}
|
||||||
|
>
|
||||||
|
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
|
||||||
|
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
|
||||||
|
{children}
|
||||||
|
</AriaDialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* The single-choice picker (milestone 23, ruling 4), replacing every native
|
||||||
|
* `<select>` in the app.
|
||||||
|
*
|
||||||
|
* RAC composes a Select out of Label, Button, SelectValue, Popover, ListBox and
|
||||||
|
* ListBoxItem; those parts are the Select, not extra components adopted beyond
|
||||||
|
* ruling 4's scope, and nothing outside this file imports them.
|
||||||
|
*
|
||||||
|
* Keys are strings because a `<select>`'s value was a string. A call site that
|
||||||
|
* models an id converts on both edges.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { Button, Label, ListBox, ListBoxItem, Popover, Select as AriaSelect, SelectValue } from "react-aria-components";
|
||||||
|
import { colors } from "./tokens.stylex";
|
||||||
|
import { styles as shared } from "./styles";
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
options: readonly SelectOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** The visible label. Omit it only when `aria-label` names the control. */
|
||||||
|
label?: string;
|
||||||
|
"aria-label"?: string;
|
||||||
|
/**
|
||||||
|
* `field` matches a full-width form input, `compactField` the smaller one a
|
||||||
|
* dialog uses, `inline` a control sitting in a row of other controls.
|
||||||
|
*/
|
||||||
|
variant?: "field" | "compactField" | "inline";
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
root: {
|
||||||
|
display: "block",
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "0.5rem",
|
||||||
|
textAlign: "left",
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
compact: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
/** Explicit, so RAC's default `react-aria-SelectValue` class does not land. */
|
||||||
|
value: {
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
chevron: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
width: "var(--trigger-width)",
|
||||||
|
maxHeight: "16rem",
|
||||||
|
overflowY: "auto",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.borderStrong,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
color: colors.text,
|
||||||
|
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||||
|
},
|
||||||
|
listBox: {
|
||||||
|
outlineStyle: "none",
|
||||||
|
paddingBlock: "0.25rem",
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.375rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The inverted row is the listbox convention, and the ring is the milestone-9
|
||||||
|
* floor; an option gets both. RAC focuses the option's own DOM node, so the
|
||||||
|
* shared `:focus-visible` ring does apply here — it is drawn inset because an
|
||||||
|
* option flush against a scrolling popover clips an outset one, and recoloured
|
||||||
|
* because the focus token is the same blue this row just painted behind it.
|
||||||
|
*/
|
||||||
|
itemFocused: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
color: colors.primaryText,
|
||||||
|
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||||
|
},
|
||||||
|
itemSelected: {
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Select({ options, value, onChange, label, "aria-label": ariaLabel, variant = "field" }: Props) {
|
||||||
|
const base = variant === "field" ? shared.input : shared.smallInput;
|
||||||
|
const block = variant === "compactField" ? styles.compact : null;
|
||||||
|
return (
|
||||||
|
<AriaSelect
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
value={value}
|
||||||
|
onChange={(key) => onChange(String(key ?? ""))}
|
||||||
|
{...stylex.props(styles.root)}
|
||||||
|
>
|
||||||
|
{label !== undefined && <Label {...stylex.props(styles.label)}>{label}</Label>}
|
||||||
|
<Button className={() => stylex.props(base, block, styles.trigger, shared.focusRing).className ?? ""}>
|
||||||
|
<SelectValue className={() => stylex.props(styles.value).className ?? ""} />
|
||||||
|
<span aria-hidden="true" {...stylex.props(styles.chevron)}>
|
||||||
|
▾
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||||
|
<ListBox {...stylex.props(styles.listBox)}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<ListBoxItem
|
||||||
|
key={option.value}
|
||||||
|
id={option.value}
|
||||||
|
textValue={option.label}
|
||||||
|
className={({ isFocused, isSelected }) =>
|
||||||
|
stylex.props(
|
||||||
|
styles.item,
|
||||||
|
shared.insetFocusRing,
|
||||||
|
isSelected && styles.itemSelected,
|
||||||
|
isFocused && styles.itemFocused,
|
||||||
|
).className ?? ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</ListBoxItem>
|
||||||
|
))}
|
||||||
|
</ListBox>
|
||||||
|
</Popover>
|
||||||
|
</AriaSelect>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* The tab switcher (milestone 23, ruling 4).
|
||||||
|
*
|
||||||
|
* The hand-rolled version spelled the ARIA attributes by hand but had no
|
||||||
|
* keyboard navigation; React Aria brings arrow-key movement and roving
|
||||||
|
* tabindex with the same roles. State stays inside RAC — no caller needs to
|
||||||
|
* read which tab is open.
|
||||||
|
*
|
||||||
|
* RAC exposes state as render-prop booleans, so every rule below is a
|
||||||
|
* boolean-guarded style object: StyleX cannot express `[data-selected]`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { Tab, TabList, TabPanel, Tabs as AriaTabs } from "react-aria-components";
|
||||||
|
import { colors } from "./tokens.stylex";
|
||||||
|
|
||||||
|
export interface TabSpec {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
content: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The tab list's accessible name. */
|
||||||
|
label: string;
|
||||||
|
tabs: readonly TabSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
list: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
marginBottom: -1,
|
||||||
|
borderBottomWidth: 2,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: "transparent",
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: colors.textMuted,
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
tabSelected: {
|
||||||
|
borderBottomColor: colors.primary,
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
},
|
||||||
|
tabHovered: {
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The milestone-9 focus floor. A Tab is a `div` with a roving tabindex, so
|
||||||
|
* the ring is driven by RAC's `isFocusVisible` rather than `:focus-visible`.
|
||||||
|
*/
|
||||||
|
tabFocusVisible: {
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineStyle: "solid",
|
||||||
|
outlineColor: colors.focus,
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
outlineStyle: "none",
|
||||||
|
},
|
||||||
|
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
|
||||||
|
root: {
|
||||||
|
display: "block",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Tabs({ label, tabs }: Props) {
|
||||||
|
return (
|
||||||
|
<AriaTabs className={() => stylex.props(styles.root).className ?? ""}>
|
||||||
|
<TabList aria-label={label} className={() => stylex.props(styles.list).className ?? ""}>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Tab
|
||||||
|
key={tab.id}
|
||||||
|
id={tab.id}
|
||||||
|
className={({ isSelected, isHovered, isFocusVisible }) =>
|
||||||
|
stylex.props(
|
||||||
|
styles.tab,
|
||||||
|
isHovered && !isSelected && styles.tabHovered,
|
||||||
|
isSelected && styles.tabSelected,
|
||||||
|
isFocusVisible && styles.tabFocusVisible,
|
||||||
|
).className ?? ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</TabList>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabPanel key={tab.id} id={tab.id} className={() => stylex.props(styles.panel).className ?? ""}>
|
||||||
|
{tab.content}
|
||||||
|
</TabPanel>
|
||||||
|
))}
|
||||||
|
</AriaTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* The shared Tailwind class vocabulary (milestone 18, ruling 10).
|
|
||||||
*
|
|
||||||
* Every interactive element must carry `focusRing`; that is the milestone-9
|
|
||||||
* accessibility floor. Compose these constants for one-off variants
|
|
||||||
* (`` `${buttonClass} md:hidden` ``) instead of re-spelling the literal.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
|
|
||||||
|
|
||||||
/** The ring drawn inside the element, for controls flush against a panel edge. */
|
|
||||||
export const insetFocusRing = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600";
|
|
||||||
|
|
||||||
export const inputClass = `mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
|
|
||||||
export const smallInputClass = `rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
|
|
||||||
|
|
||||||
export const buttonClass = `rounded border border-zinc-300 px-3 py-1.5 text-sm ${focusRing} dark:border-zinc-700`;
|
|
||||||
export const smallButtonClass = `rounded border border-zinc-300 px-2 py-1 text-sm ${focusRing} dark:border-zinc-700`;
|
|
||||||
export const largeButtonClass = `rounded border border-zinc-300 px-3 py-2 font-medium ${focusRing} dark:border-zinc-700`;
|
|
||||||
|
|
||||||
export const primaryButtonClass = `rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 ${focusRing}`;
|
|
||||||
export const largePrimaryButtonClass = `rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 ${focusRing}`;
|
|
||||||
|
|
||||||
export const rowButtonClass = `rounded px-2 py-1 text-sm text-blue-600 ${focusRing} dark:text-blue-400`;
|
|
||||||
export const linkButtonClass = `text-sm font-medium text-blue-600 ${focusRing} dark:text-blue-400`;
|
|
||||||
export const dangerLinkButtonClass = `text-sm font-medium text-red-600 disabled:opacity-50 ${focusRing} dark:text-red-400`;
|
|
||||||
export const retryButtonClass = `mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 ${focusRing} dark:border-red-800 dark:text-red-200`;
|
|
||||||
|
|
||||||
export const thClass = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
|
|
||||||
export const tdClass = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
|
|
||||||
export const tableWrapClass = "mt-4 overflow-x-auto";
|
|
||||||
export const formCardClass = "mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800";
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { styles } from "./styles";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The StyleX compile-time transform must run in the vitest pipeline, not just
|
||||||
|
* in `vite build`. Without the plugin these tests do not fail an assertion —
|
||||||
|
* importing the module throws `Unexpected 'stylex.defineVars' call at runtime`
|
||||||
|
* before a single case runs, which is the louder failure of the two
|
||||||
|
* (milestone 23, ruling 1).
|
||||||
|
*/
|
||||||
|
describe("the StyleX build integration", () => {
|
||||||
|
it("compiles stylex.props into a class name", () => {
|
||||||
|
render(
|
||||||
|
<button type="button" {...stylex.props(styles.button, styles.focusRing)}>
|
||||||
|
probe
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
const probe = screen.getByRole("button", { name: "probe" });
|
||||||
|
expect(probe.className).not.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps composed styles distinct from a single style", () => {
|
||||||
|
const one = stylex.props(styles.button).className;
|
||||||
|
const two = stylex.props(styles.button, styles.focusRing).className;
|
||||||
|
expect(one).toBeTruthy();
|
||||||
|
expect(two).not.toBe(one);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* The shared style vocabulary (milestone 23, ruling 3; replaces the Tailwind
|
||||||
|
* class constants of milestone 18, ruling 10).
|
||||||
|
*
|
||||||
|
* Every interactive element must carry `focusRing` or `insetFocusRing`; that
|
||||||
|
* is the milestone-9 accessibility floor. Compose at the call site with
|
||||||
|
* `stylex.props(styles.button, extra)` instead of re-spelling a variant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { colors } from "./tokens.stylex";
|
||||||
|
|
||||||
|
const FOCUS = ":focus-visible";
|
||||||
|
const DISABLED = ":disabled";
|
||||||
|
|
||||||
|
/** The half-fade loop a placeholder runs while its data is in flight. */
|
||||||
|
const pulseFrames = stylex.keyframes({
|
||||||
|
"50%": { opacity: 0.5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const styles = stylex.create({
|
||||||
|
/** The ring drawn outside the element. */
|
||||||
|
focusRing: {
|
||||||
|
outlineWidth: { default: null, [FOCUS]: 2 },
|
||||||
|
outlineStyle: { default: null, [FOCUS]: "solid" },
|
||||||
|
outlineColor: { default: null, [FOCUS]: colors.focus },
|
||||||
|
outlineOffset: { default: null, [FOCUS]: 2 },
|
||||||
|
},
|
||||||
|
/** The ring drawn inside the element, for controls flush against a panel edge. */
|
||||||
|
insetFocusRing: {
|
||||||
|
outlineWidth: { default: null, [FOCUS]: 2 },
|
||||||
|
outlineStyle: { default: null, [FOCUS]: "solid" },
|
||||||
|
outlineColor: { default: null, [FOCUS]: colors.focus },
|
||||||
|
outlineOffset: { default: null, [FOCUS]: -2 },
|
||||||
|
},
|
||||||
|
|
||||||
|
input: {
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.borderStrong,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
color: colors.text,
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
},
|
||||||
|
smallInput: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.borderStrong,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
color: colors.text,
|
||||||
|
paddingInline: "0.5rem",
|
||||||
|
paddingBlock: "0.375rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
|
||||||
|
button: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.borderStrong,
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.375rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
smallButton: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.borderStrong,
|
||||||
|
paddingInline: "0.5rem",
|
||||||
|
paddingBlock: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
largeButton: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.borderStrong,
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
|
||||||
|
primaryButton: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderStyle: "none",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
color: colors.primaryText,
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.375rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||||
|
},
|
||||||
|
largePrimaryButton: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderStyle: "none",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
color: colors.primaryText,
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||||
|
},
|
||||||
|
|
||||||
|
rowButton: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderStyle: "none",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
paddingInline: "0.5rem",
|
||||||
|
paddingBlock: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
},
|
||||||
|
linkButton: {
|
||||||
|
borderStyle: "none",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
},
|
||||||
|
dangerLinkButton: {
|
||||||
|
borderStyle: "none",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
padding: 0,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: colors.danger,
|
||||||
|
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.dangerBorder,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.375rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: colors.dangerText,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Marks an element as waiting on data. Carries no colour of its own. */
|
||||||
|
pulse: {
|
||||||
|
animationName: pulseFrames,
|
||||||
|
animationDuration: "2s",
|
||||||
|
animationTimingFunction: "cubic-bezier(0.4, 0, 0.6, 1)",
|
||||||
|
animationIterationCount: "infinite",
|
||||||
|
},
|
||||||
|
/** Digits of equal width, so a column of counts does not jitter as it updates. */
|
||||||
|
tabularNums: {
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
},
|
||||||
|
/** For a value the operator reads character by character: a domain, an IP, a URL. */
|
||||||
|
mono: {
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||||
|
},
|
||||||
|
/** Visible to a screen reader only; the element keeps its place in the a11y tree. */
|
||||||
|
srOnly: {
|
||||||
|
position: "absolute",
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
padding: 0,
|
||||||
|
margin: -1,
|
||||||
|
overflow: "hidden",
|
||||||
|
clipPath: "inset(50%)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
th: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.borderStrong,
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
textAlign: "left",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
td: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
},
|
||||||
|
tableWrap: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
overflowX: "auto",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Not a faithful port: `space-y-3` set sibling margins on a block container,
|
||||||
|
* this is a flex formatting context. StyleX has no way to write the `> * + *`
|
||||||
|
* selector that `space-y` compiles to, so the rhythm can only come from
|
||||||
|
* `gap`. Child sizing and margin behaviour differ from the Tailwind original;
|
||||||
|
* both call sites are plain vertical form stacks, where they do not.
|
||||||
|
*/
|
||||||
|
formCard: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.75rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
maxWidth: "32rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
padding: "1rem",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* The design tokens (milestone 23, ruling 2).
|
||||||
|
*
|
||||||
|
* Values are the Tailwind 4 zinc/blue/red ramps the app rendered before the
|
||||||
|
* StyleX conversion, carried over verbatim so the palette does not shift.
|
||||||
|
* Each token holds its dark value under `prefers-color-scheme: dark` — the
|
||||||
|
* same media strategy Tailwind 4 defaulted to. There is no theme toggle and
|
||||||
|
* no `data-theme` attribute; the scheme follows the operating system.
|
||||||
|
*
|
||||||
|
* Name tokens by role, never by shade: a call site asks for `border`, not for
|
||||||
|
* zinc-200.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
|
||||||
|
const DARK = "@media (prefers-color-scheme: dark)";
|
||||||
|
|
||||||
|
export const colors = stylex.defineVars({
|
||||||
|
/** The page ground. */
|
||||||
|
surface: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(14.1% 0.005 285.823)" },
|
||||||
|
/** Cards, dialogs and inputs sitting on top of the ground. */
|
||||||
|
surfaceRaised: { default: "#fff", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||||
|
/** The fill under a pointer on a nav item or a menu row. */
|
||||||
|
surfaceHover: { default: "oklch(96.7% 0.001 286.375)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
||||||
|
/** Hairlines between rows and around cards. */
|
||||||
|
border: { default: "oklch(92% 0.004 286.32)", [DARK]: "oklch(27.4% 0.006 286.033)" },
|
||||||
|
/** Control outlines, which need more contrast than a row divider. */
|
||||||
|
borderStrong: { default: "oklch(87.1% 0.006 286.286)", [DARK]: "oklch(37% 0.013 285.805)" },
|
||||||
|
text: { default: "oklch(21% 0.006 285.885)", [DARK]: "oklch(96.7% 0.001 286.375)" },
|
||||||
|
/**
|
||||||
|
* Secondary text. Most call sites were a flat zinc-500 before the conversion,
|
||||||
|
* with no dark override, which measured 4.12:1 on the dark ground and so
|
||||||
|
* failed WCAG AA; the dark value here is zinc-400, which measures 7.56:1.
|
||||||
|
*/
|
||||||
|
textMuted: { default: "oklch(55.2% 0.016 285.938)", [DARK]: "oklch(70.5% 0.015 286.067)" },
|
||||||
|
/**
|
||||||
|
* Secondary text that already adapted before the conversion: zinc-600 on the
|
||||||
|
* light ground, zinc-400 on the dark one. It is a separate token because
|
||||||
|
* `textMuted`'s lighter light value measures 4.62:1 against 7.40:1 here, and
|
||||||
|
* these call sites are small text — table headers, an inactive nav item, a
|
||||||
|
* chart legend — where that loss shows.
|
||||||
|
*/
|
||||||
|
textSecondary: { default: "oklch(44.2% 0.017 285.786)", [DARK]: "oklch(70.5% 0.015 286.067)" },
|
||||||
|
/** Primary actions. Identical in both schemes, as before the conversion. */
|
||||||
|
primary: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(54.6% 0.245 262.881)" },
|
||||||
|
primaryText: { default: "#fff", [DARK]: "#fff" },
|
||||||
|
/** Primary as foreground: lightened in dark so it clears the ground. */
|
||||||
|
primaryOnSurface: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(70.7% 0.165 254.624)" },
|
||||||
|
/**
|
||||||
|
* Warnings: a degraded condition the operator can still act on, as against
|
||||||
|
* `danger`, which is a failure or a destructive action. The amber ramp.
|
||||||
|
*/
|
||||||
|
warnSurface: { default: "oklch(98.7% 0.022 95.277)", [DARK]: "oklch(27.9% 0.077 45.635)" },
|
||||||
|
warnBorder: { default: "oklch(87.9% 0.169 91.605)", [DARK]: "oklch(47.3% 0.137 46.201)" },
|
||||||
|
/** A control's outline inside a warning banner, which the banner border would swallow. */
|
||||||
|
warnBorderStrong: { default: "oklch(82.8% 0.189 84.429)", [DARK]: "oklch(55.5% 0.163 48.998)" },
|
||||||
|
warnText: { default: "oklch(41.4% 0.112 45.904)", [DARK]: "oklch(96.2% 0.059 95.617)" },
|
||||||
|
danger: { default: "oklch(57.7% 0.245 27.325)", [DARK]: "oklch(70.4% 0.191 22.216)" },
|
||||||
|
dangerSurface: { default: "oklch(97.1% 0.013 17.38)", [DARK]: "oklch(25.8% 0.092 26.042)" },
|
||||||
|
dangerBorder: { default: "oklch(80.8% 0.114 19.571)", [DARK]: "oklch(44.4% 0.177 26.899)" },
|
||||||
|
dangerText: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
||||||
|
/** The focus ring colour. The ring itself is a floor, not a variant. */
|
||||||
|
focus: { default: "oklch(54.6% 0.245 262.881)", [DARK]: "oklch(54.6% 0.245 262.881)" },
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider, type QueryClient as QC } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider, type QueryClient as QC } from "@tanstack/react-query";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import { useCrudForm } from "./useCrudForm";
|
import { useCrudForm } from "./useCrudForm";
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
@@ -41,6 +42,50 @@ function setup() {
|
|||||||
return { hook, created, updated, removed };
|
return { hook, created, updated, removed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The delete path is only finished when a real dialog drives it, so this
|
||||||
|
* harness wires the hook's pending-delete state to the component operators
|
||||||
|
* actually see (ruling 5). `window.confirm` no longer exists to stub.
|
||||||
|
*/
|
||||||
|
function DeleteHarness({ onRemoved }: { onRemoved: (id: number) => void }) {
|
||||||
|
const { onDelete, pendingDelete, confirmPendingDelete, cancelPendingDelete } = useCrudForm<Row, string>({
|
||||||
|
create: (_qc: QC) => ({ mutationFn: async () => undefined }),
|
||||||
|
update: (_qc: QC) => ({ mutationFn: async () => undefined }),
|
||||||
|
remove: (_qc: QC) => ({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
onRemoved(id);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
confirmDelete: (row) => `Delete "${row.name}"?`,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => onDelete({ id: 3, name: "gamma" })}>
|
||||||
|
Delete gamma
|
||||||
|
</button>
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={pendingDelete !== null}
|
||||||
|
title="Delete row"
|
||||||
|
message={pendingDelete?.message ?? ""}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={confirmPendingDelete}
|
||||||
|
onCancel={cancelPendingDelete}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeleteHarness() {
|
||||||
|
const removed: number[] = [];
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<DeleteHarness onRemoved={(id) => removed.push(id)} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
test("the create form submits through the create mutation and closes", async () => {
|
test("the create form submits through the create mutation and closes", async () => {
|
||||||
@@ -96,18 +141,34 @@ test("openForm clears a stale create error so the reopened form starts clean", a
|
|||||||
await waitFor(() => expect(hook.result.current.create.error).toBeNull());
|
await waitFor(() => expect(hook.result.current.create.error).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
test("delete asks for confirmation and only removes when confirmed", async () => {
|
test("onDelete opens the confirmation instead of removing the row", () => {
|
||||||
const { hook, removed } = setup();
|
const { hook, removed } = setup();
|
||||||
const confirm = vi.fn(() => false);
|
|
||||||
vi.stubGlobal("confirm", confirm);
|
|
||||||
|
|
||||||
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
|
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
|
||||||
expect(confirm).toHaveBeenCalledWith('Delete "gamma"?');
|
|
||||||
|
expect(hook.result.current.pendingDelete).toEqual({ entity: { id: 3, name: "gamma" }, message: 'Delete "gamma"?' });
|
||||||
expect(removed).toEqual([]);
|
expect(removed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
confirm.mockReturnValue(true);
|
test("cancelling the confirmation dialog leaves the row alone", async () => {
|
||||||
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
|
const removed = renderDeleteHarness();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete gamma" }));
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
expect(dialog.textContent).toContain('Delete "gamma"?');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
|
expect(removed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("confirming the dialog removes the row by id", async () => {
|
||||||
|
const removed = renderDeleteHarness();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete gamma" }));
|
||||||
|
await screen.findByRole("alertdialog");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
await waitFor(() => expect(removed).toEqual([3]));
|
await waitFor(() => expect(removed).toEqual([3]));
|
||||||
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("closeForm closes whichever form is open", () => {
|
test("closeForm closes whichever form is open", () => {
|
||||||
|
|||||||
@@ -4,20 +4,28 @@ import { useMutation, useQueryClient, type QueryClient, type UseMutationOptions
|
|||||||
/** Which form is open: none, the create form, or the edit form for one row. */
|
/** Which form is open: none, the create form, or the edit form for one row. */
|
||||||
export type CrudFormState<Entity> = { mode: "create" } | { mode: "edit"; entity: Entity };
|
export type CrudFormState<Entity> = { mode: "create" } | { mode: "edit"; entity: Entity };
|
||||||
|
|
||||||
|
/** The row a confirmation dialog is open for, and the message it shows. */
|
||||||
|
export interface PendingDelete<Entity> {
|
||||||
|
entity: Entity;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
type MutationFactory<Variables> = (queryClient: QueryClient) => UseMutationOptions<unknown, Error, Variables>;
|
type MutationFactory<Variables> = (queryClient: QueryClient) => UseMutationOptions<unknown, Error, Variables>;
|
||||||
|
|
||||||
export interface CrudFormSpec<Entity extends { id: number }, Input> {
|
export interface CrudFormSpec<Entity extends { id: number }, Input> {
|
||||||
create: MutationFactory<Input>;
|
create: MutationFactory<Input>;
|
||||||
update: MutationFactory<{ id: number; input: Input }>;
|
update: MutationFactory<{ id: number; input: Input }>;
|
||||||
remove: MutationFactory<number>;
|
remove: MutationFactory<number>;
|
||||||
/** The `window.confirm` text shown before a delete. */
|
/** The message the confirmation dialog shows before a delete. */
|
||||||
confirmDelete: (entity: Entity) => string;
|
confirmDelete: (entity: Entity) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The create/edit/delete plumbing shared by the local DNS tabs (ruling 10):
|
* The create/edit/delete plumbing shared by the local DNS tabs (ruling 10):
|
||||||
* three mutations, the open-form state, and the three handlers. The field JSX
|
* three mutations, the open-form state, the pending-delete state and the
|
||||||
* and the table stay in the caller.
|
* handlers. The field JSX, the table and the confirmation dialog stay in the
|
||||||
|
* caller: `onDelete` opens the confirmation instead of deciding anything, so
|
||||||
|
* the hook never reaches for `window.confirm` (ruling 5).
|
||||||
*/
|
*/
|
||||||
export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudFormSpec<Entity, Input>) {
|
export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudFormSpec<Entity, Input>) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -25,6 +33,7 @@ export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudForm
|
|||||||
const update = useMutation(spec.update(queryClient));
|
const update = useMutation(spec.update(queryClient));
|
||||||
const remove = useMutation(spec.remove(queryClient));
|
const remove = useMutation(spec.remove(queryClient));
|
||||||
const [form, setForm] = useState<CrudFormState<Entity> | null>(null);
|
const [form, setForm] = useState<CrudFormState<Entity> | null>(null);
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<PendingDelete<Entity> | null>(null);
|
||||||
|
|
||||||
function openForm(next: CrudFormState<Entity>) {
|
function openForm(next: CrudFormState<Entity>) {
|
||||||
create.reset();
|
create.reset();
|
||||||
@@ -46,9 +55,30 @@ export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudForm
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onDelete(entity: Entity) {
|
function onDelete(entity: Entity) {
|
||||||
if (!window.confirm(spec.confirmDelete(entity))) return;
|
setPendingDelete({ entity, message: spec.confirmDelete(entity) });
|
||||||
remove.mutate(entity.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { create, update, remove, form, openForm, closeForm, onSubmit, onDelete };
|
function confirmPendingDelete() {
|
||||||
|
if (pendingDelete === null) return;
|
||||||
|
remove.mutate(pendingDelete.entity.id);
|
||||||
|
setPendingDelete(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPendingDelete() {
|
||||||
|
setPendingDelete(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
form,
|
||||||
|
openForm,
|
||||||
|
closeForm,
|
||||||
|
onSubmit,
|
||||||
|
onDelete,
|
||||||
|
pendingDelete,
|
||||||
|
confirmPendingDelete,
|
||||||
|
cancelPendingDelete,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-5
@@ -1,11 +1,40 @@
|
|||||||
/// <reference types="vitest/config" />
|
/// <reference types="vitest/config" />
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import stylex from "@stylexjs/unplugin";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { type Plugin, defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig({
|
/**
|
||||||
plugins: [react(), tailwindcss()],
|
* StyleX's Vite plugin polls its CSS store every 150ms from `configureServer`
|
||||||
|
* and pushes an HMR event when it changes. It clears that timer from the close
|
||||||
|
* event of `server.httpServer`. Vitest runs the dev server in middleware mode,
|
||||||
|
* where `httpServer` is null, so the timer is never cleared: the run finishes,
|
||||||
|
* the event loop stays awake, and vitest kills the worker after ten seconds with
|
||||||
|
* "something prevents Vite server from exiting". A test needs StyleX's
|
||||||
|
* transform, which is a different hook, and never needs its HMR — so drop this
|
||||||
|
* one hook under vitest and keep the plugin.
|
||||||
|
*
|
||||||
|
* Remove this once @stylexjs/unplugin clears the timer on `buildEnd` or guards
|
||||||
|
* the hook, and check that `npm test` still exits without the warning.
|
||||||
|
*/
|
||||||
|
function withoutDevServerPolling(plugin: Plugin | Plugin[]): Plugin[] {
|
||||||
|
return (Array.isArray(plugin) ? plugin : [plugin]).map(({ configureServer: _dropped, ...rest }) => rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stylexPlugin(mode: string): Plugin | Plugin[] {
|
||||||
|
// StyleX resolves the `.stylex.ts` theme file itself, so it needs the `@`
|
||||||
|
// alias too — `resolve.alias` below is Vite's and the plugin cannot see it.
|
||||||
|
const configured = stylex.vite({
|
||||||
|
useCSSLayers: true,
|
||||||
|
dev: mode === "development",
|
||||||
|
runtimeInjection: false,
|
||||||
|
aliases: { "@/*": [fileURLToPath(new URL("./src/*", import.meta.url))] },
|
||||||
|
});
|
||||||
|
return process.env.VITEST === undefined ? configured : withoutDevServerPolling(configured);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => ({
|
||||||
|
plugins: [stylexPlugin(mode), react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
@@ -20,5 +49,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
globals: true,
|
globals: true,
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* jsdom implements no `CSS` interface at all, and React Aria's collection code
|
||||||
|
* calls `CSS.escape` unguarded when it looks an item up by key
|
||||||
|
* (`react-aria/dist/private/selection/utils.mjs:22`). Without this, opening a
|
||||||
|
* RAC Select or moving between RAC Tabs throws in tests but works in a browser.
|
||||||
|
*
|
||||||
|
* The implementation is CSSOM's `serialize an identifier` algorithm
|
||||||
|
* (https://drafts.csswg.org/cssom/#serialize-an-identifier), not an
|
||||||
|
* approximation: a wrong escape would silently break the key lookups instead of
|
||||||
|
* failing loudly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function escapeIdentifier(value: string): string {
|
||||||
|
let result = "";
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const code = value.charCodeAt(index);
|
||||||
|
const char = value[index]!;
|
||||||
|
if (code === 0x0000) {
|
||||||
|
result += "�";
|
||||||
|
} else if (
|
||||||
|
(code >= 0x0001 && code <= 0x001f) ||
|
||||||
|
code === 0x007f ||
|
||||||
|
(index === 0 && code >= 0x0030 && code <= 0x0039) ||
|
||||||
|
(index === 1 && code >= 0x0030 && code <= 0x0039 && value.charCodeAt(0) === 0x002d)
|
||||||
|
) {
|
||||||
|
result += `\\${code.toString(16)} `;
|
||||||
|
} else if (index === 0 && code === 0x002d && value.length === 1) {
|
||||||
|
result += `\\${char}`;
|
||||||
|
} else if (
|
||||||
|
code >= 0x0080 ||
|
||||||
|
code === 0x002d ||
|
||||||
|
code === 0x005f ||
|
||||||
|
(code >= 0x0030 && code <= 0x0039) ||
|
||||||
|
(code >= 0x0041 && code <= 0x005a) ||
|
||||||
|
(code >= 0x0061 && code <= 0x007a)
|
||||||
|
) {
|
||||||
|
result += char;
|
||||||
|
} else {
|
||||||
|
result += `\\${char}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalThis.CSS === undefined) {
|
||||||
|
Object.defineProperty(globalThis, "CSS", { value: { escape: escapeIdentifier }, configurable: true });
|
||||||
|
} else if (typeof globalThis.CSS.escape !== "function") {
|
||||||
|
globalThis.CSS.escape = escapeIdentifier;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user