From 648d9b4496bae0915986d460b8a9f5f06075628f Mon Sep 17 00:00:00 2001 From: m5r Date: Sat, 22 Aug 2026 16:45:15 +0200 Subject: [PATCH] milestone 30: overview as a dashboard, explicit health contract, period aggregations --- CHANGELOG.md | 11 +- PLAN.md | 33 +- .../activity/ActivityDetailPage.test.tsx | 44 +- .../features/activity/ActivityDetailPage.tsx | 10 +- .../features/activity/ActivityPage.test.tsx | 3 + .../features/activity/LiveActivity.test.tsx | 41 +- admin/src/features/activity/LiveActivity.tsx | 1 + .../features/activity/ProvenanceDetail.tsx | 11 +- .../src/features/activity/RelatedActions.tsx | 10 +- .../features/dashboard/DashboardPage.test.tsx | 252 ------ .../src/features/dashboard/DashboardPage.tsx | 173 ----- admin/src/features/dashboard/DiskCard.tsx | 99 --- .../src/features/dashboard/HealthBanners.tsx | 68 -- admin/src/features/dashboard/StatCards.tsx | 85 --- .../dashboard/UpstreamHealthTable.test.tsx | 165 ---- .../dashboard/UpstreamHealthTable.tsx | 226 ------ .../diagnostics/DiagnosticDetailPage.test.tsx | 3 + .../diagnostics/DiagnosticsPage.test.tsx | 6 +- .../features/diagnostics/DiagnosticsPage.tsx | 3 + .../features/diagnostics/HealthStrip.test.tsx | 195 +++++ .../src/features/diagnostics/HealthStrip.tsx | 178 +++++ .../features/diagnostics/eventCopy.test.ts | 17 + admin/src/features/diagnostics/eventCopy.ts | 12 +- .../features/diagnostics/healthFacts.test.ts | 97 +++ admin/src/features/diagnostics/healthFacts.ts | 133 ++++ admin/src/features/overview/ClientChart.tsx | 282 +++++++ admin/src/features/overview/Donut.tsx | 209 +++++ .../features/overview/OverviewPage.test.tsx | 400 ++++++++++ admin/src/features/overview/OverviewPage.tsx | 248 ++++++ admin/src/features/overview/StatTiles.tsx | 156 ++++ .../TimeseriesChart.test.tsx | 0 .../TimeseriesChart.tsx | 0 .../chartLayout.test.ts | 0 .../{dashboard => overview}/chartLayout.ts | 93 ++- .../src/features/overview/donutLayout.test.ts | 47 ++ admin/src/features/overview/donutLayout.ts | 95 +++ .../features/overview/overviewWindow.test.tsx | 292 +++++++ admin/src/features/overview/overviewWindow.ts | 249 ++++++ admin/src/features/overview/period.ts | 23 + .../features/overview/seriesColors.test.ts | 73 ++ admin/src/features/overview/seriesColors.ts | 84 ++ .../src/features/pause/PauseControl.test.tsx | 287 +++++++ admin/src/features/pause/PauseControl.tsx | 181 +++++ admin/src/features/pause/PauseWidget.test.tsx | 194 ----- admin/src/features/pause/PauseWidget.tsx | 187 ----- admin/src/features/pause/protection.ts | 64 ++ .../features/upstreams/UpstreamsPage.test.tsx | 2 +- .../src/features/upstreams/UpstreamsPage.tsx | 4 +- admin/src/lib/api.ts | 16 +- admin/src/lib/contractSamples.gen.ts | 144 +++- admin/src/lib/format.test.ts | 9 +- admin/src/lib/format.ts | 11 + admin/src/lib/healthFixture.ts | 19 + admin/src/lib/queries.ts | 46 +- admin/src/lib/types.ts | 133 ++-- admin/src/routes.tsx | 83 +- admin/src/shell/AppShell.test.tsx | 213 ++++-- admin/src/shell/AppShell.tsx | 68 +- admin/src/shell/diagnosticsBadge.test.ts | 38 + admin/src/shell/diagnosticsBadge.ts | 37 + docs/how-to/troubleshoot.md | 10 +- docs/reference/api.md | 10 +- docs/reference/configuration.md | 2 +- docs/tutorial/first-run.md | 2 +- specs/milestone-30.md | 146 ++++ specs/ui-redesign.md | 48 +- src/app.zig | 190 ++++- src/cli.zig | 8 +- src/storage/db.zig | 225 ++++++ src/storage/events.zig | 123 ++- src/storage/logger.zig | 400 +++++++++- src/storage/querylog_schema.zig | 32 +- src/storage/repositories/events_repo.zig | 18 + src/storage/repositories/queries_repo.zig | 549 +++++++++++++- .../repositories/upstream_history_repo.zig | 493 ------------ src/storage/retention.zig | 104 +-- src/tests.zig | 3 - src/upstream/health.zig | 5 +- src/upstream/history.zig | 716 ------------------ src/upstream/pool.zig | 113 +-- src/web/handlers/health.zig | 499 +++++++----- src/web/handlers/queries.zig | 42 +- src/web/handlers/stats.zig | 254 ++++++- src/web/handlers/upstream_health.zig | 585 -------------- src/web/metrics.zig | 63 +- src/web/openapi.yaml | 420 ++++++---- src/web/routes.zig | 7 +- src/web/server.zig | 82 +- src/web/web_integration_test.zig | 449 ++++++++++- 89 files changed, 7222 insertions(+), 4239 deletions(-) delete mode 100644 admin/src/features/dashboard/DashboardPage.test.tsx delete mode 100644 admin/src/features/dashboard/DashboardPage.tsx delete mode 100644 admin/src/features/dashboard/DiskCard.tsx delete mode 100644 admin/src/features/dashboard/HealthBanners.tsx delete mode 100644 admin/src/features/dashboard/StatCards.tsx delete mode 100644 admin/src/features/dashboard/UpstreamHealthTable.test.tsx delete mode 100644 admin/src/features/dashboard/UpstreamHealthTable.tsx create mode 100644 admin/src/features/diagnostics/HealthStrip.test.tsx create mode 100644 admin/src/features/diagnostics/HealthStrip.tsx create mode 100644 admin/src/features/diagnostics/healthFacts.test.ts create mode 100644 admin/src/features/diagnostics/healthFacts.ts create mode 100644 admin/src/features/overview/ClientChart.tsx create mode 100644 admin/src/features/overview/Donut.tsx create mode 100644 admin/src/features/overview/OverviewPage.test.tsx create mode 100644 admin/src/features/overview/OverviewPage.tsx create mode 100644 admin/src/features/overview/StatTiles.tsx rename admin/src/features/{dashboard => overview}/TimeseriesChart.test.tsx (100%) rename admin/src/features/{dashboard => overview}/TimeseriesChart.tsx (100%) rename admin/src/features/{dashboard => overview}/chartLayout.test.ts (100%) rename admin/src/features/{dashboard => overview}/chartLayout.ts (53%) create mode 100644 admin/src/features/overview/donutLayout.test.ts create mode 100644 admin/src/features/overview/donutLayout.ts create mode 100644 admin/src/features/overview/overviewWindow.test.tsx create mode 100644 admin/src/features/overview/overviewWindow.ts create mode 100644 admin/src/features/overview/period.ts create mode 100644 admin/src/features/overview/seriesColors.test.ts create mode 100644 admin/src/features/overview/seriesColors.ts create mode 100644 admin/src/features/pause/PauseControl.test.tsx create mode 100644 admin/src/features/pause/PauseControl.tsx delete mode 100644 admin/src/features/pause/PauseWidget.test.tsx delete mode 100644 admin/src/features/pause/PauseWidget.tsx create mode 100644 admin/src/features/pause/protection.ts create mode 100644 admin/src/lib/healthFixture.ts create mode 100644 admin/src/shell/diagnosticsBadge.test.ts create mode 100644 admin/src/shell/diagnosticsBadge.ts create mode 100644 specs/milestone-30.md delete mode 100644 src/storage/repositories/upstream_history_repo.zig delete mode 100644 src/upstream/history.zig delete mode 100644 src/web/handlers/upstream_health.zig diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f65961..3a39441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,15 +13,24 @@ Query provenance: every logged query becomes exactly explainable — what the po - **Every logged query has a detail page.** A row in Activity now links to `/activity/queries/{id}`, which explains that one query in the order it was decided: the request, the group it was matched under, the policy verdict with the rule that produced it and the blocklist source that rule came from, any CNAME uncloaking or safe-search rewrite, the route the answer took — blocked, local, forward zone, upstream or cache — and what the client got back, RCODE and duration included. `GET /api/queries/{id}` serves the same object; an id that retention has already deleted is a 404. The live view carries the same provenance for the queries it streams, so a query is explainable as it happens as well as afterwards. - **Query Log, Live and Lookup are one Activity page.** `/activity` is the single surface for what nxdns answered: History reads the stored log, Live reads the stream, and both show the same seven columns — Time, Domain, Client, Type, Result, Route, Duration. The mode and every filter live in the URL, so an investigation is one link that shows the recipient exactly what you were looking at, and an absolute time range stays that range instead of drifting as the day goes on. A new **Result** column says what the client actually got — `Blocked`, `NOERROR`, `SERVFAIL` and the rest — with the **Route** column beside it saying how the answer was produced, which is the pair the old Status column could not show: a blocked name is answered with NOERROR, and reading only the code made a block look like a success. Both unhappy cases are marked by weight and shape as well as colour. Switching between History and Live keeps your filters, and leaving Live closes the stream instead of holding a viewer slot open. A live row that the log has not written yet opens its own provenance in place — no invented row id — and the open detail stays put while the 500-row buffer scrolls past underneath it. Domain testing moves to `/activity/test` as **Current policy simulation**, worded so it can never be misread as an account of a query that already happened. - **Diagnostics can be scoped to an absolute window.** `/diagnostics?since=…&until=…` now validates and applies both bounds to the active and resolved lists, and the page states the window it is showing with a way to clear it. A query's detail page links here with the five minutes either side of that query, which is where the underlying failure text for a SERVFAIL lives. -- **The query log and the dashboard say how far back the history goes.** `GET /api/queries`, `/api/stats` and `/api/stats/timeseries` each carry a `coverage` object: `available_since`, the first second the file can answer for, and `complete`, whether the window you asked for begins inside it. A period that starts before the query log does now says so instead of charting the missing part as zero — which is what a recreate, a retention pass or a fresh install would otherwise look like. +- **The query log and the stats endpoints say how far back the history goes.** `GET /api/queries` and all five `/api/stats*` endpoints each carry a `coverage` object: `available_since`, the first second the file can answer for, and `complete`, whether the window you asked for begins inside it. A period that starts before the query log does now says so instead of charting the missing part as zero — which is what a recreate, a retention pass or a fresh install would otherwise look like. +- **Three new period breakdowns: `GET /api/stats/types`, `/api/stats/routes` and `/api/stats/clients`.** They take the same `period` parameter as `/api/stats` and report over the same UTC-aligned window, so every panel of one page describes the same span. `types` counts queries per DNS type, with the queries that recorded no type kept as their own row instead of dropped — the numeric type only, since naming types is the admin's job and a second table in the server would drift out of agreement with it. `routes` counts queries by how they were answered, grouping upstream rows by the answering resolver and forward-zone rows by the zone, with blocked, cache, local and rejected answers carrying no source. `clients` returns one bucketed series per client, aligned exactly like `/api/stats/timeseries` so the two charts share an x-axis: the eight busiest clients in the window are named and everything else sums into an `other` series, which is always present and always the same length as the named ones. +- **Each of those responses is read atomically.** Every window-bounded read — the five stats endpoints and `GET /api/queries` — now takes its rows and its coverage watermark inside one SQLite read transaction. A retention pass that runs mid-response can no longer hand back rows from before the prune tagged with an `available_since` from after it, and the clients breakdown ranks and buckets from one database state rather than two. The transaction is a deferred read, so it never blocks the query logger or retention. ### Removed +- **The upstream-history subsystem and `GET /api/upstream/health` are gone.** nxdns recorded every upstream exchange into per-minute aggregates in `querylog.db` so the dashboard could show each upstream's counts, success rate and last failure over the selected period. The Overview replacement drops that table, which left a writer whose only reader was its own failure signal, so the whole subsystem goes: the accumulator and its flush task, the `upstream_targets` and `upstream_minute` tables, the `/api/upstream/health` endpoint, the retention pass over the minute rows, and the four `nxdns_upstream_history_*` and one `nxdns_retention_upstream_rows_pruned_total` Prometheus metrics. What replaces it: `/api/health` says how many upstreams are available of how many enabled, `/metrics` keeps the live per-upstream `nxdns_upstream_up` and `nxdns_upstream_success_rate` series, and a failing upstream is a Diagnostics episode (`upstream.exchange`) with its own error text and duration. Ranged per-upstream counts are not replaced. Existing `upstream_history.write` diagnostics entries stay readable; nothing writes new ones, and any that were still open when you upgrade are closed at the first start. - **`/queries`, `/queries/{id}`, `/live` and `/lookup` are gone, and bookmarks to them break.** There is no redirect and no alias: the paths simply stop resolving, and the app shows its not-found page. Everything those pages did is on `/activity`, `/activity/queries/{id}` and `/activity/test`. Three navigation entries collapse into one, "Activity". The API is untouched — `/api/queries`, `/api/queries/{id}`, `/api/queries/live` and `/api/lookup` all answer exactly as before. - **The Status column, and the block reason on every row.** The reason a query was blocked was repeated on each of a hundred rows and pushed the answer the client saw off the table. Result and Route replace it; the exact rule, the blocklist source and the historical group stay one click away on the query's detail page, which is the only place they were ever readable. ### Changed +- **The Dashboard is now Overview, and it takes Pi-hole's layout.** `/` redirects to `/overview`, and the page answers one question — what the resolver did over a period you choose — instead of laying out six widgets. The 1h/24h/7d/30d period is URL state (`/overview?period=1h`), so the view you are reading is a link you can send. Top to bottom: four neutral stat tiles — queries, blocked with its share, distinct clients, average response time — each linking into the rows behind its number; the query-volume timeline split blocked, cached and other; a new per-client chart on the same axis, the busiest clients named — by their registered or reverse-DNS name where they have one, exactly as the query tables name them — and the rest summed as "other"; and two donuts, query types and how queries were answered, with each upstream and forward zone named separately. Colours follow the identity of a client, a type or a route rather than its rank, so one client overtaking another between refreshes does not repaint the page. Each donut is drawn as decoration with a visible legend beside it and a table a screen reader reads instead of the graphic. All five panels describe one window — matched on the period, both bounds and the coverage watermark together — so a refresh that straddles a bucket boundary, or a retention pass mid-page, can never put a headline count above a chart of a different span. Panels load, fail and retry on their own: a failing donut leaves the charts standing. A period with nothing in it says "No queries in this period." rather than drawing an empty frame. +- **The five health conditions moved to Diagnostics, and the nav item says when to look.** Protection, Upstreams, Query history, Diagnostics and Storage are now a compact strip at the top of `/diagnostics`, above the episodes that explain them, instead of a status list on the landing page. Each states its state in words and an icon as well as colour. A healthy condition is quiet; a degraded one is highlighted and links to what can fix it: protection to Blocklists, no reachable upstream to Upstreams, and a losing or failed query log or a low or critical disk to this same page filtered to the component that failed, with any time window cleared so the filter cannot hide the episodes it points at. Dropped rows are reported with the time of the newest drop, so a loss stays visible after the box recovers. When a health poll fails, the conditions on screen are labelled as the last reading that arrived rather than passing for the current state, and a Retry sits beside them. The Diagnostics navigation item carries a badge with the number of open episodes; it shows a plain "!" when the rollup is degraded with nothing open, and also when the last health poll failed, because an unknown must not look like good news. The badge is absent only when health answered and there was nothing to report. +- **Pause moved to the sidebar, and the header indicator is gone.** The header carries nothing but the menu button and Log out. Pause and Resume sit at the foot of the navigation sidebar, above the version label, in both the desktop rail and the phone drawer — one global runtime action in the one place that belongs to the resolver rather than to whichever page you are on. The control still appears beside the detail of a query that was blocked, which is the other place the action answers what you are looking at. The control says what it is doing as well as what it offers: Pause while filtering is on, and while it is off, "Paused until 14:05" — or plain "Paused" when the pause has no end — above the Resume button, on every page. "Resume" on its own would name an action without naming the state it ends, and with the header indicator gone no other page could tell you filtering was off. Both controls read the same `protection` condition, so they cannot disagree, and a pause or resume is reflected immediately rather than at the next poll. Nothing offers to pause while protection is unavailable, since pausing a resolver with no filter snapshot changes nothing. +- **`GET /api/health` changed shape completely.** The body is now `status` plus five condition objects — `protection`, `upstreams`, `query_history`, `diagnostics` and `disk` — and `status` is `degraded` when, and only when, one of them is in a degrading state: protection `unavailable`, upstreams `unavailable`, query history `losing` or `failed`, diagnostics `unavailable`, or disk `low` or `critical`. Nothing can degrade the rollup without appearing in the response any more; the old hidden upstream-history contribution was the reason for the rewrite. A paused protection is reported and does not degrade, because it is a choice you made rather than a fault. `queries_dropped` and `writer_failed` fold into `query_history`, which also carries `last_drop_s`, the time of the newest dropped row; `refreshes_gated` and `snapshot_generation` leave the body and stay in `/metrics`, as do the disk `db_bytes`, `log_bytes` and `sample_failures` fields. The disk monitor's `warn` state is reported as `low`, because `warn` reads as a log level rather than as a quantity of disk. This is a breaking change to a documented endpoint, taken pre-v0.1 rather than carried. +- **Upgrading resets your query history a second time.** Dropping the `upstream_targets` and `upstream_minute` tables changes the `querylog.db` schema fingerprint, and that file is never migrated, so the first start after this release sets the old one aside as `querylog.db.schema-changed-` and creates a fresh one — exactly as the provenance change above does, and in the same start. `config.db` is untouched. +- **`GET /api/stats` no longer reports `cached`.** The standalone cache card is gone from Overview, so the totals field behind it has no consumer. Cache hits stay visible in the query-volume timeline's blocked/cached/other split, in `GET /api/stats/timeseries`, and in `nxdns_cache_hits_total`. - **`GET /api/queries` rows changed shape.** Each row gains `qclass`, `rcode`, `policy_action`, `policy_reason` and `route_kind`, and `block_reason` is gone: the reason a query was blocked is now one of a closed set of values rather than a formatted string. No table column shows it — the reason is read on the query's detail page, and by an API client from `policy_reason` on the row. `blocked`, `cache_hit`, `upstream` and every other existing field are unchanged. - **Upgrading resets your query history.** The `query_log` table gains the provenance columns below, and `querylog.db` is never migrated (it holds expendable log rows, so a schema change replaces the file instead of upgrading it). On the first start after the upgrade the old file is set aside as `querylog.db.schema-changed-` and a fresh one is created. Nothing else is touched: `config.db` keeps your configuration and your diagnostics history. The recreate files a resolved `query_log.recreated` diagnostics entry naming the file that was kept and the timestamp the new history begins at, and a new `querylog_meta` table records that coverage start, so the dashboard can say "history is available from ..." instead of charting an empty range as zero. The set-aside file is a working SQLite database and can be deleted once you have decided you do not want it. - **`logging.query_log_buffer_max` now accepts 1 to 37449, down from 1 to 1000000.** The queued entry carries every new provenance field by value and is about four times as wide as before — 1792 bytes against 432 — so the meaningful bound is bytes rather than entries. The ceiling is computed at compile time from the width of the entry so that the queue's worst case stays within 64 MiB, and it moves whenever that width does. The default of 10000 is unchanged and costs about 17 MiB. A configuration above the new ceiling is rejected at startup with the ceiling in the message. diff --git a/PLAN.md b/PLAN.md index 0fb7ec9..cf9168d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -25,9 +25,9 @@ Serves a household LAN (≈2–20 devices). Portfolio-grade public repo with ext - Domain filtering: blocklists (hosts/domains/ABP, including `@@||name^` exception lines), custom rules (allow/block; exact, parent-walk, wildcard, regex), CNAME uncloaking (depth 8), per-group safe-search rewrite. - DNS caching: positive + negative, in-memory only. - Client/group model: IPv4 + IPv6 parity, per-client group assignment, per-group source assignments. -- Query logging + analytics: async batched writes to SQLite (WAL), retention cleanup, dashboard + time buckets, live SSE stream. +- Query logging + analytics: async batched writes to SQLite (WAL), retention cleanup, Overview + time buckets, live SSE stream. - Web app + REST API: LAN/Tailscale admin UI, optional password auth, OpenAPI schema + CI contract tests. -- Observability: upstream health API + UI, disk monitor with UI banner, bounded log rotation, Prometheus `/metrics`. +- Observability: `/api/health` conditions surfaced as a Diagnostics badge and health strip, live upstream availability in `/metrics`, a failing upstream as a Diagnostics episode, disk monitor with UI banner, bounded log rotation, Prometheus `/metrics`. - Ops: config authority chosen by the invocation (database, or a file named by `--config`), `nxdns export`/`import` (ZON), scheduled + manual blocklist updates, TLS cert watcher + reload, auto-migration on upgrade, systemd service + Dockerfile + compose. ### 2.2 Out of Scope (permanent scope decisions, not deferrals) @@ -325,7 +325,7 @@ Per-group boolean. Rewrites known engine domains to their safe-search CNAME targ - Schemes: `https://…` → DoH, `tls://host:853` → DoT. - Ordered by priority; sequential attempt; per-upstream failure counters; exponential backoff with jitter; success resets. -- `UpstreamHealth` per upstream: last_success_at, last_error_at, last_error_message, rolling success rate, consecutive failures, backoff-until. This is routing state: it drives failover and backoff, and is exposed through `/metrics` and `nxdns check`. `GET /api/upstream/health?period=…` exposes none of it except the live `enabled`/`available` pair; its counts, success rate and last failure are ranged aggregates read from the per-minute upstream history in `querylog.db`, so the dashboard's period scopes them like every other number on the page. +- `UpstreamHealth` per upstream: last_success_at, last_error_at, last_error_message, rolling success rate, consecutive failures, backoff-until. This is routing state: it drives failover and backoff, and is exposed through `/metrics` and `nxdns check`. There is no per-upstream API surface: `/api/health` reports the pool as available-of-enabled, and a failing upstream is a Diagnostics episode (`upstream.exchange`). - DoH client: `std.http.Client` with `content-type/accept: application/dns-message`; strict status + payload checks. - `platform/tls_client.zig` enforces per-connection read/write deadlines, classifies TLS errors explicitly, retries with backoff. Integration tests cover timeout/hang scenarios so compiler upgrades can't silently regress them. - Connect, read, and total-budget timeouts each configurable. @@ -480,24 +480,6 @@ CREATE TABLE query_log ( CREATE INDEX idx_query_log_ts ON query_log(timestamp); CREATE INDEX idx_query_log_client ON query_log(client_ip); CREATE INDEX idx_query_log_domain ON query_log(domain_id); - -CREATE TABLE upstream_targets ( - id INTEGER PRIMARY KEY, - url TEXT NOT NULL UNIQUE -- the historical identity: config.db ids cannot cross database files -); - -CREATE TABLE upstream_minute ( - upstream_id INTEGER NOT NULL REFERENCES upstream_targets(id), - minute_ts INTEGER NOT NULL, - successes INTEGER NOT NULL, - failures INTEGER NOT NULL, - last_failure_ts INTEGER, - last_error TEXT, - PRIMARY KEY (upstream_id, minute_ts), - CHECK (successes >= 0), - CHECK (failures >= 0) -) WITHOUT ROWID; -CREATE INDEX idx_upstream_minute_ts ON upstream_minute(minute_ts); ``` ### 11.4 Query Logger @@ -552,7 +534,7 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables. ### 13.1 Endpoints - `POST /api/auth/login`, `POST /api/auth/logout` -- `GET /api/stats?period=…`, `GET /api/stats/timeseries?period=…` +- `GET /api/stats?period=…`, `GET /api/stats/timeseries?period=…`, `GET /api/stats/types?period=…`, `GET /api/stats/routes?period=…`, `GET /api/stats/clients?period=…` - `GET /api/queries` (filter + paginate), `GET /api/queries/live` (SSE, per-IP cap) - `GET/PUT /api/clients/{id}` - `GET/POST/PUT/DELETE /api/groups…`, `/api/blocklists…`, `/api/rules…`, `/api/local-records…`, `/api/forward-zones…` @@ -560,9 +542,8 @@ Scalars in `settings(key, value)`; ordered/structured items in dedicated tables. - `GET /api/lookup?domain=…&group_id=…` - `GET/POST /api/pause` - `GET/PUT /api/settings` -- `GET /api/upstream/health?period=…` - `POST /api/certs/reload` -- `GET /api/health` — overall + disk + upstream + queries_dropped rollup +- `GET /api/health` — five condition objects (protection, upstreams, query history, diagnostics, disk) and the status computed from exactly their states - `GET /metrics` — Prometheus text exposition: query counters (total/blocked/cached), per-upstream health, cache stats, queries_dropped, disk gauges - `GET /api/version`, `GET /api/openapi.yaml` @@ -574,7 +555,7 @@ Hand-maintained `openapi.yaml`, served at `GET /api/openapi.yaml` and mirrored b ## 14. Frontend -Pages: Dashboard (stats + upstream health + disk), Query log, Live log, Clients, Groups, Blocklists, Rules, Local DNS (records + forward zones), Domain lookup, Settings. +Pages: Overview (stat tiles, queries over time, client activity over time, and query-type and upstream breakdowns, all over one period), Query log, Live log, Clients, Groups, Blocklists, Rules, Local DNS (records + forward zones), Domain lookup, Settings. Requirements: responsive desktop/mobile; route loaders for initial fetch; TanStack Query for cache/retries; error/loading states on every data view; works with auth enabled or disabled; restart-required banner. @@ -632,7 +613,7 @@ systemd unit (`AmbientCapabilities=CAP_NET_BIND_SERVICE`, hardened, writable `/v - **Unit**: DNS encode/decode; rule precedence + wildcard matcher; cache put/get/TTL rewrite; ZON loading + export round-trip; rate limiter; migration runner (fresh + stepwise upgrade). - **Fuzz**: DNS parser malformed-packet fuzzing; blocklist parser fuzzing. - **Integration**: UDP/TCP query path; blocked path; allow-over-block; wildcard precedence; CNAME uncloaking block; local records + forward zones; upstream failover/backoff/health; disk-full degradation; querylog.db corruption recovery; API CRUD; auth on/off; SSE; contract tests. -- **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; dashboard + live log. +- **Manual**: `dig @pi example.com` / blocked domain / local record; DoH/DoT client checks; Overview + live log. --- diff --git a/admin/src/features/activity/ActivityDetailPage.test.tsx b/admin/src/features/activity/ActivityDetailPage.test.tsx index f86d672..b0654c7 100644 --- a/admin/src/features/activity/ActivityDetailPage.test.tsx +++ b/admin/src/features/activity/ActivityDetailPage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { AuthProvider } from "@/auth/store"; @@ -6,6 +6,7 @@ import { createQueryClient } from "@/lib/queryClient"; import { createAppRouter } from "@/routes"; import type { QueryDetail } from "@/lib/types"; import { provenance } from "@/features/queries/provenanceFixture"; +import { health } from "@/lib/healthFixture"; function detail(id: number, sections: Parameters[0] = {}): QueryDetail { return { id, ...provenance(sections) }; @@ -16,6 +17,7 @@ let responses: Record; beforeEach(() => { responses = { "/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, + "/api/health": health(), }; vi.stubGlobal( "fetch", @@ -310,3 +312,43 @@ test("a row retention has pruned explains the 404 and keeps the way back to the domain: "gone", }); }); + +/** + * The related-actions region of a query detail. Scoped on purpose: the sidebar + * carries a Pause of its own, and this is the one that answers "this query was + * blocked and should not have been". + */ +function related(): HTMLElement { + return screen.getByRole("region", { name: "Related" }); +} + +test("a blocked query's Related offers Pause; an allowed one has nothing to pause about", async () => { + responses["/api/queries/50"] = detail(50, { + policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" }, + route: { kind: "blocked", upstream: "" }, + }); + renderDetail(50); + + await screen.findByRole("heading", { name: "example.com" }); + await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy()); + + cleanup(); + responses["/api/queries/51"] = detail(51, { policy: { action: "allow", reason: "no_match", matched: "" } }); + renderDetail(51); + + await screen.findByRole("heading", { name: "example.com" }); + expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull(); +}); + +test("the Pause action stays away while protection is unavailable", async () => { + responses["/api/health"] = health({ protection: { state: "unavailable", until: null } }); + responses["/api/queries/52"] = detail(52, { + policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" }, + route: { kind: "blocked", upstream: "" }, + }); + renderDetail(52); + + await screen.findByRole("heading", { name: "example.com" }); + await waitFor(() => expect(screen.getByText("Diagnostics around this query")).toBeTruthy()); + expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull(); +}); diff --git a/admin/src/features/activity/ActivityDetailPage.tsx b/admin/src/features/activity/ActivityDetailPage.tsx index 817d5dd..3e2fde6 100644 --- a/admin/src/features/activity/ActivityDetailPage.tsx +++ b/admin/src/features/activity/ActivityDetailPage.tsx @@ -68,7 +68,15 @@ export default function ActivityDetailPage() { } + relatedActions={ + + } /> ); diff --git a/admin/src/features/activity/ActivityPage.test.tsx b/admin/src/features/activity/ActivityPage.test.tsx index 2a7af32..e95ae6a 100644 --- a/admin/src/features/activity/ActivityPage.test.tsx +++ b/admin/src/features/activity/ActivityPage.test.tsx @@ -9,6 +9,7 @@ import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { AuthProvider } from "@/auth/store"; import { createQueryClient } from "@/lib/queryClient"; import { createAppRouter } from "@/routes"; +import { health } from "@/lib/healthFixture"; import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types"; import { queryRow } from "@/features/queries/provenanceFixture"; @@ -91,6 +92,8 @@ function stubFetch(handler: (url: string) => Response | Promise) { fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url === "/api/version") return Promise.resolve(json(VERSION)); + // The shell reads health on every route for the Diagnostics nav badge. + if (url === "/api/health") return Promise.resolve(json(health())); return Promise.resolve(handler(url)); }); vi.stubGlobal("fetch", fetchMock); diff --git a/admin/src/features/activity/LiveActivity.test.tsx b/admin/src/features/activity/LiveActivity.test.tsx index 6b0ad56..359e31a 100644 --- a/admin/src/features/activity/LiveActivity.test.tsx +++ b/admin/src/features/activity/LiveActivity.test.tsx @@ -6,7 +6,7 @@ * which subtree the URL mounts, not by a prop a caller could pass. */ -import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { AuthProvider } from "@/auth/store"; @@ -14,6 +14,7 @@ import { createQueryClient } from "@/lib/queryClient"; import { createAppRouter } from "@/routes"; import type { Client } from "@/lib/types"; import { provenance, queryRow } from "@/features/queries/provenanceFixture"; +import { health } from "@/lib/healthFixture"; import { FakeEventSource } from "./fakeEventSource"; function client(ip: string, name: string, learnedName: string): Client { @@ -50,6 +51,8 @@ function stubFetch(handler: (url: string) => Response | Promise = () = const url = String(input); if (url === "/api/version") return Promise.resolve(json(VERSION)); if (url === "/api/clients") return Promise.resolve(json({ clients: CLIENTS })); + // The shell reads health on every route for the Diagnostics nav badge. + if (url === "/api/health") return Promise.resolve(json(health())); return Promise.resolve(handler(url)); }); vi.stubGlobal("fetch", fetchMock); @@ -180,6 +183,8 @@ test("rows stream in as bare IPs while the client list is still loading", async fetchMock = vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url === "/api/version") return Promise.resolve(json(VERSION)); + // The shell reads health on every route for the Diagnostics nav badge. + if (url === "/api/health") return Promise.resolve(json(health())); return new Promise((resolve) => { if (url !== "/api/clients") { resolve(json({})); @@ -397,3 +402,37 @@ test("leaving live closes the stream, and coming back opens exactly one fresh on expect(sources).toHaveLength(2); expect(sources[1]!.closed).toBe(false); }); + +/** + * The related-actions region of a query detail. Scoped on purpose: the sidebar + * carries a Pause of its own, and this is the one that answers "this query was + * blocked and should not have been". + */ +function related(): HTMLElement { + return screen.getByRole("region", { name: "Related" }); +} + +test("a streamed blocked row carries the same Pause action as the persisted detail", async () => { + await openLive(); + act(() => + sources[0]!.emit( + "query", + frame(1000, "streamed.example", { + policy: { action: "block", reason: "blocklist_domain", matched: "streamed.example" }, + route: { kind: "blocked", upstream: "" }, + }), + ), + ); + fireEvent.click(screen.getByRole("button", { name: "streamed.example" })); + + await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy()); +}); + +test("a streamed row that was allowed offers nothing to pause", async () => { + await openLive(); + act(() => sources[0]!.emit("query", frame(1001, "allowed.example", { policy: { action: "allow" } }))); + fireEvent.click(screen.getByRole("button", { name: "allowed.example" })); + + await screen.findByRole("heading", { level: 1, name: "allowed.example" }); + expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull(); +}); diff --git a/admin/src/features/activity/LiveActivity.tsx b/admin/src/features/activity/LiveActivity.tsx index 2f6790a..391d10e 100644 --- a/admin/src/features/activity/LiveActivity.tsx +++ b/admin/src/features/activity/LiveActivity.tsx @@ -268,6 +268,7 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi client={summary.client_ip} ts={summary.ts} origin={origin} + blocked={row.event.policy.action === "block"} /> } /> diff --git a/admin/src/features/activity/ProvenanceDetail.tsx b/admin/src/features/activity/ProvenanceDetail.tsx index 7a0ea76..b4c5a03 100644 --- a/admin/src/features/activity/ProvenanceDetail.tsx +++ b/admin/src/features/activity/ProvenanceDetail.tsx @@ -274,8 +274,13 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio -
-

