milestone 9: react spa admin ui, frontend ci and embedded dist

This commit is contained in:
2026-08-02 13:04:09 +02:00
parent 5253c47303
commit 617cc966a2
82 changed files with 11833 additions and 17 deletions
+78 -6
View File
@@ -8,6 +8,7 @@ on:
env:
ZIG_VERSION: "0.16.0"
NODE_VERSION: "24"
jobs:
test:
@@ -24,6 +25,43 @@ jobs:
- name: Run test suite (unit + hermetic loopback integration)
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:
runs-on: ubuntu-24.04
@@ -35,19 +73,42 @@ jobs:
with:
version: ${{ env.ZIG_VERSION }}
- name: Build static musl executables
run: zig build cross
- 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 file(1)
- name: Build the web UI
working-directory: web
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 install -qq -y file
sudo apt-get install -qq -y $missing
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: |
set -euo pipefail
size_limit=$((15 * 1024 * 1024))
for triple in x86_64-linux-musl aarch64-linux-musl; do
binary="zig-out/cross/$triple/nxdns"
if [ ! -f "$binary" ]; then
@@ -63,4 +124,15 @@ jobs:
exit 1
;;
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
+2
View File
@@ -1,3 +1,5 @@
.zig-cache/
zig-out/
zig-pkg/
web/node_modules/
web/dist/
+409
View File
@@ -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 = queriesblockedcached 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
View File
@@ -410,21 +410,32 @@ test "dev-mode disk reads refuse a symlink that escapes the root" {
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).?;
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(std.mem.containsAtLeast(u8, index.bytes, 1, "/api/health"));
try testing.expect(index.bytes.len > 0);
const favicon = find(embedded, "/favicon.svg").?;
try testing.expectEqualStrings("image/svg+xml", favicon.content_type);
for (embedded) |file| {
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").?;
try testing.expect(gz.gzip);
try testing.expect(gz.file.bytes.len < index.bytes.len);
// The gzip member header: build-time compression, not an accident.
try testing.expectEqual(@as(u8, 0x1f), gz.file.bytes[0]);
try testing.expectEqual(@as(u8, 0x8b), gz.file.bytes[1]);
var occurrences: usize = 0;
for (embedded) |other| {
if (std.mem.eql(u8, other.path, file.path)) occurrences += 1;
}
try testing.expectEqual(@as(usize, 1), occurrences);
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" {
+3
View File
@@ -0,0 +1,3 @@
dist/
dist-placeholder/
package-lock.json
+13
View File
@@ -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>
+3083
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -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"
}
}
+4
View File
@@ -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

+97
View File
@@ -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("/");
});
+117
View File
@@ -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>
);
}
+89
View File
@@ -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,
});
});
+105
View File
@@ -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("");
});
+115
View File
@@ -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>
);
}
+128
View File
@@ -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.");
});
+74
View File
@@ -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>
);
}
+35
View File
@@ -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>;
}
+44
View File
@@ -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);
});
});
+101
View File
@@ -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>
);
}
+135
View File
@@ -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);
});
+188
View File
@@ -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>
);
}
+22
View File
@@ -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);
});
+11
View File
@@ -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();
});
+114
View File
@@ -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>
);
}
+26
View File
@@ -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);
}
}
+101
View File
@@ -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]);
});
});
+53
View File
@@ -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);
});
+171
View File
@@ -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" });
});
+78
View File
@@ -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>
);
}
+247
View File
@@ -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>
);
}
+202
View File
@@ -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");
});
+211
View File
@@ -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>
);
}
+194
View File
@@ -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");
});
+124
View File
@@ -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();
});
+261
View File
@@ -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>
);
}
+18
View File
@@ -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("—");
});
+27
View File
@@ -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}`;
}
+108
View File
@@ -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.");
});
+171
View File
@@ -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);
});
+329
View File
@@ -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);
}
+39
View File
@@ -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>
);
}
+96
View File
@@ -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] }));
});
+235
View File
@@ -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 });
+23
View File
@@ -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");
});
+27
View File
@@ -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`;
}
+279
View File
@@ -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 });
},
});
+42
View File
@@ -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,
},
},
});
}
+104
View File
@@ -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" },
});
});
+40
View File
@@ -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);
}
+403
View File
@@ -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"]>;
}
+24
View File
@@ -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>,
);
+218
View File
@@ -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>;
}
}
+125
View File
@@ -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();
});
+127
View File
@@ -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>
);
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+25
View File
@@ -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"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+21
View File
@@ -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"]
}
+24
View File
@@ -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,
},
});