From 617cc966a262a6496576927cff99936c33aeefc5 Mon Sep 17 00:00:00 2001 From: m5r Date: Sun, 2 Aug 2026 13:04:09 +0200 Subject: [PATCH] milestone 9: react spa admin ui, frontend ci and embedded dist --- .gitea/workflows/ci.yml | 84 +- .gitignore | 2 + specs/milestone-9.md | 409 +++ src/web/static.zig | 33 +- web/.prettierignore | 3 + web/index.html | 13 + web/package-lock.json | 3083 +++++++++++++++++ web/package.json | 48 + web/public/favicon.svg | 4 + web/src/auth/LoginPage.test.tsx | 97 + web/src/auth/LoginPage.tsx | 117 + web/src/auth/store.test.tsx | 89 + web/src/auth/store.tsx | 105 + web/src/features/blocklists/BlocklistForm.tsx | 89 + .../blocklists/BlocklistsPage.test.tsx | 177 + .../features/blocklists/BlocklistsPage.tsx | 170 + .../blocklists/SourceStatusSection.tsx | 81 + web/src/features/clients/ClientEditDialog.tsx | 86 + web/src/features/clients/ClientsPage.test.tsx | 125 + web/src/features/clients/ClientsPage.tsx | 115 + web/src/features/clients/PrefixesEditor.tsx | 128 + web/src/features/clients/prefixEditor.test.ts | 86 + web/src/features/clients/prefixEditor.ts | 74 + .../features/dashboard/DashboardPage.test.tsx | 163 + web/src/features/dashboard/DashboardPage.tsx | 99 + web/src/features/dashboard/DiskCard.tsx | 35 + web/src/features/dashboard/HealthBanners.tsx | 43 + web/src/features/dashboard/StatCards.tsx | 44 + .../features/dashboard/TimeseriesChart.tsx | 219 ++ .../dashboard/UpstreamHealthTable.tsx | 70 + .../features/dashboard/chartLayout.test.ts | 93 + web/src/features/dashboard/chartLayout.ts | 101 + .../features/groups/GroupSourcesEditor.tsx | 79 + web/src/features/groups/GroupsPage.test.tsx | 135 + web/src/features/groups/GroupsPage.tsx | 188 + web/src/features/groups/sourceSet.test.ts | 22 + web/src/features/groups/sourceSet.ts | 11 + web/src/features/live/LiveLogPage.test.tsx | 80 + web/src/features/live/LiveLogPage.tsx | 114 + web/src/features/live/fakeEventSource.ts | 26 + web/src/features/live/ringBuffer.test.ts | 101 + web/src/features/live/ringBuffer.ts | 53 + web/src/features/live/useLiveQueries.test.tsx | 213 ++ web/src/features/live/useLiveQueries.ts | 171 + web/src/features/local/LocalDnsPage.test.tsx | 85 + web/src/features/local/LocalDnsPage.tsx | 78 + web/src/features/local/RecordsTab.tsx | 247 ++ web/src/features/local/ZonesTab.tsx | 202 ++ web/src/features/lookup/LookupPage.test.tsx | 93 + web/src/features/lookup/LookupPage.tsx | 211 ++ web/src/features/pause/PauseWidget.test.tsx | 194 ++ web/src/features/pause/PauseWidget.tsx | 124 + .../features/queries/QueryLogPage.test.tsx | 257 ++ web/src/features/queries/QueryLogPage.tsx | 261 ++ web/src/features/queries/qtype.test.ts | 18 + web/src/features/queries/qtype.ts | 27 + web/src/features/rules/RulesPage.test.tsx | 108 + web/src/features/rules/RulesPage.tsx | 171 + .../features/settings/RestartBanner.test.tsx | 28 + web/src/features/settings/RestartBanner.tsx | 21 + .../features/settings/SettingsPage.test.tsx | 234 ++ web/src/features/settings/SettingsPage.tsx | 329 ++ web/src/features/settings/restartBanner.ts | 27 + web/src/lib/InlineError.tsx | 39 + web/src/lib/api.test.ts | 96 + web/src/lib/api.ts | 235 ++ web/src/lib/format.test.ts | 23 + web/src/lib/format.ts | 27 + web/src/lib/queries.ts | 279 ++ web/src/lib/queryClient.ts | 42 + web/src/lib/settingsDiff.test.ts | 104 + web/src/lib/settingsDiff.ts | 40 + web/src/lib/types.ts | 403 +++ web/src/main.tsx | 24 + web/src/routes.tsx | 218 ++ web/src/shell/AppShell.test.tsx | 125 + web/src/shell/AppShell.tsx | 127 + web/src/styles.css | 1 + web/tsconfig.app.json | 25 + web/tsconfig.json | 4 + web/tsconfig.node.json | 21 + web/vite.config.ts | 24 + 82 files changed, 11833 insertions(+), 17 deletions(-) create mode 100644 specs/milestone-9.md create mode 100644 web/.prettierignore create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/favicon.svg create mode 100644 web/src/auth/LoginPage.test.tsx create mode 100644 web/src/auth/LoginPage.tsx create mode 100644 web/src/auth/store.test.tsx create mode 100644 web/src/auth/store.tsx create mode 100644 web/src/features/blocklists/BlocklistForm.tsx create mode 100644 web/src/features/blocklists/BlocklistsPage.test.tsx create mode 100644 web/src/features/blocklists/BlocklistsPage.tsx create mode 100644 web/src/features/blocklists/SourceStatusSection.tsx create mode 100644 web/src/features/clients/ClientEditDialog.tsx create mode 100644 web/src/features/clients/ClientsPage.test.tsx create mode 100644 web/src/features/clients/ClientsPage.tsx create mode 100644 web/src/features/clients/PrefixesEditor.tsx create mode 100644 web/src/features/clients/prefixEditor.test.ts create mode 100644 web/src/features/clients/prefixEditor.ts create mode 100644 web/src/features/dashboard/DashboardPage.test.tsx create mode 100644 web/src/features/dashboard/DashboardPage.tsx create mode 100644 web/src/features/dashboard/DiskCard.tsx create mode 100644 web/src/features/dashboard/HealthBanners.tsx create mode 100644 web/src/features/dashboard/StatCards.tsx create mode 100644 web/src/features/dashboard/TimeseriesChart.tsx create mode 100644 web/src/features/dashboard/UpstreamHealthTable.tsx create mode 100644 web/src/features/dashboard/chartLayout.test.ts create mode 100644 web/src/features/dashboard/chartLayout.ts create mode 100644 web/src/features/groups/GroupSourcesEditor.tsx create mode 100644 web/src/features/groups/GroupsPage.test.tsx create mode 100644 web/src/features/groups/GroupsPage.tsx create mode 100644 web/src/features/groups/sourceSet.test.ts create mode 100644 web/src/features/groups/sourceSet.ts create mode 100644 web/src/features/live/LiveLogPage.test.tsx create mode 100644 web/src/features/live/LiveLogPage.tsx create mode 100644 web/src/features/live/fakeEventSource.ts create mode 100644 web/src/features/live/ringBuffer.test.ts create mode 100644 web/src/features/live/ringBuffer.ts create mode 100644 web/src/features/live/useLiveQueries.test.tsx create mode 100644 web/src/features/live/useLiveQueries.ts create mode 100644 web/src/features/local/LocalDnsPage.test.tsx create mode 100644 web/src/features/local/LocalDnsPage.tsx create mode 100644 web/src/features/local/RecordsTab.tsx create mode 100644 web/src/features/local/ZonesTab.tsx create mode 100644 web/src/features/lookup/LookupPage.test.tsx create mode 100644 web/src/features/lookup/LookupPage.tsx create mode 100644 web/src/features/pause/PauseWidget.test.tsx create mode 100644 web/src/features/pause/PauseWidget.tsx create mode 100644 web/src/features/queries/QueryLogPage.test.tsx create mode 100644 web/src/features/queries/QueryLogPage.tsx create mode 100644 web/src/features/queries/qtype.test.ts create mode 100644 web/src/features/queries/qtype.ts create mode 100644 web/src/features/rules/RulesPage.test.tsx create mode 100644 web/src/features/rules/RulesPage.tsx create mode 100644 web/src/features/settings/RestartBanner.test.tsx create mode 100644 web/src/features/settings/RestartBanner.tsx create mode 100644 web/src/features/settings/SettingsPage.test.tsx create mode 100644 web/src/features/settings/SettingsPage.tsx create mode 100644 web/src/features/settings/restartBanner.ts create mode 100644 web/src/lib/InlineError.tsx create mode 100644 web/src/lib/api.test.ts create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/format.test.ts create mode 100644 web/src/lib/format.ts create mode 100644 web/src/lib/queries.ts create mode 100644 web/src/lib/queryClient.ts create mode 100644 web/src/lib/settingsDiff.test.ts create mode 100644 web/src/lib/settingsDiff.ts create mode 100644 web/src/lib/types.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/routes.tsx create mode 100644 web/src/shell/AppShell.test.tsx create mode 100644 web/src/shell/AppShell.tsx create mode 100644 web/src/styles.css create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0bc66e4..1915cc8 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 03cb27d..4ddfa3b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .zig-cache/ zig-out/ zig-pkg/ +web/node_modules/ +web/dist/ diff --git a/specs/milestone-9.md b/specs/milestone-9.md new file mode 100644 index 0000000..ba61a2c --- /dev/null +++ b/specs/milestone-9.md @@ -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 `` + a hover state). The data is small + (60-168 buckets) and fixed-shape; a chart library is a liability with no payoff. + This is complete, not a stub: axes, tooltip, empty state, responsive width. +5. **Router**: code-based route tree (no file-based codegen plugin). Every data route + uses a TanStack Router loader that primes TanStack Query (`ensureQueryData`) — + PLAN.md:562 "route loaders for initial fetch". Pending/error components on every + route; TanStack Query handles cache/retry (no retry on 4xx; retry 429 after + Retry-After). +6. **API layer**: one `src/lib/api.ts` typed client over `fetch` with + `credentials: "same-origin"`. Types in `src/lib/types.ts` transcribed by hand from + openapi.yaml (snake_case preserved; no codegen dependency). Error model: + `ApiError{status, message, retryAfter?}` from the `{error}` envelope; 503 rendered + distinctly from 500 ("server starting/degraded" vs "internal error"). +7. **Auth flow**: `/login` route outside the app shell. On any 401 the query layer + redirects to `/login?redirect=<path>`. Login POST with `auth_required=false` in the + response → auth is off → navigate straight in (the login page short-circuits by + probing once). Logout button in the shell (hidden when auth is off). Session state + lives in a tiny auth store fed by responses, not a poll. Works with auth on AND off + (PLAN.md:562) — with auth off the SPA never shows login. +8. **Pause control is global**: a shell-header widget (pause/resume with duration + presets 60s/5m/30m/indefinite, countdown from `until`, `paused` disambiguates null + `until`). Not a page. +9. **Live log page**: `EventSource` on `/api/queries/live`; ring buffer of 500 rows in + memory, newest first; rows keyed by a monotonically increasing client counter (the + SSE payload has NO id). On `error`/close the browser auto-reconnects (server sends + `retry: 3000`); on reconnect the page re-syncs the gap via `GET /api/queries` + (since = last seen ts) and shows a "stream resumed, N missed" notice. Pause-stream + button (client-side freeze) included. 429 from the SSE cap → visible "too many live + viewers" state, retry button. +10. **Query log page**: keyset pagination exactly per contract (`limit`, `before`, + follow `next_before` until null; newest first). Filters: domain substring, client + exact, blocked tri-state, since/until (datetime-local inputs → unix seconds). + "Load more" appends; filter change resets the cursor. +11. **Settings page**: sections rendered from the GET envelope; a diff-based PUT sends + ONLY changed fields (partial patch per contract). Every key is restart-required + today: after a successful PUT of anything except `web.password`, set a persistent + "restart to apply" banner (dismiss resets on next change; PLAN.md:531). + `web.password` is a write-only field with confirm input; on change the API kills + all sessions — the SPA expects the next request to 401 and routes to login. + `web.auth_enabled` is read-only derived. `web.password_hash` is never sent. +12. **Blocklists page** shows per-source `skipped_regex_count` (PLAN.md:38) and source + status (state, last_success, counts, last_error). "Update now" calls + `POST /api/blocklists/update`, REPLACES list state from the 202 snapshot, disables + the button while in flight. +13. **Dashboard**: stats totals + timeseries chart (period picker 1h/24h/7d/30d), + upstream health table (PLAN.md:641), disk card from `/api/health` + (state/free/db/log bytes) with the warning banner when `disk.state != "ok"` + (PLAN.md:465), plus queries_dropped/writer_failed indicators. Auto-refresh via + Query `refetchInterval` 30s (health 10s). +14. **Groups page** includes the group↔sources assignment editor + (`PUT /api/groups/{id}/sources`, full-set checkboxes) and safe_search toggle. + Default group (id 1) shows but blocks rename/delete client-side too (server 409s). +15. **Clients page**: table of all clients (`hand_edited` badge), edit name/group, no + create (rows appear from DNS activity — say so in the empty state), delete with + "re-materializes on next query" note. Client-prefixes editor on the same page: + whole-list editing per the PUT contract. +16. **Local DNS page**: two tabs (records, forward zones), CRUD forms per contract. + **Domain lookup page**: domain + group select → renders the full pipeline verdict + (local_records, forward_zone, blocked/reason/matched/source_url, + safe_search_rewrite). +17. **Errors and loading**: every route has skeleton/pending UI and an error boundary + with the ApiError message + retry (PLAN.md:562). Mutations surface 400/409 messages + inline at the form, 429 with countdown, 401 via the global redirect. +18. **Responsive**: sidebar nav collapses to a top bar + drawer under `md:`; tables get + `overflow-x-auto` wrappers; the dashboard grid stacks. Desktop and mobile per + PLAN.md:562 — no separate mobile pages. +19. **Formatting/l10n**: timestamps rendered in the browser locale from unix seconds + (`Intl.DateTimeFormat`), byte counts humanized (KiB/MiB/GiB), µs durations shown as + ms with one decimal. One `src/lib/format.ts`, tested. +20. **Size budget**: PLAN.md:639 — stripped static binary < 15 MB with assets (< 10 MB + without → SPA budget ≈ 5 MB embedded, plain + .gz both count). React+TanStack+ + Tailwind lands far under that; CI asserts the final cross binaries < 15 MB. +21. **Zig side is frozen.** No changes to src/, build.zig, tools/, or openapi.yaml. If + the SPA reveals an API bug, STOP and report — do not work around silently. The only + Zig-adjacent deliverables are ci.yml additions and `.gitignore` entries. +22. **CI**: new `frontend` job (setup-node@v4, `NODE_VERSION: "24"` env pin, + `cache: npm`, `cache-dependency-path: web/package-lock.json`; `npm ci` → + `prettier --check` → oxlint → `tsc --noEmit` → `vitest run` → `vite build`), + uploading `web/dist` as an artifact is NOT needed — instead the existing `cross` + job gains: setup-node, `npm ci && npm run build` (prefix web), then + `zig build cross -Dweb-dist=web/dist`, then the existing static assert plus a + size assert (< 15 MB per exe). The `test` job stays Zig-only. +23. **Accessibility floor**: semantic elements, labeled inputs, focus-visible styles, + buttons not divs. No ARIA deep-dive beyond what semantics give. +24. **No new pages, no dark-mode toggle bikeshed** (Tailwind default palette, system + `prefers-color-scheme` via CSS only), no i18n framework, no state library beyond + TanStack Query + two tiny stores (auth, restart-banner) in React context. + +## Sessions + +F1 first (scaffold), then F2 (api/lib) sequential on F1, then F3-F8 parallel (pages; +disjoint files), then F9 (CI + embed + smoke) after all. + +--- + +## Session F1: scaffold + shell + +Owns: `web/package.json`, `web/package-lock.json`, `web/index.html`, +`web/vite.config.ts`, `web/tsconfig*.json`, `web/.oxlintrc.json` (only if needed), +`web/src/main.tsx`, `web/src/routes.tsx` (route tree with lazy page imports and +placeholder page stubs F3-F8 replace), `web/src/shell/` (layout, sidebar/topbar nav, +`PauseWidget` SLOT — an import of `../features/pause/PauseWidget` that F8 fills; F1 +ships the real widget file with a disabled placeholder), `web/src/styles.css` +(tailwind), `.gitignore` additions (web/dist, web/node_modules), Prettier config in +package.json (house values), scripts: dev/build/typecheck/lint/format/test. + +Vite: `@vitejs/plugin-react`, tailwind v4 via `@tailwindcss/vite`, build target +baseline-widely-available (vite 8 default), no proxy needed for build; dev proxy +`/api` + `/metrics` → `http://127.0.0.1:8080` for `vite dev` against a running nxdns. +Route tree: `/login` bare; shell routes `/`, `/queries`, `/live`, `/clients`, +`/groups`, `/blocklists`, `/rules`, `/local-dns`, `/lookup`, `/settings`. + +Acceptance: `npm ci && npm run build` produces `web/dist` with `/index.html`; +typecheck/lint/format clean; placeholder pages render; nav works with keyboard. + +### F1 As built + +Pinned: react 19.2.8, @tanstack/react-router 1.170.18, @tanstack/react-query 5.101.4, +vite 8.1.5, typescript 6.0.3, tailwindcss 4.3.3 (@tailwindcss/vite), vitest 4.1.10, +@testing-library/react 16.3.2, jsdom 29.1.1, prettier 3.9.6, oxlint 1.75.0. All exact. +~/.npmrc enforces min-release-age=7 (newest gate-clearing versions chosen) and +ignore-scripts=true (works; native bins are optionalDependencies). TS6 deprecates +baseUrl — tsconfig.app.json uses `paths` without it. Dist: 344K (main chunk 278.5 kB / +88.2 kB gz, 11 lazy page chunks, CSS 8.8 kB). `zig build -Dweb-dist=web/dist` exit 0. +- Alias `@/*` → `web/src/*` (tsconfig paths + vite resolve.alias). +- Router: code-based in src/routes.tsx; `createAppRouter(history?)` exported (tests + pass createMemoryHistory); `Register` declared so `Link to` is typed; pages wired + via lazyRouteComponent at stable paths — every page keeps a default export at its + path; page sessions never edit routes.tsx. Shell = pathless layout route id "shell"; + /login hangs off root outside it; defaultPreload "intent". +- Shell: AppShell renders PauseWidget (default export, no props) from + features/pause/PauseWidget, header right; F8 replaces the file in place. + VersionFooter inside AppShell.tsx (F2 wires /api/version). Restart banner has no + premade slot — F8 mounts it in AppShell (sequential edit). Nav: aria-current + + activeProps; drawer under md: with aria-expanded/controls. +- Vitest lives in vite.config.ts (jsdom, globals: true; tsconfig types include + vitest/globals). lint = `oxlint src vite.config.ts`; prettier ignores dist/, + dist-placeholder/, package-lock.json (.prettierignore). +- Page filenames: QueryLogPage/LiveLogPage per F4 naming; LoginPage placeholder in + src/auth/ (F2 replaces). + +--- + +## Session F2: API layer + auth + query plumbing + +Owns: `web/src/lib/` (`api.ts`, `types.ts`, `format.ts`, `queryClient.ts`, +`queries.ts` — queryOptions per resource, mutation helpers with invalidation), +`web/src/auth/` (store, `LoginPage`, 401 redirect wiring), tests for api/format/ +pagination/settings-diff helpers. + +types.ts transcribed from src/web/openapi.yaml — every shape the pages consume +(QueryRow, QueriesPage, StatsTotals, Timeseries, Lookup, UpstreamHealth, Health, +Version, Group, Blocklist, SourceStatus, Rule, LocalRecord, ForwardZone, Client, +ClientPrefix, Upstream, SettingsEnvelope, Pause, Login). ApiError per ruling 6; +401 hook per ruling 7; 429 retry per ruling 5; settings diff builder per ruling 11 +(pure function, tested). + +Acceptance: vitest green; typecheck clean; login/logout round trip works against a +live `nxdns run` (manual smoke; document the transcript). + +### F2 As built + +lib/{types,api,queryClient,queries,format,settingsDiff}.ts + tests (22 green); +auth/{store.tsx,LoginPage.tsx}; routes.tsx gained context+loaders+default +pending/error components; main.tsx providers; AppShell gained logout button + live +VersionFooter. Live-server smoke verified every consumed shape with auth on AND off; +no API bugs. Main chunk 319 kB (100 kB gz). +- api.ts: `request<T>` core (same-origin, 204→void, `{error}` envelope, Retry-After on + 429), `requestText`, functions for all 55 route-method pairs, list envelopes + unwrapped to arrays, `liveQueriesUrl` for F4. +- queryClient: staleTime 30s; no retry on 4xx except 429 (max 2, delay=retryAfter); + QueryCache+MutationCache onError → `/login?redirect=<path+search>` on 401 (skipped + on /login). +- queries.ts factories: healthQuery (10s refetch), versionQuery, statsQuery/ + timeseriesQuery(period), upstreamHealthQuery (30s), queriesQuery(filter), + lookupQuery(domain, groupId?), groupsQuery, groupSourcesQuery(id), blocklistsQuery, + rulesQuery, localRecordsQuery, forwardZonesQuery, clientsQuery, clientPrefixesQuery, + upstreamsQuery, pauseQuery, settingsQuery. Mutations: `xxxMutation(queryClient)` → + useMutation options; invalidation map in the F2 report; NOTE + `blocklistsUpdateNowMutation` seeds `queryKeys.blocklistSources` from the 202 + snapshot — the ONLY feed for source status (no GET exists); F6 reads that key. +- Auth store: `useAuth()` → {authRequired: bool|null, probe(), login(password), + logout()}. Probe = POST login with empty password (no status GET), StrictMode-deduped; + authRequired mirrored in sessionStorage `nxdns_auth_required`. +- Loaders prime: dashboard stats+timeseries("24h")+health+upstreamHealth; queries + `queriesQuery({})` (F4 "load more" calls api.getQueries imperatively and appends); + clients clients+prefixes+groups; groups groups+blocklists; rules rules+groups; + local-dns records+zones; lookup groups; settings settings; live none. Pages use the + same factory the loader primed (useSuspenseQuery/useQuery). +- Router error component is ApiError-aware (503 "starting or degraded", 429 countdown, + ≥500 generic); Retry = router.invalidate(). +- Page-test pattern: wrap in AuthProvider+QueryClientProvider, pass the same qc to + `createAppRouter(history, qc)`, stub fetch per-URL (see AppShell.test.tsx). +- api.ts sends `{}` on bodyless POSTs (logout, blocklists/update) — belt-and-braces + over the W9 server fix. + +--- + +## Session F3: Dashboard + SVG chart + +Owns: `web/src/features/dashboard/` (page, StatCards, TimeseriesChart (SVG, ruling 4), +UpstreamHealthTable, DiskCard, HealthBanners) + chart unit tests (bucket→bar math, +empty state). + +## Session F4: Query log + Live log + +Owns: `web/src/features/queries/` (QueryLogPage, filters, cursor pagination per ruling +10) and `web/src/features/live/` (LiveLogPage per ruling 9: EventSource wrapper hook +with injected EventSource for tests, ring buffer, gap re-sync, freeze, cap-hit state). +Tests: ring buffer, gap-resync math, EventSource hook with a fake. + +## Session F5: Clients + Groups + +Owns: `web/src/features/clients/` (table, edit dialog, prefixes editor per ruling 15) +and `web/src/features/groups/` (list, create/rename/delete, safe_search, sources +assignment per ruling 14). + +## Session F6: Blocklists + Rules + +Owns: `web/src/features/blocklists/` (ruling 12) and `web/src/features/rules/` +(table with group/kind/action columns, create form with pattern kind select, delete). + +## Session F7: Local DNS + Lookup + +Owns: `web/src/features/local/` (records + zones tabs, CRUD forms) and +`web/src/features/lookup/` (ruling 16). + +## Session F8: Settings + Pause widget + +Owns: `web/src/features/settings/` (ruling 11: section forms, diff PUT, restart +banner store + banner component mounted in the shell via F1's slot, password flow) +and `web/src/features/pause/PauseWidget.tsx` (REPLACES F1's placeholder; ruling 8). +Tests: diff builder edge cases (nested partial, password only, no-op), banner logic. + +### F3-F8 As built (page wave) + +All six sessions green; 100 web tests total after the wave; main chunk 318 kB +(99 kB gz), pages as lazy chunks. +- **F3 Dashboard**: chartLayout.ts pure layout (layoutTimeseries, niceTicks 1/2/5) + + TimeseriesChart({data}) — self-measuring SVG stacked bars (blocked/cached/other, + other = queries−blocked−cached clamped ≥0), sr-only data table, role="img". + Axis ticks use a short Intl formatter (formatTime is tooltip/sr-only only). + UpstreamHealthTable shows total_failures; success_rate ×100 (0..1 verified in + pool.zig). HealthBanners: disk warn/critical (critical text per PLAN.md:466), + writer_failed, queries_dropped>0, all role="alert". 12 tests. +- **F4 Query/Live**: QueryLogPage exports QueryCells/QueryTableHead/BlockedCell, + reused by LiveLogPage (both F4-owned). qtype.ts names 19 codes, TYPE<n> fallback. + Filters in component state (not URL). ringBuffer.ts: 500 newest-first + mergeGap + (dedup key ts|domain|client_ip|qtype|blocked|upstream — SSE rows have no id; + same-second identical queries can over-dedup, accepted at household scale). + useLiveQueries hook (injected EventSourceLike factory + fetchSince): states + connecting/open/retrying/capped, gap re-sync getQueries({since, limit:500}) — + gaps >500 replace the buffer wholesale; CAP_ERROR_THRESHOLD=3 consecutive errors + → capped state (EventSource cannot see 429; message says cap OR unreachable), + open resets the counter. Freeze = display-only snapshot; the ring keeps filling; + Resume swaps to current. 25 tests. +- **F5 Clients/Groups**: prefixEditor.ts pure reducer (priority "" omitted → server + default 100); baseline resets only on save/discard (dirty edits never clobbered by + refetch). clientUpdateMutation takes {id, edit} (spec prompt said input — code + wins). Groups: safe_search toggle PUTs with unchanged name (server 409s only on + actual rename of default — verified in groups.zig); default group protections + client-side + visible note; sourceSet.ts toggle/sameSet. 18 tests. +- **F6 Blocklists/Rules**: rule enums confirmed kind=[exact,wildcard], + action=[allow,block]. Status section reads queryKeys.blocklistSources via + queryClient.getQueryData at render (no GET exists; mutation setQueryData + re-renders); edit PUT preserves is_suggested. 3 tests. +- **F7 Local/Lookup**: rtype=[A,AAAA,CNAME], ttl optional default 300. SPEC + CORRECTION: Lookup.local_records is a BOOLEAN (ruling 16 said "list" loosely) — + rendered Yes/No; verdict priority local > blocked > forwarded > allowed (pipeline + order per lookup.zig), verdictOf exported. Local mutations live, no banner. + 4 tests. +- **F8 Settings/Pause**: patchRequiresRestart(patch) exported — banner raised for + any patch except bare web.password; restartBanner.ts is a useSyncExternalStore + MODULE store (accepted deviation from ruling 24's "context": keeps the AppShell + edit to import + mount). Number inputs NaN-guard blocks Save. PauseWidget: + refetchInterval 5s only while paused, 1s countdown, formatRemaining exported. + 16 tests. +- **Orchestrator integration**: the wave produced three near-identical inline-error + components (clients+groups InlineError, local FormError) — hoisted post-wave to + `src/lib/InlineError.tsx` (the richer variant: 400/409 verbatim, 429 countdown, + 503 distinct, non-Error → "could not reach"), seven imports updated, duplicates + deleted. Dashboard's InlineError (query-level, onRetry) is a different component + and stays local. Full chain re-verified green after the hoist. + +--- + +## Session F9: CI + embed + smoke (after F3-F8) + +Owns: `.gitea/workflows/ci.yml` (ruling 22). Steps: verify `npm ci` reproducibility, +frontend job, cross-job additions with `-Dweb-dist=web/dist` + size assert. +Smoke (report transcript): `npm run build`; `zig build -Dweb-dist=web/dist`; boot; +curl `/` returns the SPA index (not the placeholder); a deep link (`/settings`) +returns 200 index; an asset serves gzip with ETag/304; login via browser-shaped curl +flow still works; SIGTERM 0. Also `zig build test` and `-Dintegration` still green +(the Zig tests embed the dist too — W10 static tests must still pass against the SPA +dist; if one pins placeholder content, report it, do not edit Zig). + +### F9 As built + +ci.yml +77/-5: top-level `NODE_VERSION: "24"`; new `frontend` job (setup-node@v4, +cache npm on web/package-lock.json; npm ci → format:check → lint → typecheck → +`npm test` → build); `cross` job builds the SPA then +`zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe` — RULED deviation: +build.zig has no strip option and Debug exes are 87/32 MB (25.6 MB stripped), so the +PLAN:639 budget is only meaningful for release builds; PLAN:50 calls these release +binaries. Size assert strips COPIES via binutils (zig objcopy --strip-all is +unimplemented in 0.16; aarch64 needs binutils-aarch64-linux-gnu, apt-installed +conditionally). Measured ReleaseSafe: raw ~24.2 MB each; stripped x86_64 5,501,624 +bytes (PASS < 15 MB), aarch64 PT_LOAD total 4.87 MB (~5 MB stripped, asserted in CI). +`test` job stays Zig-only on the placeholder dist. +Smoke (embedded SPA): / serves the vite index (not the placeholder), /settings deep +link 200, main chunk served gzip with ETag then 304 on If-None-Match, login round +trip (401 → cookie → 200 → logout → 401), SIGTERM exit 0. +Finding, fixed post-session under an orchestrator ruling (narrow Zig-freeze +exception): the W8 test at static.zig:413 pinned placeholder strings and failed with +any real dist — rewritten dist-agnostic (structural asserts: /index.html exists, +text/html, gz siblings have base entries, quoted etags, no duplicate paths). +Import CLI note: the config file is positional on `nxdns import` (no --config flag). + +--- + +## Module layout (new) + +web/{package.json, package-lock.json, index.html, vite.config.ts, tsconfig*.json}, +web/src/{main.tsx, routes.tsx, styles.css}, web/src/shell/*, web/src/lib/*, +web/src/auth/*, web/src/features/{dashboard,queries,live,clients,groups,blocklists, +rules,local,lookup,settings,pause}/*. + +## File ownership + +F1 scaffold+shell+routes; F2 lib+auth; F3-F8 exactly their feature dirs (routes.tsx +lazy imports point at stable paths F1 fixes up-front, so page sessions never edit +routes.tsx; F8 alone replaces the PauseWidget file F1 created); F9 ci.yml. Orchestrator: +spec, integration wiring if any. Parallel sessions never share a file. + +## Acceptance (milestone complete) + +- [ ] `npm ci`, format/lint/typecheck/vitest, `vite build` all green in web/. +- [ ] All ten pages implemented per rulings; login + pause + restart banner work. +- [ ] `zig build cross -Dweb-dist=web/dist` green; exes < 15 MB; W10 suite still green. +- [ ] F9 smoke transcript: SPA served embedded, deep link 200, gzip+ETag, SIGTERM 0. +- [ ] CI has the frontend job and the cross-job embed per ruling 22. +- [ ] Spec As-built synced per session. + +## Review (Codex, As built) + +Round 1: 7 important + 4 minor, all fixed. +- auth: AuthProvider probes once on mount when authRequired is null (dedupe kept; + sessionStorage fast path, probe overwrites); logout swallows ONLY 401 — other errors + rethrow and AppShell's LogoutButton renders them via InlineError, navigating only on + success; LoginPage 429 is a ticking lockout (submit + Enter disabled until zero). +- queries: load-more generation counter — filter apply/clear increments; then/catch/ + finally discard stale completions (no old-filter rows, no stale cursor); imperative + 401s route through `handleUnauthorized` (newly exported from lib/queryClient). +- live: gap-resync 401 → handleUnauthorized (never resyncFailed); entering capped + fires a one-shot injectable probeSession (default GET /api/pause) so an expired + session redirects to login instead of reading as "capped". +- settings: fieldset disabled while the PUT is pending (no silently dropped edits); + errors via InlineError. +- pause: mutation errors rendered via InlineError in both branches (were silent); + stale error resets on pause-state flip. +- blocklists/rules: all mutation errors via InlineError (429 countdown); form error + props are Error|null now. +- Ripple: the auth mount probe legitimately fires POST /api/auth/login before page + fetches — LocalDnsPage.test's first-POST assertion narrowed to match by URL. +After fixes: 24 files / 120 web tests green; Zig suite with the fresh dist 0 failed. + +Round 2: 1 important + 1 minor, both fixed. (a) keepPreviousData left the OLD +filter's cursor clickable during the placeholder window — loadMore and the button now +bail on base.isPlaceholderData (chosen over isFetching so background refetches of the +current key stay usable); regression test proves no new-filter/old-cursor request. +(b) safeRedirect accepted backslash network paths — now +`/^\/(?![/\\])/.test(raw) && !raw.includes("\\")`, with unit cases. + +Round 3: no findings. Final: 121 web tests green. + +## Anti-requirements + +No SSR, no chart library, no codegen (openapi→TS), no msw, no ESLint, no i18n, no +dark-mode toggle, no WebSocket, no service worker/PWA, no Zig changes, no docs/api +rendering (Phase 10), no DoH/DoT UI (Phase 9 adds settings sections it already has). diff --git a/src/web/static.zig b/src/web/static.zig index b75a18c..4399fb8 100644 --- a/src/web/static.zig +++ b/src/web/static.zig @@ -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" { diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..993c281 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,3 @@ +dist/ +dist-placeholder/ +package-lock.json diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..26400f7 --- /dev/null +++ b/web/index.html @@ -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 + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..b07f7e1 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3083 @@ +{ + "name": "nxdns-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nxdns-web", + "version": "0.0.0", + "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" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.75.0.tgz", + "integrity": "sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.75.0.tgz", + "integrity": "sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.75.0.tgz", + "integrity": "sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.75.0.tgz", + "integrity": "sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.75.0.tgz", + "integrity": "sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.75.0.tgz", + "integrity": "sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.75.0.tgz", + "integrity": "sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.75.0.tgz", + "integrity": "sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.75.0.tgz", + "integrity": "sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.75.0.tgz", + "integrity": "sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.75.0.tgz", + "integrity": "sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.75.0.tgz", + "integrity": "sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.75.0.tgz", + "integrity": "sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.75.0.tgz", + "integrity": "sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.75.0.tgz", + "integrity": "sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.75.0.tgz", + "integrity": "sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.75.0.tgz", + "integrity": "sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.75.0.tgz", + "integrity": "sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.75.0.tgz", + "integrity": "sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/history": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", + "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.170.18", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.18.tgz", + "integrity": "sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.171.15", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.171.15", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.15.tgz", + "integrity": "sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "cookie-es": "^3.0.0", + "seroval": "^1.5.4", + "seroval-plugins": "^1.5.4" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbot": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.1.tgz", + "integrity": "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/oxlint": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.75.0.tgz", + "integrity": "sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.75.0", + "@oxlint/binding-android-arm64": "1.75.0", + "@oxlint/binding-darwin-arm64": "1.75.0", + "@oxlint/binding-darwin-x64": "1.75.0", + "@oxlint/binding-freebsd-x64": "1.75.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.75.0", + "@oxlint/binding-linux-arm-musleabihf": "1.75.0", + "@oxlint/binding-linux-arm64-gnu": "1.75.0", + "@oxlint/binding-linux-arm64-musl": "1.75.0", + "@oxlint/binding-linux-ppc64-gnu": "1.75.0", + "@oxlint/binding-linux-riscv64-gnu": "1.75.0", + "@oxlint/binding-linux-riscv64-musl": "1.75.0", + "@oxlint/binding-linux-s390x-gnu": "1.75.0", + "@oxlint/binding-linux-x64-gnu": "1.75.0", + "@oxlint/binding-linux-x64-musl": "1.75.0", + "@oxlint/binding-openharmony-arm64": "1.75.0", + "@oxlint/binding-win32-arm64-msvc": "1.75.0", + "@oxlint/binding-win32-ia32-msvc": "1.75.0", + "@oxlint/binding-win32-x64-msvc": "1.75.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/seroval": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", + "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", + "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..ab7e0b6 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..b976625 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,4 @@ + + + nx + diff --git a/web/src/auth/LoginPage.test.tsx b/web/src/auth/LoginPage.test.tsx new file mode 100644 index 0000000..4956049 --- /dev/null +++ b/web/src/auth/LoginPage.test.tsx @@ -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 = {}): 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( + + + + + , + ); +} + +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("/"); +}); diff --git a/web/src/auth/LoginPage.tsx b/web/src/auth/LoginPage.tsx new file mode 100644 index 0000000..f2c460d --- /dev/null +++ b/web/src/auth/LoginPage.tsx @@ -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(null); + const [busy, setBusy] = useState(false); + + const retryAfter = error instanceof ApiError && error.status === 429 ? (error.retryAfter ?? null) : null; + const [remaining, setRemaining] = useState(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) { + 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 ( +
+
+