Related

+ {/* A named region, because the Pause it may offer is not the only Pause + on screen: the sidebar carries one too, and the two answer different + questions. */} +
+

These read the current configuration, which may no longer be the one that decided this query.

@@ -294,7 +299,7 @@ export default function ProvenanceDetail({ provenance, persistedId, relatedActio

)}
{relatedActions}
-
+ ); } diff --git a/admin/src/features/activity/RelatedActions.tsx b/admin/src/features/activity/RelatedActions.tsx index bd04d05..a8ebce1 100644 --- a/admin/src/features/activity/RelatedActions.tsx +++ b/admin/src/features/activity/RelatedActions.tsx @@ -11,6 +11,7 @@ import { Link } from "@tanstack/react-router"; import * as stylex from "@stylexjs/stylex"; +import PauseControl from "@/features/pause/PauseControl"; import { styles as shared } from "@/ui/styles"; import { provenanceRelatedLink } from "./ProvenanceDetail"; import { diagnosticsBounds, relatedBounds } from "./relatedLinks"; @@ -23,9 +24,15 @@ interface Props { ts: number; /** The Activity search the reader came from; its bounds win over the defaults. */ origin: Pick; + /** + * This query was blocked. Pausing is a valid answer to a block the reader + * disagrees with, and to nothing else here — so the control appears for a + * block and not beside an allowed query it could not have caused. + */ + blocked: boolean; } -export default function RelatedActions({ domain, client, ts, origin }: Props) { +export default function RelatedActions({ domain, client, ts, origin, blocked }: Props) { const bounds = relatedBounds(ts, origin); const window = diagnosticsBounds(ts); return ( @@ -54,6 +61,7 @@ export default function RelatedActions({ domain, client, ts, origin }: Props) { > Diagnostics around this query + {blocked && } ); } diff --git a/admin/src/features/dashboard/DashboardPage.test.tsx b/admin/src/features/dashboard/DashboardPage.test.tsx deleted file mode 100644 index 45b3fb3..0000000 --- a/admin/src/features/dashboard/DashboardPage.test.tsx +++ /dev/null @@ -1,252 +0,0 @@ -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"; - -/** Wall clock at import; the upstream fixtures date their failures against it. */ -const NOW_S = Math.floor(Date.now() / 1000); - -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, - coverage: { complete: true, available_since: 0 }, - }, - "/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 }, - ], - coverage: { complete: true, available_since: 0 }, - }, - "/api/stats?period=1h": { - period: "1h", - since: 0, - until: 3600, - queries: 12, - blocked: 3, - cached: 0, - clients: 2, - avg_response_time_us: null, - coverage: { complete: true, available_since: 0 }, - }, - "/api/stats/timeseries?period=1h": { - period: "1h", - since: 0, - until: 3600, - bucket_seconds: 60, - buckets: [], - coverage: { complete: true, available_since: 0 }, - }, - "/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, - diagnostics: { state: "recording", active_warnings: 1, active_errors: 0 }, - }, - "/api/upstream/health?period=24h": { - period: "24h", - since: NOW_S - 86_400, - until: NOW_S, - available: 1, - total: 2, - complete: true, - upstreams: [ - { - url: "https://dns.example/dns-query", - enabled: true, - available: false, - period: { - attempts: 100, - successes: 90, - failures: 10, - success_rate: 0.9, - // 3h30m before the fixture's now, far from a unit boundary. - last_failure_at: NOW_S - 12_600, - last_failure_error: "timeout", - }, - }, - { - url: "udp://9.9.9.9:53", - enabled: true, - available: true, - period: { - attempts: 100, - successes: 100, - failures: 0, - success_rate: 1, - last_failure_at: null, - last_failure_error: null, - }, - }, - ], - }, - "/api/upstream/health?period=1h": { - period: "1h", - since: NOW_S - 3600, - until: NOW_S, - available: 1, - total: 1, - complete: true, - upstreams: [ - { - url: "https://dns.example/dns-query", - enabled: true, - available: true, - period: { - attempts: 7, - successes: 6, - failures: 1, - success_rate: 6 / 7, - last_failure_at: NOW_S - 300, - last_failure_error: "timeout", - }, - }, - ], - }, - "/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, -}; - -// Endpoints forced to fail with a 4xx, which the query client does not retry. -let failing: Set; - -beforeEach(() => { - failing = new Set(); - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (failing.has(url)) { - return new Response(JSON.stringify({ error: "upstream health unavailable" }), { - status: 400, - headers: { "content-type": "application/json" }, - }); - } - 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("Storage now")).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("1/2 available")).toBeTruthy(); -}); - -test("live state is labeled on its own card, not by a section that disowns the picker", async () => { - renderDashboard(); - await screen.findByRole("heading", { name: "Dashboard" }); - - expect(screen.getByText("Storage now")).toBeTruthy(); - expect(screen.queryByRole("region", { name: "Right now" })).toBeNull(); - expect(screen.queryByText("Right now")).toBeNull(); - expect(screen.queryByText("Snapshot state; the period above does not apply.")).toBeNull(); -}); - -test("the period picker rescopes the upstream table", async () => { - renderDashboard(); - await screen.findByRole("heading", { name: "Dashboard" }); - await screen.findByText("90.0%"); - - fireEvent.click(screen.getByRole("button", { name: "1h" })); - - await screen.findByText("85.7%"); - expect(screen.getByRole("columnheader", { name: "Selected period · 1h" })).toBeTruthy(); - expect(screen.queryByText("90.0%")).toBeNull(); -}); - -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(); -}); - -test("one failing endpoint degrades its own widget on cold navigation", async () => { - failing.add("/api/upstream/health?period=24h"); - renderDashboard(); - await screen.findByRole("heading", { name: "Dashboard" }); - - // The page renders; only the upstream widget carries the error. - await screen.findByText("upstream health unavailable"); - expect(screen.queryByText("Something went wrong")).toBeNull(); - expect(screen.queryByText("Request failed (400)")).toBeNull(); - - expect(screen.getByText("1,000")).toBeTruthy(); - expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy(); - expect(screen.getByText("Storage now")).toBeTruthy(); - expect(screen.queryByText("https://dns.example/dns-query")).toBeNull(); -}); diff --git a/admin/src/features/dashboard/DashboardPage.tsx b/admin/src/features/dashboard/DashboardPage.tsx deleted file mode 100644 index 6863fc0..0000000 --- a/admin/src/features/dashboard/DashboardPage.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { useState } from "react"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import * as stylex from "@stylexjs/stylex"; -import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries"; -import type { Period } from "@/lib/types"; -import CoverageNotice from "@/lib/CoverageNotice"; -import InlineError from "@/lib/InlineError"; -import { styles as shared } from "@/ui/styles"; -import { colors } from "@/ui/tokens.stylex"; -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"]; - -const styles = stylex.create({ - page: { - display: "flex", - flexDirection: "column", - gap: "1rem", - }, - titleRow: { - display: "flex", - flexWrap: "wrap", - alignItems: "center", - justifyContent: "space-between", - gap: "0.75rem", - }, - heading: { - fontSize: "1.5rem", - lineHeight: "2rem", - fontWeight: 600, - }, - periodGroup: { - display: "flex", - gap: "0.25rem", - }, - period: { - borderStyle: "none", - borderRadius: "0.25rem", - paddingInline: "0.625rem", - paddingBlock: "0.25rem", - fontSize: "0.875rem", - lineHeight: "1.25rem", - }, - /** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */ - periodSelected: { - backgroundColor: { - default: "oklch(92% 0.004 286.32)", - "@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)", - }, - color: colors.text, - fontWeight: 500, - }, - periodIdle: { - backgroundColor: { default: "transparent", ":hover": colors.surfaceHover }, - color: colors.textSecondary, - }, - /** Dynamic: the caller sizes the placeholder to the widget it stands in for. */ - skeletonHeight: (height: number) => ({ height }), - skeleton: { - borderRadius: "0.25rem", - backgroundColor: { - default: "oklch(92% 0.004 286.32)", - "@media (prefers-color-scheme: dark)": "oklch(27.4% 0.006 286.033)", - }, - }, - /** The chart takes two thirds beside the storage card from `lg`, one column below. */ - panelGrid: { - display: "grid", - gap: "1rem", - gridTemplateColumns: { - default: "repeat(1, minmax(0, 1fr))", - "@media (min-width: 1024px)": "2fr 1fr", - }, - }, - panel: { - borderRadius: "0.25rem", - borderWidth: 1, - borderStyle: "solid", - borderColor: colors.border, - backgroundColor: colors.surfaceRaised, - paddingInline: "1rem", - paddingBlock: "0.75rem", - }, - panelHeading: { - marginBottom: "0.75rem", - fontSize: "0.875rem", - lineHeight: "1.25rem", - fontWeight: 600, - }, -}); - -function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) { - return ( -
- {PERIODS.map((option) => ( - - ))} -
- ); -} - -function Skeleton({ height }: { height: number }) { - return