milestone 9: react spa admin ui, frontend ci and embedded dist
This commit is contained in:
+78
-6
@@ -8,6 +8,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
ZIG_VERSION: "0.16.0"
|
ZIG_VERSION: "0.16.0"
|
||||||
|
NODE_VERSION: "24"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -24,6 +25,43 @@ jobs:
|
|||||||
- name: Run test suite (unit + hermetic loopback integration)
|
- name: Run test suite (unit + hermetic loopback integration)
|
||||||
run: zig build test -Dintegration
|
run: zig build test -Dintegration
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: web
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
working-directory: web
|
||||||
|
run: npm run format:check
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
working-directory: web
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
working-directory: web
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
working-directory: web
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: web
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
cross:
|
cross:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
@@ -35,19 +73,42 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: ${{ env.ZIG_VERSION }}
|
version: ${{ env.ZIG_VERSION }}
|
||||||
|
|
||||||
- name: Build static musl executables
|
- name: Set up Node
|
||||||
run: zig build cross
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
- name: Install file(1)
|
- name: Build the web UI
|
||||||
|
working-directory: web
|
||||||
run: |
|
run: |
|
||||||
if ! command -v file >/dev/null 2>&1; then
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# ReleaseSafe because the < 15 MB budget (PLAN §18) is for release
|
||||||
|
# binaries; a Debug build strips to ~25 MB and can never meet it.
|
||||||
|
- name: Build static musl executables
|
||||||
|
run: zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
|
||||||
|
|
||||||
|
- name: Install file(1) and strip tooling
|
||||||
|
run: |
|
||||||
|
missing=""
|
||||||
|
command -v file >/dev/null 2>&1 || missing="$missing file"
|
||||||
|
command -v objcopy >/dev/null 2>&1 || missing="$missing binutils"
|
||||||
|
command -v aarch64-linux-gnu-objcopy >/dev/null 2>&1 || missing="$missing binutils-aarch64-linux-gnu"
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -qq -y file
|
sudo apt-get install -qq -y $missing
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Assert executables exist and are statically linked
|
# The size budget applies to stripped binaries (PLAN §18) and
|
||||||
|
# `zig build cross` does not strip, so the assert measures a
|
||||||
|
# stripped copy and leaves the built artifact untouched.
|
||||||
|
- name: Assert executables are statically linked and within the size budget
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
size_limit=$((15 * 1024 * 1024))
|
||||||
for triple in x86_64-linux-musl aarch64-linux-musl; do
|
for triple in x86_64-linux-musl aarch64-linux-musl; do
|
||||||
binary="zig-out/cross/$triple/nxdns"
|
binary="zig-out/cross/$triple/nxdns"
|
||||||
if [ ! -f "$binary" ]; then
|
if [ ! -f "$binary" ]; then
|
||||||
@@ -63,4 +124,15 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
case "$triple" in
|
||||||
|
x86_64-*) strip_tool=objcopy ;;
|
||||||
|
aarch64-*) strip_tool=aarch64-linux-gnu-objcopy ;;
|
||||||
|
esac
|
||||||
|
"$strip_tool" --strip-all "$binary" "$binary.stripped"
|
||||||
|
size=$(stat -c %s "$binary.stripped")
|
||||||
|
echo "$triple: stripped size $size bytes"
|
||||||
|
if [ "$size" -ge "$size_limit" ]; then
|
||||||
|
echo "stripped executable exceeds the 15 MB budget: $binary"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
.zig-cache/
|
.zig-cache/
|
||||||
zig-out/
|
zig-out/
|
||||||
zig-pkg/
|
zig-pkg/
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
# Milestone 9: React SPA admin UI (PLAN §14, §3.14)
|
||||||
|
|
||||||
|
Goal: the complete admin UI — ten pages per PLAN.md:560, login, pause control, restart
|
||||||
|
banner — built with Vite + React + TypeScript + Tailwind + TanStack Router/Query,
|
||||||
|
embedded in the binary via the existing `-Dweb-dist` pipeline, with a frontend CI job.
|
||||||
|
|
||||||
|
Ground truth: PLAN.md:118-120 (auth UX), :138-140 (stack + embedding), :145/:579 (CI),
|
||||||
|
:454-456 (live view), :531 (restart banner), :560-562 (pages + requirements), :639 (size
|
||||||
|
budget), :641 (upstream health in UI); specs/milestone-8.md W8/W9 As-built (asset
|
||||||
|
pipeline, SPA fallback, --web-dev); src/web/openapi.yaml (the API contract — authorative
|
||||||
|
for every body shape; do NOT guess fields, read it).
|
||||||
|
|
||||||
|
## Rulings (binding)
|
||||||
|
|
||||||
|
1. **Stack per PLAN.md:138, not house style**: Vite + React 19 + TypeScript + Tailwind
|
||||||
|
v4 (CSS-first config, no tailwind.config.js) + TanStack Router + TanStack Query. The
|
||||||
|
reference repos use StyleX and no TanStack; PLAN is the source of truth here. SPA, no
|
||||||
|
SSR, base `/`.
|
||||||
|
2. **Location**: everything under `web/` (PLAN.md:234): `web/package.json`, `web/src/`,
|
||||||
|
`web/index.html`, build output `web/dist/` (gitignored). `web/dist-placeholder/`
|
||||||
|
stays untouched and stays the `-Dweb-dist` default.
|
||||||
|
3. **Toolchain**: node >= 24 (`engines`), npm with committed `web/package-lock.json`.
|
||||||
|
Dependencies exact-pinned (house style). Checks: `tsc --noEmit`, Prettier (house
|
||||||
|
config: tabs, tabWidth 4, printWidth 120, semi, double quotes, trailing commas all),
|
||||||
|
oxlint (zero-config), vitest + @testing-library/react + jsdom for tests. No ESLint,
|
||||||
|
no Biome, no msw — tests stub `fetch`/`EventSource` by hand.
|
||||||
|
4. **No charting dependency.** The dashboard chart is a hand-rolled SVG component
|
||||||
|
(stacked bars for queries/blocked/cached over the fixed zero-filled buckets, axis
|
||||||
|
labels, hover tooltip via native `<title>` + a hover state). The data is small
|
||||||
|
(60-168 buckets) and fixed-shape; a chart library is a liability with no payoff.
|
||||||
|
This is complete, not a stub: axes, tooltip, empty state, responsive width.
|
||||||
|
5. **Router**: code-based route tree (no file-based codegen plugin). Every data route
|
||||||
|
uses a TanStack Router loader that primes TanStack Query (`ensureQueryData`) —
|
||||||
|
PLAN.md:562 "route loaders for initial fetch". Pending/error components on every
|
||||||
|
route; TanStack Query handles cache/retry (no retry on 4xx; retry 429 after
|
||||||
|
Retry-After).
|
||||||
|
6. **API layer**: one `src/lib/api.ts` typed client over `fetch` with
|
||||||
|
`credentials: "same-origin"`. Types in `src/lib/types.ts` transcribed by hand from
|
||||||
|
openapi.yaml (snake_case preserved; no codegen dependency). Error model:
|
||||||
|
`ApiError{status, message, retryAfter?}` from the `{error}` envelope; 503 rendered
|
||||||
|
distinctly from 500 ("server starting/degraded" vs "internal error").
|
||||||
|
7. **Auth flow**: `/login` route outside the app shell. On any 401 the query layer
|
||||||
|
redirects to `/login?redirect=<path>`. Login POST with `auth_required=false` in the
|
||||||
|
response → auth is off → navigate straight in (the login page short-circuits by
|
||||||
|
probing once). Logout button in the shell (hidden when auth is off). Session state
|
||||||
|
lives in a tiny auth store fed by responses, not a poll. Works with auth on AND off
|
||||||
|
(PLAN.md:562) — with auth off the SPA never shows login.
|
||||||
|
8. **Pause control is global**: a shell-header widget (pause/resume with duration
|
||||||
|
presets 60s/5m/30m/indefinite, countdown from `until`, `paused` disambiguates null
|
||||||
|
`until`). Not a page.
|
||||||
|
9. **Live log page**: `EventSource` on `/api/queries/live`; ring buffer of 500 rows in
|
||||||
|
memory, newest first; rows keyed by a monotonically increasing client counter (the
|
||||||
|
SSE payload has NO id). On `error`/close the browser auto-reconnects (server sends
|
||||||
|
`retry: 3000`); on reconnect the page re-syncs the gap via `GET /api/queries`
|
||||||
|
(since = last seen ts) and shows a "stream resumed, N missed" notice. Pause-stream
|
||||||
|
button (client-side freeze) included. 429 from the SSE cap → visible "too many live
|
||||||
|
viewers" state, retry button.
|
||||||
|
10. **Query log page**: keyset pagination exactly per contract (`limit`, `before`,
|
||||||
|
follow `next_before` until null; newest first). Filters: domain substring, client
|
||||||
|
exact, blocked tri-state, since/until (datetime-local inputs → unix seconds).
|
||||||
|
"Load more" appends; filter change resets the cursor.
|
||||||
|
11. **Settings page**: sections rendered from the GET envelope; a diff-based PUT sends
|
||||||
|
ONLY changed fields (partial patch per contract). Every key is restart-required
|
||||||
|
today: after a successful PUT of anything except `web.password`, set a persistent
|
||||||
|
"restart to apply" banner (dismiss resets on next change; PLAN.md:531).
|
||||||
|
`web.password` is a write-only field with confirm input; on change the API kills
|
||||||
|
all sessions — the SPA expects the next request to 401 and routes to login.
|
||||||
|
`web.auth_enabled` is read-only derived. `web.password_hash` is never sent.
|
||||||
|
12. **Blocklists page** shows per-source `skipped_regex_count` (PLAN.md:38) and source
|
||||||
|
status (state, last_success, counts, last_error). "Update now" calls
|
||||||
|
`POST /api/blocklists/update`, REPLACES list state from the 202 snapshot, disables
|
||||||
|
the button while in flight.
|
||||||
|
13. **Dashboard**: stats totals + timeseries chart (period picker 1h/24h/7d/30d),
|
||||||
|
upstream health table (PLAN.md:641), disk card from `/api/health`
|
||||||
|
(state/free/db/log bytes) with the warning banner when `disk.state != "ok"`
|
||||||
|
(PLAN.md:465), plus queries_dropped/writer_failed indicators. Auto-refresh via
|
||||||
|
Query `refetchInterval` 30s (health 10s).
|
||||||
|
14. **Groups page** includes the group↔sources assignment editor
|
||||||
|
(`PUT /api/groups/{id}/sources`, full-set checkboxes) and safe_search toggle.
|
||||||
|
Default group (id 1) shows but blocks rename/delete client-side too (server 409s).
|
||||||
|
15. **Clients page**: table of all clients (`hand_edited` badge), edit name/group, no
|
||||||
|
create (rows appear from DNS activity — say so in the empty state), delete with
|
||||||
|
"re-materializes on next query" note. Client-prefixes editor on the same page:
|
||||||
|
whole-list editing per the PUT contract.
|
||||||
|
16. **Local DNS page**: two tabs (records, forward zones), CRUD forms per contract.
|
||||||
|
**Domain lookup page**: domain + group select → renders the full pipeline verdict
|
||||||
|
(local_records, forward_zone, blocked/reason/matched/source_url,
|
||||||
|
safe_search_rewrite).
|
||||||
|
17. **Errors and loading**: every route has skeleton/pending UI and an error boundary
|
||||||
|
with the ApiError message + retry (PLAN.md:562). Mutations surface 400/409 messages
|
||||||
|
inline at the form, 429 with countdown, 401 via the global redirect.
|
||||||
|
18. **Responsive**: sidebar nav collapses to a top bar + drawer under `md:`; tables get
|
||||||
|
`overflow-x-auto` wrappers; the dashboard grid stacks. Desktop and mobile per
|
||||||
|
PLAN.md:562 — no separate mobile pages.
|
||||||
|
19. **Formatting/l10n**: timestamps rendered in the browser locale from unix seconds
|
||||||
|
(`Intl.DateTimeFormat`), byte counts humanized (KiB/MiB/GiB), µs durations shown as
|
||||||
|
ms with one decimal. One `src/lib/format.ts`, tested.
|
||||||
|
20. **Size budget**: PLAN.md:639 — stripped static binary < 15 MB with assets (< 10 MB
|
||||||
|
without → SPA budget ≈ 5 MB embedded, plain + .gz both count). React+TanStack+
|
||||||
|
Tailwind lands far under that; CI asserts the final cross binaries < 15 MB.
|
||||||
|
21. **Zig side is frozen.** No changes to src/, build.zig, tools/, or openapi.yaml. If
|
||||||
|
the SPA reveals an API bug, STOP and report — do not work around silently. The only
|
||||||
|
Zig-adjacent deliverables are ci.yml additions and `.gitignore` entries.
|
||||||
|
22. **CI**: new `frontend` job (setup-node@v4, `NODE_VERSION: "24"` env pin,
|
||||||
|
`cache: npm`, `cache-dependency-path: web/package-lock.json`; `npm ci` →
|
||||||
|
`prettier --check` → oxlint → `tsc --noEmit` → `vitest run` → `vite build`),
|
||||||
|
uploading `web/dist` as an artifact is NOT needed — instead the existing `cross`
|
||||||
|
job gains: setup-node, `npm ci && npm run build` (prefix web), then
|
||||||
|
`zig build cross -Dweb-dist=web/dist`, then the existing static assert plus a
|
||||||
|
size assert (< 15 MB per exe). The `test` job stays Zig-only.
|
||||||
|
23. **Accessibility floor**: semantic elements, labeled inputs, focus-visible styles,
|
||||||
|
buttons not divs. No ARIA deep-dive beyond what semantics give.
|
||||||
|
24. **No new pages, no dark-mode toggle bikeshed** (Tailwind default palette, system
|
||||||
|
`prefers-color-scheme` via CSS only), no i18n framework, no state library beyond
|
||||||
|
TanStack Query + two tiny stores (auth, restart-banner) in React context.
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
F1 first (scaffold), then F2 (api/lib) sequential on F1, then F3-F8 parallel (pages;
|
||||||
|
disjoint files), then F9 (CI + embed + smoke) after all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session F1: scaffold + shell
|
||||||
|
|
||||||
|
Owns: `web/package.json`, `web/package-lock.json`, `web/index.html`,
|
||||||
|
`web/vite.config.ts`, `web/tsconfig*.json`, `web/.oxlintrc.json` (only if needed),
|
||||||
|
`web/src/main.tsx`, `web/src/routes.tsx` (route tree with lazy page imports and
|
||||||
|
placeholder page stubs F3-F8 replace), `web/src/shell/` (layout, sidebar/topbar nav,
|
||||||
|
`PauseWidget` SLOT — an import of `../features/pause/PauseWidget` that F8 fills; F1
|
||||||
|
ships the real widget file with a disabled placeholder), `web/src/styles.css`
|
||||||
|
(tailwind), `.gitignore` additions (web/dist, web/node_modules), Prettier config in
|
||||||
|
package.json (house values), scripts: dev/build/typecheck/lint/format/test.
|
||||||
|
|
||||||
|
Vite: `@vitejs/plugin-react`, tailwind v4 via `@tailwindcss/vite`, build target
|
||||||
|
baseline-widely-available (vite 8 default), no proxy needed for build; dev proxy
|
||||||
|
`/api` + `/metrics` → `http://127.0.0.1:8080` for `vite dev` against a running nxdns.
|
||||||
|
Route tree: `/login` bare; shell routes `/`, `/queries`, `/live`, `/clients`,
|
||||||
|
`/groups`, `/blocklists`, `/rules`, `/local-dns`, `/lookup`, `/settings`.
|
||||||
|
|
||||||
|
Acceptance: `npm ci && npm run build` produces `web/dist` with `/index.html`;
|
||||||
|
typecheck/lint/format clean; placeholder pages render; nav works with keyboard.
|
||||||
|
|
||||||
|
### F1 As built
|
||||||
|
|
||||||
|
Pinned: react 19.2.8, @tanstack/react-router 1.170.18, @tanstack/react-query 5.101.4,
|
||||||
|
vite 8.1.5, typescript 6.0.3, tailwindcss 4.3.3 (@tailwindcss/vite), vitest 4.1.10,
|
||||||
|
@testing-library/react 16.3.2, jsdom 29.1.1, prettier 3.9.6, oxlint 1.75.0. All exact.
|
||||||
|
~/.npmrc enforces min-release-age=7 (newest gate-clearing versions chosen) and
|
||||||
|
ignore-scripts=true (works; native bins are optionalDependencies). TS6 deprecates
|
||||||
|
baseUrl — tsconfig.app.json uses `paths` without it. Dist: 344K (main chunk 278.5 kB /
|
||||||
|
88.2 kB gz, 11 lazy page chunks, CSS 8.8 kB). `zig build -Dweb-dist=web/dist` exit 0.
|
||||||
|
- Alias `@/*` → `web/src/*` (tsconfig paths + vite resolve.alias).
|
||||||
|
- Router: code-based in src/routes.tsx; `createAppRouter(history?)` exported (tests
|
||||||
|
pass createMemoryHistory); `Register` declared so `Link to` is typed; pages wired
|
||||||
|
via lazyRouteComponent at stable paths — every page keeps a default export at its
|
||||||
|
path; page sessions never edit routes.tsx. Shell = pathless layout route id "shell";
|
||||||
|
/login hangs off root outside it; defaultPreload "intent".
|
||||||
|
- Shell: AppShell renders PauseWidget (default export, no props) from
|
||||||
|
features/pause/PauseWidget, header right; F8 replaces the file in place.
|
||||||
|
VersionFooter inside AppShell.tsx (F2 wires /api/version). Restart banner has no
|
||||||
|
premade slot — F8 mounts it in AppShell (sequential edit). Nav: aria-current +
|
||||||
|
activeProps; drawer under md: with aria-expanded/controls.
|
||||||
|
- Vitest lives in vite.config.ts (jsdom, globals: true; tsconfig types include
|
||||||
|
vitest/globals). lint = `oxlint src vite.config.ts`; prettier ignores dist/,
|
||||||
|
dist-placeholder/, package-lock.json (.prettierignore).
|
||||||
|
- Page filenames: QueryLogPage/LiveLogPage per F4 naming; LoginPage placeholder in
|
||||||
|
src/auth/ (F2 replaces).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session F2: API layer + auth + query plumbing
|
||||||
|
|
||||||
|
Owns: `web/src/lib/` (`api.ts`, `types.ts`, `format.ts`, `queryClient.ts`,
|
||||||
|
`queries.ts` — queryOptions per resource, mutation helpers with invalidation),
|
||||||
|
`web/src/auth/` (store, `LoginPage`, 401 redirect wiring), tests for api/format/
|
||||||
|
pagination/settings-diff helpers.
|
||||||
|
|
||||||
|
types.ts transcribed from src/web/openapi.yaml — every shape the pages consume
|
||||||
|
(QueryRow, QueriesPage, StatsTotals, Timeseries, Lookup, UpstreamHealth, Health,
|
||||||
|
Version, Group, Blocklist, SourceStatus, Rule, LocalRecord, ForwardZone, Client,
|
||||||
|
ClientPrefix, Upstream, SettingsEnvelope, Pause, Login). ApiError per ruling 6;
|
||||||
|
401 hook per ruling 7; 429 retry per ruling 5; settings diff builder per ruling 11
|
||||||
|
(pure function, tested).
|
||||||
|
|
||||||
|
Acceptance: vitest green; typecheck clean; login/logout round trip works against a
|
||||||
|
live `nxdns run` (manual smoke; document the transcript).
|
||||||
|
|
||||||
|
### F2 As built
|
||||||
|
|
||||||
|
lib/{types,api,queryClient,queries,format,settingsDiff}.ts + tests (22 green);
|
||||||
|
auth/{store.tsx,LoginPage.tsx}; routes.tsx gained context+loaders+default
|
||||||
|
pending/error components; main.tsx providers; AppShell gained logout button + live
|
||||||
|
VersionFooter. Live-server smoke verified every consumed shape with auth on AND off;
|
||||||
|
no API bugs. Main chunk 319 kB (100 kB gz).
|
||||||
|
- api.ts: `request<T>` core (same-origin, 204→void, `{error}` envelope, Retry-After on
|
||||||
|
429), `requestText`, functions for all 55 route-method pairs, list envelopes
|
||||||
|
unwrapped to arrays, `liveQueriesUrl` for F4.
|
||||||
|
- queryClient: staleTime 30s; no retry on 4xx except 429 (max 2, delay=retryAfter);
|
||||||
|
QueryCache+MutationCache onError → `/login?redirect=<path+search>` on 401 (skipped
|
||||||
|
on /login).
|
||||||
|
- queries.ts factories: healthQuery (10s refetch), versionQuery, statsQuery/
|
||||||
|
timeseriesQuery(period), upstreamHealthQuery (30s), queriesQuery(filter),
|
||||||
|
lookupQuery(domain, groupId?), groupsQuery, groupSourcesQuery(id), blocklistsQuery,
|
||||||
|
rulesQuery, localRecordsQuery, forwardZonesQuery, clientsQuery, clientPrefixesQuery,
|
||||||
|
upstreamsQuery, pauseQuery, settingsQuery. Mutations: `xxxMutation(queryClient)` →
|
||||||
|
useMutation options; invalidation map in the F2 report; NOTE
|
||||||
|
`blocklistsUpdateNowMutation` seeds `queryKeys.blocklistSources` from the 202
|
||||||
|
snapshot — the ONLY feed for source status (no GET exists); F6 reads that key.
|
||||||
|
- Auth store: `useAuth()` → {authRequired: bool|null, probe(), login(password),
|
||||||
|
logout()}. Probe = POST login with empty password (no status GET), StrictMode-deduped;
|
||||||
|
authRequired mirrored in sessionStorage `nxdns_auth_required`.
|
||||||
|
- Loaders prime: dashboard stats+timeseries("24h")+health+upstreamHealth; queries
|
||||||
|
`queriesQuery({})` (F4 "load more" calls api.getQueries imperatively and appends);
|
||||||
|
clients clients+prefixes+groups; groups groups+blocklists; rules rules+groups;
|
||||||
|
local-dns records+zones; lookup groups; settings settings; live none. Pages use the
|
||||||
|
same factory the loader primed (useSuspenseQuery/useQuery).
|
||||||
|
- Router error component is ApiError-aware (503 "starting or degraded", 429 countdown,
|
||||||
|
≥500 generic); Retry = router.invalidate().
|
||||||
|
- Page-test pattern: wrap in AuthProvider+QueryClientProvider, pass the same qc to
|
||||||
|
`createAppRouter(history, qc)`, stub fetch per-URL (see AppShell.test.tsx).
|
||||||
|
- api.ts sends `{}` on bodyless POSTs (logout, blocklists/update) — belt-and-braces
|
||||||
|
over the W9 server fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session F3: Dashboard + SVG chart
|
||||||
|
|
||||||
|
Owns: `web/src/features/dashboard/` (page, StatCards, TimeseriesChart (SVG, ruling 4),
|
||||||
|
UpstreamHealthTable, DiskCard, HealthBanners) + chart unit tests (bucket→bar math,
|
||||||
|
empty state).
|
||||||
|
|
||||||
|
## Session F4: Query log + Live log
|
||||||
|
|
||||||
|
Owns: `web/src/features/queries/` (QueryLogPage, filters, cursor pagination per ruling
|
||||||
|
10) and `web/src/features/live/` (LiveLogPage per ruling 9: EventSource wrapper hook
|
||||||
|
with injected EventSource for tests, ring buffer, gap re-sync, freeze, cap-hit state).
|
||||||
|
Tests: ring buffer, gap-resync math, EventSource hook with a fake.
|
||||||
|
|
||||||
|
## Session F5: Clients + Groups
|
||||||
|
|
||||||
|
Owns: `web/src/features/clients/` (table, edit dialog, prefixes editor per ruling 15)
|
||||||
|
and `web/src/features/groups/` (list, create/rename/delete, safe_search, sources
|
||||||
|
assignment per ruling 14).
|
||||||
|
|
||||||
|
## Session F6: Blocklists + Rules
|
||||||
|
|
||||||
|
Owns: `web/src/features/blocklists/` (ruling 12) and `web/src/features/rules/`
|
||||||
|
(table with group/kind/action columns, create form with pattern kind select, delete).
|
||||||
|
|
||||||
|
## Session F7: Local DNS + Lookup
|
||||||
|
|
||||||
|
Owns: `web/src/features/local/` (records + zones tabs, CRUD forms) and
|
||||||
|
`web/src/features/lookup/` (ruling 16).
|
||||||
|
|
||||||
|
## Session F8: Settings + Pause widget
|
||||||
|
|
||||||
|
Owns: `web/src/features/settings/` (ruling 11: section forms, diff PUT, restart
|
||||||
|
banner store + banner component mounted in the shell via F1's slot, password flow)
|
||||||
|
and `web/src/features/pause/PauseWidget.tsx` (REPLACES F1's placeholder; ruling 8).
|
||||||
|
Tests: diff builder edge cases (nested partial, password only, no-op), banner logic.
|
||||||
|
|
||||||
|
### F3-F8 As built (page wave)
|
||||||
|
|
||||||
|
All six sessions green; 100 web tests total after the wave; main chunk 318 kB
|
||||||
|
(99 kB gz), pages as lazy chunks.
|
||||||
|
- **F3 Dashboard**: chartLayout.ts pure layout (layoutTimeseries, niceTicks 1/2/5) +
|
||||||
|
TimeseriesChart({data}) — self-measuring SVG stacked bars (blocked/cached/other,
|
||||||
|
other = queries−blocked−cached clamped ≥0), sr-only data table, role="img".
|
||||||
|
Axis ticks use a short Intl formatter (formatTime is tooltip/sr-only only).
|
||||||
|
UpstreamHealthTable shows total_failures; success_rate ×100 (0..1 verified in
|
||||||
|
pool.zig). HealthBanners: disk warn/critical (critical text per PLAN.md:466),
|
||||||
|
writer_failed, queries_dropped>0, all role="alert". 12 tests.
|
||||||
|
- **F4 Query/Live**: QueryLogPage exports QueryCells/QueryTableHead/BlockedCell,
|
||||||
|
reused by LiveLogPage (both F4-owned). qtype.ts names 19 codes, TYPE<n> fallback.
|
||||||
|
Filters in component state (not URL). ringBuffer.ts: 500 newest-first + mergeGap
|
||||||
|
(dedup key ts|domain|client_ip|qtype|blocked|upstream — SSE rows have no id;
|
||||||
|
same-second identical queries can over-dedup, accepted at household scale).
|
||||||
|
useLiveQueries hook (injected EventSourceLike factory + fetchSince): states
|
||||||
|
connecting/open/retrying/capped, gap re-sync getQueries({since, limit:500}) —
|
||||||
|
gaps >500 replace the buffer wholesale; CAP_ERROR_THRESHOLD=3 consecutive errors
|
||||||
|
→ capped state (EventSource cannot see 429; message says cap OR unreachable),
|
||||||
|
open resets the counter. Freeze = display-only snapshot; the ring keeps filling;
|
||||||
|
Resume swaps to current. 25 tests.
|
||||||
|
- **F5 Clients/Groups**: prefixEditor.ts pure reducer (priority "" omitted → server
|
||||||
|
default 100); baseline resets only on save/discard (dirty edits never clobbered by
|
||||||
|
refetch). clientUpdateMutation takes {id, edit} (spec prompt said input — code
|
||||||
|
wins). Groups: safe_search toggle PUTs with unchanged name (server 409s only on
|
||||||
|
actual rename of default — verified in groups.zig); default group protections
|
||||||
|
client-side + visible note; sourceSet.ts toggle/sameSet. 18 tests.
|
||||||
|
- **F6 Blocklists/Rules**: rule enums confirmed kind=[exact,wildcard],
|
||||||
|
action=[allow,block]. Status section reads queryKeys.blocklistSources via
|
||||||
|
queryClient.getQueryData at render (no GET exists; mutation setQueryData
|
||||||
|
re-renders); edit PUT preserves is_suggested. 3 tests.
|
||||||
|
- **F7 Local/Lookup**: rtype=[A,AAAA,CNAME], ttl optional default 300. SPEC
|
||||||
|
CORRECTION: Lookup.local_records is a BOOLEAN (ruling 16 said "list" loosely) —
|
||||||
|
rendered Yes/No; verdict priority local > blocked > forwarded > allowed (pipeline
|
||||||
|
order per lookup.zig), verdictOf exported. Local mutations live, no banner.
|
||||||
|
4 tests.
|
||||||
|
- **F8 Settings/Pause**: patchRequiresRestart(patch) exported — banner raised for
|
||||||
|
any patch except bare web.password; restartBanner.ts is a useSyncExternalStore
|
||||||
|
MODULE store (accepted deviation from ruling 24's "context": keeps the AppShell
|
||||||
|
edit to import + mount). Number inputs NaN-guard blocks Save. PauseWidget:
|
||||||
|
refetchInterval 5s only while paused, 1s countdown, formatRemaining exported.
|
||||||
|
16 tests.
|
||||||
|
- **Orchestrator integration**: the wave produced three near-identical inline-error
|
||||||
|
components (clients+groups InlineError, local FormError) — hoisted post-wave to
|
||||||
|
`src/lib/InlineError.tsx` (the richer variant: 400/409 verbatim, 429 countdown,
|
||||||
|
503 distinct, non-Error → "could not reach"), seven imports updated, duplicates
|
||||||
|
deleted. Dashboard's InlineError (query-level, onRetry) is a different component
|
||||||
|
and stays local. Full chain re-verified green after the hoist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session F9: CI + embed + smoke (after F3-F8)
|
||||||
|
|
||||||
|
Owns: `.gitea/workflows/ci.yml` (ruling 22). Steps: verify `npm ci` reproducibility,
|
||||||
|
frontend job, cross-job additions with `-Dweb-dist=web/dist` + size assert.
|
||||||
|
Smoke (report transcript): `npm run build`; `zig build -Dweb-dist=web/dist`; boot;
|
||||||
|
curl `/` returns the SPA index (not the placeholder); a deep link (`/settings`)
|
||||||
|
returns 200 index; an asset serves gzip with ETag/304; login via browser-shaped curl
|
||||||
|
flow still works; SIGTERM 0. Also `zig build test` and `-Dintegration` still green
|
||||||
|
(the Zig tests embed the dist too — W10 static tests must still pass against the SPA
|
||||||
|
dist; if one pins placeholder content, report it, do not edit Zig).
|
||||||
|
|
||||||
|
### F9 As built
|
||||||
|
|
||||||
|
ci.yml +77/-5: top-level `NODE_VERSION: "24"`; new `frontend` job (setup-node@v4,
|
||||||
|
cache npm on web/package-lock.json; npm ci → format:check → lint → typecheck →
|
||||||
|
`npm test` → build); `cross` job builds the SPA then
|
||||||
|
`zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe` — RULED deviation:
|
||||||
|
build.zig has no strip option and Debug exes are 87/32 MB (25.6 MB stripped), so the
|
||||||
|
PLAN:639 budget is only meaningful for release builds; PLAN:50 calls these release
|
||||||
|
binaries. Size assert strips COPIES via binutils (zig objcopy --strip-all is
|
||||||
|
unimplemented in 0.16; aarch64 needs binutils-aarch64-linux-gnu, apt-installed
|
||||||
|
conditionally). Measured ReleaseSafe: raw ~24.2 MB each; stripped x86_64 5,501,624
|
||||||
|
bytes (PASS < 15 MB), aarch64 PT_LOAD total 4.87 MB (~5 MB stripped, asserted in CI).
|
||||||
|
`test` job stays Zig-only on the placeholder dist.
|
||||||
|
Smoke (embedded SPA): / serves the vite index (not the placeholder), /settings deep
|
||||||
|
link 200, main chunk served gzip with ETag then 304 on If-None-Match, login round
|
||||||
|
trip (401 → cookie → 200 → logout → 401), SIGTERM exit 0.
|
||||||
|
Finding, fixed post-session under an orchestrator ruling (narrow Zig-freeze
|
||||||
|
exception): the W8 test at static.zig:413 pinned placeholder strings and failed with
|
||||||
|
any real dist — rewritten dist-agnostic (structural asserts: /index.html exists,
|
||||||
|
text/html, gz siblings have base entries, quoted etags, no duplicate paths).
|
||||||
|
Import CLI note: the config file is positional on `nxdns import` (no --config flag).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module layout (new)
|
||||||
|
|
||||||
|
web/{package.json, package-lock.json, index.html, vite.config.ts, tsconfig*.json},
|
||||||
|
web/src/{main.tsx, routes.tsx, styles.css}, web/src/shell/*, web/src/lib/*,
|
||||||
|
web/src/auth/*, web/src/features/{dashboard,queries,live,clients,groups,blocklists,
|
||||||
|
rules,local,lookup,settings,pause}/*.
|
||||||
|
|
||||||
|
## File ownership
|
||||||
|
|
||||||
|
F1 scaffold+shell+routes; F2 lib+auth; F3-F8 exactly their feature dirs (routes.tsx
|
||||||
|
lazy imports point at stable paths F1 fixes up-front, so page sessions never edit
|
||||||
|
routes.tsx; F8 alone replaces the PauseWidget file F1 created); F9 ci.yml. Orchestrator:
|
||||||
|
spec, integration wiring if any. Parallel sessions never share a file.
|
||||||
|
|
||||||
|
## Acceptance (milestone complete)
|
||||||
|
|
||||||
|
- [ ] `npm ci`, format/lint/typecheck/vitest, `vite build` all green in web/.
|
||||||
|
- [ ] All ten pages implemented per rulings; login + pause + restart banner work.
|
||||||
|
- [ ] `zig build cross -Dweb-dist=web/dist` green; exes < 15 MB; W10 suite still green.
|
||||||
|
- [ ] F9 smoke transcript: SPA served embedded, deep link 200, gzip+ETag, SIGTERM 0.
|
||||||
|
- [ ] CI has the frontend job and the cross-job embed per ruling 22.
|
||||||
|
- [ ] Spec As-built synced per session.
|
||||||
|
|
||||||
|
## Review (Codex, As built)
|
||||||
|
|
||||||
|
Round 1: 7 important + 4 minor, all fixed.
|
||||||
|
- auth: AuthProvider probes once on mount when authRequired is null (dedupe kept;
|
||||||
|
sessionStorage fast path, probe overwrites); logout swallows ONLY 401 — other errors
|
||||||
|
rethrow and AppShell's LogoutButton renders them via InlineError, navigating only on
|
||||||
|
success; LoginPage 429 is a ticking lockout (submit + Enter disabled until zero).
|
||||||
|
- queries: load-more generation counter — filter apply/clear increments; then/catch/
|
||||||
|
finally discard stale completions (no old-filter rows, no stale cursor); imperative
|
||||||
|
401s route through `handleUnauthorized` (newly exported from lib/queryClient).
|
||||||
|
- live: gap-resync 401 → handleUnauthorized (never resyncFailed); entering capped
|
||||||
|
fires a one-shot injectable probeSession (default GET /api/pause) so an expired
|
||||||
|
session redirects to login instead of reading as "capped".
|
||||||
|
- settings: fieldset disabled while the PUT is pending (no silently dropped edits);
|
||||||
|
errors via InlineError.
|
||||||
|
- pause: mutation errors rendered via InlineError in both branches (were silent);
|
||||||
|
stale error resets on pause-state flip.
|
||||||
|
- blocklists/rules: all mutation errors via InlineError (429 countdown); form error
|
||||||
|
props are Error|null now.
|
||||||
|
- Ripple: the auth mount probe legitimately fires POST /api/auth/login before page
|
||||||
|
fetches — LocalDnsPage.test's first-POST assertion narrowed to match by URL.
|
||||||
|
After fixes: 24 files / 120 web tests green; Zig suite with the fresh dist 0 failed.
|
||||||
|
|
||||||
|
Round 2: 1 important + 1 minor, both fixed. (a) keepPreviousData left the OLD
|
||||||
|
filter's cursor clickable during the placeholder window — loadMore and the button now
|
||||||
|
bail on base.isPlaceholderData (chosen over isFetching so background refetches of the
|
||||||
|
current key stay usable); regression test proves no new-filter/old-cursor request.
|
||||||
|
(b) safeRedirect accepted backslash network paths — now
|
||||||
|
`/^\/(?![/\\])/.test(raw) && !raw.includes("\\")`, with unit cases.
|
||||||
|
|
||||||
|
Round 3: no findings. Final: 121 web tests green.
|
||||||
|
|
||||||
|
## Anti-requirements
|
||||||
|
|
||||||
|
No SSR, no chart library, no codegen (openapi→TS), no msw, no ESLint, no i18n, no
|
||||||
|
dark-mode toggle, no WebSocket, no service worker/PWA, no Zig changes, no docs/api
|
||||||
|
rendering (Phase 10), no DoH/DoT UI (Phase 9 adds settings sections it already has).
|
||||||
+22
-11
@@ -410,21 +410,32 @@ test "dev-mode disk reads refuse a symlink that escapes the root" {
|
|||||||
try testing.expect(!resolvesUnderRoot(root, io, "missing.txt"));
|
try testing.expect(!resolvesUnderRoot(root, io, "missing.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "the placeholder dist is embedded with its gzip siblings" {
|
test "the embedded dist has an index and consistent gzip siblings" {
|
||||||
|
try testing.expect(embedded.len > 0);
|
||||||
|
|
||||||
const index = find(embedded, index_path).?;
|
const index = find(embedded, index_path).?;
|
||||||
try testing.expectEqualStrings("text/html; charset=utf-8", index.content_type);
|
try testing.expectEqualStrings("text/html; charset=utf-8", index.content_type);
|
||||||
try testing.expect(std.mem.containsAtLeast(u8, index.bytes, 1, "nxdns"));
|
try testing.expect(index.bytes.len > 0);
|
||||||
try testing.expect(std.mem.containsAtLeast(u8, index.bytes, 1, "/api/health"));
|
|
||||||
|
|
||||||
const favicon = find(embedded, "/favicon.svg").?;
|
for (embedded) |file| {
|
||||||
try testing.expectEqualStrings("image/svg+xml", favicon.content_type);
|
try testing.expect(file.etag.len >= 3);
|
||||||
|
try testing.expectEqual(@as(u8, '"'), file.etag[0]);
|
||||||
|
try testing.expectEqual(@as(u8, '"'), file.etag[file.etag.len - 1]);
|
||||||
|
|
||||||
const gz = select(embedded, index_path, "gzip").?;
|
var occurrences: usize = 0;
|
||||||
try testing.expect(gz.gzip);
|
for (embedded) |other| {
|
||||||
try testing.expect(gz.file.bytes.len < index.bytes.len);
|
if (std.mem.eql(u8, other.path, file.path)) occurrences += 1;
|
||||||
// The gzip member header: build-time compression, not an accident.
|
}
|
||||||
try testing.expectEqual(@as(u8, 0x1f), gz.file.bytes[0]);
|
try testing.expectEqual(@as(usize, 1), occurrences);
|
||||||
try testing.expectEqual(@as(u8, 0x8b), gz.file.bytes[1]);
|
|
||||||
|
if (std.mem.endsWith(u8, file.path, ".gz")) {
|
||||||
|
const base = find(embedded, file.path[0 .. file.path.len - 3]).?;
|
||||||
|
try testing.expect(file.bytes.len < base.bytes.len);
|
||||||
|
// The gzip member header: build-time compression, not an accident.
|
||||||
|
try testing.expectEqual(@as(u8, 0x1f), file.bytes[0]);
|
||||||
|
try testing.expectEqual(@as(u8, 0x8b), file.bytes[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test "embedded entries agree with the dev-mode content type map" {
|
test "embedded entries agree with the dev-mode content type map" {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
dist/
|
||||||
|
dist-placeholder/
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>nxdns</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3083
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "nxdns-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"typecheck": "tsc -b",
|
||||||
|
"lint": "oxlint src vite.config.ts",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"useTabs": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"printWidth": 120,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "5.101.4",
|
||||||
|
"@tanstack/react-router": "1.170.18",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "4.3.3",
|
||||||
|
"@testing-library/dom": "10.4.1",
|
||||||
|
"@testing-library/react": "16.3.2",
|
||||||
|
"@types/node": "26.1.1",
|
||||||
|
"@types/react": "19.2.17",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "6.0.4",
|
||||||
|
"jsdom": "29.1.1",
|
||||||
|
"oxlint": "1.75.0",
|
||||||
|
"prettier": "3.9.6",
|
||||||
|
"tailwindcss": "4.3.3",
|
||||||
|
"typescript": "6.0.3",
|
||||||
|
"vite": "8.1.5",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#18181b" />
|
||||||
|
<text x="16" y="22" font-family="ui-monospace, monospace" font-size="13" font-weight="bold" fill="#4ade80" text-anchor="middle">nx</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 262 B |
@@ -0,0 +1,97 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
|
import { safeRedirect } from "@/auth/LoginPage";
|
||||||
|
import { AuthProvider, resetAuthProbeForTests } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
|
||||||
|
function jsonResponse(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json", ...headers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
resetAuthProbeForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function flushAll() {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(0);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoginRoute() {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/login"] }), queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("429 login shows a ticking countdown and keeps submit disabled until it ends", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
if (String(input) !== "/api/auth/login") return jsonResponse({ error: "not stubbed" }, 404);
|
||||||
|
const body = JSON.parse(String(init?.body)) as { password: string };
|
||||||
|
if (body.password === "") return jsonResponse({ error: "password required" }, 401);
|
||||||
|
return jsonResponse({ error: "rate limited" }, 429, { "retry-after": "3" });
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
renderLoginRoute();
|
||||||
|
await flushAll();
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Password");
|
||||||
|
fireEvent.change(input, { target: { value: "wrong" } });
|
||||||
|
fireEvent.submit(input.closest("form") as HTMLFormElement);
|
||||||
|
await flushAll();
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: "Log in" }) as HTMLButtonElement;
|
||||||
|
expect(screen.getByRole("alert").textContent).toBe("Too many attempts. Try again in 3s.");
|
||||||
|
expect(button.disabled).toBe(true);
|
||||||
|
|
||||||
|
const callsAtLockout = fetchMock.mock.calls.length;
|
||||||
|
fireEvent.submit(input.closest("form") as HTMLFormElement);
|
||||||
|
await flushAll();
|
||||||
|
expect(fetchMock.mock.calls.length).toBe(callsAtLockout);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("alert").textContent).toBe("Too many attempts. Try again in 2s.");
|
||||||
|
expect(button.disabled).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("alert").textContent).toBe("Too many attempts. Try again shortly.");
|
||||||
|
expect(button.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("safeRedirect only allows same-origin absolute paths", () => {
|
||||||
|
expect(safeRedirect(undefined)).toBe("/");
|
||||||
|
expect(safeRedirect("/queries")).toBe("/queries");
|
||||||
|
expect(safeRedirect("/queries?x=1")).toBe("/queries?x=1");
|
||||||
|
expect(safeRedirect("//evil.example")).toBe("/");
|
||||||
|
expect(safeRedirect("https://evil.example")).toBe("/");
|
||||||
|
expect(safeRedirect("/\\evil.example")).toBe("/");
|
||||||
|
expect(safeRedirect("/\\\\evil.example")).toBe("/");
|
||||||
|
expect(safeRedirect("\\evil")).toBe("/");
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { useRouter, useSearch } from "@tanstack/react-router";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/auth/store";
|
||||||
|
|
||||||
|
export function safeRedirect(raw: string | undefined): string {
|
||||||
|
if (raw === undefined) return "/";
|
||||||
|
if (!/^\/(?![/\\])/.test(raw) || raw.includes("\\")) return "/";
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown, remaining: number | null): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
if (error.status === 401) return "Incorrect password.";
|
||||||
|
if (error.status === 429) {
|
||||||
|
return remaining !== null && remaining > 0
|
||||||
|
? `Too many attempts. Try again in ${remaining}s.`
|
||||||
|
: "Too many attempts. Try again shortly.";
|
||||||
|
}
|
||||||
|
if (error.status === 503) return "The server is starting or degraded. Try again shortly.";
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "Could not reach the server.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { authRequired, probe, login } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const search = useSearch({ from: "/login" });
|
||||||
|
const redirect = safeRedirect(search.redirect);
|
||||||
|
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const retryAfter = error instanceof ApiError && error.status === 429 ? (error.retryAfter ?? null) : null;
|
||||||
|
const [remaining, setRemaining] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRemaining(retryAfter);
|
||||||
|
if (retryAfter === null) return;
|
||||||
|
const timer = setInterval(() => setRemaining((s) => (s === null || s <= 1 ? 0 : s - 1)), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [error, retryAfter]);
|
||||||
|
|
||||||
|
const lockedOut = remaining !== null && remaining > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authRequired === false) {
|
||||||
|
router.history.replace(redirect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (authRequired === null) {
|
||||||
|
probe()
|
||||||
|
.then((required) => {
|
||||||
|
if (!required) router.history.replace(redirect);
|
||||||
|
})
|
||||||
|
.catch((probeError: unknown) => setError(probeError));
|
||||||
|
}
|
||||||
|
}, [authRequired, probe, redirect, router]);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy || lockedOut) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await login(password);
|
||||||
|
router.history.push(redirect);
|
||||||
|
} catch (loginError) {
|
||||||
|
setError(loginError);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<section className="w-full max-w-sm">
|
||||||
|
<h1 className="text-2xl font-semibold">nxdns</h1>
|
||||||
|
{authRequired !== true ? (
|
||||||
|
<p className="mt-4 text-zinc-500">Checking whether a password is required…</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={onSubmit} className="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
className="mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || lockedOut}
|
||||||
|
className="w-full rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
>
|
||||||
|
{busy ? "Logging in…" : "Log in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{error !== null && (
|
||||||
|
<p role="alert" className="mt-4 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{errorMessage(error, remaining)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { AuthProvider, resetAuthProbeForTests, useAuth } from "@/auth/store";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "nxdns_auth_required";
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AuthProvider>{children}</AuthProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json", ...headers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
resetAuthProbeForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mounting with nothing stored probes and settles authRequired true on 401", async () => {
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
if (String(input) === "/api/auth/login") return jsonResponse({ error: "password required" }, 401);
|
||||||
|
return jsonResponse({ error: "not stubbed" }, 404);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
expect(result.current.authRequired).toBeNull();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.authRequired).toBe(true));
|
||||||
|
expect(sessionStorage.getItem(STORAGE_KEY)).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mounting with nothing stored probes and settles authRequired false when auth is off", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => jsonResponse({ authenticated: true, auth_required: false })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.authRequired).toBe(false));
|
||||||
|
expect(sessionStorage.getItem(STORAGE_KEY)).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stored value is the fast path: no probe fires on mount", async () => {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, "true");
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
expect(result.current.authRequired).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {});
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logout swallows a 401 from an already-dead session", async () => {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, "true");
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => jsonResponse({ error: "unauthorized" }, 401)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
await expect(result.current.logout()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logout rethrows non-401 errors such as 429", async () => {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, "true");
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => jsonResponse({ error: "rate limited" }, 429, { "retry-after": "7" })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
await expect(result.current.logout()).rejects.toMatchObject({
|
||||||
|
name: "ApiError",
|
||||||
|
status: 429,
|
||||||
|
retryAfter: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { ApiError, login as apiLogin, logout as apiLogout } from "@/lib/api";
|
||||||
|
import type { LoginResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "nxdns_auth_required";
|
||||||
|
|
||||||
|
export function readStoredAuthRequired(): boolean | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw === null ? null : raw === "true";
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberAuthRequired(value: boolean): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, String(value));
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable; the probe will run again next load.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduped across StrictMode double-effects: one empty-password login answers
|
||||||
|
// whether auth is on (401 → on; 200 with auth_required=false → off).
|
||||||
|
let probePromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
function probeAuthRequired(): Promise<boolean> {
|
||||||
|
probePromise ??= apiLogin({ password: "" }).then(
|
||||||
|
(response) => {
|
||||||
|
rememberAuthRequired(response.auth_required);
|
||||||
|
return response.auth_required;
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
probePromise = null;
|
||||||
|
if (error instanceof ApiError && error.status === 401) {
|
||||||
|
rememberAuthRequired(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return probePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAuthProbeForTests(): void {
|
||||||
|
probePromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthStore {
|
||||||
|
/** null until a login response, a probe, or a stored value settles it. */
|
||||||
|
authRequired: boolean | null;
|
||||||
|
/** Resolves true when a password is required (form must be shown). */
|
||||||
|
probe: () => Promise<boolean>;
|
||||||
|
login: (password: string) => Promise<LoginResponse>;
|
||||||
|
/** Ends the session server-side; swallows an already-dead session's 401. */
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthStore | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [authRequired, setAuthRequired] = useState<boolean | null>(readStoredAuthRequired);
|
||||||
|
|
||||||
|
const probe = useCallback(async () => {
|
||||||
|
const required = await probeAuthRequired();
|
||||||
|
setAuthRequired(required);
|
||||||
|
return required;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authRequired !== null) return;
|
||||||
|
probe().catch(() => {
|
||||||
|
// Server unreachable; LoginPage's own probe surfaces the error.
|
||||||
|
});
|
||||||
|
}, [authRequired, probe]);
|
||||||
|
|
||||||
|
const login = useCallback(async (password: string) => {
|
||||||
|
const response = await apiLogin({ password });
|
||||||
|
rememberAuthRequired(response.auth_required);
|
||||||
|
setAuthRequired(response.auth_required);
|
||||||
|
return response;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await apiLogout();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 401) return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<AuthStore>(
|
||||||
|
() => ({ authRequired, probe, login, logout }),
|
||||||
|
[authRequired, probe, login, logout],
|
||||||
|
);
|
||||||
|
return <AuthContext value={value}>{children}</AuthContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthStore {
|
||||||
|
const store = useContext(AuthContext);
|
||||||
|
if (store === null) throw new Error("useAuth requires an AuthProvider");
|
||||||
|
return store;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||||
|
|
||||||
|
const INPUT_CLASS =
|
||||||
|
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
|
||||||
|
|
||||||
|
interface BlocklistFormProps {
|
||||||
|
initial?: Blocklist;
|
||||||
|
busy: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
onSubmit: (input: BlocklistInput) => Promise<void>;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel }: BlocklistFormProps) {
|
||||||
|
const [url, setUrl] = useState(initial?.url ?? "");
|
||||||
|
const [name, setName] = useState(initial?.name ?? "");
|
||||||
|
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await onSubmit({ url: url.trim(), name: name.trim(), enabled });
|
||||||
|
if (initial === undefined) {
|
||||||
|
setUrl("");
|
||||||
|
setName("");
|
||||||
|
setEnabled(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The page renders the mutation error inline below the form.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3">
|
||||||
|
<h2 className="text-lg font-medium">{initial === undefined ? "Add source" : `Edit ${initial.name}`}</h2>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="blocklist-url" className="block text-sm font-medium">
|
||||||
|
URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="blocklist-url"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
value={url}
|
||||||
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="blocklist-name" className="block text-sm font-medium">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="blocklist-name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
>
|
||||||
|
{initial === undefined ? "Add source" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
{onCancel !== undefined && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<InlineError error={error} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
|
import { AuthProvider } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
|
||||||
|
const BLOCKLISTS = {
|
||||||
|
blocklists: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
url: "https://example.com/hosts.txt",
|
||||||
|
name: "StevenBlack",
|
||||||
|
enabled: true,
|
||||||
|
is_suggested: true,
|
||||||
|
last_updated: 1700000000,
|
||||||
|
domain_count: 1000,
|
||||||
|
wildcard_count: 10,
|
||||||
|
skipped_regex_count: 3,
|
||||||
|
checksum: "abc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
url: "https://example.org/list.txt",
|
||||||
|
name: "Custom",
|
||||||
|
enabled: false,
|
||||||
|
is_suggested: false,
|
||||||
|
last_updated: null,
|
||||||
|
domain_count: 0,
|
||||||
|
wildcard_count: 0,
|
||||||
|
skipped_regex_count: 0,
|
||||||
|
checksum: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESPONSES: Record<string, unknown> = {
|
||||||
|
"/api/blocklists": BLOCKLISTS,
|
||||||
|
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolveUpdate: ((response: Response) => void) | null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resolveUpdate = null;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/blocklists/update" && init?.method === "POST") {
|
||||||
|
return new Promise<Response>((resolve) => {
|
||||||
|
resolveUpdate = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const payload = RESPONSES[url];
|
||||||
|
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderBlocklistsRoute() {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/blocklists"] }), queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renders the source table and the status empty state", async () => {
|
||||||
|
renderBlocklistsRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Blocklists" });
|
||||||
|
|
||||||
|
expect(screen.getByText("StevenBlack")).toBeTruthy();
|
||||||
|
expect(screen.getByText("https://example.com/hosts.txt")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Suggested")).toBeTruthy();
|
||||||
|
expect(screen.getByText("1000")).toBeTruthy();
|
||||||
|
expect(screen.getByText("10")).toBeTruthy();
|
||||||
|
expect(screen.getByText("3")).toBeTruthy();
|
||||||
|
expect(screen.getByText("never")).toBeTruthy();
|
||||||
|
|
||||||
|
const enabledToggle = screen.getByLabelText("StevenBlack enabled") as HTMLInputElement;
|
||||||
|
expect(enabledToggle.checked).toBe(true);
|
||||||
|
const disabledToggle = screen.getByLabelText("Custom enabled") as HTMLInputElement;
|
||||||
|
expect(disabledToggle.checked).toBe(false);
|
||||||
|
|
||||||
|
expect(screen.getByText(/run .Update now. to fetch status/)).toBeTruthy();
|
||||||
|
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update now disables the button, then replaces the status section from the 202 snapshot", async () => {
|
||||||
|
renderBlocklistsRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Blocklists" });
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
const pending = (await screen.findByRole("button", { name: "Updating…" })) as HTMLButtonElement;
|
||||||
|
expect(pending.disabled).toBe(true);
|
||||||
|
expect(resolveUpdate).not.toBeNull();
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
state: "loaded",
|
||||||
|
loaded: true,
|
||||||
|
last_attempt: 1700000100,
|
||||||
|
last_success: 1700000100,
|
||||||
|
url: "https://example.com/hosts.txt",
|
||||||
|
last_error: "",
|
||||||
|
domains: 1200,
|
||||||
|
wildcards: 12,
|
||||||
|
skipped_regex: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
state: "fetch_failed",
|
||||||
|
loaded: false,
|
||||||
|
last_attempt: 1700000100,
|
||||||
|
last_success: 0,
|
||||||
|
url: "https://example.org/list.txt",
|
||||||
|
last_error: "connect timed out",
|
||||||
|
domains: 0,
|
||||||
|
wildcards: 0,
|
||||||
|
skipped_regex: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
resolveUpdate!(
|
||||||
|
new Response(JSON.stringify(snapshot), { status: 202, headers: { "content-type": "application/json" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByText("loaded");
|
||||||
|
expect(screen.getByText("fetch_failed")).toBeTruthy();
|
||||||
|
expect(screen.getByText("connect timed out")).toBeTruthy();
|
||||||
|
expect(screen.getByText("1200")).toBeTruthy();
|
||||||
|
expect(screen.getByText("12")).toBeTruthy();
|
||||||
|
expect(screen.getByText("4")).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
|
||||||
|
expect(screen.getByText(/Update completed/)).toBeTruthy();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const idle = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
|
||||||
|
expect(idle.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update now shows a countdown when rate limited with Retry-After", async () => {
|
||||||
|
renderBlocklistsRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Blocklists" });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
|
||||||
|
await screen.findByRole("button", { name: "Updating…" });
|
||||||
|
expect(resolveUpdate).not.toBeNull();
|
||||||
|
|
||||||
|
resolveUpdate!(
|
||||||
|
new Response(JSON.stringify({ error: "rate limited" }), {
|
||||||
|
status: 429,
|
||||||
|
headers: { "content-type": "application/json", "Retry-After": "7" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toBe("Rate limited. Try again in 7s.");
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { formatTime } from "@/lib/format";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import {
|
||||||
|
blocklistCreateMutation,
|
||||||
|
blocklistDeleteMutation,
|
||||||
|
blocklistUpdateMutation,
|
||||||
|
blocklistsQuery,
|
||||||
|
blocklistsUpdateNowMutation,
|
||||||
|
queryKeys,
|
||||||
|
} from "@/lib/queries";
|
||||||
|
import type { Blocklist, BlocklistInput, SourceStatus } from "@/lib/types";
|
||||||
|
import BlocklistForm from "./BlocklistForm";
|
||||||
|
import SourceStatusSection from "./SourceStatusSection";
|
||||||
|
|
||||||
|
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
|
||||||
|
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
|
||||||
|
|
||||||
|
export default function BlocklistsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
|
||||||
|
const [editing, setEditing] = useState<Blocklist | null>(null);
|
||||||
|
|
||||||
|
const create = useMutation(blocklistCreateMutation(queryClient));
|
||||||
|
const save = useMutation(blocklistUpdateMutation(queryClient));
|
||||||
|
const toggle = useMutation(blocklistUpdateMutation(queryClient));
|
||||||
|
const remove = useMutation(blocklistDeleteMutation(queryClient));
|
||||||
|
const updateNow = useMutation(blocklistsUpdateNowMutation(queryClient));
|
||||||
|
|
||||||
|
// Fed only by the update-now 202 snapshot (no GET exists); the mutation's
|
||||||
|
// state change re-renders this page right after setQueryData runs.
|
||||||
|
const sources = queryClient.getQueryData<SourceStatus[]>(queryKeys.blocklistSources);
|
||||||
|
const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
|
||||||
|
|
||||||
|
async function submitForm(input: BlocklistInput) {
|
||||||
|
if (editing === null) {
|
||||||
|
await create.mutateAsync(input);
|
||||||
|
} else {
|
||||||
|
await save.mutateAsync({ id: editing.id, input: { ...input, is_suggested: editing.is_suggested } });
|
||||||
|
setEditing(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEnabled(b: Blocklist) {
|
||||||
|
toggle.mutate({
|
||||||
|
id: b.id,
|
||||||
|
input: { url: b.url, name: b.name, enabled: !b.enabled, is_suggested: b.is_suggested },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBlocklist(b: Blocklist) {
|
||||||
|
if (window.confirm(`Delete blocklist "${b.name}"? Its domains stop being blocked.`)) {
|
||||||
|
remove.mutate(b.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formError = editing === null ? create.error : save.error;
|
||||||
|
const tableError = remove.error ?? toggle.error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold">Blocklists</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateNow.mutate()}
|
||||||
|
disabled={updateNow.isPending}
|
||||||
|
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
>
|
||||||
|
{updateNow.isPending ? "Updating…" : "Update now"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{updateNow.isSuccess && !updateNow.isPending && (
|
||||||
|
<p className="mt-2 text-sm text-green-700 dark:text-green-400" role="status">
|
||||||
|
Update completed; source status refreshed below.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<InlineError error={updateNow.error} />
|
||||||
|
|
||||||
|
{blocklists.length === 0 ? (
|
||||||
|
<p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 overflow-x-auto">
|
||||||
|
<table className="w-full min-w-max border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={TH_CLASS}>Name</th>
|
||||||
|
<th className={TH_CLASS}>URL</th>
|
||||||
|
<th className={TH_CLASS}>Enabled</th>
|
||||||
|
<th className={TH_CLASS}>Domains</th>
|
||||||
|
<th className={TH_CLASS}>Wildcards</th>
|
||||||
|
<th className={TH_CLASS}>Skipped regex</th>
|
||||||
|
<th className={TH_CLASS}>Last updated</th>
|
||||||
|
<th className={TH_CLASS}>
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{blocklists.map((b) => (
|
||||||
|
<tr key={b.id}>
|
||||||
|
<td className={TD_CLASS}>
|
||||||
|
<span className="font-medium">{b.name}</span>
|
||||||
|
{b.is_suggested && (
|
||||||
|
<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 className={TD_CLASS}>
|
||||||
|
<span className="block max-w-72 truncate" title={b.url}>
|
||||||
|
{b.url}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={TD_CLASS}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`${b.name} enabled`}
|
||||||
|
checked={b.enabled}
|
||||||
|
disabled={toggle.isPending}
|
||||||
|
onChange={() => toggleEnabled(b)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className={`${TD_CLASS} tabular-nums`}>{b.domain_count}</td>
|
||||||
|
<td className={`${TD_CLASS} tabular-nums`}>{b.wildcard_count}</td>
|
||||||
|
<td className={`${TD_CLASS} tabular-nums`}>{b.skipped_regex_count}</td>
|
||||||
|
<td className={TD_CLASS}>
|
||||||
|
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||||
|
</td>
|
||||||
|
<td className={TD_CLASS}>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(b)}
|
||||||
|
className="text-sm font-medium text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteBlocklist(b)}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<InlineError error={tableError} />
|
||||||
|
|
||||||
|
<BlocklistForm
|
||||||
|
key={editing?.id ?? "add"}
|
||||||
|
initial={editing ?? undefined}
|
||||||
|
busy={editing === null ? create.isPending : save.isPending}
|
||||||
|
error={formError}
|
||||||
|
onSubmit={submitForm}
|
||||||
|
onCancel={editing === null ? undefined : () => setEditing(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SourceStatusSection sources={sources} namesById={namesById} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { formatTime } from "@/lib/format";
|
||||||
|
import type { SourceStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
|
||||||
|
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
|
||||||
|
|
||||||
|
function formatAttempt(unixSeconds: number): string {
|
||||||
|
return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceStatusSectionProps {
|
||||||
|
sources: SourceStatus[] | undefined;
|
||||||
|
namesById: ReadonlyMap<number, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SourceStatusSection({ sources, namesById }: SourceStatusSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="text-lg font-medium">Source status</h2>
|
||||||
|
{sources === undefined ? (
|
||||||
|
<p className="mt-2 text-zinc-500">
|
||||||
|
No status snapshot yet — run “Update now” to fetch status for every enabled source.
|
||||||
|
</p>
|
||||||
|
) : sources.length === 0 ? (
|
||||||
|
<p className="mt-2 text-zinc-500">The last update ran against no enabled sources.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 overflow-x-auto">
|
||||||
|
<table className="w-full min-w-max border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={TH_CLASS}>Source</th>
|
||||||
|
<th className={TH_CLASS}>State</th>
|
||||||
|
<th className={TH_CLASS}>Last attempt</th>
|
||||||
|
<th className={TH_CLASS}>Last success</th>
|
||||||
|
<th className={TH_CLASS}>Domains</th>
|
||||||
|
<th className={TH_CLASS}>Wildcards</th>
|
||||||
|
<th className={TH_CLASS}>Skipped regex</th>
|
||||||
|
<th className={TH_CLASS}>Last error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sources.map((source) => (
|
||||||
|
<tr key={source.id}>
|
||||||
|
<td className={TD_CLASS}>
|
||||||
|
<span className="font-medium">{namesById.get(source.id) ?? source.url}</span>
|
||||||
|
<span className="mt-0.5 block max-w-64 truncate text-xs text-zinc-500">
|
||||||
|
{source.url}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={TD_CLASS}>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
source.loaded
|
||||||
|
? "text-green-700 dark:text-green-400"
|
||||||
|
: "text-red-600 dark:text-red-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{source.state}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={TD_CLASS}>{formatAttempt(source.last_attempt)}</td>
|
||||||
|
<td className={TD_CLASS}>{formatAttempt(source.last_success)}</td>
|
||||||
|
<td className={`${TD_CLASS} tabular-nums`}>{source.domains}</td>
|
||||||
|
<td className={`${TD_CLASS} tabular-nums`}>{source.wildcards}</td>
|
||||||
|
<td className={`${TD_CLASS} tabular-nums`}>{source.skipped_regex}</td>
|
||||||
|
<td className={TD_CLASS}>
|
||||||
|
{source.last_error === "" ? (
|
||||||
|
<span className="text-zinc-400">—</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-600 dark:text-red-400">{source.last_error}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { clientUpdateMutation } from "@/lib/queries";
|
||||||
|
import type { Client, Group } from "@/lib/types";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
client: Client;
|
||||||
|
groups: Group[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"mt-1 w-full rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
|
||||||
|
|
||||||
|
export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const mutation = useMutation(clientUpdateMutation(queryClient));
|
||||||
|
const [name, setName] = useState(client.name);
|
||||||
|
const [groupId, setGroupId] = useState(client.group_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Edit client ${client.ip}`}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold">Edit {client.ip}</h2>
|
||||||
|
<form
|
||||||
|
className="mt-4 space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
mutation.mutate(
|
||||||
|
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
|
||||||
|
{ onSuccess: onClose },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="block text-sm font-medium">
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm font-medium">
|
||||||
|
Group
|
||||||
|
<select
|
||||||
|
value={String(groupId)}
|
||||||
|
onChange={(event) => setGroupId(Number(event.target.value))}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<option key={group.id} value={String(group.id)}>
|
||||||
|
{group.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<InlineError error={mutation.error} />
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
|
import { AuthProvider } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
|
||||||
|
const GROUPS = {
|
||||||
|
groups: [
|
||||||
|
{ id: 1, name: "default", safe_search: false },
|
||||||
|
{ id: 2, name: "kids", safe_search: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLIENTS = {
|
||||||
|
clients: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
ip: "192.168.1.10",
|
||||||
|
name: "laptop",
|
||||||
|
group_id: 1,
|
||||||
|
group: "default",
|
||||||
|
hand_edited: true,
|
||||||
|
first_seen: 1700000000,
|
||||||
|
last_seen: 1700003600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
ip: "192.168.1.11",
|
||||||
|
name: "",
|
||||||
|
group_id: 2,
|
||||||
|
group: "kids",
|
||||||
|
hand_edited: false,
|
||||||
|
first_seen: 1700000000,
|
||||||
|
last_seen: 1700007200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREFIXES = {
|
||||||
|
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||||
|
|
||||||
|
function stubFetch(map: Record<string, unknown>) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const key = `${init?.method ?? "GET"} ${String(input)}`;
|
||||||
|
const payload = map[key];
|
||||||
|
if (payload === undefined) {
|
||||||
|
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderClientsPage(map: Record<string, unknown>) {
|
||||||
|
stubFetch(map);
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/clients"] }), queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByRole("heading", { name: "Clients" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = {
|
||||||
|
"GET /api/clients": CLIENTS,
|
||||||
|
"GET /api/client-prefixes": PREFIXES,
|
||||||
|
"GET /api/groups": GROUPS,
|
||||||
|
"GET /api/version": VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders the client table with group names and one hand-edited badge", async () => {
|
||||||
|
await renderClientsPage(BASE);
|
||||||
|
|
||||||
|
expect(screen.getByText("192.168.1.10")).toBeTruthy();
|
||||||
|
expect(screen.getByText("192.168.1.11")).toBeTruthy();
|
||||||
|
expect(screen.getByText("laptop")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText("edited")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows the DNS-activity empty state when there are no clients", async () => {
|
||||||
|
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
|
||||||
|
|
||||||
|
expect(screen.getByText(/rows appear automatically as devices on the network make dns queries/i)).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("table")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit opens a dialog seeded with the client's name and group", async () => {
|
||||||
|
await renderClientsPage(BASE);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
|
||||||
|
const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" });
|
||||||
|
expect(dialog).toBeTruthy();
|
||||||
|
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
|
||||||
|
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prefix editor starts clean and dirties on add", async () => {
|
||||||
|
await renderClientsPage(BASE);
|
||||||
|
|
||||||
|
expect((screen.getByLabelText("Prefix 1") as HTMLInputElement).value).toBe("192.168.1.0/24");
|
||||||
|
const save = screen.getByRole("button", { name: "Save prefixes" }) as HTMLButtonElement;
|
||||||
|
expect(save.disabled).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add prefix" }));
|
||||||
|
expect(save.disabled).toBe(false);
|
||||||
|
expect((screen.getByLabelText("Prefix 2") as HTMLInputElement).value).toBe("");
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
|
||||||
|
import { formatTime } from "@/lib/format";
|
||||||
|
import type { Client } from "@/lib/types";
|
||||||
|
import ClientEditDialog from "./ClientEditDialog";
|
||||||
|
import PrefixesEditor from "./PrefixesEditor";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
|
||||||
|
const cellClass = "px-3 py-2";
|
||||||
|
const buttonClass =
|
||||||
|
"rounded border border-zinc-300 px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
|
||||||
|
|
||||||
|
export default function ClientsPage() {
|
||||||
|
const { data: clients } = useSuspenseQuery(clientsQuery());
|
||||||
|
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
|
||||||
|
const { data: groups } = useSuspenseQuery(groupsQuery());
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
||||||
|
const [editing, setEditing] = useState<Client | null>(null);
|
||||||
|
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-semibold">Clients</h1>
|
||||||
|
{clients.length === 0 ? (
|
||||||
|
<p className="mt-4 text-zinc-500">
|
||||||
|
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
||||||
|
nothing to create by hand.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[48rem] text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700">
|
||||||
|
<th className={cellClass}>IP</th>
|
||||||
|
<th className={cellClass}>Name</th>
|
||||||
|
<th className={cellClass}>Group</th>
|
||||||
|
<th className={cellClass}>First seen</th>
|
||||||
|
<th className={cellClass}>Last seen</th>
|
||||||
|
<th className={cellClass}>
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{clients.map((client) => (
|
||||||
|
<tr key={client.id} className="border-b border-zinc-100 dark:border-zinc-800">
|
||||||
|
<td className={`${cellClass} font-mono`}>{client.ip}</td>
|
||||||
|
<td className={cellClass}>
|
||||||
|
{client.name === "" ? <span className="text-zinc-400">—</span> : client.name}
|
||||||
|
{client.hand_edited && (
|
||||||
|
<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
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={cellClass}>{client.group}</td>
|
||||||
|
<td className={cellClass}>{formatTime(client.first_seen)}</td>
|
||||||
|
<td className={cellClass}>{formatTime(client.last_seen)}</td>
|
||||||
|
<td className={`${cellClass} text-right`}>
|
||||||
|
{confirmingId === client.id ? (
|
||||||
|
<span className="inline-flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
Deleted clients re-materialize on their next DNS query.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmingId(null);
|
||||||
|
deleteMutation.mutate(client.id);
|
||||||
|
}}
|
||||||
|
className={`${buttonClass} text-red-700 dark:text-red-400`}
|
||||||
|
>
|
||||||
|
Confirm delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmingId(null)}
|
||||||
|
className={buttonClass}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(client)}
|
||||||
|
className={buttonClass}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmingId(client.id)}
|
||||||
|
className={`${buttonClass} text-red-700 dark:text-red-400`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<InlineError error={deleteMutation.error} />
|
||||||
|
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
||||||
|
<PrefixesEditor prefixes={prefixes} groups={groups} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useReducer, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { clientPrefixesPutMutation } from "@/lib/queries";
|
||||||
|
import type { ClientPrefix, Group } from "@/lib/types";
|
||||||
|
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prefixes: ClientPrefix[];
|
||||||
|
groups: Group[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass = "rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
|
||||||
|
|
||||||
|
export default function PrefixesEditor({ prefixes, groups }: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
||||||
|
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||||
|
const [validation, setValidation] = useState<string | null>(null);
|
||||||
|
const dirty = isDirty(state);
|
||||||
|
const defaultGroupId = groups.find((group) => group.id === 1)?.id ?? groups[0]?.id ?? 1;
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const problem = firstProblem(state.rows);
|
||||||
|
setValidation(problem);
|
||||||
|
if (problem !== null) return;
|
||||||
|
mutation.mutate(toInputs(state.rows), {
|
||||||
|
onSuccess: (stored) => dispatch({ type: "reset", prefixes: stored }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-10">
|
||||||
|
<h2 className="text-xl font-semibold">Client prefixes</h2>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
|
||||||
|
match wins.
|
||||||
|
</p>
|
||||||
|
{state.rows.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-zinc-500">No prefixes configured.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-4 space-y-2">
|
||||||
|
{state.rows.map((row, index) => (
|
||||||
|
<li key={index} className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label={`Prefix ${index + 1}`}
|
||||||
|
placeholder="192.168.1.0/24"
|
||||||
|
value={row.prefix}
|
||||||
|
onChange={(event) =>
|
||||||
|
dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
|
||||||
|
}
|
||||||
|
className={`${inputClass} w-52`}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
aria-label={`Group for prefix ${index + 1}`}
|
||||||
|
value={String(row.group_id)}
|
||||||
|
onChange={(event) =>
|
||||||
|
dispatch({ type: "edit", index, patch: { group_id: Number(event.target.value) } })
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<option key={group.id} value={String(group.id)}>
|
||||||
|
{group.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
aria-label={`Priority for prefix ${index + 1}`}
|
||||||
|
placeholder="100"
|
||||||
|
value={row.priority}
|
||||||
|
onChange={(event) =>
|
||||||
|
dispatch({ type: "edit", index, patch: { priority: event.target.value } })
|
||||||
|
}
|
||||||
|
className={`${inputClass} w-20`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: "remove", index })}
|
||||||
|
className="rounded border border-zinc-300 px-2 py-1.5 text-sm text-red-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:text-red-400"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{validation !== null && (
|
||||||
|
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-400">
|
||||||
|
{validation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<InlineError error={mutation.error} />
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: "add", groupId: defaultGroupId })}
|
||||||
|
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
Add prefix
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={save}
|
||||||
|
disabled={!dirty || mutation.isPending}
|
||||||
|
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
>
|
||||||
|
Save prefixes
|
||||||
|
</button>
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setValidation(null);
|
||||||
|
dispatch({ type: "reset", prefixes });
|
||||||
|
}}
|
||||||
|
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
Discard changes
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { ClientPrefix } from "@/lib/types";
|
||||||
|
import {
|
||||||
|
firstProblem,
|
||||||
|
initPrefixEditor,
|
||||||
|
isDirty,
|
||||||
|
prefixEditorReducer,
|
||||||
|
toInputs,
|
||||||
|
type PrefixEditorState,
|
||||||
|
} from "./prefixEditor";
|
||||||
|
|
||||||
|
const server: ClientPrefix[] = [
|
||||||
|
{ id: 1, prefix: "192.168.1.0/24", group_id: 1, group: "default", priority: 100 },
|
||||||
|
{ id: 2, prefix: "10.0.0.0/8", group_id: 2, group: "kids", priority: 50 },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("init mirrors the server rows into baseline and rows", () => {
|
||||||
|
const state = initPrefixEditor(server);
|
||||||
|
expect(state.rows).toEqual([
|
||||||
|
{ prefix: "192.168.1.0/24", group_id: 1, priority: "100" },
|
||||||
|
{ prefix: "10.0.0.0/8", group_id: 2, priority: "50" },
|
||||||
|
]);
|
||||||
|
expect(state.baseline).toEqual(state.rows);
|
||||||
|
expect(isDirty(state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add appends an empty row with the given group and marks dirty", () => {
|
||||||
|
const state = prefixEditorReducer(initPrefixEditor(server), { type: "add", groupId: 1 });
|
||||||
|
expect(state.rows).toHaveLength(3);
|
||||||
|
expect(state.rows[2]).toEqual({ prefix: "", group_id: 1, priority: "" });
|
||||||
|
expect(isDirty(state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remove drops the row at the index", () => {
|
||||||
|
const state = prefixEditorReducer(initPrefixEditor(server), { type: "remove", index: 0 });
|
||||||
|
expect(state.rows).toEqual([{ prefix: "10.0.0.0/8", group_id: 2, priority: "50" }]);
|
||||||
|
expect(isDirty(state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit patches a single row", () => {
|
||||||
|
const state = prefixEditorReducer(initPrefixEditor(server), {
|
||||||
|
type: "edit",
|
||||||
|
index: 1,
|
||||||
|
patch: { group_id: 1, priority: "10" },
|
||||||
|
});
|
||||||
|
expect(state.rows[1]).toEqual({ prefix: "10.0.0.0/8", group_id: 1, priority: "10" });
|
||||||
|
expect(state.rows[0]).toEqual(state.baseline[0]);
|
||||||
|
expect(isDirty(state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editing a field back to its baseline value is clean again", () => {
|
||||||
|
let state: PrefixEditorState = initPrefixEditor(server);
|
||||||
|
state = prefixEditorReducer(state, { type: "edit", index: 0, patch: { priority: "7" } });
|
||||||
|
expect(isDirty(state)).toBe(true);
|
||||||
|
state = prefixEditorReducer(state, { type: "edit", index: 0, patch: { priority: "100" } });
|
||||||
|
expect(isDirty(state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reset adopts new server rows and clears dirtiness", () => {
|
||||||
|
let state = prefixEditorReducer(initPrefixEditor(server), { type: "add", groupId: 1 });
|
||||||
|
state = prefixEditorReducer(state, { type: "reset", prefixes: server });
|
||||||
|
expect(isDirty(state)).toBe(false);
|
||||||
|
expect(state.rows).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
|
||||||
|
expect(
|
||||||
|
toInputs([
|
||||||
|
{ prefix: " 192.168.1.0/24 ", group_id: 1, priority: "25" },
|
||||||
|
{ prefix: "10.0.0.0/8", group_id: 2, priority: "" },
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ prefix: "192.168.1.0/24", group_id: 1, priority: 25 },
|
||||||
|
{ prefix: "10.0.0.0/8", group_id: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("firstProblem flags empty prefixes and non-integer priorities", () => {
|
||||||
|
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
|
||||||
|
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required.");
|
||||||
|
expect(
|
||||||
|
firstProblem([
|
||||||
|
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
|
||||||
|
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
|
||||||
|
]),
|
||||||
|
).toBe("Row 2: priority must be a whole number.");
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { ClientPrefix, ClientPrefixInput } from "@/lib/types";
|
||||||
|
|
||||||
|
export interface PrefixRow {
|
||||||
|
prefix: string;
|
||||||
|
group_id: number;
|
||||||
|
/** Raw input text; empty means "use the server default (100)". */
|
||||||
|
priority: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrefixEditorState {
|
||||||
|
baseline: PrefixRow[];
|
||||||
|
rows: PrefixRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PrefixEditorAction =
|
||||||
|
| { type: "reset"; prefixes: ClientPrefix[] }
|
||||||
|
| { type: "add"; groupId: number }
|
||||||
|
| { type: "remove"; index: number }
|
||||||
|
| { type: "edit"; index: number; patch: Partial<PrefixRow> };
|
||||||
|
|
||||||
|
function fromServer(prefixes: ClientPrefix[]): PrefixRow[] {
|
||||||
|
return prefixes.map((p) => ({ prefix: p.prefix, group_id: p.group_id, priority: String(p.priority) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initPrefixEditor(prefixes: ClientPrefix[]): PrefixEditorState {
|
||||||
|
const rows = fromServer(prefixes);
|
||||||
|
return { baseline: rows, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prefixEditorReducer(state: PrefixEditorState, action: PrefixEditorAction): PrefixEditorState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "reset":
|
||||||
|
return initPrefixEditor(action.prefixes);
|
||||||
|
case "add":
|
||||||
|
return { ...state, rows: [...state.rows, { prefix: "", group_id: action.groupId, priority: "" }] };
|
||||||
|
case "remove":
|
||||||
|
return { ...state, rows: state.rows.filter((_, i) => i !== action.index) };
|
||||||
|
case "edit":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
rows: state.rows.map((row, i) => (i === action.index ? { ...row, ...action.patch } : row)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameRow(a: PrefixRow, b: PrefixRow): boolean {
|
||||||
|
return a.prefix === b.prefix && a.group_id === b.group_id && a.priority === b.priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDirty(state: PrefixEditorState): boolean {
|
||||||
|
if (state.rows.length !== state.baseline.length) return true;
|
||||||
|
return state.rows.some((row, i) => {
|
||||||
|
const base = state.baseline[i];
|
||||||
|
return base === undefined || !sameRow(row, base);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstProblem(rows: PrefixRow[]): string | null {
|
||||||
|
for (const [i, row] of rows.entries()) {
|
||||||
|
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`;
|
||||||
|
const priority = row.priority.trim();
|
||||||
|
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toInputs(rows: PrefixRow[]): ClientPrefixInput[] {
|
||||||
|
return rows.map((row) => {
|
||||||
|
const input: ClientPrefixInput = { prefix: row.prefix.trim(), group_id: row.group_id };
|
||||||
|
const priority = row.priority.trim();
|
||||||
|
if (priority !== "") input.priority = Number(priority);
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
|
import { AuthProvider } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
|
||||||
|
const RESPONSES: Record<string, unknown> = {
|
||||||
|
"/api/stats?period=24h": {
|
||||||
|
period: "24h",
|
||||||
|
since: 0,
|
||||||
|
until: 86400,
|
||||||
|
queries: 1000,
|
||||||
|
blocked: 250,
|
||||||
|
cached: 100,
|
||||||
|
clients: 7,
|
||||||
|
avg_response_time_us: 2345,
|
||||||
|
},
|
||||||
|
"/api/stats/timeseries?period=24h": {
|
||||||
|
period: "24h",
|
||||||
|
since: 0,
|
||||||
|
until: 86400,
|
||||||
|
bucket_seconds: 1800,
|
||||||
|
buckets: [
|
||||||
|
{ ts: 0, queries: 60, blocked: 20, cached: 10 },
|
||||||
|
{ ts: 1800, queries: 40, blocked: 0, cached: 0 },
|
||||||
|
{ ts: 3600, queries: 0, blocked: 0, cached: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"/api/stats?period=1h": {
|
||||||
|
period: "1h",
|
||||||
|
since: 0,
|
||||||
|
until: 3600,
|
||||||
|
queries: 12,
|
||||||
|
blocked: 3,
|
||||||
|
cached: 0,
|
||||||
|
clients: 2,
|
||||||
|
avg_response_time_us: null,
|
||||||
|
},
|
||||||
|
"/api/stats/timeseries?period=1h": {
|
||||||
|
period: "1h",
|
||||||
|
since: 0,
|
||||||
|
until: 3600,
|
||||||
|
bucket_seconds: 60,
|
||||||
|
buckets: [],
|
||||||
|
},
|
||||||
|
"/api/health": {
|
||||||
|
status: "degraded",
|
||||||
|
disk: {
|
||||||
|
state: "warn",
|
||||||
|
free_bytes: 400 * 1024 * 1024,
|
||||||
|
db_bytes: 12 * 1024 * 1024,
|
||||||
|
log_bytes: 2048,
|
||||||
|
sample_failures: 0,
|
||||||
|
},
|
||||||
|
upstreams: { available: 1, total: 2 },
|
||||||
|
queries_dropped: 5,
|
||||||
|
writer_failed: false,
|
||||||
|
refreshes_gated: 0,
|
||||||
|
snapshot_generation: 3,
|
||||||
|
},
|
||||||
|
"/api/upstream/health": {
|
||||||
|
upstreams: [
|
||||||
|
{
|
||||||
|
url: "https://dns.example/dns-query",
|
||||||
|
enabled: true,
|
||||||
|
available: false,
|
||||||
|
consecutive_failures: 4,
|
||||||
|
total_successes: 90,
|
||||||
|
total_failures: 10,
|
||||||
|
success_rate: 0.9,
|
||||||
|
last_error: "timeout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "udp://9.9.9.9:53",
|
||||||
|
enabled: true,
|
||||||
|
available: true,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
total_successes: 100,
|
||||||
|
total_failures: 0,
|
||||||
|
success_rate: 1,
|
||||||
|
last_error: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
available: 1,
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
const payload = RESPONSES[url];
|
||||||
|
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderDashboard() {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("dashboard renders stats, chart, disk card, upstream table and health banners", async () => {
|
||||||
|
renderDashboard();
|
||||||
|
await screen.findByRole("heading", { name: "Dashboard" });
|
||||||
|
|
||||||
|
expect(screen.getByText("1,000")).toBeTruthy();
|
||||||
|
expect(screen.getByText("250")).toBeTruthy();
|
||||||
|
expect(screen.getByText("25.0%")).toBeTruthy();
|
||||||
|
expect(screen.getByText("7")).toBeTruthy();
|
||||||
|
expect(screen.getByText("2.3 ms")).toBeTruthy();
|
||||||
|
|
||||||
|
expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
|
||||||
|
expect(screen.getByText("Blocked", { selector: "li" })).toBeTruthy();
|
||||||
|
|
||||||
|
expect(screen.getByText("Disk")).toBeTruthy();
|
||||||
|
expect(screen.getByText("warn")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText("400.0 MiB").length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("12.0 MiB")).toBeTruthy();
|
||||||
|
expect(screen.getByText("2.0 KiB")).toBeTruthy();
|
||||||
|
|
||||||
|
const alerts = screen.getAllByRole("alert");
|
||||||
|
expect(alerts.some((alert) => /disk space low/i.test(alert.textContent ?? ""))).toBe(true);
|
||||||
|
expect(alerts.some((alert) => /5 queries dropped/i.test(alert.textContent ?? ""))).toBe(true);
|
||||||
|
|
||||||
|
expect(screen.getByText("https://dns.example/dns-query")).toBeTruthy();
|
||||||
|
expect(screen.getByText("90.0%")).toBeTruthy();
|
||||||
|
expect(screen.getByText("100.0%")).toBeTruthy();
|
||||||
|
expect(screen.getByText("timeout")).toBeTruthy();
|
||||||
|
expect(screen.getByText("1/2 available")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("period picker refetches stats and shows the empty chart state", async () => {
|
||||||
|
renderDashboard();
|
||||||
|
await screen.findByRole("heading", { name: "Dashboard" });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "1h" }));
|
||||||
|
|
||||||
|
await screen.findByText("12");
|
||||||
|
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
|
||||||
|
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
|
||||||
|
await screen.findByText("No queries in this period.");
|
||||||
|
expect(screen.getByText("—", { selector: "span" })).toBeTruthy();
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
|
||||||
|
import type { Period } from "@/lib/types";
|
||||||
|
import DiskCard from "./DiskCard";
|
||||||
|
import HealthBanners from "./HealthBanners";
|
||||||
|
import StatCards from "./StatCards";
|
||||||
|
import TimeseriesChart from "./TimeseriesChart";
|
||||||
|
import UpstreamHealthTable from "./UpstreamHealthTable";
|
||||||
|
|
||||||
|
const PERIODS: Period[] = ["1h", "24h", "7d", "30d"];
|
||||||
|
|
||||||
|
function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label="Period" className="flex gap-1">
|
||||||
|
{PERIODS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={option === period}
|
||||||
|
onClick={() => onChange(option)}
|
||||||
|
className={`rounded px-2.5 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ${
|
||||||
|
option === period
|
||||||
|
? "bg-zinc-200 font-medium text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
|
||||||
|
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineError({ error, onRetry }: { error: unknown; onRetry: () => void }) {
|
||||||
|
const message = error instanceof ApiError ? error.message : "request failed";
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
|
||||||
|
Failed to load: {message}{" "}
|
||||||
|
<button type="button" onClick={onRetry} className="font-medium underline">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Skeleton({ height }: { height: number }) {
|
||||||
|
return <div aria-hidden="true" className="animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" style={{ height }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [period, setPeriod] = useState<Period>("24h");
|
||||||
|
const stats = useQuery({ ...statsQuery(period), placeholderData: keepPreviousData });
|
||||||
|
const timeseries = useQuery({ ...timeseriesQuery(period), placeholderData: keepPreviousData });
|
||||||
|
const health = useQuery(healthQuery());
|
||||||
|
const upstreamHealth = useQuery(upstreamHealthQuery());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold">Dashboard</h1>
|
||||||
|
<PeriodPicker period={period} onChange={setPeriod} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{health.data !== undefined && <HealthBanners health={health.data} />}
|
||||||
|
|
||||||
|
{stats.isError ? (
|
||||||
|
<InlineError error={stats.error} onRetry={() => void stats.refetch()} />
|
||||||
|
) : stats.data === undefined ? (
|
||||||
|
<Skeleton height={76} />
|
||||||
|
) : (
|
||||||
|
<StatCards stats={stats.data} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
||||||
|
<section className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold">Queries over time</h2>
|
||||||
|
{timeseries.isError ? (
|
||||||
|
<InlineError error={timeseries.error} onRetry={() => void timeseries.refetch()} />
|
||||||
|
) : timeseries.data === undefined ? (
|
||||||
|
<Skeleton height={240} />
|
||||||
|
) : (
|
||||||
|
<TimeseriesChart data={timeseries.data} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{health.data === undefined ? <Skeleton height={160} /> : <DiskCard disk={health.data.disk} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{upstreamHealth.isError ? (
|
||||||
|
<InlineError error={upstreamHealth.error} onRetry={() => void upstreamHealth.refetch()} />
|
||||||
|
) : upstreamHealth.data === undefined ? (
|
||||||
|
<Skeleton height={120} />
|
||||||
|
) : (
|
||||||
|
<UpstreamHealthTable health={upstreamHealth.data} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { formatBytes } from "@/lib/format";
|
||||||
|
import type { Health } from "@/lib/types";
|
||||||
|
|
||||||
|
const STATE_CLASSES: Record<Health["disk"]["state"], string> = {
|
||||||
|
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",
|
||||||
|
critical: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DiskCard({ disk }: { disk: Health["disk"] }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<h2 className="flex items-center justify-between text-sm font-semibold">
|
||||||
|
Disk
|
||||||
|
<span className={`rounded px-2 py-0.5 text-xs font-medium ${STATE_CLASSES[disk.state]}`}>
|
||||||
|
{disk.state}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<dl className="mt-3 space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-zinc-500">Free</dt>
|
||||||
|
<dd className="tabular-nums">{formatBytes(disk.free_bytes)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-zinc-500">Database</dt>
|
||||||
|
<dd className="tabular-nums">{formatBytes(disk.db_bytes)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-zinc-500">Logs</dt>
|
||||||
|
<dd className="tabular-nums">{formatBytes(disk.log_bytes)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { formatBytes } from "@/lib/format";
|
||||||
|
import type { Health } from "@/lib/types";
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<p role="alert" className={`rounded border px-4 py-2 text-sm ${classes}`}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HealthBanners({ health }: { health: Health }) {
|
||||||
|
const banners: React.ReactNode[] = [];
|
||||||
|
if (health.disk.state !== "ok") {
|
||||||
|
banners.push(
|
||||||
|
<Banner key="disk" tone={health.disk.state === "critical" ? "critical" : "warn"}>
|
||||||
|
{health.disk.state === "critical"
|
||||||
|
? `Disk critically low: ${formatBytes(health.disk.free_bytes)} free. Blocklist updates and log flushes are stopped.`
|
||||||
|
: `Disk space low: ${formatBytes(health.disk.free_bytes)} free.`}
|
||||||
|
</Banner>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (health.writer_failed) {
|
||||||
|
banners.push(
|
||||||
|
<Banner key="writer" tone="critical">
|
||||||
|
Query log writer failed; new queries are not being persisted.
|
||||||
|
</Banner>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (health.queries_dropped > 0) {
|
||||||
|
banners.push(
|
||||||
|
<Banner key="dropped" tone="warn">
|
||||||
|
{health.queries_dropped.toLocaleString()} queries dropped from the log buffer.
|
||||||
|
</Banner>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (banners.length === 0) return null;
|
||||||
|
return <div className="space-y-2">{banners}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { formatMicros } from "@/lib/format";
|
||||||
|
import type { StatsTotals } from "@/lib/types";
|
||||||
|
|
||||||
|
const numberFormat = new Intl.NumberFormat();
|
||||||
|
|
||||||
|
function percentOf(part: number, total: number): string | null {
|
||||||
|
if (total === 0) return null;
|
||||||
|
return `${((part / total) * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ label, value, detail }: { label: string; value: string; detail?: string | null }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<dt className="text-sm text-zinc-500">{label}</dt>
|
||||||
|
<dd>
|
||||||
|
<span className="text-2xl font-semibold tabular-nums">{value}</span>
|
||||||
|
{detail != null && <span className="ml-2 text-sm text-zinc-500 tabular-nums">{detail}</span>}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatCards({ stats }: { stats: StatsTotals }) {
|
||||||
|
return (
|
||||||
|
<dl className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5">
|
||||||
|
<Card label="Queries" value={numberFormat.format(stats.queries)} />
|
||||||
|
<Card
|
||||||
|
label="Blocked"
|
||||||
|
value={numberFormat.format(stats.blocked)}
|
||||||
|
detail={percentOf(stats.blocked, stats.queries)}
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
label="Cached"
|
||||||
|
value={numberFormat.format(stats.cached)}
|
||||||
|
detail={percentOf(stats.cached, stats.queries)}
|
||||||
|
/>
|
||||||
|
<Card label="Clients" value={numberFormat.format(stats.clients)} />
|
||||||
|
<Card
|
||||||
|
label="Avg response"
|
||||||
|
value={stats.avg_response_time_us === null ? "—" : formatMicros(stats.avg_response_time_us)}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { formatTime } from "@/lib/format";
|
||||||
|
import type { StatsTimeseries } from "@/lib/types";
|
||||||
|
import { isEmptyTimeseries, layoutTimeseries, type BarLayout } from "./chartLayout";
|
||||||
|
|
||||||
|
// Series colors validated for CVD separation and 3:1 surface contrast in both
|
||||||
|
// modes (Tailwind red-500 / blue-500 / emerald-600; same hex light and dark).
|
||||||
|
const SERIES = [
|
||||||
|
{ key: "blocked", label: "Blocked", color: "#ef4444" },
|
||||||
|
{ key: "cached", label: "Cached", color: "#059669" },
|
||||||
|
{ key: "other", label: "Other", color: "#3b82f6" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CHART_HEIGHT = 240;
|
||||||
|
const FALLBACK_WIDTH = 640;
|
||||||
|
|
||||||
|
function useContainerWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [width, setWidth] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (el === null) return;
|
||||||
|
setWidth(el.clientWidth);
|
||||||
|
if (typeof ResizeObserver === "undefined") return;
|
||||||
|
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
return [ref, width];
|
||||||
|
}
|
||||||
|
|
||||||
|
const compact = new Intl.NumberFormat(undefined, { notation: "compact" });
|
||||||
|
|
||||||
|
function formatTick(ts: number, bucketSeconds: number): string {
|
||||||
|
const date = new Date(ts * 1000);
|
||||||
|
if (bucketSeconds >= 86_400) {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function barSummary(bar: BarLayout): string {
|
||||||
|
return `${formatTime(bar.bucket.ts)}: ${bar.bucket.queries} queries, ${bar.bucket.blocked} blocked, ${bar.bucket.cached} cached`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ bar, chartWidth }: { bar: BarLayout; chartWidth: number }) {
|
||||||
|
const centerX = bar.slot.x + bar.slot.width / 2;
|
||||||
|
const leftHalf = centerX < chartWidth / 2;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
style={leftHalf ? { left: Math.min(centerX + 8, chartWidth - 160) } : { right: chartWidth - centerX + 8 }}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{formatTime(bar.bucket.ts)}</div>
|
||||||
|
<dl className="mt-1 space-y-0.5">
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<dt className="text-zinc-500">Queries</dt>
|
||||||
|
<dd className="tabular-nums">{bar.bucket.queries}</dd>
|
||||||
|
</div>
|
||||||
|
{SERIES.map((series) => (
|
||||||
|
<div key={series.key} className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="flex items-center gap-1.5 text-zinc-500">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="inline-block size-2 rounded-xs"
|
||||||
|
style={{ backgroundColor: series.color }}
|
||||||
|
/>
|
||||||
|
{series.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="tabular-nums">{series.key === "other" ? bar.other : bar.bucket[series.key]}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimeseriesChart({ data }: { data: StatsTimeseries }) {
|
||||||
|
const [containerRef, measuredWidth] = useContainerWidth();
|
||||||
|
const [hovered, setHovered] = useState<number | null>(null);
|
||||||
|
const width = measuredWidth > 0 ? measuredWidth : FALLBACK_WIDTH;
|
||||||
|
|
||||||
|
if (data.buckets.length === 0 || isEmptyTimeseries(data.buckets)) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = layoutTimeseries(data.buckets, width, CHART_HEIGHT);
|
||||||
|
const baseline = layout.plot.y + layout.plot.height;
|
||||||
|
const hoveredBar = hovered !== null ? layout.bars[hovered] : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
aria-label={`Queries over time, ${data.buckets.length} buckets: blocked, cached and other queries per bucket`}
|
||||||
|
width="100%"
|
||||||
|
height={CHART_HEIGHT}
|
||||||
|
viewBox={`0 0 ${width} ${CHART_HEIGHT}`}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
>
|
||||||
|
{layout.yTicks.map((tick) => (
|
||||||
|
<g key={tick.value}>
|
||||||
|
<line
|
||||||
|
x1={layout.plot.x}
|
||||||
|
x2={layout.plot.x + layout.plot.width}
|
||||||
|
y1={tick.y}
|
||||||
|
y2={tick.y}
|
||||||
|
className="stroke-zinc-200 dark:stroke-zinc-800"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={layout.plot.x - 6}
|
||||||
|
y={tick.y}
|
||||||
|
textAnchor="end"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
className="fill-zinc-500 text-[10px] tabular-nums"
|
||||||
|
>
|
||||||
|
{compact.format(tick.value)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
<line
|
||||||
|
x1={layout.plot.x}
|
||||||
|
x2={layout.plot.x + layout.plot.width}
|
||||||
|
y1={baseline}
|
||||||
|
y2={baseline}
|
||||||
|
className="stroke-zinc-300 dark:stroke-zinc-700"
|
||||||
|
/>
|
||||||
|
{layout.xTicks.map((tick) => (
|
||||||
|
<text
|
||||||
|
key={tick.ts}
|
||||||
|
x={tick.x}
|
||||||
|
y={baseline + 14}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-zinc-500 text-[10px]"
|
||||||
|
>
|
||||||
|
{formatTick(tick.ts, data.bucket_seconds)}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{layout.bars.map((bar, i) => (
|
||||||
|
<g key={bar.bucket.ts} opacity={hovered === null || hovered === i ? 1 : 0.55}>
|
||||||
|
{SERIES.map((series) => {
|
||||||
|
const rect = bar.segments[series.key];
|
||||||
|
if (rect.height <= 0) return null;
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={series.key}
|
||||||
|
x={rect.x}
|
||||||
|
y={rect.y}
|
||||||
|
width={rect.width}
|
||||||
|
height={rect.height}
|
||||||
|
fill={series.color}
|
||||||
|
className="stroke-zinc-50 dark:stroke-zinc-950"
|
||||||
|
strokeWidth={rect.width > 3 ? 1 : 0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{layout.bars.map((bar, i) => (
|
||||||
|
<rect
|
||||||
|
key={bar.bucket.ts}
|
||||||
|
x={bar.slot.x}
|
||||||
|
y={bar.slot.y}
|
||||||
|
width={bar.slot.width}
|
||||||
|
height={bar.slot.height}
|
||||||
|
fill="transparent"
|
||||||
|
onMouseEnter={() => setHovered(i)}
|
||||||
|
>
|
||||||
|
<title>{barSummary(bar)}</title>
|
||||||
|
</rect>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{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">
|
||||||
|
{SERIES.map((series) => (
|
||||||
|
<li key={series.key} className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="inline-block size-2.5 rounded-xs"
|
||||||
|
style={{ backgroundColor: series.color }}
|
||||||
|
/>
|
||||||
|
{series.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<table className="sr-only">
|
||||||
|
<caption>Queries per time bucket</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Time</th>
|
||||||
|
<th scope="col">Queries</th>
|
||||||
|
<th scope="col">Blocked</th>
|
||||||
|
<th scope="col">Cached</th>
|
||||||
|
<th scope="col">Other</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{layout.bars.map((bar) => (
|
||||||
|
<tr key={bar.bucket.ts}>
|
||||||
|
<th scope="row">{formatTime(bar.bucket.ts)}</th>
|
||||||
|
<td>{bar.bucket.queries}</td>
|
||||||
|
<td>{bar.bucket.blocked}</td>
|
||||||
|
<td>{bar.bucket.cached}</td>
|
||||||
|
<td>{bar.other}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { UpstreamHealth } from "@/lib/types";
|
||||||
|
|
||||||
|
function YesNo({ value, badValue }: { value: boolean; badValue: boolean }) {
|
||||||
|
const bad = value === badValue;
|
||||||
|
return <span className={bad ? "text-red-700 dark:text-red-400" : ""}>{value ? "yes" : "no"}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UpstreamHealthTable({ health }: { health: UpstreamHealth }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<h2 className="flex items-baseline justify-between px-4 pt-3 text-sm font-semibold">
|
||||||
|
Upstreams
|
||||||
|
<span className="text-xs font-normal text-zinc-500 tabular-nums">
|
||||||
|
{health.available}/{health.total} available
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{health.upstreams.length === 0 ? (
|
||||||
|
<p className="px-4 py-3 text-sm text-zinc-500">No upstreams configured.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="mt-2 w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-200 text-left text-xs text-zinc-500 dark:border-zinc-800">
|
||||||
|
<th scope="col" className="px-4 py-2 font-medium">
|
||||||
|
URL
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-2 font-medium">
|
||||||
|
Enabled
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-2 font-medium">
|
||||||
|
Available
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-2 text-right font-medium">
|
||||||
|
Failures
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-2 text-right font-medium">
|
||||||
|
Success rate
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-2 font-medium">
|
||||||
|
Last error
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{health.upstreams.map((upstream) => (
|
||||||
|
<tr
|
||||||
|
key={upstream.url}
|
||||||
|
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800/50"
|
||||||
|
>
|
||||||
|
<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} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<YesNo value={upstream.available} badValue={false} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums">{upstream.total_failures}</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums">
|
||||||
|
{(upstream.success_rate * 100).toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-zinc-500">{upstream.last_error || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Bucket } from "@/lib/types";
|
||||||
|
import { MARGIN, isEmptyTimeseries, layoutTimeseries, niceTicks } from "./chartLayout";
|
||||||
|
|
||||||
|
function bucket(ts: number, queries: number, blocked = 0, cached = 0): Bucket {
|
||||||
|
return { ts, queries, blocked, cached };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("niceTicks", () => {
|
||||||
|
test("zero max yields a single zero tick", () => {
|
||||||
|
expect(niceTicks(0)).toEqual([0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("picks a 1/2/5 step and extends past max", () => {
|
||||||
|
expect(niceTicks(7)).toEqual([0, 2, 4, 6, 8]);
|
||||||
|
expect(niceTicks(100)).toEqual([0, 50, 100]);
|
||||||
|
expect(niceTicks(1234)).toEqual([0, 500, 1000, 1500]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isEmptyTimeseries", () => {
|
||||||
|
test("true for no buckets and for all-zero buckets", () => {
|
||||||
|
expect(isEmptyTimeseries([])).toBe(true);
|
||||||
|
expect(isEmptyTimeseries([bucket(0, 0), bucket(60, 0)])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("false when any bucket has queries", () => {
|
||||||
|
expect(isEmptyTimeseries([bucket(0, 0), bucket(60, 3)])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("layoutTimeseries", () => {
|
||||||
|
test("segment heights are proportional and stack to the queries total", () => {
|
||||||
|
const layout = layoutTimeseries([bucket(0, 100, 40, 10), bucket(60, 50, 0, 0)], 480, 240);
|
||||||
|
const plotHeight = 240 - MARGIN.top - MARGIN.bottom;
|
||||||
|
const baseline = MARGIN.top + plotHeight;
|
||||||
|
const [first, second] = layout.bars;
|
||||||
|
|
||||||
|
expect(layout.scaleMax).toBe(100);
|
||||||
|
expect(first.other).toBe(50);
|
||||||
|
expect(first.segments.blocked.height).toBeCloseTo(plotHeight * 0.4);
|
||||||
|
expect(first.segments.cached.height).toBeCloseTo(plotHeight * 0.1);
|
||||||
|
expect(first.segments.other.height).toBeCloseTo(plotHeight * 0.5);
|
||||||
|
expect(first.segments.blocked.y + first.segments.blocked.height).toBeCloseTo(baseline);
|
||||||
|
expect(first.segments.cached.y + first.segments.cached.height).toBeCloseTo(first.segments.blocked.y);
|
||||||
|
expect(first.segments.other.y + first.segments.other.height).toBeCloseTo(first.segments.cached.y);
|
||||||
|
expect(first.segments.other.y).toBeCloseTo(MARGIN.top);
|
||||||
|
expect(second.segments.other.height).toBeCloseTo(plotHeight * 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clamps other at zero when blocked + cached exceed queries", () => {
|
||||||
|
const layout = layoutTimeseries([bucket(0, 10, 8, 5)], 480, 240);
|
||||||
|
expect(layout.bars[0].other).toBe(0);
|
||||||
|
expect(layout.bars[0].segments.other.height).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero data still lays out zero-height bars on a unit scale", () => {
|
||||||
|
const layout = layoutTimeseries([bucket(0, 0), bucket(60, 0)], 480, 240);
|
||||||
|
expect(layout.scaleMax).toBe(1);
|
||||||
|
expect(layout.bars).toHaveLength(2);
|
||||||
|
for (const bar of layout.bars) {
|
||||||
|
expect(bar.segments.blocked.height).toBe(0);
|
||||||
|
expect(bar.segments.cached.height).toBe(0);
|
||||||
|
expect(bar.segments.other.height).toBe(0);
|
||||||
|
}
|
||||||
|
expect(layout.yTicks).toEqual([{ value: 0, y: MARGIN.top + (240 - MARGIN.top - MARGIN.bottom) }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single bucket fills the plot width minus the gap", () => {
|
||||||
|
const layout = layoutTimeseries([bucket(0, 5, 1, 1)], 480, 240);
|
||||||
|
const plotWidth = 480 - MARGIN.left - MARGIN.right;
|
||||||
|
const bar = layout.bars[0];
|
||||||
|
expect(bar.slot.width).toBeCloseTo(plotWidth);
|
||||||
|
expect(bar.segments.blocked.width).toBeCloseTo(plotWidth - 2);
|
||||||
|
expect(bar.segments.blocked.x).toBeCloseTo(MARGIN.left + 1);
|
||||||
|
expect(layout.xTicks).toEqual([{ ts: 0, x: MARGIN.left + plotWidth / 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("x ticks thin out when buckets outnumber the label budget", () => {
|
||||||
|
const buckets = Array.from({ length: 168 }, (_, i) => bucket(i * 3600, i));
|
||||||
|
const layout = layoutTimeseries(buckets, 800, 240);
|
||||||
|
expect(layout.xTicks.length).toBeLessThan(buckets.length / 10);
|
||||||
|
expect(layout.xTicks[0].ts).toBe(0);
|
||||||
|
const xs = layout.xTicks.map((tick) => tick.x);
|
||||||
|
expect([...xs].sort((a, b) => a - b)).toEqual(xs);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty bucket list yields no bars and no x ticks", () => {
|
||||||
|
const layout = layoutTimeseries([], 480, 240);
|
||||||
|
expect(layout.bars).toEqual([]);
|
||||||
|
expect(layout.xTicks).toEqual([]);
|
||||||
|
expect(layout.scaleMax).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { Bucket } from "@/lib/types";
|
||||||
|
|
||||||
|
export interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarLayout {
|
||||||
|
bucket: Bucket;
|
||||||
|
/** queries - blocked - cached, clamped at 0. */
|
||||||
|
other: number;
|
||||||
|
slot: Rect;
|
||||||
|
segments: {
|
||||||
|
blocked: Rect;
|
||||||
|
cached: Rect;
|
||||||
|
other: Rect;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartLayout {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
plot: Rect;
|
||||||
|
scaleMax: number;
|
||||||
|
bars: BarLayout[];
|
||||||
|
yTicks: { value: number; y: number }[];
|
||||||
|
xTicks: { ts: number; x: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;
|
||||||
|
const BAR_GAP = 2;
|
||||||
|
const MIN_X_LABEL_PX = 90;
|
||||||
|
|
||||||
|
/** Tick values from 0 upward in a 1/2/5 step, extended until the last tick covers `max`. */
|
||||||
|
export function niceTicks(max: number, targetCount = 4): number[] {
|
||||||
|
if (max <= 0) return [0];
|
||||||
|
const rawStep = max / targetCount;
|
||||||
|
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||||
|
const normalized = rawStep / magnitude;
|
||||||
|
const step = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
|
||||||
|
const ticks: number[] = [];
|
||||||
|
for (let value = 0; ; value += step) {
|
||||||
|
ticks.push(value);
|
||||||
|
if (value >= max) break;
|
||||||
|
}
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmptyTimeseries(buckets: Bucket[]): boolean {
|
||||||
|
return buckets.every((bucket) => bucket.queries === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutTimeseries(buckets: Bucket[], width: number, height: number): ChartLayout {
|
||||||
|
const plot: Rect = {
|
||||||
|
x: MARGIN.left,
|
||||||
|
y: MARGIN.top,
|
||||||
|
width: Math.max(0, width - MARGIN.left - MARGIN.right),
|
||||||
|
height: Math.max(0, height - MARGIN.top - MARGIN.bottom),
|
||||||
|
};
|
||||||
|
const maxQueries = buckets.reduce((max, bucket) => Math.max(max, bucket.queries), 0);
|
||||||
|
const tickValues = niceTicks(maxQueries);
|
||||||
|
const scaleMax = Math.max(tickValues[tickValues.length - 1], 1);
|
||||||
|
const baseline = plot.y + plot.height;
|
||||||
|
const toHeight = (value: number) => (value / scaleMax) * plot.height;
|
||||||
|
|
||||||
|
const slotWidth = buckets.length > 0 ? plot.width / buckets.length : 0;
|
||||||
|
const barWidth = Math.max(1, slotWidth - BAR_GAP);
|
||||||
|
|
||||||
|
const bars: BarLayout[] = buckets.map((bucket, i) => {
|
||||||
|
const slotX = plot.x + i * slotWidth;
|
||||||
|
const barX = slotX + (slotWidth - barWidth) / 2;
|
||||||
|
const other = Math.max(0, bucket.queries - bucket.blocked - bucket.cached);
|
||||||
|
const blockedH = toHeight(bucket.blocked);
|
||||||
|
const cachedH = toHeight(bucket.cached);
|
||||||
|
const otherH = toHeight(other);
|
||||||
|
return {
|
||||||
|
bucket,
|
||||||
|
other,
|
||||||
|
slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height },
|
||||||
|
segments: {
|
||||||
|
blocked: { x: barX, y: baseline - blockedH, width: barWidth, height: blockedH },
|
||||||
|
cached: { x: barX, y: baseline - blockedH - cachedH, width: barWidth, height: cachedH },
|
||||||
|
other: { x: barX, y: baseline - blockedH - cachedH - otherH, width: barWidth, height: otherH },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const yTicks = tickValues.map((value) => ({ value, y: baseline - toHeight(value) }));
|
||||||
|
|
||||||
|
const labelStep =
|
||||||
|
buckets.length > 0 && plot.width > 0
|
||||||
|
? Math.max(1, Math.ceil((buckets.length * MIN_X_LABEL_PX) / plot.width))
|
||||||
|
: 1;
|
||||||
|
const xTicks = bars
|
||||||
|
.filter((_, i) => i % labelStep === 0)
|
||||||
|
.map((bar) => ({ ts: bar.bucket.ts, x: bar.slot.x + bar.slot.width / 2 }));
|
||||||
|
|
||||||
|
return { width, height, plot, scaleMax, bars, yTicks, xTicks };
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
|
||||||
|
import type { Blocklist } from "@/lib/types";
|
||||||
|
import { sameSet, toggleSource } from "./sourceSet";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groupId: number;
|
||||||
|
blocklists: Blocklist[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const sources = useQuery(groupSourcesQuery(groupId));
|
||||||
|
const mutation = useMutation(groupSourcesPutMutation(queryClient));
|
||||||
|
const [selected, setSelected] = useState<number[] | null>(null);
|
||||||
|
|
||||||
|
if (sources.isPending) {
|
||||||
|
return (
|
||||||
|
<p role="status" className="mt-3 text-sm text-zinc-500">
|
||||||
|
Loading sources…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sources.isError) return <InlineError error={sources.error} />;
|
||||||
|
|
||||||
|
if (blocklists.length === 0) {
|
||||||
|
return (
|
||||||
|
<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 dirty = !sameSet(current, sources.data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{blocklists.map((blocklist) => (
|
||||||
|
<li key={blocklist.id}>
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={current.includes(blocklist.id)}
|
||||||
|
onChange={() => setSelected(toggleSource(current, blocklist.id))}
|
||||||
|
/>
|
||||||
|
{blocklist.name}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<InlineError error={mutation.error} />
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!dirty || mutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
|
||||||
|
}
|
||||||
|
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
>
|
||||||
|
Save sources
|
||||||
|
</button>
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
|
import type { Mock } from "vitest";
|
||||||
|
import { AuthProvider } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
|
||||||
|
const GROUPS = {
|
||||||
|
groups: [
|
||||||
|
{ id: 1, name: "default", safe_search: false },
|
||||||
|
{ id: 2, name: "kids", safe_search: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLOCKLISTS = {
|
||||||
|
blocklists: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
url: "https://example.com/ads.txt",
|
||||||
|
name: "Ads",
|
||||||
|
enabled: true,
|
||||||
|
is_suggested: false,
|
||||||
|
last_updated: null,
|
||||||
|
domain_count: 100,
|
||||||
|
wildcard_count: 0,
|
||||||
|
skipped_regex_count: 0,
|
||||||
|
checksum: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
url: "https://example.com/malware.txt",
|
||||||
|
name: "Malware",
|
||||||
|
enabled: true,
|
||||||
|
is_suggested: false,
|
||||||
|
last_updated: null,
|
||||||
|
domain_count: 50,
|
||||||
|
wildcard_count: 0,
|
||||||
|
skipped_regex_count: 0,
|
||||||
|
checksum: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||||
|
|
||||||
|
const BASE = {
|
||||||
|
"GET /api/groups": GROUPS,
|
||||||
|
"GET /api/blocklists": BLOCKLISTS,
|
||||||
|
"GET /api/version": VERSION,
|
||||||
|
"GET /api/groups/2/sources": { source_ids: [1] },
|
||||||
|
"PUT /api/groups/2/sources": { source_ids: [1, 2] },
|
||||||
|
};
|
||||||
|
|
||||||
|
function stubFetch(map: Record<string, unknown>) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const key = `${init?.method ?? "GET"} ${String(input)}`;
|
||||||
|
const payload = map[key];
|
||||||
|
if (payload === undefined) {
|
||||||
|
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderGroupsPage(map: Record<string, unknown>) {
|
||||||
|
stubFetch(map);
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/groups"] }), queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByRole("heading", { name: "Groups" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupRow(name: string): HTMLElement {
|
||||||
|
const row = screen.getByText(name).closest("li");
|
||||||
|
if (row === null) throw new Error(`no row for group ${name}`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists groups; the default group blocks rename and delete client-side", async () => {
|
||||||
|
await renderGroupsPage(BASE);
|
||||||
|
|
||||||
|
const defaultRow = groupRow("default");
|
||||||
|
expect((within(defaultRow).getByRole("button", { name: "Rename" }) as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
expect((within(defaultRow).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
expect(within(defaultRow).getByText("The default group cannot be renamed or deleted.")).toBeTruthy();
|
||||||
|
|
||||||
|
const kidsRow = groupRow("kids");
|
||||||
|
expect((within(kidsRow).getByRole("button", { name: "Rename" }) as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
expect((within(kidsRow).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
expect((within(kidsRow).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expanding sources loads the set, toggling saves the full set via PUT", async () => {
|
||||||
|
await renderGroupsPage(BASE);
|
||||||
|
|
||||||
|
const kidsRow = groupRow("kids");
|
||||||
|
fireEvent.click(within(kidsRow).getByRole("button", { name: "Sources" }));
|
||||||
|
|
||||||
|
const ads = (await within(kidsRow).findByRole("checkbox", { name: "Ads" })) as HTMLInputElement;
|
||||||
|
const malware = within(kidsRow).getByRole("checkbox", { name: "Malware" }) as HTMLInputElement;
|
||||||
|
expect(ads.checked).toBe(true);
|
||||||
|
expect(malware.checked).toBe(false);
|
||||||
|
|
||||||
|
const save = within(kidsRow).getByRole("button", { name: "Save sources" }) as HTMLButtonElement;
|
||||||
|
expect(save.disabled).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.click(malware);
|
||||||
|
expect(save.disabled).toBe(false);
|
||||||
|
fireEvent.click(save);
|
||||||
|
|
||||||
|
await waitFor(() => expect(save.disabled).toBe(true));
|
||||||
|
const calls = (fetch as unknown as Mock).mock.calls as [RequestInfo | URL, RequestInit | undefined][];
|
||||||
|
const put = calls.find(([, init]) => init?.method === "PUT");
|
||||||
|
expect(put).toBeTruthy();
|
||||||
|
expect(String(put![0])).toBe("/api/groups/2/sources");
|
||||||
|
expect(JSON.parse(String(put![1]?.body))).toEqual({ source_ids: [1, 2] });
|
||||||
|
expect(malware.checked).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
blocklistsQuery,
|
||||||
|
groupCreateMutation,
|
||||||
|
groupDeleteMutation,
|
||||||
|
groupsQuery,
|
||||||
|
groupUpdateMutation,
|
||||||
|
} from "@/lib/queries";
|
||||||
|
import type { Blocklist, Group } from "@/lib/types";
|
||||||
|
import GroupSourcesEditor from "./GroupSourcesEditor";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
|
||||||
|
const DEFAULT_GROUP_ID = 1;
|
||||||
|
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
|
||||||
|
|
||||||
|
const inputClass = "rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
|
||||||
|
const buttonClass =
|
||||||
|
"rounded border border-zinc-300 px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 dark:border-zinc-700";
|
||||||
|
const primaryButtonClass =
|
||||||
|
"rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
|
||||||
|
|
||||||
|
export default function GroupsPage() {
|
||||||
|
const { data: groups } = useSuspenseQuery(groupsQuery());
|
||||||
|
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const createMutation = useMutation(groupCreateMutation(queryClient));
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-semibold">Groups</h1>
|
||||||
|
<form
|
||||||
|
className="mt-4 flex flex-wrap items-center gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = newName.trim();
|
||||||
|
if (name === "") return;
|
||||||
|
createMutation.mutate({ name }, { onSuccess: () => setNewName("") });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="text-sm font-medium" htmlFor="new-group-name">
|
||||||
|
New group
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-group-name"
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(event) => setNewName(event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={createMutation.isPending} className={primaryButtonClass}>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<InlineError error={createMutation.error} />
|
||||||
|
<ul className="mt-6 space-y-4">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<GroupRow key={group.id} group={group} blocklists={blocklists} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[] }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const updateMutation = useMutation(groupUpdateMutation(queryClient));
|
||||||
|
const deleteMutation = useMutation(groupDeleteMutation(queryClient));
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [name, setName] = useState(group.name);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const isDefault = group.id === DEFAULT_GROUP_ID;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="rounded border border-zinc-200 p-4 dark:border-zinc-700">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{renaming ? (
|
||||||
|
<form
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (trimmed === "") return;
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ id: group.id, input: { name: trimmed, safe_search: group.safe_search } },
|
||||||
|
{ onSuccess: () => setRenaming(false) },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label={`New name for ${group.name}`}
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={updateMutation.isPending} className={buttonClass}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setName(group.name);
|
||||||
|
setRenaming(false);
|
||||||
|
}}
|
||||||
|
className={buttonClass}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{group.name}</span>
|
||||||
|
)}
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={group.safe_search}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateMutation.mutate({
|
||||||
|
id: group.id,
|
||||||
|
input: { name: group.name, safe_search: event.target.checked },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Safe search
|
||||||
|
</label>
|
||||||
|
<span className="ml-auto inline-flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => setExpanded((open) => !open)}
|
||||||
|
className={buttonClass}
|
||||||
|
>
|
||||||
|
Sources
|
||||||
|
</button>
|
||||||
|
{!renaming && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isDefault}
|
||||||
|
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
setName(group.name);
|
||||||
|
setRenaming(true);
|
||||||
|
}}
|
||||||
|
className={buttonClass}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{confirming ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirming(false);
|
||||||
|
deleteMutation.mutate(group.id);
|
||||||
|
}}
|
||||||
|
className={`${buttonClass} text-red-700 dark:text-red-400`}
|
||||||
|
>
|
||||||
|
Confirm delete
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setConfirming(false)} className={buttonClass}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isDefault}
|
||||||
|
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
className={`${buttonClass} text-red-700 dark:text-red-400`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isDefault && <p className="mt-2 text-xs text-zinc-500">{DEFAULT_GROUP_NOTE}</p>}
|
||||||
|
<InlineError error={updateMutation.error ?? deleteMutation.error} />
|
||||||
|
{expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { sameSet, toggleSource } from "./sourceSet";
|
||||||
|
|
||||||
|
test("toggleSource adds a missing id keeping ascending order", () => {
|
||||||
|
expect(toggleSource([1, 3], 2)).toEqual([1, 2, 3]);
|
||||||
|
expect(toggleSource([], 5)).toEqual([5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toggleSource removes a present id", () => {
|
||||||
|
expect(toggleSource([1, 2, 3], 2)).toEqual([1, 3]);
|
||||||
|
expect(toggleSource([5], 5)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toggleSource twice is a no-op set-wise", () => {
|
||||||
|
expect(toggleSource(toggleSource([1, 2], 3), 3)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sameSet compares regardless of order", () => {
|
||||||
|
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
|
||||||
|
expect(sameSet([], [])).toBe(true);
|
||||||
|
expect(sameSet([1, 2], [1, 2, 3])).toBe(false);
|
||||||
|
expect(sameSet([1, 2], [1, 4])).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function toggleSource(ids: number[], id: number): number[] {
|
||||||
|
if (ids.includes(id)) return ids.filter((existing) => existing !== id);
|
||||||
|
return [...ids, id].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameSet(a: number[], b: number[]): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
const sortedA = [...a].sort((x, y) => x - y);
|
||||||
|
const sortedB = [...b].sort((x, y) => x - y);
|
||||||
|
return sortedA.every((value, i) => value === sortedB[i]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import type { LiveQueryEvent } from "@/lib/types";
|
||||||
|
import { FakeEventSource } from "./fakeEventSource";
|
||||||
|
import LiveLogPage from "./LiveLogPage";
|
||||||
|
|
||||||
|
function frame(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): { data: string } {
|
||||||
|
const payload: LiveQueryEvent = {
|
||||||
|
ts,
|
||||||
|
domain,
|
||||||
|
client_ip: "192.0.2.10",
|
||||||
|
qtype: 1,
|
||||||
|
blocked: false,
|
||||||
|
block_reason: "",
|
||||||
|
response_time_us: 500,
|
||||||
|
cache_hit: true,
|
||||||
|
upstream: "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return { data: JSON.stringify(payload) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const sources: FakeEventSource[] = [];
|
||||||
|
const createEventSource = (url: string) => {
|
||||||
|
const es = new FakeEventSource(url);
|
||||||
|
sources.push(es);
|
||||||
|
return es;
|
||||||
|
};
|
||||||
|
render(<LiveLogPage createEventSource={createEventSource} />);
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("streams rows, flags blocked ones, and freezes the display", () => {
|
||||||
|
const sources = renderPage();
|
||||||
|
expect(screen.getByText("Connecting…")).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
expect(screen.getByRole("status", { name: "Live" })).toBeTruthy();
|
||||||
|
expect(screen.getByText("Waiting for queries…")).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
sources[0]!.emit("query", frame(1000, "ok.example"));
|
||||||
|
sources[0]!.emit(
|
||||||
|
"query",
|
||||||
|
frame(1001, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack", qtype: 28 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("ok.example")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Blocked")).toBeTruthy();
|
||||||
|
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
|
||||||
|
expect(screen.getByText("AAAA")).toBeTruthy();
|
||||||
|
const blockedRow = screen.getByText("ads.example").closest("tr");
|
||||||
|
expect(blockedRow?.className).toContain("bg-red-50");
|
||||||
|
|
||||||
|
const freeze = screen.getByRole("button", { name: "Freeze" });
|
||||||
|
fireEvent.click(freeze);
|
||||||
|
expect(freeze.getAttribute("aria-pressed")).toBe("true");
|
||||||
|
|
||||||
|
act(() => sources[0]!.emit("query", frame(1002, "later.example")));
|
||||||
|
expect(screen.queryByText("later.example")).toBeNull();
|
||||||
|
expect(screen.getByText(/3 in buffer/)).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
||||||
|
expect(screen.getByText("later.example")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated connection failures show the viewer-cap state with a retry button", () => {
|
||||||
|
const sources = renderPage();
|
||||||
|
act(() => {
|
||||||
|
sources[0]!.emit("error");
|
||||||
|
sources[0]!.emit("error");
|
||||||
|
sources[0]!.emit("error");
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("alert").textContent).toContain("too many live viewers");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
expect(sources).toHaveLength(2);
|
||||||
|
expect(screen.getByText("Connecting…")).toBeTruthy();
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
|
||||||
|
import { RING_CAPACITY } from "./ringBuffer";
|
||||||
|
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
|
||||||
|
|
||||||
|
const buttonClass =
|
||||||
|
"rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: StreamStatus }) {
|
||||||
|
const styles: Record<StreamStatus, [string, string]> = {
|
||||||
|
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 (
|
||||||
|
<span role="status" aria-label={label} className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${tone}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freeze is display-only: the stream stays open and the 500-row ring buffer keeps
|
||||||
|
* filling; Resume shows the current buffer (anything pushed out meanwhile is gone). */
|
||||||
|
export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) {
|
||||||
|
const live = useLiveQueries({ createEventSource });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold">Live</h1>
|
||||||
|
<StatusPill status={live.status} />
|
||||||
|
<button type="button" onClick={live.toggleFreeze} aria-pressed={live.frozen} className={buttonClass}>
|
||||||
|
{live.frozen ? "Resume" : "Freeze"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{live.frozen && (
|
||||||
|
<p className="mt-2 text-sm text-zinc-500" role="status">
|
||||||
|
Display frozen — new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
|
||||||
|
kept).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{live.missed !== null && (
|
||||||
|
<div
|
||||||
|
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>
|
||||||
|
Stream resumed —{" "}
|
||||||
|
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={live.dismissMissed} className="font-medium underline">
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{live.resyncFailed && (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-red-700 dark:text-red-300">
|
||||||
|
Stream resumed, but re-syncing the gap failed — some queries may be missing here.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{live.status === "capped" && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 rounded border border-red-300 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
address), or the server is unreachable.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={live.retry}
|
||||||
|
className="mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-red-800 dark:text-red-200"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{live.rows.length === 0 ? (
|
||||||
|
live.status !== "capped" && (
|
||||||
|
<p className="mt-6 text-zinc-500">
|
||||||
|
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 overflow-x-auto rounded border border-zinc-200 dark:border-zinc-800">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<QueryTableHead />
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
|
{live.rows.map((row) => (
|
||||||
|
<tr key={row.key} className={row.blocked ? "bg-red-50 dark:bg-red-950/40" : ""}>
|
||||||
|
<QueryCells row={row} />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-zinc-500">
|
||||||
|
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
|
||||||
|
{RING_CAPACITY} kept).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { EventSourceLike } from "./useLiveQueries";
|
||||||
|
|
||||||
|
/** Test double for the injected EventSource constructor. */
|
||||||
|
export class FakeEventSource implements EventSourceLike {
|
||||||
|
readonly url: string;
|
||||||
|
closed = false;
|
||||||
|
private listeners = new Map<string, Array<(event: { data?: unknown }) => void>>();
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void {
|
||||||
|
const existing = this.listeners.get(type) ?? [];
|
||||||
|
existing.push(listener);
|
||||||
|
this.listeners.set(type, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(type: string, event: { data?: unknown } = {}): void {
|
||||||
|
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
|
||||||
|
import { RING_CAPACITY, mergeGap, pushRow, type LiveRow } from "./ringBuffer";
|
||||||
|
|
||||||
|
function event(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): LiveQueryEvent {
|
||||||
|
return {
|
||||||
|
ts,
|
||||||
|
domain,
|
||||||
|
client_ip: "192.0.2.10",
|
||||||
|
qtype: 1,
|
||||||
|
blocked: false,
|
||||||
|
block_reason: "",
|
||||||
|
response_time_us: 500,
|
||||||
|
cache_hit: false,
|
||||||
|
upstream: "udp://9.9.9.9:53",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveRow(key: number, ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): LiveRow {
|
||||||
|
return { ...event(ts, domain, overrides), key };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchedRow(id: number, ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): QueryRow {
|
||||||
|
return { id, ...event(ts, domain, overrides) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function counter(start = 100): () => number {
|
||||||
|
let n = start;
|
||||||
|
return () => ++n;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pushRow", () => {
|
||||||
|
test("prepends newest-first", () => {
|
||||||
|
let rows: LiveRow[] = [];
|
||||||
|
rows = pushRow(rows, liveRow(1, 10, "a.example"));
|
||||||
|
rows = pushRow(rows, liveRow(2, 11, "b.example"));
|
||||||
|
expect(rows.map((r) => r.domain)).toEqual(["b.example", "a.example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops the oldest beyond capacity", () => {
|
||||||
|
let rows: LiveRow[] = [];
|
||||||
|
for (let i = 0; i < 5; i++) rows = pushRow(rows, liveRow(i, i, `d${i}.example`), 3);
|
||||||
|
expect(rows).toHaveLength(3);
|
||||||
|
expect(rows.map((r) => r.key)).toEqual([4, 3, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default capacity is 500", () => {
|
||||||
|
let rows: LiveRow[] = [];
|
||||||
|
for (let i = 0; i < RING_CAPACITY + 10; i++) rows = pushRow(rows, liveRow(i, i, "x.example"));
|
||||||
|
expect(rows).toHaveLength(RING_CAPACITY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeGap", () => {
|
||||||
|
test("skips rows already in the buffer and counts only new ones", () => {
|
||||||
|
const buffer = [liveRow(2, 100, "seen.example"), liveRow(1, 99, "old.example")];
|
||||||
|
const fetched = [
|
||||||
|
fetchedRow(30, 102, "gap2.example"),
|
||||||
|
fetchedRow(29, 101, "gap1.example"),
|
||||||
|
fetchedRow(28, 100, "seen.example"),
|
||||||
|
];
|
||||||
|
const { rows, missed } = mergeGap(buffer, fetched, counter());
|
||||||
|
expect(missed).toBe(2);
|
||||||
|
expect(rows.map((r) => r.domain)).toEqual(["gap2.example", "gap1.example", "seen.example", "old.example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no additions returns the buffer unchanged with missed 0", () => {
|
||||||
|
const buffer = [liveRow(1, 100, "seen.example")];
|
||||||
|
const { rows, missed } = mergeGap(buffer, [fetchedRow(5, 100, "seen.example")], counter());
|
||||||
|
expect(missed).toBe(0);
|
||||||
|
expect(rows).toBe(buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rows differing only in qtype are not deduplicated", () => {
|
||||||
|
const buffer = [liveRow(1, 100, "dual.example", { qtype: 1 })];
|
||||||
|
const fetched = [fetchedRow(5, 100, "dual.example", { qtype: 28 })];
|
||||||
|
const { missed } = mergeGap(buffer, fetched, counter());
|
||||||
|
expect(missed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assigns fresh keys from the counter and drops the id", () => {
|
||||||
|
const { rows } = mergeGap([], [fetchedRow(77, 100, "gap.example")], counter(200));
|
||||||
|
expect(rows[0]?.key).toBe(201);
|
||||||
|
expect("id" in (rows[0] ?? {})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("result is capped at capacity, keeping the newest", () => {
|
||||||
|
const buffer = [liveRow(3, 300, "live.example")];
|
||||||
|
const fetched = [fetchedRow(2, 302, "g2.example"), fetchedRow(1, 301, "g1.example")];
|
||||||
|
const { rows, missed } = mergeGap(buffer, fetched, counter(), 2);
|
||||||
|
expect(missed).toBe(2);
|
||||||
|
expect(rows.map((r) => r.domain)).toEqual(["g2.example", "g1.example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merged rows stay sorted newest-first by ts", () => {
|
||||||
|
const buffer = [liveRow(4, 105, "after-reopen.example"), liveRow(3, 100, "before.example")];
|
||||||
|
const fetched = [fetchedRow(9, 103, "gap.example")];
|
||||||
|
const { rows } = mergeGap(buffer, fetched, counter());
|
||||||
|
expect(rows.map((r) => r.ts)).toEqual([105, 103, 100]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
|
||||||
|
|
||||||
|
/** A live stream row; `key` is a client-side monotonic counter (SSE frames carry no id). */
|
||||||
|
export interface LiveRow extends LiveQueryEvent {
|
||||||
|
key: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RING_CAPACITY = 500;
|
||||||
|
|
||||||
|
/** Prepend `row` (rows are newest-first) and drop the oldest beyond `capacity`. */
|
||||||
|
export function pushRow(rows: LiveRow[], row: LiveRow, capacity: number = RING_CAPACITY): LiveRow[] {
|
||||||
|
const next = [row, ...rows];
|
||||||
|
return next.length > capacity ? next.slice(0, capacity) : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `since` on GET /api/queries is inclusive, so the re-sync fetch returns the
|
||||||
|
// last-seen row(s) again; live rows have no id, so identity is this tuple.
|
||||||
|
function signature(row: LiveQueryEvent): string {
|
||||||
|
return `${row.ts}|${row.domain}|${row.client_ip}|${row.qtype ?? -1}|${row.blocked}|${row.upstream}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge rows fetched for a reconnect gap (newest-first, from GET /api/queries)
|
||||||
|
* into the buffer. Rows already present are skipped; `missed` counts what was
|
||||||
|
* actually added. The result stays newest-first (stable sort by ts) and capped.
|
||||||
|
*/
|
||||||
|
export function mergeGap(
|
||||||
|
rows: LiveRow[],
|
||||||
|
fetched: QueryRow[],
|
||||||
|
nextKey: () => number,
|
||||||
|
capacity: number = RING_CAPACITY,
|
||||||
|
): { rows: LiveRow[]; missed: number } {
|
||||||
|
const seen = new Set(rows.map(signature));
|
||||||
|
const added: LiveRow[] = [];
|
||||||
|
for (const row of fetched) {
|
||||||
|
const event: LiveQueryEvent = {
|
||||||
|
ts: row.ts,
|
||||||
|
domain: row.domain,
|
||||||
|
client_ip: row.client_ip,
|
||||||
|
qtype: row.qtype,
|
||||||
|
blocked: row.blocked,
|
||||||
|
block_reason: row.block_reason,
|
||||||
|
response_time_us: row.response_time_us,
|
||||||
|
cache_hit: row.cache_hit,
|
||||||
|
upstream: row.upstream,
|
||||||
|
};
|
||||||
|
if (seen.has(signature(event))) continue;
|
||||||
|
added.push({ ...event, key: nextKey() });
|
||||||
|
}
|
||||||
|
if (added.length === 0) return { rows, missed: 0 };
|
||||||
|
const merged = [...added, ...rows].sort((a, b) => b.ts - a.ts).slice(0, capacity);
|
||||||
|
return { rows: merged, missed: added.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import type { LiveQueryEvent, QueriesPage, QueryRow } from "@/lib/types";
|
||||||
|
import { FakeEventSource } from "./fakeEventSource";
|
||||||
|
import { CAP_ERROR_THRESHOLD, useLiveQueries } from "./useLiveQueries";
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
function stubLocationAssign() {
|
||||||
|
const assign = vi.fn();
|
||||||
|
vi.stubGlobal("location", { pathname: "/live", search: "", assign });
|
||||||
|
return assign;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): { data: string } {
|
||||||
|
const payload: LiveQueryEvent = {
|
||||||
|
ts,
|
||||||
|
domain,
|
||||||
|
client_ip: "192.0.2.10",
|
||||||
|
qtype: 1,
|
||||||
|
blocked: false,
|
||||||
|
block_reason: "",
|
||||||
|
response_time_us: 500,
|
||||||
|
cache_hit: false,
|
||||||
|
upstream: "udp://9.9.9.9:53",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return { data: JSON.stringify(payload) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchedRow(id: number, ts: number, domain: string): QueryRow {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
ts,
|
||||||
|
domain,
|
||||||
|
client_ip: "192.0.2.10",
|
||||||
|
qtype: 1,
|
||||||
|
blocked: false,
|
||||||
|
block_reason: "",
|
||||||
|
response_time_us: 500,
|
||||||
|
cache_hit: false,
|
||||||
|
upstream: "udp://9.9.9.9:53",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(fetchSince?: (since: number) => Promise<QueriesPage>, probeSession?: () => Promise<unknown>) {
|
||||||
|
const sources: FakeEventSource[] = [];
|
||||||
|
const createEventSource = (url: string) => {
|
||||||
|
const es = new FakeEventSource(url);
|
||||||
|
sources.push(es);
|
||||||
|
return es;
|
||||||
|
};
|
||||||
|
const probe = probeSession ?? (() => Promise.resolve());
|
||||||
|
const hook = renderHook(() => useLiveQueries({ createEventSource, fetchSince, probeSession: probe }));
|
||||||
|
return { sources, hook };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("open then frames: rows newest-first with increasing keys", () => {
|
||||||
|
const { sources, hook } = setup();
|
||||||
|
expect(sources).toHaveLength(1);
|
||||||
|
expect(hook.result.current.status).toBe("connecting");
|
||||||
|
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
expect(hook.result.current.status).toBe("open");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
sources[0]!.emit("query", frame(1000, "a.example"));
|
||||||
|
sources[0]!.emit("query", frame(1001, "b.example"));
|
||||||
|
});
|
||||||
|
const rows = hook.result.current.rows;
|
||||||
|
expect(rows.map((r) => r.domain)).toEqual(["b.example", "a.example"]);
|
||||||
|
expect(rows[0]!.key).toBeGreaterThan(rows[1]!.key);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed and non-string frames are ignored", () => {
|
||||||
|
const { sources, hook } = setup();
|
||||||
|
act(() => {
|
||||||
|
sources[0]!.emit("open");
|
||||||
|
sources[0]!.emit("query", { data: "{not json" });
|
||||||
|
sources[0]!.emit("query", {});
|
||||||
|
});
|
||||||
|
expect(hook.result.current.rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error then reopen re-syncs the gap since the last seen ts", async () => {
|
||||||
|
const fetchSince = vi.fn((since: number): Promise<QueriesPage> => {
|
||||||
|
return Promise.resolve({
|
||||||
|
queries: [fetchedRow(9, 1002, "gap.example"), fetchedRow(8, since, "a.example")],
|
||||||
|
next_before: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const { sources, hook } = setup(fetchSince);
|
||||||
|
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
expect(fetchSince).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
|
||||||
|
act(() => sources[0]!.emit("error"));
|
||||||
|
expect(hook.result.current.status).toBe("retrying");
|
||||||
|
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
expect(hook.result.current.status).toBe("open");
|
||||||
|
expect(fetchSince).toHaveBeenCalledWith(1000);
|
||||||
|
|
||||||
|
await waitFor(() => expect(hook.result.current.missed).toBe(1));
|
||||||
|
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["gap.example", "a.example"]);
|
||||||
|
|
||||||
|
act(() => hook.result.current.dismissMissed());
|
||||||
|
expect(hook.result.current.missed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed re-sync sets resyncFailed", async () => {
|
||||||
|
const fetchSince = vi.fn((): Promise<QueriesPage> => Promise.reject(new Error("boom")));
|
||||||
|
const { sources, hook } = setup(fetchSince);
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
|
||||||
|
act(() => sources[0]!.emit("error"));
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
await waitFor(() => expect(hook.result.current.resyncFailed).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 401 gap re-sync redirects to login instead of setting resyncFailed", async () => {
|
||||||
|
const assign = stubLocationAssign();
|
||||||
|
const fetchSince = vi.fn((): Promise<QueriesPage> => Promise.reject(new ApiError(401, "unauthorized")));
|
||||||
|
const { sources, hook } = setup(fetchSince);
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
|
||||||
|
act(() => sources[0]!.emit("error"));
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||||
|
expect(hook.result.current.resyncFailed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cap trip with a valid session probes once and stays capped", async () => {
|
||||||
|
const assign = stubLocationAssign();
|
||||||
|
const probeSession = vi.fn((): Promise<unknown> => Promise.resolve({}));
|
||||||
|
const { sources, hook } = setup(undefined, probeSession);
|
||||||
|
act(() => {
|
||||||
|
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
|
||||||
|
});
|
||||||
|
expect(hook.result.current.status).toBe("capped");
|
||||||
|
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||||
|
await act(async () => {});
|
||||||
|
expect(assign).not.toHaveBeenCalled();
|
||||||
|
expect(hook.result.current.status).toBe("capped");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cap trip with an expired session redirects to login", async () => {
|
||||||
|
const assign = stubLocationAssign();
|
||||||
|
const probeSession = vi.fn((): Promise<unknown> => Promise.reject(new ApiError(401, "unauthorized")));
|
||||||
|
const { sources } = setup(undefined, probeSession);
|
||||||
|
act(() => {
|
||||||
|
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||||
|
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated errors without open hit the cap state; retry reconnects", () => {
|
||||||
|
const { sources, hook } = setup();
|
||||||
|
act(() => {
|
||||||
|
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
|
||||||
|
});
|
||||||
|
expect(hook.result.current.status).toBe("capped");
|
||||||
|
expect(sources[0]!.closed).toBe(true);
|
||||||
|
|
||||||
|
act(() => hook.result.current.retry());
|
||||||
|
expect(sources).toHaveLength(2);
|
||||||
|
expect(hook.result.current.status).toBe("connecting");
|
||||||
|
|
||||||
|
act(() => sources[1]!.emit("open"));
|
||||||
|
expect(hook.result.current.status).toBe("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a successful open resets the consecutive error count", () => {
|
||||||
|
const { sources, hook } = setup();
|
||||||
|
act(() => sources[0]!.emit("error"));
|
||||||
|
act(() => sources[0]!.emit("error"));
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
act(() => sources[0]!.emit("error"));
|
||||||
|
expect(hook.result.current.status).toBe("retrying");
|
||||||
|
expect(sources[0]!.closed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("freeze keeps the display fixed while the buffer keeps filling", () => {
|
||||||
|
const { sources, hook } = setup();
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
|
||||||
|
|
||||||
|
act(() => hook.result.current.toggleFreeze());
|
||||||
|
expect(hook.result.current.frozen).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
sources[0]!.emit("query", frame(1001, "b.example"));
|
||||||
|
sources[0]!.emit("query", frame(1002, "c.example"));
|
||||||
|
});
|
||||||
|
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["a.example"]);
|
||||||
|
expect(hook.result.current.liveCount).toBe(3);
|
||||||
|
|
||||||
|
act(() => hook.result.current.toggleFreeze());
|
||||||
|
expect(hook.result.current.frozen).toBe(false);
|
||||||
|
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["c.example", "b.example", "a.example"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stale sources are ignored after retry and closed on unmount", () => {
|
||||||
|
const { sources, hook } = setup();
|
||||||
|
act(() => hook.result.current.retry());
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "stale.example")));
|
||||||
|
expect(hook.result.current.rows).toHaveLength(0);
|
||||||
|
|
||||||
|
hook.unmount();
|
||||||
|
expect(sources[1]!.closed).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import * as api from "@/lib/api";
|
||||||
|
import { handleUnauthorized } from "@/lib/queryClient";
|
||||||
|
import type { LiveQueryEvent, QueriesPage } from "@/lib/types";
|
||||||
|
import { RING_CAPACITY, mergeGap, pushRow, type LiveRow } from "./ringBuffer";
|
||||||
|
|
||||||
|
export type StreamStatus = "connecting" | "open" | "retrying" | "capped";
|
||||||
|
|
||||||
|
/** Minimal EventSource surface so tests can inject a fake. */
|
||||||
|
export interface EventSourceLike {
|
||||||
|
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void;
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventSourceFactory = (url: string) => EventSourceLike;
|
||||||
|
|
||||||
|
export interface LiveQueriesOptions {
|
||||||
|
url?: string;
|
||||||
|
createEventSource?: EventSourceFactory;
|
||||||
|
fetchSince?: (since: number) => Promise<QueriesPage>;
|
||||||
|
/** Cheap session-gated GET fired once on entering capped, to distinguish an expired session from a real cap. */
|
||||||
|
probeSession?: () => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SSE cap rejection (429) is invisible to EventSource beyond a bare
|
||||||
|
// `error` event; this many consecutive errors without an intervening `open`
|
||||||
|
// (the browser retries every 3s per the server's `retry: 3000`) stops the
|
||||||
|
// stream and surfaces a manual-retry state.
|
||||||
|
export const CAP_ERROR_THRESHOLD = 3;
|
||||||
|
|
||||||
|
const defaultEventSource: EventSourceFactory = (url) => new EventSource(url);
|
||||||
|
const defaultFetchSince = (since: number): Promise<QueriesPage> => api.getQueries({ since, limit: RING_CAPACITY });
|
||||||
|
const defaultProbeSession = (): Promise<unknown> => api.getPause();
|
||||||
|
|
||||||
|
function isUnauthorized(error: unknown): boolean {
|
||||||
|
return error instanceof api.ApiError && error.status === 401;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveQueries {
|
||||||
|
/** Newest-first; the freeze-time snapshot while frozen. */
|
||||||
|
rows: LiveRow[];
|
||||||
|
/** Size of the live buffer, which keeps filling while frozen. */
|
||||||
|
liveCount: number;
|
||||||
|
status: StreamStatus;
|
||||||
|
/** Rows recovered by the reconnect re-sync; null until a re-sync happens or after dismissal. */
|
||||||
|
missed: number | null;
|
||||||
|
resyncFailed: boolean;
|
||||||
|
frozen: boolean;
|
||||||
|
toggleFreeze: () => void;
|
||||||
|
retry: () => void;
|
||||||
|
dismissMissed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
|
||||||
|
const [rows, setRows] = useState<LiveRow[]>([]);
|
||||||
|
const [status, setStatus] = useState<StreamStatus>("connecting");
|
||||||
|
const [missed, setMissed] = useState<number | null>(null);
|
||||||
|
const [resyncFailed, setResyncFailed] = useState(false);
|
||||||
|
const [frozen, setFrozen] = useState(false);
|
||||||
|
const [frozenRows, setFrozenRows] = useState<LiveRow[]>([]);
|
||||||
|
|
||||||
|
const bufferRef = useRef<LiveRow[]>([]);
|
||||||
|
const keyRef = useRef(0);
|
||||||
|
const lastSeenTsRef = useRef<number | null>(null);
|
||||||
|
const everOpenRef = useRef(false);
|
||||||
|
const errorsRef = useRef(0);
|
||||||
|
const esRef = useRef<EventSourceLike | null>(null);
|
||||||
|
|
||||||
|
const optionsRef = useRef(options);
|
||||||
|
optionsRef.current = options;
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
esRef.current?.close();
|
||||||
|
errorsRef.current = 0;
|
||||||
|
setStatus("connecting");
|
||||||
|
const opts = optionsRef.current;
|
||||||
|
const fetchSince = opts?.fetchSince ?? defaultFetchSince;
|
||||||
|
const probeSession = opts?.probeSession ?? defaultProbeSession;
|
||||||
|
const es = (opts?.createEventSource ?? defaultEventSource)(opts?.url ?? api.liveQueriesUrl);
|
||||||
|
esRef.current = es;
|
||||||
|
|
||||||
|
es.addEventListener("open", () => {
|
||||||
|
if (esRef.current !== es) return;
|
||||||
|
errorsRef.current = 0;
|
||||||
|
setStatus("open");
|
||||||
|
const since = lastSeenTsRef.current;
|
||||||
|
if (everOpenRef.current && since !== null) {
|
||||||
|
setResyncFailed(false);
|
||||||
|
fetchSince(since).then(
|
||||||
|
(page) => {
|
||||||
|
if (esRef.current !== es) return;
|
||||||
|
const merged = mergeGap(bufferRef.current, page.queries, () => ++keyRef.current);
|
||||||
|
bufferRef.current = merged.rows;
|
||||||
|
setRows(merged.rows);
|
||||||
|
setMissed(merged.missed);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
if (esRef.current !== es) return;
|
||||||
|
if (isUnauthorized(error)) {
|
||||||
|
handleUnauthorized(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResyncFailed(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
everOpenRef.current = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener("query", (event) => {
|
||||||
|
if (esRef.current !== es) return;
|
||||||
|
if (typeof event.data !== "string") return;
|
||||||
|
let payload: LiveQueryEvent;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(event.data) as LiveQueryEvent;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSeenTsRef.current = payload.ts;
|
||||||
|
bufferRef.current = pushRow(bufferRef.current, { ...payload, key: ++keyRef.current });
|
||||||
|
setRows(bufferRef.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener("error", () => {
|
||||||
|
if (esRef.current !== es) return;
|
||||||
|
errorsRef.current += 1;
|
||||||
|
if (errorsRef.current >= CAP_ERROR_THRESHOLD) {
|
||||||
|
es.close();
|
||||||
|
setStatus("capped");
|
||||||
|
// EventSource cannot surface a 401; an expired session looks
|
||||||
|
// identical to the cap. Probe once on entering capped so the
|
||||||
|
// user lands on login instead of a misleading capped message.
|
||||||
|
probeSession().catch(handleUnauthorized);
|
||||||
|
} else {
|
||||||
|
setStatus("retrying");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
esRef.current?.close();
|
||||||
|
esRef.current = null;
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
const toggleFreeze = () => {
|
||||||
|
if (frozen) {
|
||||||
|
setFrozen(false);
|
||||||
|
} else {
|
||||||
|
setFrozen(true);
|
||||||
|
setFrozenRows(bufferRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: frozen ? frozenRows : rows,
|
||||||
|
liveCount: rows.length,
|
||||||
|
status,
|
||||||
|
missed,
|
||||||
|
resyncFailed,
|
||||||
|
frozen,
|
||||||
|
toggleFreeze,
|
||||||
|
retry: connect,
|
||||||
|
dismissMissed: () => {
|
||||||
|
setMissed(null);
|
||||||
|
setResyncFailed(false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
|
import { AuthProvider } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
import type { LocalRecord, LocalRecordInput } from "@/lib/types";
|
||||||
|
|
||||||
|
let records: LocalRecord[];
|
||||||
|
let fetchMock: ReturnType<typeof createFetchMock>;
|
||||||
|
|
||||||
|
function json(payload: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFetchMock() {
|
||||||
|
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
if (url === "/api/local-records" && method === "GET") return json({ local_records: records });
|
||||||
|
if (url === "/api/local-records" && method === "POST") {
|
||||||
|
const body = JSON.parse(String(init?.body)) as LocalRecordInput;
|
||||||
|
const created: LocalRecord = { id: 99, ttl: body.ttl ?? 300, ...body };
|
||||||
|
records = [...records, created];
|
||||||
|
return json(created, 201);
|
||||||
|
}
|
||||||
|
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({ error: "not stubbed" }, 404);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
records = [{ id: 1, name: "nas.lan.home", rtype: "A", value: "192.168.1.10", ttl: 300 }];
|
||||||
|
fetchMock = createFetchMock();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/local-dns"] }), queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renders the records table and switches to the forward zones tab", async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Local DNS" });
|
||||||
|
await screen.findByText("nas.lan.home");
|
||||||
|
expect(screen.getByText("192.168.1.10")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
|
||||||
|
await screen.findByText("lan.home");
|
||||||
|
expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a record: POST body per LocalRecordInput, list refreshes", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("nas.lan.home");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add record" }));
|
||||||
|
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Type"), { target: { value: "AAAA" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
await screen.findByText("printer.lan.home");
|
||||||
|
|
||||||
|
const post = fetchMock.mock.calls.find(
|
||||||
|
([input, init]) => init?.method === "POST" && String(input) === "/api/local-records",
|
||||||
|
);
|
||||||
|
expect(post).toBeTruthy();
|
||||||
|
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" });
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import RecordsTab from "@/features/local/RecordsTab";
|
||||||
|
import ZonesTab from "@/features/local/ZonesTab";
|
||||||
|
|
||||||
|
type Tab = "records" | "zones";
|
||||||
|
|
||||||
|
function TabButton({
|
||||||
|
id,
|
||||||
|
controls,
|
||||||
|
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 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ${
|
||||||
|
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() {
|
||||||
|
const [tab, setTab] = useState<Tab>("records");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-semibold">Local DNS</h1>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Local DNS"
|
||||||
|
className="mt-4 flex gap-2 border-b border-zinc-200 dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useId, useState, type FormEvent } from "react";
|
||||||
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
localRecordCreateMutation,
|
||||||
|
localRecordDeleteMutation,
|
||||||
|
localRecordUpdateMutation,
|
||||||
|
localRecordsQuery,
|
||||||
|
} from "@/lib/queries";
|
||||||
|
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
|
||||||
|
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
|
||||||
|
const primaryButtonClass =
|
||||||
|
"rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
|
||||||
|
const secondaryButtonClass =
|
||||||
|
"rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
|
||||||
|
const rowButtonClass =
|
||||||
|
"rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400";
|
||||||
|
|
||||||
|
type FormState = { mode: "create" } | { mode: "edit"; record: LocalRecord };
|
||||||
|
|
||||||
|
function RecordForm({
|
||||||
|
initial,
|
||||||
|
busy,
|
||||||
|
error,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
initial?: LocalRecord;
|
||||||
|
busy: boolean;
|
||||||
|
error: unknown;
|
||||||
|
onSubmit: (input: LocalRecordInput) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
const [name, setName] = useState(initial?.name ?? "");
|
||||||
|
const [rtype, setRtype] = useState<LocalRecordType>(initial?.rtype ?? "A");
|
||||||
|
const [value, setValue] = useState(initial?.value ?? "");
|
||||||
|
const [ttl, setTtl] = useState(initial === undefined ? "" : String(initial.ttl));
|
||||||
|
|
||||||
|
function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const input: LocalRecordInput = { name: name.trim(), rtype, value: value.trim() };
|
||||||
|
if (ttl.trim() !== "") input.ttl = Number(ttl);
|
||||||
|
onSubmit(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
className="mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium">{initial === undefined ? "New record" : `Edit ${initial.name}`}</h3>
|
||||||
|
<div>
|
||||||
|
<label htmlFor={`${id}-name`} className="block text-sm font-medium">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${id}-name`}
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="nas.lan.home"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor={`${id}-rtype`} className="block text-sm font-medium">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`${id}-rtype`}
|
||||||
|
value={rtype}
|
||||||
|
onChange={(event) => setRtype(event.target.value as LocalRecordType)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{RTYPES.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor={`${id}-value`} className="block text-sm font-medium">
|
||||||
|
Value
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${id}-value`}
|
||||||
|
required
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
placeholder={
|
||||||
|
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor={`${id}-ttl`} className="block text-sm font-medium">
|
||||||
|
TTL (seconds)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${id}-ttl`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={ttl}
|
||||||
|
onChange={(event) => setTtl(event.target.value)}
|
||||||
|
placeholder="300"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={busy} className={primaryButtonClass}>
|
||||||
|
{busy ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onCancel} className={secondaryButtonClass}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<InlineError error={error} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecordsTab() {
|
||||||
|
const records = useSuspenseQuery(localRecordsQuery()).data;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const create = useMutation(localRecordCreateMutation(queryClient));
|
||||||
|
const update = useMutation(localRecordUpdateMutation(queryClient));
|
||||||
|
const remove = useMutation(localRecordDeleteMutation(queryClient));
|
||||||
|
const [form, setForm] = useState<FormState | null>(null);
|
||||||
|
|
||||||
|
function openForm(next: FormState) {
|
||||||
|
create.reset();
|
||||||
|
update.reset();
|
||||||
|
setForm(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(input: LocalRecordInput) {
|
||||||
|
if (form === null) return;
|
||||||
|
if (form.mode === "create") {
|
||||||
|
create.mutate(input, { onSuccess: () => setForm(null) });
|
||||||
|
} else {
|
||||||
|
update.mutate({ id: form.record.id, input }, { onSuccess: () => setForm(null) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDelete(record: LocalRecord) {
|
||||||
|
if (!window.confirm(`Delete record "${record.name}"?`)) return;
|
||||||
|
remove.mutate(record.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
|
||||||
|
<button type="button" onClick={() => openForm({ mode: "create" })} className={primaryButtonClass}>
|
||||||
|
Add record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<InlineError error={remove.error} />
|
||||||
|
{form?.mode === "create" && (
|
||||||
|
<RecordForm
|
||||||
|
busy={create.isPending}
|
||||||
|
error={create.error}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onCancel={() => setForm(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
|
||||||
|
<th scope="col" className="py-2 pr-4 font-medium">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="py-2 pr-4 font-medium">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="py-2 pr-4 font-medium">
|
||||||
|
Value
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="py-2 pr-4 font-medium">
|
||||||
|
TTL
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="py-2">
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{records.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-4 text-zinc-500">
|
||||||
|
No local records yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{records.map((record) => (
|
||||||
|
<tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900">
|
||||||
|
{form?.mode === "edit" && form.record.id === record.id ? (
|
||||||
|
<td colSpan={5}>
|
||||||
|
<RecordForm
|
||||||
|
initial={record}
|
||||||
|
busy={update.isPending}
|
||||||
|
error={update.error}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onCancel={() => setForm(null)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="py-2 pr-4 font-mono">{record.name}</td>
|
||||||
|
<td className="py-2 pr-4">{record.rtype}</td>
|
||||||
|
<td className="py-2 pr-4 font-mono">{record.value}</td>
|
||||||
|
<td className="py-2 pr-4">{record.ttl}</td>
|
||||||
|
<td className="py-2 text-right whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openForm({ mode: "edit", record })}
|
||||||
|
className={rowButtonClass}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(record)}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { useId, useState, type FormEvent } from "react";
|
||||||
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
forwardZoneCreateMutation,
|
||||||
|
forwardZoneDeleteMutation,
|
||||||
|
forwardZoneUpdateMutation,
|
||||||
|
forwardZonesQuery,
|
||||||
|
} from "@/lib/queries";
|
||||||
|
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
|
||||||
|
const primaryButtonClass =
|
||||||
|
"rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
|
||||||
|
const secondaryButtonClass =
|
||||||
|
"rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
|
||||||
|
const rowButtonClass =
|
||||||
|
"rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400";
|
||||||
|
|
||||||
|
type FormState = { mode: "create" } | { mode: "edit"; zone: ForwardZone };
|
||||||
|
|
||||||
|
function ZoneForm({
|
||||||
|
initial,
|
||||||
|
busy,
|
||||||
|
error,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
initial?: ForwardZone;
|
||||||
|
busy: boolean;
|
||||||
|
error: unknown;
|
||||||
|
onSubmit: (input: ForwardZoneInput) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
const [zone, setZone] = useState(initial?.zone ?? "");
|
||||||
|
const [resolver, setResolver] = useState(initial?.resolver ?? "");
|
||||||
|
|
||||||
|
function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit({ zone: zone.trim(), resolver: resolver.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
className="mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3>
|
||||||
|
<div>
|
||||||
|
<label htmlFor={`${id}-zone`} className="block text-sm font-medium">
|
||||||
|
Zone
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${id}-zone`}
|
||||||
|
required
|
||||||
|
value={zone}
|
||||||
|
onChange={(event) => setZone(event.target.value)}
|
||||||
|
placeholder="lan.home"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor={`${id}-resolver`} className="block text-sm font-medium">
|
||||||
|
Resolver
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${id}-resolver`}
|
||||||
|
required
|
||||||
|
value={resolver}
|
||||||
|
onChange={(event) => setResolver(event.target.value)}
|
||||||
|
placeholder="udp://192.168.1.1:53"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={busy} className={primaryButtonClass}>
|
||||||
|
{busy ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onCancel} className={secondaryButtonClass}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<InlineError error={error} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ZonesTab() {
|
||||||
|
const zones = useSuspenseQuery(forwardZonesQuery()).data;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const create = useMutation(forwardZoneCreateMutation(queryClient));
|
||||||
|
const update = useMutation(forwardZoneUpdateMutation(queryClient));
|
||||||
|
const remove = useMutation(forwardZoneDeleteMutation(queryClient));
|
||||||
|
const [form, setForm] = useState<FormState | null>(null);
|
||||||
|
|
||||||
|
function openForm(next: FormState) {
|
||||||
|
create.reset();
|
||||||
|
update.reset();
|
||||||
|
setForm(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(input: ForwardZoneInput) {
|
||||||
|
if (form === null) return;
|
||||||
|
if (form.mode === "create") {
|
||||||
|
create.mutate(input, { onSuccess: () => setForm(null) });
|
||||||
|
} else {
|
||||||
|
update.mutate({ id: form.zone.id, input }, { onSuccess: () => setForm(null) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDelete(zone: ForwardZone) {
|
||||||
|
if (!window.confirm(`Delete forward zone "${zone.zone}"?`)) return;
|
||||||
|
remove.mutate(zone.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Names under these zones go to their own resolver. Changes apply live.
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={() => openForm({ mode: "create" })} className={primaryButtonClass}>
|
||||||
|
Add zone
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<InlineError error={remove.error} />
|
||||||
|
{form?.mode === "create" && (
|
||||||
|
<ZoneForm
|
||||||
|
busy={create.isPending}
|
||||||
|
error={create.error}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onCancel={() => setForm(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
|
||||||
|
<th scope="col" className="py-2 pr-4 font-medium">
|
||||||
|
Zone
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="py-2 pr-4 font-medium">
|
||||||
|
Resolver
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="py-2">
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{zones.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="py-4 text-zinc-500">
|
||||||
|
No forward zones yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{zones.map((zone) => (
|
||||||
|
<tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900">
|
||||||
|
{form?.mode === "edit" && form.zone.id === zone.id ? (
|
||||||
|
<td colSpan={3}>
|
||||||
|
<ZoneForm
|
||||||
|
initial={zone}
|
||||||
|
busy={update.isPending}
|
||||||
|
error={update.error}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onCancel={() => setForm(null)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td className="py-2 pr-4 font-mono">{zone.zone}</td>
|
||||||
|
<td className="py-2 pr-4 font-mono">{zone.resolver}</td>
|
||||||
|
<td className="py-2 text-right whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openForm({ mode: "edit", zone })}
|
||||||
|
className={rowButtonClass}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(zone)}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
className={`${rowButtonClass} text-red-600 dark:text-red-400`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
|
import { AuthProvider } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
import type { LookupResult } from "@/lib/types";
|
||||||
|
|
||||||
|
const BLOCKED: LookupResult = {
|
||||||
|
domain: "ads.example",
|
||||||
|
group_id: 1,
|
||||||
|
local_records: false,
|
||||||
|
forward_zone: null,
|
||||||
|
blocked: true,
|
||||||
|
reason: "blocklist_domain",
|
||||||
|
matched: "ads.example",
|
||||||
|
source_url: "https://lists.test/a",
|
||||||
|
safe_search_rewrite: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetchMock: ReturnType<typeof createFetchMock>;
|
||||||
|
|
||||||
|
function json(payload: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFetchMock() {
|
||||||
|
return vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/groups") {
|
||||||
|
return json({
|
||||||
|
groups: [
|
||||||
|
{ id: 1, name: "default", safe_search: false },
|
||||||
|
{ id: 2, name: "kids", safe_search: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === "/api/lookup?domain=ads.example&group_id=1") return json(BLOCKED);
|
||||||
|
return json({ error: "not stubbed" }, 404);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = createFetchMock();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/lookup"] }), queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupCalls(): string[] {
|
||||||
|
return fetchMock.mock.calls.map(([input]) => String(input)).filter((url) => url.startsWith("/api/lookup"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("fetches nothing until submit, then renders the blocked verdict", async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Lookup" });
|
||||||
|
await screen.findByLabelText("Group");
|
||||||
|
expect(lookupCalls()).toEqual([]);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||||
|
expect(lookupCalls()).toEqual([]);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Look up" }));
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Blocked" });
|
||||||
|
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
|
||||||
|
|
||||||
|
expect(screen.getByText("blocklist_domain")).toBeTruthy();
|
||||||
|
const link = screen.getByRole("link", { name: "https://lists.test/a" }) as HTMLAnchorElement;
|
||||||
|
expect(link.href).toBe("https://lists.test/a");
|
||||||
|
expect(screen.getByText("Queries for this name get a blocked response.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults the group select to the default group (id 1)", async () => {
|
||||||
|
renderPage();
|
||||||
|
const select = (await screen.findByLabelText("Group")) as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("1");
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useState, type FormEvent, type ReactNode } from "react";
|
||||||
|
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { groupsQuery, lookupQuery } from "@/lib/queries";
|
||||||
|
import type { Group, LookupResult } from "@/lib/types";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
|
||||||
|
|
||||||
|
interface Submitted {
|
||||||
|
domain: string;
|
||||||
|
groupId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Verdict {
|
||||||
|
label: string;
|
||||||
|
className: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header priority follows the pipeline order the lookup handler documents
|
||||||
|
* (PLAN §6): local records answer first, then the block decision, then
|
||||||
|
* forward zones, then plain forwarding to the upstream pool.
|
||||||
|
*/
|
||||||
|
export function verdictOf(result: LookupResult): Verdict {
|
||||||
|
if (result.local_records) {
|
||||||
|
return {
|
||||||
|
label: "Local answer",
|
||||||
|
className: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300",
|
||||||
|
description: "A local record answers this name directly.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (result.blocked) {
|
||||||
|
return {
|
||||||
|
label: "Blocked",
|
||||||
|
className: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
|
||||||
|
description: "Queries for this name get a blocked response.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (result.forward_zone !== null) {
|
||||||
|
return {
|
||||||
|
label: "Forwarded",
|
||||||
|
className: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
||||||
|
description: `Queries go to the resolver for zone ${result.forward_zone}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: "Allowed",
|
||||||
|
className: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300",
|
||||||
|
description: "Queries resolve through the upstream pool.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
if (error.status === 503) {
|
||||||
|
return "No filter snapshot is loaded yet — the server is starting or degraded. Try again shortly.";
|
||||||
|
}
|
||||||
|
if (error.status === 429) {
|
||||||
|
return error.retryAfter !== undefined
|
||||||
|
? `Rate limited. Try again in ${error.retryAfter}s.`
|
||||||
|
: "Rate limited. Try again shortly.";
|
||||||
|
}
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "Could not reach the server.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 py-2">
|
||||||
|
<dt className="w-40 shrink-0 text-zinc-500">{label}</dt>
|
||||||
|
<dd className="min-w-0 break-words">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[] }) {
|
||||||
|
const verdict = verdictOf(result);
|
||||||
|
const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 rounded border border-zinc-200 dark:border-zinc-800">
|
||||||
|
<div className={`rounded-t px-4 py-3 ${verdict.className}`}>
|
||||||
|
<h2 className="text-lg font-semibold">{verdict.label}</h2>
|
||||||
|
<p className="text-sm">{verdict.description}</p>
|
||||||
|
</div>
|
||||||
|
<dl className="divide-y divide-zinc-100 px-4 py-2 text-sm dark:divide-zinc-900">
|
||||||
|
<DetailRow label="Domain">
|
||||||
|
<span className="font-mono">{result.domain}</span>
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Group">{groupName}</DetailRow>
|
||||||
|
<DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow>
|
||||||
|
<DetailRow label="Forward zone">
|
||||||
|
{result.forward_zone !== null ? <span className="font-mono">{result.forward_zone}</span> : "—"}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow>
|
||||||
|
<DetailRow label="Reason">
|
||||||
|
<span className="font-mono">{result.reason}</span>
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Matched pattern">
|
||||||
|
{result.matched !== "" ? <span className="font-mono">{result.matched}</span> : "—"}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Blocklist source">
|
||||||
|
{result.source_url !== null ? (
|
||||||
|
<a
|
||||||
|
href={result.source_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-600 underline dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{result.source_url}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Safe search rewrite">
|
||||||
|
{result.safe_search_rewrite !== null ? (
|
||||||
|
<span className="font-mono">{result.safe_search_rewrite}</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</DetailRow>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LookupPage() {
|
||||||
|
const groups = useSuspenseQuery(groupsQuery()).data;
|
||||||
|
const defaultGroupId = groups.find((group) => group.id === 1)?.id ?? groups[0]?.id ?? 1;
|
||||||
|
|
||||||
|
const [domain, setDomain] = useState("");
|
||||||
|
const [groupId, setGroupId] = useState(defaultGroupId);
|
||||||
|
const [submitted, setSubmitted] = useState<Submitted | null>(null);
|
||||||
|
|
||||||
|
const lookup = useQuery({
|
||||||
|
...lookupQuery(submitted?.domain ?? "", submitted?.groupId),
|
||||||
|
enabled: submitted !== null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = domain.trim();
|
||||||
|
if (trimmed === "") return;
|
||||||
|
if (submitted !== null && submitted.domain === trimmed && submitted.groupId === groupId) {
|
||||||
|
void lookup.refetch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitted({ domain: trimmed, groupId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-semibold">Lookup</h1>
|
||||||
|
<p className="mt-2 text-zinc-500">
|
||||||
|
What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={onSubmit} className="mt-6 flex max-w-2xl flex-wrap items-end gap-3">
|
||||||
|
<div className="min-w-56 grow">
|
||||||
|
<label htmlFor="lookup-domain" className="block text-sm font-medium">
|
||||||
|
Domain
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lookup-domain"
|
||||||
|
required
|
||||||
|
value={domain}
|
||||||
|
onChange={(event) => setDomain(event.target.value)}
|
||||||
|
placeholder="ads.example.com"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="lookup-group" className="block text-sm font-medium">
|
||||||
|
Group
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="lookup-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>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
disabled={lookup.isFetching}
|
||||||
|
>
|
||||||
|
Look up
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{lookup.isFetching && <p className="mt-6 text-zinc-500">Looking up…</p>}
|
||||||
|
{!lookup.isFetching && lookup.isError && (
|
||||||
|
<p role="alert" className="mt-6 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{errorMessage(lookup.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!lookup.isFetching && lookup.data !== undefined && !lookup.isError && (
|
||||||
|
<VerdictCard result={lookup.data} groups={groups} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import PauseWidget, { formatRemaining } from "@/features/pause/PauseWidget";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { queryKeys } from "@/lib/queries";
|
||||||
|
import type { PausePost, PauseState } from "@/lib/types";
|
||||||
|
|
||||||
|
let getState: PauseState;
|
||||||
|
let postBodies: PausePost[];
|
||||||
|
let postResponse: (body: PausePost) => PauseState;
|
||||||
|
let postFailure: (() => Response) | null;
|
||||||
|
|
||||||
|
function jsonResponse(payload: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
postBodies = [];
|
||||||
|
postFailure = null;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url !== "/api/pause") return jsonResponse({ error: "not stubbed" });
|
||||||
|
if (init?.method === "POST") {
|
||||||
|
const body = JSON.parse(String(init.body)) as PausePost;
|
||||||
|
postBodies.push(body);
|
||||||
|
if (postFailure !== null) return postFailure();
|
||||||
|
return jsonResponse(postResponse(body));
|
||||||
|
}
|
||||||
|
return jsonResponse(getState);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderWidget(client?: QueryClient) {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={client ?? createQueryClient()}>
|
||||||
|
<PauseWidget />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPauseTrigger(): Promise<HTMLButtonElement> {
|
||||||
|
await waitFor(() => {
|
||||||
|
const button = screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
||||||
|
expect(button.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
return screen.getByRole("button", { name: "Pause" }) as HTMLButtonElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("unpaused: duration menu pauses with the picked duration_seconds", async () => {
|
||||||
|
getState = { paused: false, until: null };
|
||||||
|
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
|
||||||
|
renderWidget();
|
||||||
|
|
||||||
|
const trigger = await findPauseTrigger();
|
||||||
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
|
||||||
|
expect(screen.getByRole("button", { name: label })).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||||
|
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
|
||||||
|
|
||||||
|
await screen.findByRole("button", { name: "Resume" });
|
||||||
|
expect(screen.getByText(/^Paused \d+:\d{2}$/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("indefinite pause sends no duration_seconds and renders without a countdown", async () => {
|
||||||
|
getState = { paused: false, until: null };
|
||||||
|
postResponse = () => ({ paused: true, until: null });
|
||||||
|
renderWidget();
|
||||||
|
|
||||||
|
fireEvent.click(await findPauseTrigger());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
|
||||||
|
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
|
||||||
|
|
||||||
|
await screen.findByRole("button", { name: "Resume" });
|
||||||
|
expect(screen.getByText("Paused")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resume posts paused false and returns to the Pause button", async () => {
|
||||||
|
getState = { paused: true, until: null };
|
||||||
|
postResponse = () => ({ paused: false, until: null });
|
||||||
|
renderWidget();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Resume" }));
|
||||||
|
await waitFor(() => expect(postBodies).toEqual([{ paused: false }]));
|
||||||
|
await screen.findByRole("button", { name: "Pause" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("timed pause counts down live", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const client = createQueryClient();
|
||||||
|
client.setQueryData(queryKeys.pause, { paused: true, until: nowSec + 90 });
|
||||||
|
renderWidget(client);
|
||||||
|
|
||||||
|
expect(screen.getByText("Paused 1:30")).toBeTruthy();
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Paused 1:28")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escape closes the duration menu", async () => {
|
||||||
|
getState = { paused: false, until: null };
|
||||||
|
postResponse = () => getState;
|
||||||
|
renderWidget();
|
||||||
|
|
||||||
|
const trigger = await findPauseTrigger();
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||||
|
fireEvent.keyDown(trigger, { key: "Escape" });
|
||||||
|
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed pause with 429 shows a ticking retry countdown", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
getState = { paused: false, until: null };
|
||||||
|
postFailure = () =>
|
||||||
|
new Response(JSON.stringify({ error: "rate limited" }), {
|
||||||
|
status: 429,
|
||||||
|
headers: { "content-type": "application/json", "Retry-After": "30" },
|
||||||
|
});
|
||||||
|
renderWidget();
|
||||||
|
|
||||||
|
fireEvent.click(await findPauseTrigger());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
expect(alert.textContent).toBe("Rate limited. Try again in 29s.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed pause with 503 shows the degraded message", async () => {
|
||||||
|
getState = { paused: false, until: null };
|
||||||
|
postFailure = () =>
|
||||||
|
new Response(JSON.stringify({ error: "unavailable" }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
renderWidget();
|
||||||
|
|
||||||
|
fireEvent.click(await findPauseTrigger());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a successful pause clears the previous mutation error", async () => {
|
||||||
|
getState = { paused: false, until: null };
|
||||||
|
postFailure = () =>
|
||||||
|
new Response(JSON.stringify({ error: "unavailable" }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
renderWidget();
|
||||||
|
|
||||||
|
fireEvent.click(await findPauseTrigger());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||||
|
await screen.findByRole("alert");
|
||||||
|
|
||||||
|
postFailure = null;
|
||||||
|
postResponse = () => ({ paused: true, until: Math.floor(Date.now() / 1000) + 300 });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||||
|
|
||||||
|
await screen.findByRole("button", { name: "Resume" });
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatRemaining renders m:ss and h:mm:ss and clamps at zero", () => {
|
||||||
|
expect(formatRemaining(0)).toBe("0:00");
|
||||||
|
expect(formatRemaining(-5)).toBe("0:00");
|
||||||
|
expect(formatRemaining(59)).toBe("0:59");
|
||||||
|
expect(formatRemaining(90)).toBe("1:30");
|
||||||
|
expect(formatRemaining(3661)).toBe("1:01:01");
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { pauseMutation, pauseQuery } from "@/lib/queries";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
|
||||||
|
const DURATIONS = [
|
||||||
|
{ label: "60 seconds", seconds: 60 },
|
||||||
|
{ label: "5 minutes", seconds: 300 },
|
||||||
|
{ label: "30 minutes", seconds: 1800 },
|
||||||
|
{ label: "Indefinitely", seconds: null },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function formatRemaining(totalSeconds: number): string {
|
||||||
|
const clamped = Math.max(0, totalSeconds);
|
||||||
|
const hours = Math.floor(clamped / 3600);
|
||||||
|
const minutes = Math.floor((clamped % 3600) / 60);
|
||||||
|
const seconds = clamped % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowSeconds(): number {
|
||||||
|
return Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useNowSeconds(active: boolean): number {
|
||||||
|
const [now, setNow] = useState(nowSeconds);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
setNow(nowSeconds());
|
||||||
|
const id = setInterval(() => setNow(nowSeconds()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [active]);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUTTON_CLASS =
|
||||||
|
"rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:text-zinc-400 dark:border-zinc-700 dark:disabled:text-zinc-600";
|
||||||
|
|
||||||
|
export default function PauseWidget() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data } = useQuery({
|
||||||
|
...pauseQuery(),
|
||||||
|
refetchInterval: (query) => (query.state.data?.paused === true ? 5000 : false),
|
||||||
|
});
|
||||||
|
const mutation = useMutation(pauseMutation(queryClient));
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const now = useNowSeconds(data?.paused === true && data.until !== null);
|
||||||
|
const paused = data?.paused === true;
|
||||||
|
const { reset } = mutation;
|
||||||
|
useEffect(() => reset(), [paused, reset]);
|
||||||
|
|
||||||
|
if (data === undefined) {
|
||||||
|
return (
|
||||||
|
<button type="button" disabled className={BUTTON_CLASS}>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.paused) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
{data.until === null ? "Paused" : `Paused ${formatRemaining(data.until - now)}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => mutation.mutate({ paused: false })}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className={BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<InlineError error={mutation.error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
aria-controls="pause-menu"
|
||||||
|
onClick={() => setMenuOpen((open) => !open)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className={BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
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 }) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
mutation.mutate(
|
||||||
|
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-left text-sm hover:bg-zinc-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<InlineError error={mutation.error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import type { QueriesPage, QueryRow } from "@/lib/types";
|
||||||
|
import QueryLogPage from "./QueryLogPage";
|
||||||
|
|
||||||
|
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
ts: 1_700_000_000 + id,
|
||||||
|
domain,
|
||||||
|
client_ip: "192.0.2.10",
|
||||||
|
qtype: 1,
|
||||||
|
blocked: false,
|
||||||
|
block_reason: "",
|
||||||
|
response_time_us: 1234,
|
||||||
|
cache_hit: false,
|
||||||
|
upstream: "udp://9.9.9.9:53",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGES: Record<string, QueriesPage> = {
|
||||||
|
"/api/queries": {
|
||||||
|
queries: [
|
||||||
|
row(20, "first.example", { qtype: 65, cache_hit: true, upstream: "" }),
|
||||||
|
row(19, "ads.example", {
|
||||||
|
blocked: true,
|
||||||
|
block_reason: "blocklist:stevenblack",
|
||||||
|
response_time_us: null,
|
||||||
|
cache_hit: null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
next_before: 19,
|
||||||
|
},
|
||||||
|
"/api/queries?before=19": {
|
||||||
|
queries: [row(5, "older.example")],
|
||||||
|
next_before: null,
|
||||||
|
},
|
||||||
|
"/api/queries?domain=ads": {
|
||||||
|
queries: [row(19, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack" })],
|
||||||
|
next_before: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
const payload = PAGES[url];
|
||||||
|
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
|
<QueryLogPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renders the first page with type names, blocked badge, and formatted cells", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
expect(screen.getByText("HTTPS")).toBeTruthy();
|
||||||
|
expect(screen.getByText("A")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Blocked")).toBeTruthy();
|
||||||
|
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
|
||||||
|
expect(screen.getByText("1.2 ms")).toBeTruthy();
|
||||||
|
expect(screen.getByText("hit")).toBeTruthy();
|
||||||
|
expect(screen.getByText("udp://9.9.9.9:53")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("load more appends the next page and stops at the end of the log", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
|
await screen.findByText("older.example");
|
||||||
|
|
||||||
|
expect(screen.getByText("first.example")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applying a filter refetches and resets the accumulated list", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
|
await screen.findByText("older.example");
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||||
|
|
||||||
|
await screen.findByText(/Showing 1 query /);
|
||||||
|
expect(screen.getByText("ads.example")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("first.example")).toBeNull();
|
||||||
|
expect(screen.queryByText("older.example")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a load-more that resolves after a filter change is discarded", async () => {
|
||||||
|
let releaseLoadMore: () => void = () => {};
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/queries?before=19") {
|
||||||
|
return new Promise<Response>((resolve) => {
|
||||||
|
releaseLoadMore = () => {
|
||||||
|
resolve(
|
||||||
|
new Response(JSON.stringify(PAGES["/api/queries?before=19"]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const payload = PAGES[url];
|
||||||
|
if (payload === undefined)
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||||
|
await screen.findByText(/Showing 1 query /);
|
||||||
|
|
||||||
|
releaseLoadMore();
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText("older.example")).toBeNull();
|
||||||
|
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("load more is disabled while a filter change shows placeholder data, then uses the fresh cursor", async () => {
|
||||||
|
let releaseFiltered: () => void = () => {};
|
||||||
|
const filteredPage: QueriesPage = {
|
||||||
|
queries: [row(19, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack" })],
|
||||||
|
next_before: 7,
|
||||||
|
};
|
||||||
|
const filteredOlderPage: QueriesPage = {
|
||||||
|
queries: [row(3, "ads.older.example")],
|
||||||
|
next_before: null,
|
||||||
|
};
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/queries?domain=ads") {
|
||||||
|
return new Promise<Response>((resolve) => {
|
||||||
|
releaseFiltered = () => {
|
||||||
|
resolve(
|
||||||
|
new Response(JSON.stringify(filteredPage), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const payload = url === "/api/queries?domain=ads&before=7" ? filteredOlderPage : PAGES[url];
|
||||||
|
if (payload === undefined)
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||||
|
|
||||||
|
const staleButton = screen.getByRole("button", { name: "Load more" });
|
||||||
|
expect(staleButton).toHaveProperty("disabled", true);
|
||||||
|
fireEvent.click(staleButton);
|
||||||
|
expect(fetchMock.mock.calls.map((call) => String(call[0]))).not.toContain("/api/queries?domain=ads&before=19");
|
||||||
|
|
||||||
|
releaseFiltered();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("first.example")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
const freshButton = screen.getByRole("button", { name: "Load more" });
|
||||||
|
expect(freshButton).toHaveProperty("disabled", false);
|
||||||
|
fireEvent.click(freshButton);
|
||||||
|
await screen.findByText("ads.older.example");
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toContain("/api/queries?domain=ads&before=7");
|
||||||
|
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
|
||||||
|
const assign = vi.fn();
|
||||||
|
vi.stubGlobal("location", { pathname: "/queries", search: "", assign });
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/queries?before=19") {
|
||||||
|
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const payload = PAGES[url];
|
||||||
|
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/queries")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
expect(screen.queryByText(/Failed to load more/)).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { useRef, useState, type FormEvent } from "react";
|
||||||
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
|
import * as api from "@/lib/api";
|
||||||
|
import { formatMicros, formatTime } from "@/lib/format";
|
||||||
|
import { queriesQuery } from "@/lib/queries";
|
||||||
|
import { handleUnauthorized } from "@/lib/queryClient";
|
||||||
|
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
||||||
|
import { qtypeName } from "./qtype";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"mt-1 w-full rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
|
||||||
|
const buttonClass =
|
||||||
|
"rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
|
||||||
|
|
||||||
|
function datetimeLocalToUnix(value: string): number | undefined {
|
||||||
|
if (value === "") return undefined;
|
||||||
|
const ms = new Date(value).getTime();
|
||||||
|
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_reason"> }) {
|
||||||
|
if (!row.blocked) return <span className="text-zinc-400">—</span>;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<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">
|
||||||
|
Blocked
|
||||||
|
</span>
|
||||||
|
{row.block_reason !== "" && <span className="text-xs text-zinc-500">{row.block_reason}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap text-zinc-500">{formatTime(row.ts)}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs break-all">{row.domain}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{row.client_ip}</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">{qtypeName(row.qtype)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<BlockedCell row={row} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap tabular-nums">
|
||||||
|
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">
|
||||||
|
{row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs break-all">{row.upstream === "" ? "—" : row.upstream}</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueryTableHead() {
|
||||||
|
const th = "px-3 py-2 font-medium text-zinc-600 dark:text-zinc-400";
|
||||||
|
return (
|
||||||
|
<thead className="bg-zinc-50 text-left dark:bg-zinc-900">
|
||||||
|
<tr>
|
||||||
|
<th className={th}>Time</th>
|
||||||
|
<th className={th}>Domain</th>
|
||||||
|
<th className={th}>Client</th>
|
||||||
|
<th className={th}>Type</th>
|
||||||
|
<th className={th}>Status</th>
|
||||||
|
<th className={th}>Response</th>
|
||||||
|
<th className={th}>Cache</th>
|
||||||
|
<th className={th}>Upstream</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QueryLogPage() {
|
||||||
|
const [domain, setDomain] = useState("");
|
||||||
|
const [client, setClient] = useState("");
|
||||||
|
const [blocked, setBlocked] = useState("any");
|
||||||
|
const [since, setSince] = useState("");
|
||||||
|
const [until, setUntil] = useState("");
|
||||||
|
|
||||||
|
const [applied, setApplied] = useState<QueriesFilter>({});
|
||||||
|
const [extra, setExtra] = useState<QueryRow[]>([]);
|
||||||
|
const [cursorOverride, setCursorOverride] = useState<number | null | undefined>(undefined);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [moreError, setMoreError] = useState<string | null>(null);
|
||||||
|
const generation = useRef(0);
|
||||||
|
|
||||||
|
const base = useQuery({ ...queriesQuery(applied), placeholderData: keepPreviousData });
|
||||||
|
|
||||||
|
const rows: QueryRow[] = [...(base.data?.queries ?? []), ...extra];
|
||||||
|
const nextBefore = cursorOverride !== undefined ? cursorOverride : (base.data?.next_before ?? null);
|
||||||
|
const filterActive = Object.keys(applied).length > 0;
|
||||||
|
|
||||||
|
function resetAccumulation() {
|
||||||
|
generation.current += 1;
|
||||||
|
setExtra([]);
|
||||||
|
setCursorOverride(undefined);
|
||||||
|
setLoadingMore(false);
|
||||||
|
setMoreError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const filter: QueriesFilter = {};
|
||||||
|
if (domain.trim() !== "") filter.domain = domain.trim();
|
||||||
|
if (client.trim() !== "") filter.client = client.trim();
|
||||||
|
if (blocked === "blocked") filter.blocked = true;
|
||||||
|
if (blocked === "allowed") filter.blocked = false;
|
||||||
|
const sinceTs = datetimeLocalToUnix(since);
|
||||||
|
if (sinceTs !== undefined) filter.since = sinceTs;
|
||||||
|
const untilTs = datetimeLocalToUnix(until);
|
||||||
|
if (untilTs !== undefined) filter.until = untilTs;
|
||||||
|
setApplied(filter);
|
||||||
|
resetAccumulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
setDomain("");
|
||||||
|
setClient("");
|
||||||
|
setBlocked("any");
|
||||||
|
setSince("");
|
||||||
|
setUntil("");
|
||||||
|
setApplied({});
|
||||||
|
resetAccumulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMore() {
|
||||||
|
if (nextBefore === null || loadingMore || base.isPlaceholderData) return;
|
||||||
|
const startedGeneration = generation.current;
|
||||||
|
setLoadingMore(true);
|
||||||
|
setMoreError(null);
|
||||||
|
api.getQueries({ ...applied, before: nextBefore })
|
||||||
|
.then((page) => {
|
||||||
|
if (generation.current !== startedGeneration) return;
|
||||||
|
setExtra((prev) => [...prev, ...page.queries]);
|
||||||
|
setCursorOverride(page.next_before);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (error instanceof api.ApiError && error.status === 401) {
|
||||||
|
handleUnauthorized(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (generation.current !== startedGeneration) return;
|
||||||
|
setMoreError(error instanceof Error ? error.message : String(error));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation.current !== startedGeneration) return;
|
||||||
|
setLoadingMore(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-semibold">Query Log</h1>
|
||||||
|
|
||||||
|
<form onSubmit={applyFilters} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<label className="block text-sm">
|
||||||
|
Domain contains
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={domain}
|
||||||
|
onChange={(event) => setDomain(event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
Client (exact)
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={client}
|
||||||
|
onChange={(event) => setClient(event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
Status
|
||||||
|
<select value={blocked} onChange={(event) => setBlocked(event.target.value)} className={inputClass}>
|
||||||
|
<option value="any">All</option>
|
||||||
|
<option value="blocked">Blocked only</option>
|
||||||
|
<option value="allowed">Allowed only</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
Since
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={since}
|
||||||
|
onChange={(event) => setSince(event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
Until
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={until}
|
||||||
|
onChange={(event) => setUntil(event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5">
|
||||||
|
<button type="submit" className={buttonClass}>
|
||||||
|
Apply filters
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={clearFilters} className={buttonClass}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
{base.isFetching && (
|
||||||
|
<span className="text-sm text-zinc-500" role="status">
|
||||||
|
Loading…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{base.data === undefined ? (
|
||||||
|
<p className="mt-6 animate-pulse text-zinc-500" role="status">
|
||||||
|
Loading query log…
|
||||||
|
</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="mt-6 text-zinc-500">
|
||||||
|
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 overflow-x-auto rounded border border-zinc-200 dark:border-zinc-800">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<QueryTableHead />
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<QueryCells row={row} />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||||
|
{nextBefore === null ? " — end of log" : ""}
|
||||||
|
</p>
|
||||||
|
{nextBefore !== null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loadingMore || base.isPlaceholderData}
|
||||||
|
className={buttonClass}
|
||||||
|
>
|
||||||
|
{loadingMore ? "Loading…" : "Load more"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{moreError !== null && (
|
||||||
|
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||||
|
Failed to load more: {moreError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { qtypeName } from "./qtype";
|
||||||
|
|
||||||
|
test("common qtype codes render as DNS type names", () => {
|
||||||
|
expect(qtypeName(1)).toBe("A");
|
||||||
|
expect(qtypeName(28)).toBe("AAAA");
|
||||||
|
expect(qtypeName(5)).toBe("CNAME");
|
||||||
|
expect(qtypeName(65)).toBe("HTTPS");
|
||||||
|
expect(qtypeName(16)).toBe("TXT");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown codes fall back to TYPE<n>", () => {
|
||||||
|
expect(qtypeName(99)).toBe("TYPE99");
|
||||||
|
expect(qtypeName(0)).toBe("TYPE0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("null qtype renders as a dash", () => {
|
||||||
|
expect(qtypeName(null)).toBe("—");
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const QTYPE_NAMES: Record<number, string> = {
|
||||||
|
1: "A",
|
||||||
|
2: "NS",
|
||||||
|
5: "CNAME",
|
||||||
|
6: "SOA",
|
||||||
|
12: "PTR",
|
||||||
|
15: "MX",
|
||||||
|
16: "TXT",
|
||||||
|
28: "AAAA",
|
||||||
|
33: "SRV",
|
||||||
|
35: "NAPTR",
|
||||||
|
43: "DS",
|
||||||
|
46: "RRSIG",
|
||||||
|
47: "NSEC",
|
||||||
|
48: "DNSKEY",
|
||||||
|
52: "TLSA",
|
||||||
|
64: "SVCB",
|
||||||
|
65: "HTTPS",
|
||||||
|
255: "ANY",
|
||||||
|
257: "CAA",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** DNS type name for common codes, `TYPE<n>` fallback (RFC 3597 style), em dash for null. */
|
||||||
|
export function qtypeName(qtype: number | null): string {
|
||||||
|
if (qtype === null) return "—";
|
||||||
|
return QTYPE_NAMES[qtype] ?? `TYPE${qtype}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
|
import { AuthProvider } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
|
||||||
|
const RESPONSES: Record<string, unknown> = {
|
||||||
|
"/api/rules": {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
group_id: 1,
|
||||||
|
group: "Default",
|
||||||
|
pattern: "ads.example.com",
|
||||||
|
kind: "exact",
|
||||||
|
action: "block",
|
||||||
|
created_at: 1700000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
group_id: 2,
|
||||||
|
group: "Kids",
|
||||||
|
pattern: "*.cdn.example.com",
|
||||||
|
kind: "wildcard",
|
||||||
|
action: "allow",
|
||||||
|
created_at: 1700000100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"/api/groups": {
|
||||||
|
groups: [
|
||||||
|
{ id: 1, name: "Default", safe_search: false },
|
||||||
|
{ id: 2, name: "Kids", safe_search: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/rules" && init?.method === "POST") {
|
||||||
|
return new Response(JSON.stringify({ error: "rate limited" }), {
|
||||||
|
status: 429,
|
||||||
|
headers: { "content-type": "application/json", "Retry-After": "5" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const payload = RESPONSES[url];
|
||||||
|
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderRulesRoute() {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/rules"] }), queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renders the rule table and the create form with contract enums", async () => {
|
||||||
|
renderRulesRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Rules" });
|
||||||
|
|
||||||
|
const table = within(screen.getByRole("table"));
|
||||||
|
expect(table.getByText("ads.example.com")).toBeTruthy();
|
||||||
|
expect(table.getByText("*.cdn.example.com")).toBeTruthy();
|
||||||
|
expect(table.getByText("block")).toBeTruthy();
|
||||||
|
expect(table.getByText("allow")).toBeTruthy();
|
||||||
|
expect(table.getByText("Kids")).toBeTruthy();
|
||||||
|
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
|
||||||
|
|
||||||
|
const kindSelect = screen.getByLabelText("Kind") as HTMLSelectElement;
|
||||||
|
expect(Array.from(kindSelect.options).map((o) => o.value)).toEqual(["exact", "wildcard"]);
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
renderRulesRoute();
|
||||||
|
await screen.findByRole("heading", { name: "Rules" });
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "ads.example.net" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toBe("Rate limited. Try again in 5s.");
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import { formatTime } from "@/lib/format";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
|
||||||
|
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
|
||||||
|
|
||||||
|
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
|
||||||
|
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
|
||||||
|
const INPUT_CLASS =
|
||||||
|
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
|
||||||
|
|
||||||
|
export default function RulesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: rules } = useSuspenseQuery(rulesQuery());
|
||||||
|
const { data: groups } = useSuspenseQuery(groupsQuery());
|
||||||
|
|
||||||
|
const create = useMutation(ruleCreateMutation(queryClient));
|
||||||
|
const remove = useMutation(ruleDeleteMutation(queryClient));
|
||||||
|
|
||||||
|
const [pattern, setPattern] = useState("");
|
||||||
|
const [kind, setKind] = useState<RuleKind>("exact");
|
||||||
|
const [action, setAction] = useState<RuleAction>("block");
|
||||||
|
const [groupId, setGroupId] = useState(groups[0]?.id ?? 1);
|
||||||
|
|
||||||
|
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
create.mutate(
|
||||||
|
{ group_id: groupId, pattern: pattern.trim(), kind, action },
|
||||||
|
{ onSuccess: () => setPattern("") },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRule(rule: Rule) {
|
||||||
|
if (window.confirm(`Delete the ${rule.action} rule for "${rule.pattern}"?`)) {
|
||||||
|
remove.mutate(rule.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-semibold">Rules</h1>
|
||||||
|
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<p className="mt-4 text-zinc-500">No allow or block rules yet. Create one below.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 overflow-x-auto">
|
||||||
|
<table className="w-full min-w-max border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={TH_CLASS}>Pattern</th>
|
||||||
|
<th className={TH_CLASS}>Kind</th>
|
||||||
|
<th className={TH_CLASS}>Action</th>
|
||||||
|
<th className={TH_CLASS}>Group</th>
|
||||||
|
<th className={TH_CLASS}>Created</th>
|
||||||
|
<th className={TH_CLASS}>
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<tr key={rule.id}>
|
||||||
|
<td className={`${TD_CLASS} font-medium`}>{rule.pattern}</td>
|
||||||
|
<td className={TD_CLASS}>{rule.kind}</td>
|
||||||
|
<td className={TD_CLASS}>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
rule.action === "allow"
|
||||||
|
? "text-green-700 dark:text-green-400"
|
||||||
|
: "text-red-600 dark:text-red-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{rule.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={TD_CLASS}>{rule.group}</td>
|
||||||
|
<td className={TD_CLASS}>{formatTime(rule.created_at)}</td>
|
||||||
|
<td className={TD_CLASS}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteRule(rule)}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<InlineError error={remove.error} />
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="mt-6 max-w-xl space-y-3">
|
||||||
|
<h2 className="text-lg font-medium">Create rule</h2>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rule-pattern" className="block text-sm font-medium">
|
||||||
|
Pattern
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="rule-pattern"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={pattern}
|
||||||
|
onChange={(event) => setPattern(event.target.value)}
|
||||||
|
placeholder="ads.example.com or *.example.com"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rule-kind" className="block text-sm font-medium">
|
||||||
|
Kind
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rule-kind"
|
||||||
|
value={kind}
|
||||||
|
onChange={(event) => setKind(event.target.value as RuleKind)}
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
>
|
||||||
|
<option value="exact">exact</option>
|
||||||
|
<option value="wildcard">wildcard</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rule-action" className="block text-sm font-medium">
|
||||||
|
Action
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rule-action"
|
||||||
|
value={action}
|
||||||
|
onChange={(event) => setAction(event.target.value as RuleAction)}
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
>
|
||||||
|
<option value="allow">allow</option>
|
||||||
|
<option value="block">block</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<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={INPUT_CLASS}
|
||||||
|
>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<option key={group.id} value={group.id}>
|
||||||
|
{group.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={create.isPending}
|
||||||
|
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
>
|
||||||
|
{create.isPending ? "Creating…" : "Create rule"}
|
||||||
|
</button>
|
||||||
|
<InlineError error={create.error} />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import RestartBanner from "@/features/settings/RestartBanner";
|
||||||
|
import { dismissRestartBanner, raiseRestartBanner } from "@/features/settings/restartBanner";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
act(() => dismissRestartBanner());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hidden until raised, dismissible, and a new raise shows it again", () => {
|
||||||
|
render(<RestartBanner />);
|
||||||
|
expect(screen.queryByRole("status")).toBeNull();
|
||||||
|
|
||||||
|
act(() => raiseRestartBanner());
|
||||||
|
expect(screen.getByRole("status").textContent).toContain("Restart nxdns to apply");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||||
|
expect(screen.queryByRole("status")).toBeNull();
|
||||||
|
|
||||||
|
act(() => raiseRestartBanner());
|
||||||
|
expect(screen.getByRole("status")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("raising while already raised keeps the banner up", () => {
|
||||||
|
render(<RestartBanner />);
|
||||||
|
act(() => raiseRestartBanner());
|
||||||
|
act(() => raiseRestartBanner());
|
||||||
|
expect(screen.getByRole("status")).toBeTruthy();
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { dismissRestartBanner, useRestartBanner } from "./restartBanner";
|
||||||
|
|
||||||
|
export default function RestartBanner() {
|
||||||
|
const raised = useRestartBanner();
|
||||||
|
if (!raised) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<span className="flex-1">Settings saved. Restart nxdns to apply.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismissRestartBanner}
|
||||||
|
className="rounded border border-amber-400 px-2 py-1 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-amber-700"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { act } from "react";
|
||||||
|
import SettingsPage, { patchRequiresRestart } from "@/features/settings/SettingsPage";
|
||||||
|
import RestartBanner from "@/features/settings/RestartBanner";
|
||||||
|
import { dismissRestartBanner } from "@/features/settings/restartBanner";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||||
|
|
||||||
|
function baseSettings(): Settings {
|
||||||
|
return {
|
||||||
|
runtime: { io_backend: "threaded" },
|
||||||
|
upstream: { connect_timeout_ms: 2000, read_timeout_ms: 3000, total_timeout_ms: 5000 },
|
||||||
|
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
|
||||||
|
blocking: { response: "zero", ttl: 300 },
|
||||||
|
cache: { size: 10000, negative_ttl_max: 300 },
|
||||||
|
web: {
|
||||||
|
enabled: true,
|
||||||
|
bind: "127.0.0.1",
|
||||||
|
port: 8080,
|
||||||
|
session_ttl_hours: 24,
|
||||||
|
api_rate_limit_per_min: 60,
|
||||||
|
api_localhost_exempt: true,
|
||||||
|
sse_max_connections_per_ip: 2,
|
||||||
|
auth_enabled: true,
|
||||||
|
},
|
||||||
|
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
|
||||||
|
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
|
||||||
|
edns: { ecs_mode: "strip" },
|
||||||
|
logging: {
|
||||||
|
level: "info",
|
||||||
|
retention_days: 30,
|
||||||
|
query_log_buffer_max: 10000,
|
||||||
|
hide_domains: false,
|
||||||
|
hide_client_ips: false,
|
||||||
|
output: "stderr",
|
||||||
|
file_path: "",
|
||||||
|
max_size_mb: 50,
|
||||||
|
max_files: 3,
|
||||||
|
},
|
||||||
|
disk: { min_free_mb: 100, warn_free_mb: 500 },
|
||||||
|
blocklist_update: { enabled: true, interval_hours: 24 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let putBodies: SettingsPatch[];
|
||||||
|
let putResponse: () => Response | Promise<Response>;
|
||||||
|
let storedSettings: Settings;
|
||||||
|
|
||||||
|
function jsonResponse(payload: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPatch(patch: SettingsPatch): void {
|
||||||
|
const settings = storedSettings as unknown as Record<string, Record<string, unknown>>;
|
||||||
|
for (const [section, fields] of Object.entries(patch)) {
|
||||||
|
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
|
||||||
|
if (section === "web" && key === "password") continue;
|
||||||
|
settings[section]![key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
act(() => dismissRestartBanner());
|
||||||
|
putBodies = [];
|
||||||
|
storedSettings = baseSettings();
|
||||||
|
putResponse = () => {
|
||||||
|
applyPatch(putBodies[putBodies.length - 1]!);
|
||||||
|
return jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] });
|
||||||
|
};
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url !== "/api/settings") return jsonResponse({ error: "not stubbed" }, 404);
|
||||||
|
if (init?.method === "PUT") {
|
||||||
|
putBodies.push(JSON.parse(String(init.body)) as SettingsPatch);
|
||||||
|
return putResponse();
|
||||||
|
}
|
||||||
|
return jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderPage() {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
|
<RestartBanner />
|
||||||
|
<Suspense fallback={<p>loading</p>}>
|
||||||
|
<SettingsPage />
|
||||||
|
</Suspense>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByRole("heading", { name: "Settings" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveButton(): HTMLButtonElement {
|
||||||
|
return screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("no changes means Save is disabled and auth_enabled shows read-only", async () => {
|
||||||
|
await renderPage();
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
expect(screen.getByText(/auth_enabled: true/).textContent).toContain("read-only");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a changed field enables Save and the PUT body is exactly the diff", async () => {
|
||||||
|
await renderPage();
|
||||||
|
const dns = screen.getByRole("group", { name: "DNS" });
|
||||||
|
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
|
||||||
|
expect(saveButton().disabled).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||||
|
expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
|
||||||
|
|
||||||
|
expect((await screen.findByRole("status")).textContent).toContain("Restart nxdns to apply");
|
||||||
|
await waitFor(() => expect(saveButton().disabled).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enum and boolean fields diff as their own types", async () => {
|
||||||
|
await renderPage();
|
||||||
|
const logging = screen.getByRole("group", { name: "Logging" });
|
||||||
|
fireEvent.change(within(logging).getByLabelText("level"), { target: { value: "debug" } });
|
||||||
|
fireEvent.click(within(logging).getByLabelText("hide_domains"));
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||||
|
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing a number field disables Save instead of sending NaN", async () => {
|
||||||
|
await renderPage();
|
||||||
|
const cache = screen.getByRole("group", { name: "Cache" });
|
||||||
|
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("password flow: note shown, confirm required, PUT sends web.password, no banner", async () => {
|
||||||
|
await renderPage();
|
||||||
|
const web = screen.getByRole("group", { name: "Web" });
|
||||||
|
const passwordInput = within(web).getByLabelText("password") as HTMLInputElement;
|
||||||
|
const confirmInput = within(web).getByLabelText("confirm password") as HTMLInputElement;
|
||||||
|
expect(passwordInput.value).toBe("");
|
||||||
|
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
|
||||||
|
expect(screen.getByText(/signs out every session/)).toBeTruthy();
|
||||||
|
expect(screen.getByText("Passwords do not match.")).toBeTruthy();
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
|
||||||
|
expect(screen.queryByText("Passwords do not match.")).toBeNull();
|
||||||
|
expect(saveButton().disabled).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||||
|
expect(putBodies[0]).toEqual({ web: { password: "hunter2" } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(passwordInput.value).toBe(""));
|
||||||
|
expect(confirmInput.value).toBe("");
|
||||||
|
expect(screen.queryByRole("status")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a mixed patch with a password still raises the banner", async () => {
|
||||||
|
await renderPage();
|
||||||
|
const web = screen.getByRole("group", { name: "Web" });
|
||||||
|
fireEvent.change(within(web).getByLabelText("session_ttl_hours"), { target: { value: "48" } });
|
||||||
|
fireEvent.change(within(web).getByLabelText("password"), { target: { value: "hunter2" } });
|
||||||
|
fireEvent.change(within(web).getByLabelText("confirm password"), { target: { value: "hunter2" } });
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
await waitFor(() => expect(putBodies).toHaveLength(1));
|
||||||
|
expect(putBodies[0]).toEqual({ web: { session_ttl_hours: 48, password: "hunter2" } });
|
||||||
|
expect(await screen.findByRole("status")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the form is disabled while the PUT is pending and re-enabled after success", async () => {
|
||||||
|
await renderPage();
|
||||||
|
let resolvePut!: (response: Response) => void;
|
||||||
|
putResponse = () => new Promise<Response>((resolve) => (resolvePut = resolve));
|
||||||
|
|
||||||
|
const dns = screen.getByRole("group", { name: "DNS" });
|
||||||
|
const port = within(dns).getByLabelText("port") as HTMLInputElement;
|
||||||
|
fireEvent.change(port, { target: { value: "5353" } });
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
|
await screen.findByRole("button", { name: "Saving…" });
|
||||||
|
expect(port.matches(":disabled")).toBe(true);
|
||||||
|
const web = screen.getByRole("group", { name: "Web" });
|
||||||
|
expect(within(web).getByLabelText("password").matches(":disabled")).toBe(true);
|
||||||
|
|
||||||
|
applyPatch(putBodies[putBodies.length - 1]!);
|
||||||
|
resolvePut(jsonResponse({ settings: storedSettings, restart_required: ["dns.port"] }));
|
||||||
|
await waitFor(() => expect(port.matches(":disabled")).toBe(false));
|
||||||
|
expect(saveButton().textContent).toBe("Save");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 429 shows the rate-limit countdown from Retry-After", async () => {
|
||||||
|
await renderPage();
|
||||||
|
putResponse = () =>
|
||||||
|
new Response(JSON.stringify({ error: "too many requests" }), {
|
||||||
|
status: 429,
|
||||||
|
headers: { "content-type": "application/json", "Retry-After": "30" },
|
||||||
|
});
|
||||||
|
const dns = screen.getByRole("group", { name: "DNS" });
|
||||||
|
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "5353" } });
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toBe("Rate limited. Try again in 30s.");
|
||||||
|
expect(screen.queryByRole("status")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a 400 validation error surfaces inline and raises no banner", async () => {
|
||||||
|
await renderPage();
|
||||||
|
putResponse = () => jsonResponse({ error: "dns.port out of range" }, 400);
|
||||||
|
const dns = screen.getByRole("group", { name: "DNS" });
|
||||||
|
fireEvent.change(within(dns).getByLabelText("port"), { target: { value: "70000" } });
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toBe("dns.port out of range");
|
||||||
|
expect(screen.queryByRole("status")).toBeNull();
|
||||||
|
expect(saveButton().disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("patchRequiresRestart ignores only a bare web.password", () => {
|
||||||
|
expect(patchRequiresRestart({ web: { password: "x" } })).toBe(false);
|
||||||
|
expect(patchRequiresRestart({ web: { password: "x", port: 9090 } })).toBe(true);
|
||||||
|
expect(patchRequiresRestart({ dns: { port: 5353 } })).toBe(true);
|
||||||
|
expect(patchRequiresRestart({ web: { password: "x" }, cache: { size: 1 } })).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import { settingsPutMutation, settingsQuery } from "@/lib/queries";
|
||||||
|
import { buildSettingsPatch } from "@/lib/settingsDiff";
|
||||||
|
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||||
|
import { raiseRestartBanner } from "./restartBanner";
|
||||||
|
|
||||||
|
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
|
||||||
|
export function patchRequiresRestart(patch: SettingsPatch): boolean {
|
||||||
|
return Object.entries(patch).some(([section, fields]) =>
|
||||||
|
Object.keys(fields as Record<string, unknown>).some((key) => !(section === "web" && key === "password")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldDef {
|
||||||
|
key: string;
|
||||||
|
kind: "number" | "text" | "boolean" | readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionDef {
|
||||||
|
section: keyof Settings;
|
||||||
|
title: string;
|
||||||
|
fields: readonly FieldDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TLS_FIELDS: readonly FieldDef[] = [
|
||||||
|
{ key: "enabled", kind: "boolean" },
|
||||||
|
{ key: "bind", kind: "text" },
|
||||||
|
{ key: "port", kind: "number" },
|
||||||
|
{ key: "cert_path", kind: "text" },
|
||||||
|
{ key: "key_path", kind: "text" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SECTIONS: readonly SectionDef[] = [
|
||||||
|
{ section: "runtime", title: "Runtime", fields: [{ key: "io_backend", kind: ["threaded", "evented"] }] },
|
||||||
|
{
|
||||||
|
section: "upstream",
|
||||||
|
title: "Upstream",
|
||||||
|
fields: [
|
||||||
|
{ key: "connect_timeout_ms", kind: "number" },
|
||||||
|
{ key: "read_timeout_ms", kind: "number" },
|
||||||
|
{ key: "total_timeout_ms", kind: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "dns",
|
||||||
|
title: "DNS",
|
||||||
|
fields: [
|
||||||
|
{ key: "bind_ipv4", kind: "text" },
|
||||||
|
{ key: "bind_ipv6", kind: "text" },
|
||||||
|
{ key: "port", kind: "number" },
|
||||||
|
{ key: "rate_limit", kind: "number" },
|
||||||
|
{ key: "rate_window_seconds", kind: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "blocking",
|
||||||
|
title: "Blocking",
|
||||||
|
fields: [
|
||||||
|
{ key: "response", kind: ["zero", "nxdomain"] },
|
||||||
|
{ key: "ttl", kind: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "cache",
|
||||||
|
title: "Cache",
|
||||||
|
fields: [
|
||||||
|
{ key: "size", kind: "number" },
|
||||||
|
{ key: "negative_ttl_max", kind: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "web",
|
||||||
|
title: "Web",
|
||||||
|
fields: [
|
||||||
|
{ key: "enabled", kind: "boolean" },
|
||||||
|
{ key: "bind", kind: "text" },
|
||||||
|
{ key: "port", kind: "number" },
|
||||||
|
{ key: "session_ttl_hours", kind: "number" },
|
||||||
|
{ key: "api_rate_limit_per_min", kind: "number" },
|
||||||
|
{ key: "api_localhost_exempt", kind: "boolean" },
|
||||||
|
{ key: "sse_max_connections_per_ip", kind: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS },
|
||||||
|
{ section: "dot_server", title: "DoT Server", fields: TLS_FIELDS },
|
||||||
|
{ section: "edns", title: "EDNS", fields: [{ key: "ecs_mode", kind: ["strip", "forward"] }] },
|
||||||
|
{
|
||||||
|
section: "logging",
|
||||||
|
title: "Logging",
|
||||||
|
fields: [
|
||||||
|
{ key: "level", kind: ["error", "warn", "info", "debug"] },
|
||||||
|
{ key: "retention_days", kind: "number" },
|
||||||
|
{ key: "query_log_buffer_max", kind: "number" },
|
||||||
|
{ key: "hide_domains", kind: "boolean" },
|
||||||
|
{ key: "hide_client_ips", kind: "boolean" },
|
||||||
|
{ key: "output", kind: ["stderr", "syslog", "file"] },
|
||||||
|
{ key: "file_path", kind: "text" },
|
||||||
|
{ key: "max_size_mb", kind: "number" },
|
||||||
|
{ key: "max_files", kind: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "disk",
|
||||||
|
title: "Disk",
|
||||||
|
fields: [
|
||||||
|
{ key: "min_free_mb", kind: "number" },
|
||||||
|
{ key: "warn_free_mb", kind: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "blocklist_update",
|
||||||
|
title: "Blocklist Update",
|
||||||
|
fields: [
|
||||||
|
{ key: "enabled", kind: "boolean" },
|
||||||
|
{ key: "interval_hours", kind: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300";
|
||||||
|
const INPUT_CLASS =
|
||||||
|
"rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
|
||||||
|
|
||||||
|
function FieldRow({
|
||||||
|
section,
|
||||||
|
def,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
section: string;
|
||||||
|
def: FieldDef;
|
||||||
|
value: unknown;
|
||||||
|
onChange: (value: unknown) => void;
|
||||||
|
}) {
|
||||||
|
const id = `${section}.${def.key}`;
|
||||||
|
if (def.kind === "boolean") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
|
checked={value as boolean}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
className="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
/>
|
||||||
|
<label htmlFor={id} className={LABEL_CLASS}>
|
||||||
|
{def.key}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Array.isArray(def.kind)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor={id} className={LABEL_CLASS}>
|
||||||
|
{def.key}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
value={value as string}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
>
|
||||||
|
{def.kind.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (def.kind === "number") {
|
||||||
|
const numeric = value as number;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor={id} className={LABEL_CLASS}>
|
||||||
|
{def.key}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
value={Number.isNaN(numeric) ? "" : numeric}
|
||||||
|
onChange={(e) => onChange(e.target.valueAsNumber)}
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor={id} className={LABEL_CLASS}>
|
||||||
|
{def.key}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
value={value as string}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { data } = useSuspenseQuery(settingsQuery());
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const mutation = useMutation(settingsPutMutation(queryClient));
|
||||||
|
const [edited, setEdited] = useState<Settings>(() => structuredClone(data.settings));
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
|
||||||
|
const passwordsMismatch = (password !== "" || confirm !== "") && password !== confirm;
|
||||||
|
const hasInvalidNumber = SECTIONS.some(({ section, fields }) =>
|
||||||
|
fields.some(
|
||||||
|
(field) => field.kind === "number" && Number.isNaN((edited[section] as Record<string, unknown>)[field.key]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const patch = buildSettingsPatch(data.settings, edited, password === "" ? undefined : password);
|
||||||
|
const saveDisabled = patch === null || passwordsMismatch || hasInvalidNumber || mutation.isPending;
|
||||||
|
|
||||||
|
function setField(section: keyof Settings, key: string, value: unknown): void {
|
||||||
|
setEdited((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[section]: { ...(prev[section] as Record<string, unknown>), [key]: value },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
if (patch === null || passwordsMismatch || hasInvalidNumber) return;
|
||||||
|
const restartNeeded = patchRequiresRestart(patch);
|
||||||
|
mutation.mutate(patch, {
|
||||||
|
onSuccess: (envelope) => {
|
||||||
|
setEdited(structuredClone(envelope.settings));
|
||||||
|
setPassword("");
|
||||||
|
setConfirm("");
|
||||||
|
if (restartNeeded) raiseRestartBanner();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
Changes are validated as a whole; every setting requires a restart to take effect.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="mt-4 max-w-3xl">
|
||||||
|
<fieldset disabled={mutation.isPending} className="space-y-6">
|
||||||
|
{SECTIONS.map(({ section, title, fields }) => (
|
||||||
|
<fieldset key={section} className="rounded border border-zinc-200 p-4 dark:border-zinc-800">
|
||||||
|
<legend className="px-1 text-sm font-semibold">{title}</legend>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{fields.map((def) => (
|
||||||
|
<FieldRow
|
||||||
|
key={def.key}
|
||||||
|
section={section}
|
||||||
|
def={def}
|
||||||
|
value={(edited[section] as Record<string, unknown>)[def.key]}
|
||||||
|
onChange={(value) => setField(section, def.key, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{section === "web" && (
|
||||||
|
<>
|
||||||
|
<p className={LABEL_CLASS}>
|
||||||
|
auth_enabled: {data.settings.web.auth_enabled ? "true" : "false"}{" "}
|
||||||
|
<span className="text-zinc-500">(derived, read-only)</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="web.password" className={LABEL_CLASS}>
|
||||||
|
password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="web.password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="web.password_confirm" className={LABEL_CLASS}>
|
||||||
|
confirm password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="web.password_confirm"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{password !== "" && (
|
||||||
|
<p className="text-sm text-amber-700 sm:col-span-2 dark:text-amber-400">
|
||||||
|
Changing the password signs out every session; you will be asked to log
|
||||||
|
in again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{passwordsMismatch && (
|
||||||
|
<p className="text-sm text-red-700 sm:col-span-2 dark:text-red-400">
|
||||||
|
Passwords do not match.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saveDisabled}
|
||||||
|
className="rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
{mutation.isError && <InlineError error={mutation.error} />}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
let raised = false;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function subscribe(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(): boolean {
|
||||||
|
return raised;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function raiseRestartBanner(): void {
|
||||||
|
raised = true;
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissRestartBanner(): void {
|
||||||
|
raised = false;
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRestartBanner(): boolean {
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
/** Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with countdown. */
|
||||||
|
export default function InlineError({ error }: { error: unknown }) {
|
||||||
|
const retryAfter = error instanceof ApiError && error.status === 429 ? (error.retryAfter ?? null) : null;
|
||||||
|
const [remaining, setRemaining] = useState<number | null>(retryAfter);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRemaining(retryAfter);
|
||||||
|
if (retryAfter === null) return;
|
||||||
|
const timer = setInterval(() => setRemaining((s) => (s === null || s <= 1 ? 0 : s - 1)), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [error, retryAfter]);
|
||||||
|
|
||||||
|
if (error === null || error === undefined) return null;
|
||||||
|
|
||||||
|
let message: string;
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
if (error.status === 429) {
|
||||||
|
message =
|
||||||
|
remaining !== null && remaining > 0
|
||||||
|
? `Rate limited. Try again in ${remaining}s.`
|
||||||
|
: "Rate limited. Try again.";
|
||||||
|
} else if (error.status === 503) {
|
||||||
|
message = "The server is starting or degraded. Try again shortly.";
|
||||||
|
} else {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message = "Could not reach the server.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p role="alert" className="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { ApiError, deleteGroup, getQueries, getStats, listGroups, login, putGroupSources } from "@/lib/api";
|
||||||
|
|
||||||
|
function jsonResponse(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json", ...headers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends same-origin credentials and parses JSON", async () => {
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ authenticated: true, auth_required: true }));
|
||||||
|
const response = await login({ password: "hunter2" });
|
||||||
|
expect(response.auth_required).toBe(true);
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0]!;
|
||||||
|
expect(url).toBe("/api/auth/login");
|
||||||
|
expect(init?.credentials).toBe("same-origin");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(init?.body).toBe(JSON.stringify({ password: "hunter2" }));
|
||||||
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||||
|
expect(headers["content-type"]).toBe("application/json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws ApiError with the {error} envelope message", async () => {
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ error: "duplicate group name" }, 409));
|
||||||
|
const failure = await listGroups().catch((e: unknown) => e);
|
||||||
|
expect(failure).toBeInstanceOf(ApiError);
|
||||||
|
expect((failure as ApiError).status).toBe(409);
|
||||||
|
expect((failure as ApiError).message).toBe("duplicate group name");
|
||||||
|
expect((failure as ApiError).retryAfter).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to a status message on a non-JSON error body", async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response("<html>bad gateway</html>", { status: 502 }));
|
||||||
|
const failure = await listGroups().catch((e: unknown) => e);
|
||||||
|
expect(failure).toBeInstanceOf(ApiError);
|
||||||
|
expect((failure as ApiError).message).toBe("HTTP 502");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses Retry-After on 429", async () => {
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ error: "rate limited" }, 429, { "Retry-After": "17" }));
|
||||||
|
const failure = await getStats("1h").catch((e: unknown) => e);
|
||||||
|
expect(failure).toBeInstanceOf(ApiError);
|
||||||
|
expect((failure as ApiError).status).toBe(429);
|
||||||
|
expect((failure as ApiError).retryAfter).toBe(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores a malformed Retry-After header", async () => {
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ error: "rate limited" }, 429, { "Retry-After": "soon" }));
|
||||||
|
const failure = await getStats().catch((e: unknown) => e);
|
||||||
|
expect((failure as ApiError).retryAfter).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves void on 204", async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
|
||||||
|
await expect(deleteGroup(3)).resolves.toBeUndefined();
|
||||||
|
expect(fetchMock.mock.calls[0]![0]).toBe("/api/groups/3");
|
||||||
|
expect(fetchMock.mock.calls[0]![1]?.method).toBe("DELETE");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unwraps list envelopes", async () => {
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ groups: [{ id: 1, name: "default", safe_search: false }] }));
|
||||||
|
const groups = await listGroups();
|
||||||
|
expect(groups).toEqual([{ id: 1, name: "default", safe_search: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serializes query filters, omitting undefined", async () => {
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ queries: [], next_before: null }));
|
||||||
|
await getQueries({ limit: 50, blocked: true, domain: "ads.example", before: undefined });
|
||||||
|
expect(fetchMock.mock.calls[0]![0]).toBe("/api/queries?limit=50&blocked=true&domain=ads.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requests with no filters carry no query string", async () => {
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ queries: [], next_before: null }));
|
||||||
|
await getQueries();
|
||||||
|
expect(fetchMock.mock.calls[0]![0]).toBe("/api/queries");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wraps and unwraps group sources", async () => {
|
||||||
|
fetchMock.mockResolvedValue(jsonResponse({ source_ids: [2, 5] }));
|
||||||
|
const stored = await putGroupSources(4, [5, 2]);
|
||||||
|
expect(stored).toEqual([2, 5]);
|
||||||
|
expect(fetchMock.mock.calls[0]![0]).toBe("/api/groups/4/sources");
|
||||||
|
expect(fetchMock.mock.calls[0]![1]?.body).toBe(JSON.stringify({ source_ids: [5, 2] }));
|
||||||
|
});
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import type {
|
||||||
|
Blocklist,
|
||||||
|
BlocklistEcho,
|
||||||
|
BlocklistInput,
|
||||||
|
Client,
|
||||||
|
ClientEdit,
|
||||||
|
ClientPrefix,
|
||||||
|
ClientPrefixInput,
|
||||||
|
ForwardZone,
|
||||||
|
ForwardZoneInput,
|
||||||
|
Group,
|
||||||
|
GroupInput,
|
||||||
|
Health,
|
||||||
|
LocalRecord,
|
||||||
|
LocalRecordInput,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
LogoutResponse,
|
||||||
|
LookupResult,
|
||||||
|
PausePost,
|
||||||
|
PauseState,
|
||||||
|
Period,
|
||||||
|
QueriesFilter,
|
||||||
|
QueriesPage,
|
||||||
|
Rule,
|
||||||
|
RuleEcho,
|
||||||
|
RuleInput,
|
||||||
|
SettingsEnvelope,
|
||||||
|
SettingsPatch,
|
||||||
|
SourceStatus,
|
||||||
|
StatsTimeseries,
|
||||||
|
StatsTotals,
|
||||||
|
Upstream,
|
||||||
|
UpstreamEcho,
|
||||||
|
UpstreamHealth,
|
||||||
|
UpstreamInput,
|
||||||
|
Version,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly retryAfter?: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string, retryAfter?: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.retryAfter = retryAfter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toApiError(res: Response): Promise<ApiError> {
|
||||||
|
let message = `HTTP ${res.status}`;
|
||||||
|
try {
|
||||||
|
const body: unknown = await res.json();
|
||||||
|
if (typeof body === "object" && body !== null && "error" in body && typeof body.error === "string") {
|
||||||
|
message = body.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-JSON error body; keep the status fallback.
|
||||||
|
}
|
||||||
|
let retryAfter: number | undefined;
|
||||||
|
if (res.status === 429) {
|
||||||
|
const header = res.headers.get("Retry-After");
|
||||||
|
const seconds = header === null ? NaN : Number(header);
|
||||||
|
if (Number.isFinite(seconds) && seconds >= 0) retryAfter = seconds;
|
||||||
|
}
|
||||||
|
return new ApiError(res.status, message, retryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: { method?: string; body?: unknown }): Promise<T> {
|
||||||
|
const body = init?.body;
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: body !== undefined ? { "content-type": "application/json" } : undefined,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await toApiError(res);
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestText(path: string): Promise<string> {
|
||||||
|
const res = await fetch(path, { credentials: "same-origin" });
|
||||||
|
if (!res.ok) throw await toApiError(res);
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryValue = string | number | boolean | undefined;
|
||||||
|
|
||||||
|
function qs(params: Record<string, QueryValue>): string {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined) search.set(key, String(value));
|
||||||
|
}
|
||||||
|
const encoded = search.toString();
|
||||||
|
return encoded === "" ? "" : `?${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitoring + meta
|
||||||
|
|
||||||
|
export const getMetrics = (): Promise<string> => requestText("/metrics");
|
||||||
|
export const getHealth = (): Promise<Health> => request("/api/health");
|
||||||
|
export const getVersion = (): Promise<Version> => request("/api/version");
|
||||||
|
export const getOpenapiYaml = (): Promise<string> => requestText("/api/openapi.yaml");
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
|
||||||
|
export const login = (body: LoginRequest): Promise<LoginResponse> =>
|
||||||
|
request("/api/auth/login", { method: "POST", body });
|
||||||
|
export const logout = (): Promise<LogoutResponse> => request("/api/auth/logout", { method: "POST", body: {} });
|
||||||
|
|
||||||
|
// Query log + stats
|
||||||
|
|
||||||
|
export const getQueries = (filter: QueriesFilter = {}): Promise<QueriesPage> =>
|
||||||
|
request(`/api/queries${qs({ ...filter })}`);
|
||||||
|
|
||||||
|
/** `EventSource` URL for the live stream; not a fetch route. */
|
||||||
|
export const liveQueriesUrl = "/api/queries/live";
|
||||||
|
|
||||||
|
export const getStats = (period?: Period): Promise<StatsTotals> => request(`/api/stats${qs({ period })}`);
|
||||||
|
export const getStatsTimeseries = (period?: Period): Promise<StatsTimeseries> =>
|
||||||
|
request(`/api/stats/timeseries${qs({ period })}`);
|
||||||
|
|
||||||
|
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
|
||||||
|
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
|
||||||
|
|
||||||
|
export const getUpstreamHealth = (): Promise<UpstreamHealth> => request("/api/upstream/health");
|
||||||
|
|
||||||
|
// Groups
|
||||||
|
|
||||||
|
export const listGroups = async (): Promise<Group[]> => (await request<{ groups: Group[] }>("/api/groups")).groups;
|
||||||
|
export const createGroup = (input: GroupInput): Promise<Group> =>
|
||||||
|
request("/api/groups", { method: "POST", body: input });
|
||||||
|
export const getGroup = (id: number): Promise<Group> => request(`/api/groups/${id}`);
|
||||||
|
export const updateGroup = (id: number, input: GroupInput): Promise<Group> =>
|
||||||
|
request(`/api/groups/${id}`, { method: "PUT", body: input });
|
||||||
|
export const deleteGroup = (id: number): Promise<void> => request(`/api/groups/${id}`, { method: "DELETE" });
|
||||||
|
|
||||||
|
export const getGroupSources = async (id: number): Promise<number[]> =>
|
||||||
|
(await request<{ source_ids: number[] }>(`/api/groups/${id}/sources`)).source_ids;
|
||||||
|
export const putGroupSources = async (id: number, sourceIds: number[]): Promise<number[]> =>
|
||||||
|
(
|
||||||
|
await request<{ source_ids: number[] }>(`/api/groups/${id}/sources`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: { source_ids: sourceIds },
|
||||||
|
})
|
||||||
|
).source_ids;
|
||||||
|
|
||||||
|
// Blocklists
|
||||||
|
|
||||||
|
export const listBlocklists = async (): Promise<Blocklist[]> =>
|
||||||
|
(await request<{ blocklists: Blocklist[] }>("/api/blocklists")).blocklists;
|
||||||
|
export const createBlocklist = (input: BlocklistInput): Promise<BlocklistEcho> =>
|
||||||
|
request("/api/blocklists", { method: "POST", body: input });
|
||||||
|
export const updateBlocklistsNow = async (): Promise<SourceStatus[]> =>
|
||||||
|
(await request<{ sources: SourceStatus[] }>("/api/blocklists/update", { method: "POST", body: {} })).sources;
|
||||||
|
export const getBlocklist = (id: number): Promise<Blocklist> => request(`/api/blocklists/${id}`);
|
||||||
|
export const updateBlocklist = (id: number, input: BlocklistInput): Promise<BlocklistEcho> =>
|
||||||
|
request(`/api/blocklists/${id}`, { method: "PUT", body: input });
|
||||||
|
export const deleteBlocklist = (id: number): Promise<void> => request(`/api/blocklists/${id}`, { method: "DELETE" });
|
||||||
|
|
||||||
|
// Rules
|
||||||
|
|
||||||
|
export const listRules = async (): Promise<Rule[]> => (await request<{ rules: Rule[] }>("/api/rules")).rules;
|
||||||
|
export const createRule = (input: RuleInput): Promise<RuleEcho> =>
|
||||||
|
request("/api/rules", { method: "POST", body: input });
|
||||||
|
export const getRule = (id: number): Promise<Rule> => request(`/api/rules/${id}`);
|
||||||
|
export const updateRule = (id: number, input: RuleInput): Promise<RuleEcho> =>
|
||||||
|
request(`/api/rules/${id}`, { method: "PUT", body: input });
|
||||||
|
export const deleteRule = (id: number): Promise<void> => request(`/api/rules/${id}`, { method: "DELETE" });
|
||||||
|
|
||||||
|
// Local records
|
||||||
|
|
||||||
|
export const listLocalRecords = async (): Promise<LocalRecord[]> =>
|
||||||
|
(await request<{ local_records: LocalRecord[] }>("/api/local-records")).local_records;
|
||||||
|
export const createLocalRecord = (input: LocalRecordInput): Promise<LocalRecord> =>
|
||||||
|
request("/api/local-records", { method: "POST", body: input });
|
||||||
|
export const getLocalRecord = (id: number): Promise<LocalRecord> => request(`/api/local-records/${id}`);
|
||||||
|
export const updateLocalRecord = (id: number, input: LocalRecordInput): Promise<LocalRecord> =>
|
||||||
|
request(`/api/local-records/${id}`, { method: "PUT", body: input });
|
||||||
|
export const deleteLocalRecord = (id: number): Promise<void> =>
|
||||||
|
request(`/api/local-records/${id}`, { method: "DELETE" });
|
||||||
|
|
||||||
|
// Forward zones
|
||||||
|
|
||||||
|
export const listForwardZones = async (): Promise<ForwardZone[]> =>
|
||||||
|
(await request<{ forward_zones: ForwardZone[] }>("/api/forward-zones")).forward_zones;
|
||||||
|
export const createForwardZone = (input: ForwardZoneInput): Promise<ForwardZone> =>
|
||||||
|
request("/api/forward-zones", { method: "POST", body: input });
|
||||||
|
export const getForwardZone = (id: number): Promise<ForwardZone> => request(`/api/forward-zones/${id}`);
|
||||||
|
export const updateForwardZone = (id: number, input: ForwardZoneInput): Promise<ForwardZone> =>
|
||||||
|
request(`/api/forward-zones/${id}`, { method: "PUT", body: input });
|
||||||
|
export const deleteForwardZone = (id: number): Promise<void> =>
|
||||||
|
request(`/api/forward-zones/${id}`, { method: "DELETE" });
|
||||||
|
|
||||||
|
// Clients + prefixes
|
||||||
|
|
||||||
|
export const listClients = async (): Promise<Client[]> =>
|
||||||
|
(await request<{ clients: Client[] }>("/api/clients")).clients;
|
||||||
|
export const getClient = (id: number): Promise<Client> => request(`/api/clients/${id}`);
|
||||||
|
export const updateClient = (id: number, edit: ClientEdit): Promise<Client> =>
|
||||||
|
request(`/api/clients/${id}`, { method: "PUT", body: edit });
|
||||||
|
export const deleteClient = (id: number): Promise<void> => request(`/api/clients/${id}`, { method: "DELETE" });
|
||||||
|
|
||||||
|
export const listClientPrefixes = async (): Promise<ClientPrefix[]> =>
|
||||||
|
(await request<{ client_prefixes: ClientPrefix[] }>("/api/client-prefixes")).client_prefixes;
|
||||||
|
export const putClientPrefixes = async (prefixes: ClientPrefixInput[]): Promise<ClientPrefix[]> =>
|
||||||
|
(
|
||||||
|
await request<{ client_prefixes: ClientPrefix[] }>("/api/client-prefixes", {
|
||||||
|
method: "PUT",
|
||||||
|
body: { client_prefixes: prefixes },
|
||||||
|
})
|
||||||
|
).client_prefixes;
|
||||||
|
|
||||||
|
// Upstreams
|
||||||
|
|
||||||
|
export const listUpstreams = async (): Promise<Upstream[]> =>
|
||||||
|
(await request<{ upstreams: Upstream[] }>("/api/upstreams")).upstreams;
|
||||||
|
export const createUpstream = (input: UpstreamInput): Promise<UpstreamEcho> =>
|
||||||
|
request("/api/upstreams", { method: "POST", body: input });
|
||||||
|
export const getUpstream = (id: number): Promise<Upstream> => request(`/api/upstreams/${id}`);
|
||||||
|
export const updateUpstream = (id: number, input: UpstreamInput): Promise<UpstreamEcho> =>
|
||||||
|
request(`/api/upstreams/${id}`, { method: "PUT", body: input });
|
||||||
|
export const deleteUpstream = (id: number): Promise<void> => request(`/api/upstreams/${id}`, { method: "DELETE" });
|
||||||
|
|
||||||
|
// Pause + settings
|
||||||
|
|
||||||
|
export const getPause = (): Promise<PauseState> => request("/api/pause");
|
||||||
|
export const postPause = (body: PausePost): Promise<PauseState> => request("/api/pause", { method: "POST", body });
|
||||||
|
|
||||||
|
export const getSettings = (): Promise<SettingsEnvelope> => request("/api/settings");
|
||||||
|
export const putSettings = (patch: SettingsPatch): Promise<SettingsEnvelope> =>
|
||||||
|
request("/api/settings", { method: "PUT", body: patch });
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { formatBytes, formatMicros, formatTime } from "@/lib/format";
|
||||||
|
|
||||||
|
test("formatTime renders unix seconds in the given locale and zone", () => {
|
||||||
|
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
|
||||||
|
expect(formatTime(1704067200, "en-US", "UTC").replace(/ /g, " ")).toBe("Jan 1, 2024, 12:00:00 AM");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatBytes humanizes with binary units", () => {
|
||||||
|
expect(formatBytes(0)).toBe("0 B");
|
||||||
|
expect(formatBytes(1023)).toBe("1023 B");
|
||||||
|
expect(formatBytes(1024)).toBe("1.0 KiB");
|
||||||
|
expect(formatBytes(1536)).toBe("1.5 KiB");
|
||||||
|
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MiB");
|
||||||
|
expect(formatBytes(3 * 1024 * 1024 * 1024)).toBe("3.0 GiB");
|
||||||
|
expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatMicros renders milliseconds with one decimal", () => {
|
||||||
|
expect(formatMicros(0)).toBe("0.0 ms");
|
||||||
|
expect(formatMicros(1234)).toBe("1.2 ms");
|
||||||
|
expect(formatMicros(999)).toBe("1.0 ms");
|
||||||
|
expect(formatMicros(2_500_000)).toBe("2500.0 ms");
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** Unix seconds → localized date-time. `locale`/`timeZone` exist for deterministic tests. */
|
||||||
|
export function formatTime(unixSeconds: number, locale?: string, timeZone?: string): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "medium",
|
||||||
|
timeZone,
|
||||||
|
}).format(new Date(unixSeconds * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const BYTE_UNITS = ["KiB", "MiB", "GiB", "TiB"] as const;
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
let value = bytes;
|
||||||
|
let unit: string = BYTE_UNITS[0];
|
||||||
|
for (const next of BYTE_UNITS) {
|
||||||
|
unit = next;
|
||||||
|
value /= 1024;
|
||||||
|
if (value < 1024) break;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(1)} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Microseconds → milliseconds with one decimal, e.g. 1234 → "1.2 ms". */
|
||||||
|
export function formatMicros(micros: number): string {
|
||||||
|
return `${(micros / 1000).toFixed(1)} ms`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { queryOptions, type QueryClient } from "@tanstack/react-query";
|
||||||
|
import * as api from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
BlocklistInput,
|
||||||
|
ClientEdit,
|
||||||
|
ClientPrefixInput,
|
||||||
|
ForwardZoneInput,
|
||||||
|
GroupInput,
|
||||||
|
LocalRecordInput,
|
||||||
|
PausePost,
|
||||||
|
Period,
|
||||||
|
QueriesFilter,
|
||||||
|
RuleInput,
|
||||||
|
SettingsPatch,
|
||||||
|
UpstreamInput,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
health: ["health"] as const,
|
||||||
|
version: ["version"] as const,
|
||||||
|
stats: (period: Period) => ["stats", period] as const,
|
||||||
|
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
|
||||||
|
queries: (filter: QueriesFilter) => ["queries", filter] as const,
|
||||||
|
upstreamHealth: ["upstream-health"] as const,
|
||||||
|
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
|
||||||
|
groups: ["groups"] as const,
|
||||||
|
groupSources: (id: number) => ["groups", id, "sources"] as const,
|
||||||
|
blocklists: ["blocklists"] as const,
|
||||||
|
/** Fed only by POST /api/blocklists/update's 202 snapshot; no GET exists. */
|
||||||
|
blocklistSources: ["blocklists", "sources"] as const,
|
||||||
|
rules: ["rules"] as const,
|
||||||
|
localRecords: ["local-records"] as const,
|
||||||
|
forwardZones: ["forward-zones"] as const,
|
||||||
|
clients: ["clients"] as const,
|
||||||
|
clientPrefixes: ["client-prefixes"] as const,
|
||||||
|
upstreams: ["upstreams"] as const,
|
||||||
|
pause: ["pause"] as const,
|
||||||
|
settings: ["settings"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const healthQuery = () =>
|
||||||
|
queryOptions({ queryKey: queryKeys.health, queryFn: api.getHealth, refetchInterval: 10_000 });
|
||||||
|
|
||||||
|
export const versionQuery = () =>
|
||||||
|
queryOptions({ queryKey: queryKeys.version, queryFn: api.getVersion, staleTime: Infinity });
|
||||||
|
|
||||||
|
export const statsQuery = (period: Period = "24h") =>
|
||||||
|
queryOptions({ queryKey: queryKeys.stats(period), queryFn: () => api.getStats(period), refetchInterval: 30_000 });
|
||||||
|
|
||||||
|
export const timeseriesQuery = (period: Period = "24h") =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: queryKeys.timeseries(period),
|
||||||
|
queryFn: () => api.getStatsTimeseries(period),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const queriesQuery = (filter: QueriesFilter = {}) =>
|
||||||
|
queryOptions({ queryKey: queryKeys.queries(filter), queryFn: () => api.getQueries(filter) });
|
||||||
|
|
||||||
|
export const upstreamHealthQuery = () =>
|
||||||
|
queryOptions({ queryKey: queryKeys.upstreamHealth, queryFn: api.getUpstreamHealth, refetchInterval: 30_000 });
|
||||||
|
|
||||||
|
export const lookupQuery = (domain: string, groupId?: number) =>
|
||||||
|
queryOptions({ queryKey: queryKeys.lookup(domain, groupId), queryFn: () => api.getLookup(domain, groupId) });
|
||||||
|
|
||||||
|
export const groupsQuery = () => queryOptions({ queryKey: queryKeys.groups, queryFn: api.listGroups });
|
||||||
|
|
||||||
|
export const groupSourcesQuery = (id: number) =>
|
||||||
|
queryOptions({ queryKey: queryKeys.groupSources(id), queryFn: () => api.getGroupSources(id) });
|
||||||
|
|
||||||
|
export const blocklistsQuery = () => queryOptions({ queryKey: queryKeys.blocklists, queryFn: api.listBlocklists });
|
||||||
|
|
||||||
|
export const rulesQuery = () => queryOptions({ queryKey: queryKeys.rules, queryFn: api.listRules });
|
||||||
|
|
||||||
|
export const localRecordsQuery = () =>
|
||||||
|
queryOptions({ queryKey: queryKeys.localRecords, queryFn: api.listLocalRecords });
|
||||||
|
|
||||||
|
export const forwardZonesQuery = () =>
|
||||||
|
queryOptions({ queryKey: queryKeys.forwardZones, queryFn: api.listForwardZones });
|
||||||
|
|
||||||
|
export const clientsQuery = () => queryOptions({ queryKey: queryKeys.clients, queryFn: api.listClients });
|
||||||
|
|
||||||
|
export const clientPrefixesQuery = () =>
|
||||||
|
queryOptions({ queryKey: queryKeys.clientPrefixes, queryFn: api.listClientPrefixes });
|
||||||
|
|
||||||
|
export const upstreamsQuery = () => queryOptions({ queryKey: queryKeys.upstreams, queryFn: api.listUpstreams });
|
||||||
|
|
||||||
|
export const pauseQuery = () => queryOptions({ queryKey: queryKeys.pause, queryFn: api.getPause });
|
||||||
|
|
||||||
|
export const settingsQuery = () => queryOptions({ queryKey: queryKeys.settings, queryFn: api.getSettings });
|
||||||
|
|
||||||
|
// Mutation option factories. Usage: useMutation(groupCreateMutation(useQueryClient())).
|
||||||
|
// Group membership and names feed lookup verdicts and the group columns on
|
||||||
|
// clients, prefixes and rules, hence the wide invalidation on group mutations.
|
||||||
|
|
||||||
|
function invalidateGroupWorld(qc: QueryClient): Promise<unknown> {
|
||||||
|
return Promise.all([
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.groups }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["lookup"] }),
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.clients }),
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.clientPrefixes }),
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.rules }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groupCreateMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (input: GroupInput) => api.createGroup(input),
|
||||||
|
onSuccess: () => invalidateGroupWorld(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const groupUpdateMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: ({ id, input }: { id: number; input: GroupInput }) => api.updateGroup(id, input),
|
||||||
|
onSuccess: () => invalidateGroupWorld(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const groupDeleteMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (id: number) => api.deleteGroup(id),
|
||||||
|
onSuccess: () => invalidateGroupWorld(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const groupSourcesPutMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: ({ id, sourceIds }: { id: number; sourceIds: number[] }) => api.putGroupSources(id, sourceIds),
|
||||||
|
onSuccess: (sourceIds: number[], { id }: { id: number; sourceIds: number[] }) => {
|
||||||
|
qc.setQueryData(queryKeys.groupSources(id), sourceIds);
|
||||||
|
return qc.invalidateQueries({ queryKey: ["lookup"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function invalidateBlocklistWorld(qc: QueryClient): Promise<unknown> {
|
||||||
|
return Promise.all([
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["lookup"] }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["groups"] }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const blocklistCreateMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (input: BlocklistInput) => api.createBlocklist(input),
|
||||||
|
onSuccess: () => invalidateBlocklistWorld(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const blocklistUpdateMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: ({ id, input }: { id: number; input: BlocklistInput }) => api.updateBlocklist(id, input),
|
||||||
|
onSuccess: () => invalidateBlocklistWorld(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const blocklistDeleteMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (id: number) => api.deleteBlocklist(id),
|
||||||
|
onSuccess: () => invalidateBlocklistWorld(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Ruling 12: the 202 snapshot REPLACES the sources cache; counters refresh. */
|
||||||
|
export const blocklistsUpdateNowMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: () => api.updateBlocklistsNow(),
|
||||||
|
onSuccess: (sources: Awaited<ReturnType<typeof api.updateBlocklistsNow>>) => {
|
||||||
|
qc.setQueryData(queryKeys.blocklistSources, sources);
|
||||||
|
return Promise.all([
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.blocklists }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["lookup"] }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function invalidateRules(qc: QueryClient): Promise<unknown> {
|
||||||
|
return Promise.all([
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.rules }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["lookup"] }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ruleCreateMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (input: RuleInput) => api.createRule(input),
|
||||||
|
onSuccess: () => invalidateRules(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ruleUpdateMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: ({ id, input }: { id: number; input: RuleInput }) => api.updateRule(id, input),
|
||||||
|
onSuccess: () => invalidateRules(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ruleDeleteMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (id: number) => api.deleteRule(id),
|
||||||
|
onSuccess: () => invalidateRules(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
function invalidateLocalRecords(qc: QueryClient): Promise<unknown> {
|
||||||
|
return Promise.all([
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.localRecords }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["lookup"] }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localRecordCreateMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (input: LocalRecordInput) => api.createLocalRecord(input),
|
||||||
|
onSuccess: () => invalidateLocalRecords(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const localRecordUpdateMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: ({ id, input }: { id: number; input: LocalRecordInput }) => api.updateLocalRecord(id, input),
|
||||||
|
onSuccess: () => invalidateLocalRecords(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const localRecordDeleteMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (id: number) => api.deleteLocalRecord(id),
|
||||||
|
onSuccess: () => invalidateLocalRecords(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
function invalidateForwardZones(qc: QueryClient): Promise<unknown> {
|
||||||
|
return Promise.all([
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.forwardZones }),
|
||||||
|
qc.invalidateQueries({ queryKey: ["lookup"] }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const forwardZoneCreateMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (input: ForwardZoneInput) => api.createForwardZone(input),
|
||||||
|
onSuccess: () => invalidateForwardZones(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forwardZoneUpdateMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: ({ id, input }: { id: number; input: ForwardZoneInput }) => api.updateForwardZone(id, input),
|
||||||
|
onSuccess: () => invalidateForwardZones(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forwardZoneDeleteMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (id: number) => api.deleteForwardZone(id),
|
||||||
|
onSuccess: () => invalidateForwardZones(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clientUpdateMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: ({ id, edit }: { id: number; edit: ClientEdit }) => api.updateClient(id, edit),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.clients }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clientDeleteMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (id: number) => api.deleteClient(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.clients }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clientPrefixesPutMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (prefixes: ClientPrefixInput[]) => api.putClientPrefixes(prefixes),
|
||||||
|
onSuccess: (stored: Awaited<ReturnType<typeof api.putClientPrefixes>>) => {
|
||||||
|
qc.setQueryData(queryKeys.clientPrefixes, stored);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function invalidateUpstreams(qc: QueryClient): Promise<unknown> {
|
||||||
|
return qc.invalidateQueries({ queryKey: queryKeys.upstreams });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const upstreamCreateMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (input: UpstreamInput) => api.createUpstream(input),
|
||||||
|
onSuccess: () => invalidateUpstreams(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const upstreamUpdateMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: ({ id, input }: { id: number; input: UpstreamInput }) => api.updateUpstream(id, input),
|
||||||
|
onSuccess: () => invalidateUpstreams(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const upstreamDeleteMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (id: number) => api.deleteUpstream(id),
|
||||||
|
onSuccess: () => invalidateUpstreams(qc),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pauseMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (body: PausePost) => api.postPause(body),
|
||||||
|
onSuccess: (state: Awaited<ReturnType<typeof api.postPause>>) => {
|
||||||
|
qc.setQueryData(queryKeys.pause, state);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const settingsPutMutation = (qc: QueryClient) => ({
|
||||||
|
mutationFn: (patch: SettingsPatch) => api.putSettings(patch),
|
||||||
|
onSuccess: (envelope: Awaited<ReturnType<typeof api.putSettings>>) => {
|
||||||
|
qc.setQueryData(queryKeys.settings, envelope);
|
||||||
|
return qc.invalidateQueries({ queryKey: queryKeys.settings });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { rememberAuthRequired } from "@/auth/store";
|
||||||
|
|
||||||
|
export function handleUnauthorized(error: unknown): void {
|
||||||
|
if (!(error instanceof ApiError) || error.status !== 401) return;
|
||||||
|
if (window.location.pathname === "/login") return;
|
||||||
|
rememberAuthRequired(true);
|
||||||
|
const current = window.location.pathname + window.location.search;
|
||||||
|
window.location.assign(`/login?redirect=${encodeURIComponent(current)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetry(failureCount: number, error: unknown): boolean {
|
||||||
|
if (error instanceof ApiError && error.status >= 400 && error.status < 500 && error.status !== 429) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return failureCount < 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryDelay(attemptIndex: number, error: unknown): number {
|
||||||
|
if (error instanceof ApiError && error.status === 429 && error.retryAfter !== undefined) {
|
||||||
|
return error.retryAfter * 1000;
|
||||||
|
}
|
||||||
|
return Math.min(1000 * 2 ** attemptIndex, 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createQueryClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
queryCache: new QueryCache({ onError: handleUnauthorized }),
|
||||||
|
mutationCache: new MutationCache({ onError: handleUnauthorized }),
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: shouldRetry,
|
||||||
|
retryDelay,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { buildSettingsPatch } from "@/lib/settingsDiff";
|
||||||
|
import type { Settings } from "@/lib/types";
|
||||||
|
|
||||||
|
function baseSettings(): Settings {
|
||||||
|
return {
|
||||||
|
runtime: { io_backend: "threaded" },
|
||||||
|
upstream: { connect_timeout_ms: 2000, read_timeout_ms: 3000, total_timeout_ms: 5000 },
|
||||||
|
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
|
||||||
|
blocking: { response: "zero", ttl: 300 },
|
||||||
|
cache: { size: 10000, negative_ttl_max: 300 },
|
||||||
|
web: {
|
||||||
|
enabled: true,
|
||||||
|
bind: "127.0.0.1",
|
||||||
|
port: 8080,
|
||||||
|
session_ttl_hours: 24,
|
||||||
|
api_rate_limit_per_min: 60,
|
||||||
|
api_localhost_exempt: true,
|
||||||
|
sse_max_connections_per_ip: 2,
|
||||||
|
auth_enabled: true,
|
||||||
|
},
|
||||||
|
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
|
||||||
|
dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
|
||||||
|
edns: { ecs_mode: "strip" },
|
||||||
|
logging: {
|
||||||
|
level: "info",
|
||||||
|
retention_days: 30,
|
||||||
|
query_log_buffer_max: 10000,
|
||||||
|
hide_domains: false,
|
||||||
|
hide_client_ips: false,
|
||||||
|
output: "stderr",
|
||||||
|
file_path: "",
|
||||||
|
max_size_mb: 50,
|
||||||
|
max_files: 3,
|
||||||
|
},
|
||||||
|
disk: { min_free_mb: 100, warn_free_mb: 500 },
|
||||||
|
blocklist_update: { enabled: true, interval_hours: 24 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(mutate: (s: Settings) => void): Settings {
|
||||||
|
const edited = structuredClone(baseSettings());
|
||||||
|
mutate(edited);
|
||||||
|
return edited;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("no changes and no password produces null", () => {
|
||||||
|
expect(buildSettingsPatch(baseSettings(), baseSettings())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty password is not a change", () => {
|
||||||
|
expect(buildSettingsPatch(baseSettings(), baseSettings(), "")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a single scalar change patches only its section field", () => {
|
||||||
|
const edited = edit((s) => {
|
||||||
|
s.dns.port = 5353;
|
||||||
|
});
|
||||||
|
expect(buildSettingsPatch(baseSettings(), edited)).toEqual({ dns: { port: 5353 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changes across sections stay grouped and minimal", () => {
|
||||||
|
const edited = edit((s) => {
|
||||||
|
s.logging.level = "debug";
|
||||||
|
s.logging.retention_days = 7;
|
||||||
|
s.cache.size = 20000;
|
||||||
|
});
|
||||||
|
expect(buildSettingsPatch(baseSettings(), edited)).toEqual({
|
||||||
|
cache: { size: 20000 },
|
||||||
|
logging: { level: "debug", retention_days: 7 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tls listener sections diff like any other", () => {
|
||||||
|
const edited = edit((s) => {
|
||||||
|
s.dot_server.enabled = true;
|
||||||
|
s.dot_server.cert_path = "/etc/nxdns/dot.pem";
|
||||||
|
});
|
||||||
|
expect(buildSettingsPatch(baseSettings(), edited)).toEqual({
|
||||||
|
dot_server: { enabled: true, cert_path: "/etc/nxdns/dot.pem" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("web.auth_enabled is never emitted even when it differs", () => {
|
||||||
|
const edited = edit((s) => {
|
||||||
|
s.web.auth_enabled = false;
|
||||||
|
s.web.port = 9090;
|
||||||
|
});
|
||||||
|
expect(buildSettingsPatch(baseSettings(), edited)).toEqual({ web: { port: 9090 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a password alone produces a web-only patch", () => {
|
||||||
|
expect(buildSettingsPatch(baseSettings(), baseSettings(), "hunter2")).toEqual({
|
||||||
|
web: { password: "hunter2" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a password merges into an existing web section diff", () => {
|
||||||
|
const edited = edit((s) => {
|
||||||
|
s.web.session_ttl_hours = 48;
|
||||||
|
});
|
||||||
|
expect(buildSettingsPatch(baseSettings(), edited, "hunter2")).toEqual({
|
||||||
|
web: { session_ttl_hours: 48, password: "hunter2" },
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Settings, SettingsPatch } from "@/lib/types";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
"runtime",
|
||||||
|
"upstream",
|
||||||
|
"dns",
|
||||||
|
"blocking",
|
||||||
|
"cache",
|
||||||
|
"web",
|
||||||
|
"doh_server",
|
||||||
|
"dot_server",
|
||||||
|
"edns",
|
||||||
|
"logging",
|
||||||
|
"disk",
|
||||||
|
"blocklist_update",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal partial patch for PUT /api/settings: only fields whose edited value
|
||||||
|
* differs from the original, grouped by section. The derived `web.auth_enabled`
|
||||||
|
* is never emitted. A non-empty `password` passes through as `web.password`.
|
||||||
|
* Returns null when nothing changed and no password was given.
|
||||||
|
*/
|
||||||
|
export function buildSettingsPatch(original: Settings, edited: Settings, password?: string): SettingsPatch | null {
|
||||||
|
const patch: Record<string, Record<string, unknown>> = {};
|
||||||
|
for (const section of SECTIONS) {
|
||||||
|
const before = original[section] as Record<string, unknown>;
|
||||||
|
const after = edited[section] as Record<string, unknown>;
|
||||||
|
let changed: Record<string, unknown> | undefined;
|
||||||
|
for (const key of Object.keys(after)) {
|
||||||
|
if (section === "web" && key === "auth_enabled") continue;
|
||||||
|
if (before[key] !== after[key]) (changed ??= {})[key] = after[key];
|
||||||
|
}
|
||||||
|
if (changed !== undefined) patch[section] = changed;
|
||||||
|
}
|
||||||
|
if (password !== undefined && password !== "") {
|
||||||
|
patch["web"] = { ...patch["web"], password };
|
||||||
|
}
|
||||||
|
return Object.keys(patch).length === 0 ? null : (patch as SettingsPatch);
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
// Hand-transcribed from src/web/openapi.yaml. Field names stay snake_case to
|
||||||
|
// match the wire format exactly; nullability mirrors the contract.
|
||||||
|
|
||||||
|
export type Period = "1h" | "24h" | "7d" | "30d";
|
||||||
|
|
||||||
|
export interface Health {
|
||||||
|
status: "ok" | "degraded";
|
||||||
|
disk: {
|
||||||
|
state: "ok" | "warn" | "critical";
|
||||||
|
free_bytes: number;
|
||||||
|
db_bytes: number;
|
||||||
|
log_bytes: number;
|
||||||
|
sample_failures: number;
|
||||||
|
};
|
||||||
|
upstreams: {
|
||||||
|
available: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
queries_dropped: number;
|
||||||
|
writer_failed: boolean;
|
||||||
|
refreshes_gated: number;
|
||||||
|
snapshot_generation: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Version {
|
||||||
|
version: string;
|
||||||
|
git_commit: string;
|
||||||
|
zig_version: string;
|
||||||
|
uptime_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
authenticated: true;
|
||||||
|
auth_required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogoutResponse {
|
||||||
|
authenticated: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryRow {
|
||||||
|
id: number;
|
||||||
|
ts: number;
|
||||||
|
domain: string;
|
||||||
|
client_ip: string;
|
||||||
|
qtype: number | null;
|
||||||
|
blocked: boolean;
|
||||||
|
block_reason: string;
|
||||||
|
response_time_us: number | null;
|
||||||
|
cache_hit: boolean | null;
|
||||||
|
upstream: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSE `event: query` payload: a QueryRow minus `id` (precedes persistence). */
|
||||||
|
export type LiveQueryEvent = Omit<QueryRow, "id">;
|
||||||
|
|
||||||
|
export interface QueriesPage {
|
||||||
|
queries: QueryRow[];
|
||||||
|
next_before: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueriesFilter {
|
||||||
|
limit?: number;
|
||||||
|
before?: number;
|
||||||
|
domain?: string;
|
||||||
|
client?: string;
|
||||||
|
blocked?: boolean;
|
||||||
|
since?: number;
|
||||||
|
until?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsTotals {
|
||||||
|
period: Period;
|
||||||
|
since: number;
|
||||||
|
until: number;
|
||||||
|
queries: number;
|
||||||
|
blocked: number;
|
||||||
|
cached: number;
|
||||||
|
clients: number;
|
||||||
|
avg_response_time_us: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bucket {
|
||||||
|
ts: number;
|
||||||
|
queries: number;
|
||||||
|
blocked: number;
|
||||||
|
cached: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsTimeseries {
|
||||||
|
period: Period;
|
||||||
|
since: number;
|
||||||
|
until: number;
|
||||||
|
bucket_seconds: number;
|
||||||
|
buckets: Bucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LookupResult {
|
||||||
|
domain: string;
|
||||||
|
group_id: number;
|
||||||
|
local_records: boolean;
|
||||||
|
forward_zone: string | null;
|
||||||
|
blocked: boolean;
|
||||||
|
reason: string;
|
||||||
|
matched: string;
|
||||||
|
source_url: string | null;
|
||||||
|
safe_search_rewrite: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpstreamHealthEntry {
|
||||||
|
url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
available: boolean;
|
||||||
|
consecutive_failures: number;
|
||||||
|
total_successes: number;
|
||||||
|
total_failures: number;
|
||||||
|
success_rate: number;
|
||||||
|
last_error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpstreamHealth {
|
||||||
|
upstreams: UpstreamHealthEntry[];
|
||||||
|
available: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
safe_search: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupInput {
|
||||||
|
name: string;
|
||||||
|
safe_search?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupSources {
|
||||||
|
source_ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Blocklist {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
is_suggested: boolean;
|
||||||
|
last_updated: number | null;
|
||||||
|
domain_count: number;
|
||||||
|
wildcard_count: number;
|
||||||
|
skipped_regex_count: number;
|
||||||
|
checksum: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlocklistInput {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
is_suggested?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlocklistEcho {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
is_suggested: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceStatus {
|
||||||
|
id: number;
|
||||||
|
state: string;
|
||||||
|
loaded: boolean;
|
||||||
|
last_attempt: number;
|
||||||
|
last_success: number;
|
||||||
|
url: string;
|
||||||
|
last_error: string;
|
||||||
|
domains: number;
|
||||||
|
wildcards: number;
|
||||||
|
skipped_regex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuleKind = "exact" | "wildcard";
|
||||||
|
export type RuleAction = "allow" | "block";
|
||||||
|
|
||||||
|
export interface Rule {
|
||||||
|
id: number;
|
||||||
|
group_id: number;
|
||||||
|
group: string;
|
||||||
|
pattern: string;
|
||||||
|
kind: RuleKind;
|
||||||
|
action: RuleAction;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleInput {
|
||||||
|
group_id: number;
|
||||||
|
pattern: string;
|
||||||
|
kind: RuleKind;
|
||||||
|
action: RuleAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleEcho {
|
||||||
|
id: number;
|
||||||
|
group_id: number;
|
||||||
|
pattern: string;
|
||||||
|
kind: RuleKind;
|
||||||
|
action: RuleAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalRecordType = "A" | "AAAA" | "CNAME";
|
||||||
|
|
||||||
|
export interface LocalRecord {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
rtype: LocalRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalRecordInput {
|
||||||
|
name: string;
|
||||||
|
rtype: LocalRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForwardZone {
|
||||||
|
id: number;
|
||||||
|
zone: string;
|
||||||
|
resolver: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForwardZoneInput {
|
||||||
|
zone: string;
|
||||||
|
resolver: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Client {
|
||||||
|
id: number;
|
||||||
|
ip: string;
|
||||||
|
name: string;
|
||||||
|
group_id: number;
|
||||||
|
group: string;
|
||||||
|
hand_edited: boolean;
|
||||||
|
first_seen: number;
|
||||||
|
last_seen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientEdit {
|
||||||
|
name?: string;
|
||||||
|
group_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientPrefix {
|
||||||
|
id: number;
|
||||||
|
prefix: string;
|
||||||
|
group_id: number;
|
||||||
|
group: string;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientPrefixInput {
|
||||||
|
prefix: string;
|
||||||
|
group_id: number;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Upstream {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
tls_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpstreamInput {
|
||||||
|
url: string;
|
||||||
|
priority?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
tls_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpstreamEcho {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
tls_name: string;
|
||||||
|
restart_required: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PauseState {
|
||||||
|
paused: boolean;
|
||||||
|
until: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PausePost {
|
||||||
|
paused: boolean;
|
||||||
|
duration_seconds?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TlsListenerSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
bind: string;
|
||||||
|
port: number;
|
||||||
|
cert_path: string;
|
||||||
|
key_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
runtime: {
|
||||||
|
io_backend: "threaded" | "evented";
|
||||||
|
};
|
||||||
|
upstream: {
|
||||||
|
connect_timeout_ms: number;
|
||||||
|
read_timeout_ms: number;
|
||||||
|
total_timeout_ms: number;
|
||||||
|
};
|
||||||
|
dns: {
|
||||||
|
bind_ipv4: string;
|
||||||
|
bind_ipv6: string;
|
||||||
|
port: number;
|
||||||
|
rate_limit: number;
|
||||||
|
rate_window_seconds: number;
|
||||||
|
};
|
||||||
|
blocking: {
|
||||||
|
response: "zero" | "nxdomain";
|
||||||
|
ttl: number;
|
||||||
|
};
|
||||||
|
cache: {
|
||||||
|
size: number;
|
||||||
|
negative_ttl_max: number;
|
||||||
|
};
|
||||||
|
web: {
|
||||||
|
enabled: boolean;
|
||||||
|
bind: string;
|
||||||
|
port: number;
|
||||||
|
session_ttl_hours: number;
|
||||||
|
api_rate_limit_per_min: number;
|
||||||
|
api_localhost_exempt: boolean;
|
||||||
|
sse_max_connections_per_ip: number;
|
||||||
|
/** Derived, read-only; true iff a password hash is stored. Never sent back. */
|
||||||
|
auth_enabled: boolean;
|
||||||
|
};
|
||||||
|
doh_server: TlsListenerSettings;
|
||||||
|
dot_server: TlsListenerSettings;
|
||||||
|
edns: {
|
||||||
|
ecs_mode: "strip" | "forward";
|
||||||
|
};
|
||||||
|
logging: {
|
||||||
|
level: "error" | "warn" | "info" | "debug";
|
||||||
|
retention_days: number;
|
||||||
|
query_log_buffer_max: number;
|
||||||
|
hide_domains: boolean;
|
||||||
|
hide_client_ips: boolean;
|
||||||
|
output: "stderr" | "syslog" | "file";
|
||||||
|
file_path: string;
|
||||||
|
max_size_mb: number;
|
||||||
|
max_files: number;
|
||||||
|
};
|
||||||
|
disk: {
|
||||||
|
min_free_mb: number;
|
||||||
|
warn_free_mb: number;
|
||||||
|
};
|
||||||
|
blocklist_update: {
|
||||||
|
enabled: boolean;
|
||||||
|
interval_hours: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsEnvelope {
|
||||||
|
settings: Settings;
|
||||||
|
restart_required: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TlsListenerPatch {
|
||||||
|
enabled?: boolean;
|
||||||
|
bind?: string;
|
||||||
|
port?: number;
|
||||||
|
cert_path?: string;
|
||||||
|
key_path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Partial update; `web.password` is write-only, `web.auth_enabled` is never sent. */
|
||||||
|
export interface SettingsPatch {
|
||||||
|
runtime?: Partial<Settings["runtime"]>;
|
||||||
|
upstream?: Partial<Settings["upstream"]>;
|
||||||
|
dns?: Partial<Settings["dns"]>;
|
||||||
|
blocking?: Partial<Settings["blocking"]>;
|
||||||
|
cache?: Partial<Settings["cache"]>;
|
||||||
|
web?: Partial<Omit<Settings["web"], "auth_enabled">> & { password?: string };
|
||||||
|
doh_server?: TlsListenerPatch;
|
||||||
|
dot_server?: TlsListenerPatch;
|
||||||
|
edns?: Partial<Settings["edns"]>;
|
||||||
|
logging?: Partial<Settings["logging"]>;
|
||||||
|
disk?: Partial<Settings["disk"]>;
|
||||||
|
blocklist_update?: Partial<Settings["blocklist_update"]>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
|
import { AuthProvider } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (root === null) throw new Error("missing #root element");
|
||||||
|
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const router = createAppRouter(undefined, queryClient);
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
createRootRouteWithContext,
|
||||||
|
createRoute,
|
||||||
|
createRouter,
|
||||||
|
lazyRouteComponent,
|
||||||
|
useRouter,
|
||||||
|
type ErrorComponentProps,
|
||||||
|
type RouterHistory,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
|
import AppShell from "@/shell/AppShell";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import {
|
||||||
|
blocklistsQuery,
|
||||||
|
clientPrefixesQuery,
|
||||||
|
clientsQuery,
|
||||||
|
forwardZonesQuery,
|
||||||
|
groupsQuery,
|
||||||
|
healthQuery,
|
||||||
|
localRecordsQuery,
|
||||||
|
queriesQuery,
|
||||||
|
rulesQuery,
|
||||||
|
settingsQuery,
|
||||||
|
statsQuery,
|
||||||
|
timeseriesQuery,
|
||||||
|
upstreamHealthQuery,
|
||||||
|
} from "@/lib/queries";
|
||||||
|
|
||||||
|
export interface RouterContext {
|
||||||
|
queryClient: QueryClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoutePending() {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-zinc-500" role="status">
|
||||||
|
<span className="animate-pulse">Loading…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteError({ error }: ErrorComponentProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
let title = "Something went wrong";
|
||||||
|
let detail = error.message;
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
if (error.status === 503) {
|
||||||
|
title = "Server starting or degraded";
|
||||||
|
detail = error.message;
|
||||||
|
} else if (error.status === 429) {
|
||||||
|
title = "Rate limited";
|
||||||
|
detail = error.retryAfter !== undefined ? `Try again in ${error.retryAfter}s.` : "Try again shortly.";
|
||||||
|
} else if (error.status >= 500) {
|
||||||
|
title = "Internal error";
|
||||||
|
} else {
|
||||||
|
title = `Request failed (${error.status})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="m-4 rounded border border-red-300 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950"
|
||||||
|
>
|
||||||
|
<h2 className="font-semibold text-red-800 dark:text-red-200">{title}</h2>
|
||||||
|
<p className="mt-1 text-sm text-red-700 dark:text-red-300">{detail}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void router.invalidate()}
|
||||||
|
className="mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-red-800 dark:text-red-200"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRoute = createRootRouteWithContext<RouterContext>()();
|
||||||
|
|
||||||
|
const loginRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/login",
|
||||||
|
validateSearch: (search: Record<string, unknown>): { redirect?: string } => ({
|
||||||
|
redirect: typeof search["redirect"] === "string" ? search["redirect"] : undefined,
|
||||||
|
}),
|
||||||
|
component: lazyRouteComponent(() => import("@/auth/LoginPage")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const shellRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
id: "shell",
|
||||||
|
component: AppShell,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dashboardRoute = createRoute({
|
||||||
|
getParentRoute: () => shellRoute,
|
||||||
|
path: "/",
|
||||||
|
loader: ({ context }) =>
|
||||||
|
Promise.all([
|
||||||
|
context.queryClient.ensureQueryData(statsQuery("24h")),
|
||||||
|
context.queryClient.ensureQueryData(timeseriesQuery("24h")),
|
||||||
|
context.queryClient.ensureQueryData(healthQuery()),
|
||||||
|
context.queryClient.ensureQueryData(upstreamHealthQuery()),
|
||||||
|
]),
|
||||||
|
component: lazyRouteComponent(() => import("@/features/dashboard/DashboardPage")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const queriesRoute = createRoute({
|
||||||
|
getParentRoute: () => shellRoute,
|
||||||
|
path: "/queries",
|
||||||
|
loader: ({ context }) => context.queryClient.ensureQueryData(queriesQuery({})),
|
||||||
|
component: lazyRouteComponent(() => import("@/features/queries/QueryLogPage")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const liveRoute = createRoute({
|
||||||
|
getParentRoute: () => shellRoute,
|
||||||
|
path: "/live",
|
||||||
|
component: lazyRouteComponent(() => import("@/features/live/LiveLogPage")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientsRoute = createRoute({
|
||||||
|
getParentRoute: () => shellRoute,
|
||||||
|
path: "/clients",
|
||||||
|
loader: ({ context }) =>
|
||||||
|
Promise.all([
|
||||||
|
context.queryClient.ensureQueryData(clientsQuery()),
|
||||||
|
context.queryClient.ensureQueryData(clientPrefixesQuery()),
|
||||||
|
context.queryClient.ensureQueryData(groupsQuery()),
|
||||||
|
]),
|
||||||
|
component: lazyRouteComponent(() => import("@/features/clients/ClientsPage")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupsRoute = createRoute({
|
||||||
|
getParentRoute: () => shellRoute,
|
||||||
|
path: "/groups",
|
||||||
|
loader: ({ context }) =>
|
||||||
|
Promise.all([
|
||||||
|
context.queryClient.ensureQueryData(groupsQuery()),
|
||||||
|
context.queryClient.ensureQueryData(blocklistsQuery()),
|
||||||
|
]),
|
||||||
|
component: lazyRouteComponent(() => import("@/features/groups/GroupsPage")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const blocklistsRoute = createRoute({
|
||||||
|
getParentRoute: () => shellRoute,
|
||||||
|
path: "/blocklists",
|
||||||
|
loader: ({ context }) => context.queryClient.ensureQueryData(blocklistsQuery()),
|
||||||
|
component: lazyRouteComponent(() => import("@/features/blocklists/BlocklistsPage")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rulesRoute = createRoute({
|
||||||
|
getParentRoute: () => shellRoute,
|
||||||
|
path: "/rules",
|
||||||
|
loader: ({ context }) =>
|
||||||
|
Promise.all([
|
||||||
|
context.queryClient.ensureQueryData(rulesQuery()),
|
||||||
|
context.queryClient.ensureQueryData(groupsQuery()),
|
||||||
|
]),
|
||||||
|
component: lazyRouteComponent(() => import("@/features/rules/RulesPage")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const localDnsRoute = createRoute({
|
||||||
|
getParentRoute: () => shellRoute,
|
||||||
|
path: "/local-dns",
|
||||||
|
loader: ({ context }) =>
|
||||||
|
Promise.all([
|
||||||
|
context.queryClient.ensureQueryData(localRecordsQuery()),
|
||||||
|
context.queryClient.ensureQueryData(forwardZonesQuery()),
|
||||||
|
]),
|
||||||
|
component: lazyRouteComponent(() => import("@/features/local/LocalDnsPage")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const lookupRoute = createRoute({
|
||||||
|
getParentRoute: () => shellRoute,
|
||||||
|
path: "/lookup",
|
||||||
|
loader: ({ context }) => context.queryClient.ensureQueryData(groupsQuery()),
|
||||||
|
component: lazyRouteComponent(() => import("@/features/lookup/LookupPage")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingsRoute = createRoute({
|
||||||
|
getParentRoute: () => shellRoute,
|
||||||
|
path: "/settings",
|
||||||
|
loader: ({ context }) => context.queryClient.ensureQueryData(settingsQuery()),
|
||||||
|
component: lazyRouteComponent(() => import("@/features/settings/SettingsPage")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeTree = rootRoute.addChildren([
|
||||||
|
loginRoute,
|
||||||
|
shellRoute.addChildren([
|
||||||
|
dashboardRoute,
|
||||||
|
queriesRoute,
|
||||||
|
liveRoute,
|
||||||
|
clientsRoute,
|
||||||
|
groupsRoute,
|
||||||
|
blocklistsRoute,
|
||||||
|
rulesRoute,
|
||||||
|
localDnsRoute,
|
||||||
|
lookupRoute,
|
||||||
|
settingsRoute,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function createAppRouter(history?: RouterHistory, queryClient: QueryClient = createQueryClient()) {
|
||||||
|
return createRouter({
|
||||||
|
routeTree,
|
||||||
|
history,
|
||||||
|
context: { queryClient },
|
||||||
|
defaultPreload: "intent",
|
||||||
|
defaultPreloadStaleTime: 0,
|
||||||
|
defaultPendingComponent: RoutePending,
|
||||||
|
defaultErrorComponent: RouteError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface Register {
|
||||||
|
router: ReturnType<typeof createAppRouter>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
|
import { AuthProvider, resetAuthProbeForTests } from "@/auth/store";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { createAppRouter } from "@/routes";
|
||||||
|
|
||||||
|
const NAV_LABELS = [
|
||||||
|
"Dashboard",
|
||||||
|
"Query Log",
|
||||||
|
"Live",
|
||||||
|
"Clients",
|
||||||
|
"Groups",
|
||||||
|
"Blocklists",
|
||||||
|
"Rules",
|
||||||
|
"Local DNS",
|
||||||
|
"Lookup",
|
||||||
|
"Settings",
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESPONSES: Record<string, unknown> = {
|
||||||
|
"/api/stats?period=24h": {
|
||||||
|
period: "24h",
|
||||||
|
since: 0,
|
||||||
|
until: 86400,
|
||||||
|
queries: 0,
|
||||||
|
blocked: 0,
|
||||||
|
cached: 0,
|
||||||
|
clients: 0,
|
||||||
|
avg_response_time_us: null,
|
||||||
|
},
|
||||||
|
"/api/stats/timeseries?period=24h": { period: "24h", since: 0, until: 86400, bucket_seconds: 1800, buckets: [] },
|
||||||
|
"/api/health": {
|
||||||
|
status: "ok",
|
||||||
|
disk: { state: "ok", free_bytes: 0, db_bytes: 0, log_bytes: 0, sample_failures: 0 },
|
||||||
|
upstreams: { available: 1, total: 1 },
|
||||||
|
queries_dropped: 0,
|
||||||
|
writer_failed: false,
|
||||||
|
refreshes_gated: 0,
|
||||||
|
snapshot_generation: null,
|
||||||
|
},
|
||||||
|
"/api/upstream/health": { upstreams: [], available: 1, total: 1 },
|
||||||
|
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
resetAuthProbeForTests();
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
const payload = RESPONSES[url];
|
||||||
|
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shell renders the dashboard route with all nav links", async () => {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Dashboard" });
|
||||||
|
|
||||||
|
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||||
|
expect(nav).toBeTruthy();
|
||||||
|
for (const label of NAV_LABELS) {
|
||||||
|
expect(screen.getByRole("link", { name: label })).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mount probe reveals the logout button and a failed logout surfaces inline", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/auth/login")
|
||||||
|
return new Response(JSON.stringify({ error: "password required" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
if (url === "/api/auth/logout")
|
||||||
|
return new Response(JSON.stringify({ error: "rate limited" }), {
|
||||||
|
status: 429,
|
||||||
|
headers: { "content-type": "application/json", "retry-after": "7" },
|
||||||
|
});
|
||||||
|
const payload = RESPONSES[url];
|
||||||
|
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/"] }), queryClient);
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
|
||||||
|
|
||||||
|
await screen.findByText("Rate limited. Try again in 7s.");
|
||||||
|
expect(screen.getByRole("heading", { name: "Dashboard" })).toBeTruthy();
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useAuth } from "@/auth/store";
|
||||||
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import { versionQuery } from "@/lib/queries";
|
||||||
|
import PauseWidget from "../features/pause/PauseWidget";
|
||||||
|
import RestartBanner from "../features/settings/RestartBanner";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ to: "/", label: "Dashboard" },
|
||||||
|
{ to: "/queries", label: "Query Log" },
|
||||||
|
{ to: "/live", label: "Live" },
|
||||||
|
{ to: "/clients", label: "Clients" },
|
||||||
|
{ to: "/groups", label: "Groups" },
|
||||||
|
{ to: "/blocklists", label: "Blocklists" },
|
||||||
|
{ to: "/rules", label: "Rules" },
|
||||||
|
{ to: "/local-dns", label: "Local DNS" },
|
||||||
|
{ to: "/lookup", label: "Lookup" },
|
||||||
|
{ to: "/settings", label: "Settings" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<li key={item.to}>
|
||||||
|
<Link
|
||||||
|
to={item.to}
|
||||||
|
onClick={onNavigate}
|
||||||
|
activeOptions={{ exact: item.to === "/" }}
|
||||||
|
activeProps={{
|
||||||
|
"aria-current": "page",
|
||||||
|
className: "bg-zinc-200 font-medium text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50",
|
||||||
|
}}
|
||||||
|
inactiveProps={{
|
||||||
|
className:
|
||||||
|
"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 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VersionFooter() {
|
||||||
|
const { data } = useQuery(versionQuery());
|
||||||
|
return (
|
||||||
|
<footer className="px-4 py-3 text-xs text-zinc-500">
|
||||||
|
{data === undefined ? "nxdns" : `nxdns v${data.version} (${data.git_commit.slice(0, 7)})`}
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogoutButton() {
|
||||||
|
const { authRequired, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
if (authRequired !== true) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
void logout().then(
|
||||||
|
() => navigate({ to: "/login" }),
|
||||||
|
(logoutError: unknown) => setError(logoutError),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
{error !== null && <InlineError error={error} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppShell() {
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
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">
|
||||||
|
<aside className="hidden border-r border-zinc-200 md:flex md:flex-col dark:border-zinc-800">
|
||||||
|
<div className="px-4 py-4 text-lg font-semibold">nxdns</div>
|
||||||
|
<nav aria-label="Main" className="flex-1 px-2">
|
||||||
|
<NavLinks />
|
||||||
|
</nav>
|
||||||
|
<VersionFooter />
|
||||||
|
</aside>
|
||||||
|
<div className="flex min-h-dvh flex-col">
|
||||||
|
<header className="flex items-center gap-3 border-b border-zinc-200 px-4 py-2 dark:border-zinc-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 md:hidden dark:border-zinc-700"
|
||||||
|
aria-expanded={drawerOpen}
|
||||||
|
aria-controls="mobile-nav"
|
||||||
|
onClick={() => setDrawerOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
Menu
|
||||||
|
</button>
|
||||||
|
<span className="text-lg font-semibold md:hidden">nxdns</span>
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<PauseWidget />
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<RestartBanner />
|
||||||
|
{drawerOpen && (
|
||||||
|
<div id="mobile-nav" className="border-b border-zinc-200 md:hidden dark:border-zinc-800">
|
||||||
|
<nav aria-label="Main" className="px-2 py-2">
|
||||||
|
<NavLinks onNavigate={() => setDrawerOpen(false)} />
|
||||||
|
</nav>
|
||||||
|
<VersionFooter />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<main className="flex-1 p-4">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2022",
|
||||||
|
"lib": ["es2023", "dom", "dom.iterable"],
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"types": ["vite/client", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["es2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/// <reference types="vitest/config" />
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:8080",
|
||||||
|
"/metrics": "http://127.0.0.1:8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user