nxdns

+ {authRequired !== true ? ( +

Checking whether a password is required…

+ ) : ( +
+
+ + 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" + /> +
+ +
+ )} + {error !== null && ( +

+ {errorMessage(error, remaining)} +

+ )} +
+
+ ); +} diff --git a/web/src/auth/store.test.tsx b/web/src/auth/store.test.tsx new file mode 100644 index 0000000..693baec --- /dev/null +++ b/web/src/auth/store.test.tsx @@ -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 {children}; +} + +function jsonResponse(payload: unknown, status = 200, headers: Record = {}): 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, + }); +}); diff --git a/web/src/auth/store.tsx b/web/src/auth/store.tsx new file mode 100644 index 0000000..d891570 --- /dev/null +++ b/web/src/auth/store.tsx @@ -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 | null = null; + +function probeAuthRequired(): Promise { + 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; + login: (password: string) => Promise; + /** Ends the session server-side; swallows an already-dead session's 401. */ + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [authRequired, setAuthRequired] = useState(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( + () => ({ authRequired, probe, login, logout }), + [authRequired, probe, login, logout], + ); + return {children}; +} + +export function useAuth(): AuthStore { + const store = useContext(AuthContext); + if (store === null) throw new Error("useAuth requires an AuthProvider"); + return store; +} diff --git a/web/src/features/blocklists/BlocklistForm.tsx b/web/src/features/blocklists/BlocklistForm.tsx new file mode 100644 index 0000000..6cb5096 --- /dev/null +++ b/web/src/features/blocklists/BlocklistForm.tsx @@ -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; + 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) { + 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 ( +
+

{initial === undefined ? "Add source" : `Edit ${initial.name}`}

+
+ + setUrl(event.target.value)} + className={INPUT_CLASS} + /> +
+
+ + setName(event.target.value)} + className={INPUT_CLASS} + /> +
+ +
+ + {onCancel !== undefined && ( + + )} +
+ + + ); +} diff --git a/web/src/features/blocklists/BlocklistsPage.test.tsx b/web/src/features/blocklists/BlocklistsPage.test.tsx new file mode 100644 index 0000000..e7265d0 --- /dev/null +++ b/web/src/features/blocklists/BlocklistsPage.test.tsx @@ -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 = { + "/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((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( + + + + + , + ); +} + +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."); +}); diff --git a/web/src/features/blocklists/BlocklistsPage.tsx b/web/src/features/blocklists/BlocklistsPage.tsx new file mode 100644 index 0000000..31c7617 --- /dev/null +++ b/web/src/features/blocklists/BlocklistsPage.tsx @@ -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(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(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 ( +
+
+

Blocklists

+ +
+ {updateNow.isSuccess && !updateNow.isPending && ( +

+ Update completed; source status refreshed below. +

+ )} + + + {blocklists.length === 0 ? ( +

No blocklist sources yet. Add one below.

+ ) : ( +
+ + + + + + + + + + + + + + + {blocklists.map((b) => ( + + + + + + + + + + + ))} + +
NameURLEnabledDomainsWildcardsSkipped regexLast updated + Actions +
+ {b.name} + {b.is_suggested && ( + + Suggested + + )} + + + {b.url} + + + toggleEnabled(b)} + /> + {b.domain_count}{b.wildcard_count}{b.skipped_regex_count} + {b.last_updated === null ? "never" : formatTime(b.last_updated)} + +
+ + +
+
+
+ )} + + + setEditing(null)} + /> + + +
+ ); +} diff --git a/web/src/features/blocklists/SourceStatusSection.tsx b/web/src/features/blocklists/SourceStatusSection.tsx new file mode 100644 index 0000000..a3948a2 --- /dev/null +++ b/web/src/features/blocklists/SourceStatusSection.tsx @@ -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; +} + +export default function SourceStatusSection({ sources, namesById }: SourceStatusSectionProps) { + return ( +
+

Source status

+ {sources === undefined ? ( +

+ No status snapshot yet — run “Update now” to fetch status for every enabled source. +

+ ) : sources.length === 0 ? ( +

The last update ran against no enabled sources.

+ ) : ( +
+ + + + + + + + + + + + + + + {sources.map((source) => ( + + + + + + + + + + + ))} + +
SourceStateLast attemptLast successDomainsWildcardsSkipped regexLast error
+ {namesById.get(source.id) ?? source.url} + + {source.url} + + + + {source.state} + + {formatAttempt(source.last_attempt)}{formatAttempt(source.last_success)}{source.domains}{source.wildcards}{source.skipped_regex} + {source.last_error === "" ? ( + + ) : ( + {source.last_error} + )} +
+
+ )} +
+ ); +} diff --git a/web/src/features/clients/ClientEditDialog.tsx b/web/src/features/clients/ClientEditDialog.tsx new file mode 100644 index 0000000..dc87414 --- /dev/null +++ b/web/src/features/clients/ClientEditDialog.tsx @@ -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 ( +
+
+

Edit {client.ip}

+
{ + event.preventDefault(); + mutation.mutate( + { id: client.id, edit: { name: name.trim(), group_id: groupId } }, + { onSuccess: onClose }, + ); + }} + > + + + +
+ + +
+ +
+
+ ); +} diff --git a/web/src/features/clients/ClientsPage.test.tsx b/web/src/features/clients/ClientsPage.test.tsx new file mode 100644 index 0000000..76f2174 --- /dev/null +++ b/web/src/features/clients/ClientsPage.test.tsx @@ -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) { + 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) { + stubFetch(map); + const queryClient = createQueryClient(); + const router = createAppRouter(createMemoryHistory({ initialEntries: ["/clients"] }), queryClient); + render( + + + + + , + ); + 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(""); +}); diff --git a/web/src/features/clients/ClientsPage.tsx b/web/src/features/clients/ClientsPage.tsx new file mode 100644 index 0000000..1a25740 --- /dev/null +++ b/web/src/features/clients/ClientsPage.tsx @@ -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(null); + const [confirmingId, setConfirmingId] = useState(null); + + return ( +
+

Clients

+ {clients.length === 0 ? ( +

+ No clients yet. Rows appear automatically as devices on the network make DNS queries — there is + nothing to create by hand. +

+ ) : ( +
+ + + + + + + + + + + + + {clients.map((client) => ( + + + + + + + + + ))} + +
IPNameGroupFirst seenLast seen + Actions +
{client.ip} + {client.name === "" ? : client.name} + {client.hand_edited && ( + + edited + + )} + {client.group}{formatTime(client.first_seen)}{formatTime(client.last_seen)} + {confirmingId === client.id ? ( + + + Deleted clients re-materialize on their next DNS query. + + + + + ) : ( + + + + + )} +
+
+ )} + + {editing !== null && setEditing(null)} />} + +
+ ); +} diff --git a/web/src/features/clients/PrefixesEditor.tsx b/web/src/features/clients/PrefixesEditor.tsx new file mode 100644 index 0000000..75dee06 --- /dev/null +++ b/web/src/features/clients/PrefixesEditor.tsx @@ -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(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 ( +
+

Client prefixes

+

+ Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority + match wins. +

+ {state.rows.length === 0 ? ( +

No prefixes configured.

+ ) : ( +
    + {state.rows.map((row, index) => ( +
  • + + dispatch({ type: "edit", index, patch: { prefix: event.target.value } }) + } + className={`${inputClass} w-52`} + /> + + + dispatch({ type: "edit", index, patch: { priority: event.target.value } }) + } + className={`${inputClass} w-20`} + /> + +
  • + ))} +
+ )} + {validation !== null && ( +

+ {validation} +

+ )} + +
+ + + {dirty && ( + + )} +
+
+ ); +} diff --git a/web/src/features/clients/prefixEditor.test.ts b/web/src/features/clients/prefixEditor.test.ts new file mode 100644 index 0000000..b19ee43 --- /dev/null +++ b/web/src/features/clients/prefixEditor.test.ts @@ -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."); +}); diff --git a/web/src/features/clients/prefixEditor.ts b/web/src/features/clients/prefixEditor.ts new file mode 100644 index 0000000..59b4a13 --- /dev/null +++ b/web/src/features/clients/prefixEditor.ts @@ -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 }; + +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; + }); +} diff --git a/web/src/features/dashboard/DashboardPage.test.tsx b/web/src/features/dashboard/DashboardPage.test.tsx new file mode 100644 index 0000000..052f132 --- /dev/null +++ b/web/src/features/dashboard/DashboardPage.test.tsx @@ -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 = { + "/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( + + + + + , + ); +} + +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(); +}); diff --git a/web/src/features/dashboard/DashboardPage.tsx b/web/src/features/dashboard/DashboardPage.tsx new file mode 100644 index 0000000..6b85341 --- /dev/null +++ b/web/src/features/dashboard/DashboardPage.tsx @@ -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 ( +
+ {PERIODS.map((option) => ( + + ))} +
+ ); +} + +function InlineError({ error, onRetry }: { error: unknown; onRetry: () => void }) { + const message = error instanceof ApiError ? error.message : "request failed"; + return ( +
+ Failed to load: {message}{" "} + +
+ ); +} + +function Skeleton({ height }: { height: number }) { + return