From fa323c7ed43f66e79b7a5c446d8934bef04317c2 Mon Sep 17 00:00:00 2001 From: m5r Date: Sat, 22 Aug 2026 10:52:56 +0200 Subject: [PATCH] =?UTF-8?q?milestone=2029:=20activity=20=E2=80=94=20histor?= =?UTF-8?q?y,=20live=20and=20policy=20simulation=20on=20one=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit query log, live and lookup merge into /activity. history filters live in the url, so a pasted link or back/forward reproduces the exact view; the result column separates servfail and nxdomain from success in the list. live is follow-by-default with freeze, and a streamed row opens its in-memory provenance detail — no correlation invented for rows sqlite has not written. lookup survives as the current policy simulation under /activity/test. investigation links carry absolute bounds, and the diagnostics page now honors since/until instead of ignoring them. the old routes are gone without aliases. --- CHANGELOG.md | 11 +- admin/src/auth/LoginPage.test.tsx | 4 +- .../ActivityDetailPage.test.tsx} | 97 ++- .../features/activity/ActivityDetailPage.tsx | 75 +++ .../src/features/activity/ActivityFilters.tsx | 230 ++++++++ .../features/activity/ActivityPage.test.tsx | 554 ++++++++++++++++++ admin/src/features/activity/ActivityPage.tsx | 152 +++++ .../src/features/activity/HistoryActivity.tsx | 181 ++++++ .../features/activity/LiveActivity.test.tsx | 399 +++++++++++++ .../LiveActivity.tsx} | 192 +++++- .../features/activity/PolicyTestPage.test.tsx | 178 ++++++ .../PolicyTestPage.tsx} | 20 +- .../ProvenanceDetail.tsx} | 111 ++-- .../src/features/activity/RelatedActions.tsx | 59 ++ admin/src/features/activity/cells.test.tsx | 114 ++++ admin/src/features/activity/cells.tsx | 171 ++++++ admin/src/features/activity/datetime.test.ts | 100 ++++ admin/src/features/activity/datetime.ts | 95 +++ .../{live => activity}/fakeEventSource.ts | 0 admin/src/features/activity/relatedLinks.ts | 42 ++ .../{live => activity}/ringBuffer.test.ts | 2 + .../features/{live => activity}/ringBuffer.ts | 5 +- admin/src/features/activity/search.test.ts | 113 ++++ admin/src/features/activity/search.ts | 88 +++ .../useLiveQueries.test.tsx | 8 +- .../{live => activity}/useLiveQueries.ts | 0 .../diagnostics/DiagnosticsPage.test.tsx | 35 ++ .../features/diagnostics/DiagnosticsPage.tsx | 55 +- admin/src/features/diagnostics/filter.ts | 28 + admin/src/features/live/LiveLogPage.test.tsx | 253 -------- admin/src/features/lookup/LookupPage.test.tsx | 95 --- .../features/queries/QueryLogPage.test.tsx | 452 -------------- admin/src/features/queries/QueryLogPage.tsx | 422 ------------- admin/src/features/queries/provenanceCopy.ts | 18 +- .../src/features/queries/provenanceFixture.ts | 2 +- admin/src/features/queries/querySummary.ts | 7 + admin/src/routes.tsx | 132 +++-- admin/src/shell/AppShell.test.tsx | 4 +- admin/src/shell/AppShell.tsx | 4 +- admin/src/ui/Select.test.tsx | 34 ++ admin/src/ui/Select.tsx | 18 +- specs/milestone-29.md | 91 +++ 42 files changed, 3225 insertions(+), 1426 deletions(-) rename admin/src/features/{queries/QueryDetailPage.test.tsx => activity/ActivityDetailPage.test.tsx} (72%) create mode 100644 admin/src/features/activity/ActivityDetailPage.tsx create mode 100644 admin/src/features/activity/ActivityFilters.tsx create mode 100644 admin/src/features/activity/ActivityPage.test.tsx create mode 100644 admin/src/features/activity/ActivityPage.tsx create mode 100644 admin/src/features/activity/HistoryActivity.tsx create mode 100644 admin/src/features/activity/LiveActivity.test.tsx rename admin/src/features/{live/LiveLogPage.tsx => activity/LiveActivity.tsx} (54%) create mode 100644 admin/src/features/activity/PolicyTestPage.test.tsx rename admin/src/features/{lookup/LookupPage.tsx => activity/PolicyTestPage.tsx} (92%) rename admin/src/features/{queries/QueryDetailPage.tsx => activity/ProvenanceDetail.tsx} (78%) create mode 100644 admin/src/features/activity/RelatedActions.tsx create mode 100644 admin/src/features/activity/cells.test.tsx create mode 100644 admin/src/features/activity/cells.tsx create mode 100644 admin/src/features/activity/datetime.test.ts create mode 100644 admin/src/features/activity/datetime.ts rename admin/src/features/{live => activity}/fakeEventSource.ts (100%) create mode 100644 admin/src/features/activity/relatedLinks.ts rename admin/src/features/{live => activity}/ringBuffer.test.ts (99%) rename admin/src/features/{live => activity}/ringBuffer.ts (96%) create mode 100644 admin/src/features/activity/search.test.ts create mode 100644 admin/src/features/activity/search.ts rename admin/src/features/{live => activity}/useLiveQueries.test.tsx (97%) rename admin/src/features/{live => activity}/useLiveQueries.ts (100%) create mode 100644 admin/src/features/diagnostics/filter.ts delete mode 100644 admin/src/features/live/LiveLogPage.test.tsx delete mode 100644 admin/src/features/lookup/LookupPage.test.tsx delete mode 100644 admin/src/features/queries/QueryLogPage.test.tsx delete mode 100644 admin/src/features/queries/QueryLogPage.tsx create mode 100644 admin/src/ui/Select.test.tsx create mode 100644 specs/milestone-29.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e6e29..7f65961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,19 @@ Query provenance: every logged query becomes exactly explainable — what the po ### Added -- **Every logged query has a detail page.** A row in the query log now links to `/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. +- **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. +### Removed + +- **`/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 -- **`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, and the status column reads it from `policy_reason`. `blocked`, `cache_hit`, `upstream` and every other existing field are unchanged. +- **`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. - **Group and blocklist source names are now capped at 64 bytes.** Both are copied into every query-log row that mentions them, so an unbounded name was an unbounded cost per row. A longer name is rejected as `GroupNameTooLong` or `SourceNameTooLong`. diff --git a/admin/src/auth/LoginPage.test.tsx b/admin/src/auth/LoginPage.test.tsx index 4956049..6f08147 100644 --- a/admin/src/auth/LoginPage.test.tsx +++ b/admin/src/auth/LoginPage.test.tsx @@ -87,8 +87,8 @@ test("429 login shows a ticking countdown and keeps submit disabled until it end 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("/activity")).toBe("/activity"); + expect(safeRedirect("/activity?x=1")).toBe("/activity?x=1"); expect(safeRedirect("//evil.example")).toBe("/"); expect(safeRedirect("https://evil.example")).toBe("/"); expect(safeRedirect("/\\evil.example")).toBe("/"); diff --git a/admin/src/features/queries/QueryDetailPage.test.tsx b/admin/src/features/activity/ActivityDetailPage.test.tsx similarity index 72% rename from admin/src/features/queries/QueryDetailPage.test.tsx rename to admin/src/features/activity/ActivityDetailPage.test.tsx index 11e9f64..f86d672 100644 --- a/admin/src/features/queries/QueryDetailPage.test.tsx +++ b/admin/src/features/activity/ActivityDetailPage.test.tsx @@ -5,7 +5,7 @@ import { AuthProvider } from "@/auth/store"; import { createQueryClient } from "@/lib/queryClient"; import { createAppRouter } from "@/routes"; import type { QueryDetail } from "@/lib/types"; -import { provenance } from "./provenanceFixture"; +import { provenance } from "@/features/queries/provenanceFixture"; function detail(id: number, sections: Parameters[0] = {}): QueryDetail { return { id, ...provenance(sections) }; @@ -37,9 +37,12 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()); -function renderDetail(id: number) { +function renderDetail(id: number, search = "") { const queryClient = createQueryClient(); - const router = createAppRouter(createMemoryHistory({ initialEntries: [`/queries/${id}`] }), queryClient); + const router = createAppRouter( + createMemoryHistory({ initialEntries: [`/activity/queries/${id}${search}`] }), + queryClient, + ); render( @@ -50,6 +53,12 @@ function renderDetail(id: number) { return router; } +/** The search parameters a link carries, so an assertion states them by name. */ +function hrefSearch(link: HTMLElement): Record { + const query = link.getAttribute("href")?.split("?")[1] ?? ""; + return Object.fromEntries(new URLSearchParams(query)); +} + /** The value beside a term, so a section's facts are read as pairs. */ function factValue(label: string): string { const term = screen.getByText(label); @@ -179,21 +188,74 @@ test("a log with hidden domains renders the server's marker, with nothing invent expect(factValue("Client")).toBe("192.0.2.10"); }); -test("the related actions carry the query's own domain and client into the live pages", async () => { - responses["/api/queries/11"] = detail(11, { request: { domain: "shop.example", client: "192.0.2.12" } }); +test("the related actions carry absolute bounds around the query, and the domain into the simulation", async () => { + responses["/api/queries/11"] = detail(11, { + request: { time: 1_700_000_000, domain: "shop.example", client: "192.0.2.12" }, + }); renderDetail(11); await screen.findByRole("heading", { name: "shop.example" }); const related = screen.getByRole("heading", { name: "Related" }).parentElement!; - expect(within(related).getByRole("link", { name: "Look up this domain now" }).getAttribute("href")).toBe( - "/lookup?domain=shop.example", - ); - expect(within(related).getByRole("link", { name: "All queries for this domain" }).getAttribute("href")).toBe( - "/queries?domain=shop.example", - ); - expect(within(related).getByRole("link", { name: "All queries from this client" }).getAttribute("href")).toBe( - "/queries?client=192.0.2.12", - ); + expect( + within(related) + .getByRole("link", { name: /Test this domain/ }) + .getAttribute("href"), + ).toBe("/activity/test?domain=shop.example"); + // No origin bound at all: five minutes either side of the query itself. + expect(hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }))).toEqual({ + mode: "history", + domain: "shop.example", + since: "1699999700", + until: "1700000300", + }); + expect(hrefSearch(within(related).getByRole("link", { name: "All activity from this client" }))).toEqual({ + mode: "history", + client: "192.0.2.12", + since: "1699999700", + until: "1700000300", + }); + // The diagnostics window is the query's own moment, never the origin's. + expect(hrefSearch(within(related).getByRole("link", { name: /Diagnostics around/ }))).toEqual({ + since: "1699999700", + until: "1700000300", + }); +}); + +test("an origin bound wins over the default window, one bound at a time", async () => { + responses["/api/queries/17"] = detail(17, { + request: { time: 1_700_000_000, domain: "shop.example", client: "192.0.2.12" }, + }); + renderDetail(17, "?mode=history&since=1600000000"); + + await screen.findByRole("heading", { name: "shop.example" }); + const related = screen.getByRole("heading", { name: "Related" }).parentElement!; + const link = hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" })); + expect(link["since"]).toBe("1600000000"); + expect(link["until"]).toBe("1700000300"); +}); + +test("both origin bounds carry through untouched", async () => { + responses["/api/queries/18"] = detail(18, { request: { time: 1_700_000_000, domain: "shop.example" } }); + renderDetail(18, "?mode=history&since=1600000000&until=1600000060"); + + await screen.findByRole("heading", { name: "shop.example" }); + const related = screen.getByRole("heading", { name: "Related" }).parentElement!; + const link = hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" })); + expect(link["since"]).toBe("1600000000"); + expect(link["until"]).toBe("1600000060"); +}); + +test("the back link restores the investigation the reader came from", async () => { + responses["/api/queries/19"] = detail(19, { request: { domain: "shop.example" } }); + renderDetail(19, "?mode=history&domain=shop&since=1600000000&blocked=true"); + + await screen.findByRole("heading", { name: "shop.example" }); + expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({ + mode: "history", + domain: "shop", + since: "1600000000", + blocked: "true", + }); }); function clientList(client: { ip: string; name: string; learned_name: string }) { @@ -239,9 +301,12 @@ test("a learned name is told as the reverse-DNS lookup it is, never as a recorde }); test("a row retention has pruned explains the 404 and keeps the way back to the log", async () => { - renderDetail(404); + renderDetail(404, "?mode=history&domain=gone"); await screen.findByRole("alert"); expect(screen.getByText(/no such query/)).toBeTruthy(); - expect(screen.getByRole("link", { name: "← Query log" }).getAttribute("href")).toBe("/queries"); + expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({ + mode: "history", + domain: "gone", + }); }); diff --git a/admin/src/features/activity/ActivityDetailPage.tsx b/admin/src/features/activity/ActivityDetailPage.tsx new file mode 100644 index 0000000..817d5dd --- /dev/null +++ b/admin/src/features/activity/ActivityDetailPage.tsx @@ -0,0 +1,75 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams, useSearch } from "@tanstack/react-router"; +import * as stylex from "@stylexjs/stylex"; +import InlineError from "@/lib/InlineError"; +import { queryDetailQuery } from "@/lib/queries"; +import type { QueryDetail } from "@/lib/types"; +import { styles as shared } from "@/ui/styles"; +import { colors } from "@/ui/tokens.stylex"; +import ProvenanceDetail from "./ProvenanceDetail"; +import RelatedActions from "./RelatedActions"; +import type { ActivitySearch } from "./search"; + +const styles = stylex.create({ + back: { + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.primaryOnSurface, + textDecorationLine: "none", + }, + loading: { + marginTop: "1rem", + color: colors.textMuted, + }, +}); + +/** + * The way back to the investigation, not to a bare list. The originating + * Activity search rides in this route's own search, so the reader returns to + * the mode, the filters and the absolute window they left — a plain `/activity` + * would silently widen the range they had chosen. + */ +function BackLink({ origin }: { origin: ActivitySearch }) { + return ( + + ← Activity + + ); +} + +export default function ActivityDetailPage() { + const { id } = useParams({ from: "/shell/activity/queries/$id" }); + const origin = useSearch({ from: "/shell/activity/queries/$id" }); + const rowId = Number(id); + const { data, error, isPending, refetch } = useQuery(queryDetailQuery(rowId)); + + if (isPending) { + return ( +

+ Loading query… +

+ ); + } + if (data === undefined) { + return ( +
+ + void refetch()} /> +
+ ); + } + + const detail: QueryDetail = data; + const { domain, client, time } = detail.request; + + return ( +
+ + } + /> +
+ ); +} diff --git a/admin/src/features/activity/ActivityFilters.tsx b/admin/src/features/activity/ActivityFilters.tsx new file mode 100644 index 0000000..b6c3f2e --- /dev/null +++ b/admin/src/features/activity/ActivityFilters.tsx @@ -0,0 +1,230 @@ +/** + * The filter row over the Activity table. + * + * The applied state is the URL, never this form: what the reader sees is what + * the link they can paste to a housemate will show. So this holds a draft only, + * and the page remounts it whenever the applied search changes — a back button + * or a pasted URL has to move the form with it, and a form that seeded itself + * once would keep showing the previous investigation's filters. + * + * In live mode the row stays visible and disabled rather than disappearing: the + * filters are retained in the URL and apply again the moment history comes + * back, and hiding them would read as having lost them. The stream itself is + * unfiltered — the server sends every query — so a row that looked usable here + * would promise filtering that is not happening. + */ + +import { useState, type FormEvent } from "react"; +import * as stylex from "@stylexjs/stylex"; +import Select from "@/ui/Select"; +import { styles as shared } from "@/ui/styles"; +import { colors } from "@/ui/tokens.stylex"; +import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime"; +import type { ActivitySearch } from "./search"; + +const STATUS_OPTIONS = [ + { value: "any", label: "All" }, + { value: "blocked", label: "Blocked only" }, + { value: "allowed", label: "Allowed only" }, +]; + +const styles = stylex.create({ + /** One column on a phone, two from `sm`, five from `lg`. */ + grid: { + marginTop: "1rem", + display: "grid", + gap: "0.75rem", + gridTemplateColumns: { + default: "repeat(1, minmax(0, 1fr))", + "@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))", + "@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))", + }, + }, + label: { + display: "block", + fontSize: "0.875rem", + lineHeight: "1.25rem", + }, + input: { + marginTop: "0.25rem", + width: "100%", + // A disabled native input keeps its value legible but reads as inert, + // matching what RAC does to the Select trigger beside it. + cursor: { default: null, ":disabled": "not-allowed" }, + opacity: { default: null, ":disabled": 0.55 }, + }, + buttonRow: { + display: "flex", + alignItems: "flex-end", + gap: "0.5rem", + gridColumn: { + default: null, + "@media (min-width: 640px)": "span 2 / span 2", + "@media (min-width: 1024px)": "span 5 / span 5", + }, + }, + toolbarButton: { + fontWeight: 500, + }, + error: { + marginTop: "0.5rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.dangerText, + }, +}); + +/** The applied filters, with `mode` left to the page that owns the switch. */ +export type AppliedFilters = Omit; + +/** Every filter off — what Clear applies, and the loader's empty-filter case. */ +export const NO_FILTERS: AppliedFilters = { + domain: undefined, + client: undefined, + blocked: undefined, + since: undefined, + until: undefined, +}; + +function blockedOption(blocked: boolean | undefined): string { + if (blocked === undefined) return "any"; + return blocked ? "blocked" : "allowed"; +} + +function optionBlocked(value: string): boolean | undefined { + if (value === "blocked") return true; + return value === "allowed" ? false : undefined; +} + +function boundError(label: string, reason: "unparseable" | "nonexistent"): string { + return reason === "unparseable" + ? `${label} is not a complete date and time.` + : `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`; +} + +interface Props { + applied: AppliedFilters; + isDisabled: boolean; + onApply: (filters: AppliedFilters) => void; + onClear: () => void; +} + +export default function ActivityFilters({ applied, isDisabled, onApply, onClear }: Props) { + const [domain, setDomain] = useState(applied.domain ?? ""); + const [client, setClient] = useState(applied.client ?? ""); + const [blocked, setBlocked] = useState(blockedOption(applied.blocked)); + const [since, setSince] = useState(() => datetimeField(applied.since)); + const [until, setUntil] = useState(() => datetimeField(applied.until)); + const [error, setError] = useState(null); + + function submit(event: FormEvent) { + event.preventDefault(); + const sinceValue = resolveDatetimeField(since); + if (!sinceValue.ok) { + setError(boundError("Since", sinceValue.reason)); + return; + } + const untilValue = resolveDatetimeField(until); + if (!untilValue.ok) { + setError(boundError("Until", untilValue.reason)); + return; + } + setError(null); + onApply({ + domain: domain.trim() === "" ? undefined : domain.trim(), + client: client.trim() === "" ? undefined : client.trim(), + blocked: optionBlocked(blocked), + since: sinceValue.value, + until: untilValue.value, + }); + } + + function clear() { + setDomain(""); + setClient(""); + setBlocked("any"); + setSince(datetimeField(undefined)); + setUntil(datetimeField(undefined)); + setError(null); + onClear(); + } + + return ( + <> +
+ + + setSince(editDatetimeField(since, event.target.value))} + {...stylex.props(shared.smallInput, styles.input, shared.focusRing)} + /> + + +
+ + +
+
+ {error !== null && ( +

+ {error} +

+ )} + + ); +} diff --git a/admin/src/features/activity/ActivityPage.test.tsx b/admin/src/features/activity/ActivityPage.test.tsx new file mode 100644 index 0000000..2a7af32 --- /dev/null +++ b/admin/src/features/activity/ActivityPage.test.tsx @@ -0,0 +1,554 @@ +/** + * Activity in history mode, through the real router: the URL is the applied + * state, so nothing here can be checked by rendering the page on its own. + */ + +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"; +import { createQueryClient } from "@/lib/queryClient"; +import { createAppRouter } from "@/routes"; +import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types"; +import { queryRow } from "@/features/queries/provenanceFixture"; + +function client(id: number, ip: string, name: string, learnedName: string): Client { + return { + id, + ip, + name, + learned_name: learnedName, + group_id: 1, + group: "default", + hand_edited: name !== "", + first_seen: 1_700_000_000, + last_seen: 1_700_000_100, + }; +} + +const CLIENTS: Client[] = [ + client(1, "192.0.2.10", "Kitchen Pi", "pi.lan"), + client(2, "192.0.2.11", "", "laptop.lan"), + client(3, "192.0.2.12", "", ""), +]; + +function row(id: number, domain: string, overrides: Partial = {}): QueryRow { + return queryRow(id, { ts: 1_700_000_000 + id, domain, upstream: "udp://9.9.9.9:53", ...overrides }); +} + +const COMPLETE: Coverage = { complete: true, available_since: 1_600_000_000 }; + +/** The blocked row every page fixture reuses. */ +const BLOCKED = { + blocked: true, + policy_action: "block", + policy_reason: "blocklist_wildcard", + route_kind: "blocked", + upstream: "", +} as const satisfies Partial; + +const PAGES: Record = { + "/api/queries": { + queries: [ + row(20, "first.example", { qtype: 65, cache_hit: true, route_kind: "cache" }), + row(19, "ads.example", { ...BLOCKED, response_time_us: null, cache_hit: null }), + ], + next_before: 19, + coverage: COMPLETE, + }, + "/api/queries?before=19": { + queries: [row(5, "older.example")], + next_before: null, + coverage: COMPLETE, + }, + "/api/queries?domain=ads": { + queries: [row(19, "ads.example", BLOCKED)], + next_before: null, + coverage: COMPLETE, + }, + "/api/queries?domain=ads&blocked=true": { + queries: [row(19, "ads.example", BLOCKED)], + next_before: null, + coverage: COMPLETE, + }, + "/api/queries?since=1700000000": { + queries: [row(20, "first.example")], + next_before: null, + coverage: COMPLETE, + }, +}; + +let fetchMock: ReturnType; + +function json(payload: unknown): Response { + return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } }); +} + +const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }; + +/** The shell's own requests, which every test serves the same way. */ +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)); + return Promise.resolve(handler(url)); + }); + vi.stubGlobal("fetch", fetchMock); +} + +function fromPages(url: string): Response { + if (url === "/api/clients") return json({ clients: CLIENTS }); + const payload = PAGES[url]; + if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); + return json(payload); +} + +beforeEach(() => { + stubFetch(fromPages); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function renderPage(path = "/activity") { + const queryClient = createQueryClient(); + const history = createMemoryHistory({ initialEntries: [path] }); + const router = createAppRouter(history, queryClient); + render( + + + + + , + ); + return { queryClient, history }; +} + +/** Every `/api/queries` URL the run asked for, list pages only. */ +function queryCalls(): string[] { + return fetchMock.mock.calls + .map((call) => String(call[0])) + .filter((url) => url === "/api/queries" || url.startsWith("/api/queries?")); +} + +test("renders the first page with the seven columns filled in", async () => { + renderPage(); + await screen.findByText("first.example"); + + expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([ + "Time", + "Domain", + "Client", + "Type", + "Result", + "Route", + "Duration", + ]); + const first = screen.getByText("first.example").closest("tr")!; + expect(within(first).getByText("HTTPS")).toBeTruthy(); + expect(within(first).getByText("NOERROR")).toBeTruthy(); + expect(within(first).getByText("Cache")).toBeTruthy(); + expect(within(first).getByText("1.2 ms")).toBeTruthy(); + + const blocked = screen.getByText("ads.example").closest("tr")!; + // The Result cell says Blocked even though the client saw NOERROR, and the + // Route cell says how: this is the pair the old Status column could not show. + expect(within(blocked).getAllByText("Blocked")).toHaveLength(2); + expect(within(blocked).getByText("—")).toBeTruthy(); + expect(screen.getByText(/Showing 2 queries/)).toBeTruthy(); +}); + +test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => { + stubFetch((url) => { + if (url === "/api/clients") return json({ clients: CLIENTS }); + if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); + return json({ + queries: [ + row(20, "named.example", { client_ip: "192.0.2.10" }), + row(19, "learned.example", { client_ip: "192.0.2.11" }), + row(18, "nameless.example", { client_ip: "192.0.2.12" }), + row(17, "stranger.example", { client_ip: "192.0.2.99" }), + ], + next_before: null, + coverage: COMPLETE, + } satisfies QueriesPage); + }); + + renderPage(); + + // A hand-typed name wins outright; the learned name never surfaces for it. + const named = await screen.findByText("Kitchen Pi"); + expect(named.getAttribute("title")).toBe("192.0.2.10"); + expect(screen.queryByText("pi.lan")).toBeNull(); + + // A learned name reads muted and nothing more here: the "learned" tag would + // repeat on every row of the table, so the Clients page carries it instead. + const learned = screen.getByText("laptop.lan"); + expect(learned.getAttribute("title")).toBe("192.0.2.11"); + expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull(); + + // A known client with neither name, and a client the loaded list has never + // seen, both fall back to the bare address with no tooltip standing in. + expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull(); + expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull(); +}); + +test("load more appends the next page and stops at the end of the log", async () => { + renderPage(); + await screen.findByText("first.example"); + + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + await screen.findByText("older.example"); + + expect(screen.getByText("first.example")).toBeTruthy(); + expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Load more" })).toBeNull(); +}); + +test("applying a filter puts it in the url, refetches, and resets the accumulated list", async () => { + const { history } = renderPage(); + await screen.findByText("first.example"); + + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + await screen.findByText("older.example"); + + fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } }); + fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); + + await screen.findByText(/Showing 1 query /); + expect(history.location.search).toContain("domain=ads"); + expect(screen.getByText("ads.example")).toBeTruthy(); + expect(screen.queryByText("first.example")).toBeNull(); + expect(screen.queryByText("older.example")).toBeNull(); +}); + +test("a load-more that resolves after a filter change is discarded", async () => { + let releaseLoadMore: () => void = () => {}; + stubFetch((url) => { + if (url === "/api/queries?before=19") { + return new Promise((resolve) => { + releaseLoadMore = () => resolve(json(PAGES["/api/queries?before=19"])); + }); + } + return fromPages(url); + }); + + renderPage(); + await screen.findByText("first.example"); + + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + + fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } }); + fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); + await screen.findByText(/Showing 1 query /); + + releaseLoadMore(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(screen.queryByText("older.example")).toBeNull(); + expect(screen.getByText(/Showing 1 query /)).toBeTruthy(); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +test("load more is disabled while a filter change shows placeholder data, then uses the fresh cursor", async () => { + let releaseFiltered: () => void = () => {}; + const filteredPage: QueriesPage = { + queries: [row(19, "ads.example", BLOCKED)], + next_before: 7, + coverage: COMPLETE, + }; + const filteredOlderPage: QueriesPage = { + queries: [row(3, "ads.older.example")], + next_before: null, + coverage: COMPLETE, + }; + stubFetch((url) => { + if (url === "/api/queries?domain=ads") { + return new Promise((resolve) => { + releaseFiltered = () => resolve(json(filteredPage)); + }); + } + if (url === "/api/queries?domain=ads&before=7") return json(filteredOlderPage); + return fromPages(url); + }); + + renderPage(); + await screen.findByText("first.example"); + + fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } }); + fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); + + const staleButton = await screen.findByRole("button", { name: "Load more" }); + expect(staleButton).toHaveProperty("disabled", true); + fireEvent.click(staleButton); + expect(queryCalls()).not.toContain("/api/queries?domain=ads&before=19"); + + releaseFiltered(); + await waitFor(() => { + expect(screen.queryByText("first.example")).toBeNull(); + }); + + const freshButton = screen.getByRole("button", { name: "Load more" }); + expect(freshButton).toHaveProperty("disabled", false); + fireEvent.click(freshButton); + await screen.findByText("ads.older.example"); + + expect(queryCalls()).toContain("/api/queries?domain=ads&before=7"); + expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy(); +}); + +test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => { + // The newest-100 window moves up while the reader has a second page open. + // Refetching only the first page would drop n20 and n19 out of the middle + // of the table; the second page must be replayed from the fresh cursor. + const before: Record = { + "/api/queries": { + queries: [row(20, "n20.example"), row(19, "n19.example")], + next_before: 19, + coverage: COMPLETE, + }, + "/api/queries?before=19": { + queries: [row(18, "n18.example"), row(17, "n17.example")], + next_before: null, + coverage: COMPLETE, + }, + }; + const after: Record = { + "/api/queries": { + queries: [row(22, "n22.example"), row(21, "n21.example")], + next_before: 21, + coverage: COMPLETE, + }, + "/api/queries?before=21": { + queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")], + next_before: null, + coverage: COMPLETE, + }, + }; + let live = before; + stubFetch((url) => { + const payload = live[url]; + if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); + return json(payload); + }); + + const { queryClient } = renderPage(); + await screen.findByText("n20.example"); + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + await screen.findByText("n17.example"); + + live = after; + await act(async () => { + await queryClient.invalidateQueries({ queryKey: ["queries"] }); + }); + + await screen.findByText("n22.example"); + const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent); + expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]); + expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy(); +}); + +test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => { + const assign = vi.fn(); + vi.stubGlobal("location", { pathname: "/activity", search: "", assign }); + stubFetch((url) => { + if (url === "/api/queries?before=19") { + return new Response(JSON.stringify({ error: "unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + return fromPages(url); + }); + + renderPage(); + await screen.findByText("first.example"); + + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + await waitFor(() => { + expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/activity")}`); + }); + + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByText(/Failed to load more/)).toBeNull(); +}); + +test("each row links into its own detail page, carrying the investigation with it", async () => { + renderPage("/activity?mode=history&since=1700000000"); + await screen.findByText("first.example"); + + const link = screen.getByRole("link", { name: "first.example" }); + expect(link.getAttribute("href")).toContain("/activity/queries/20"); + expect(link.getAttribute("href")).toContain("since=1700000000"); + // An is in the tab order by default; nothing here may opt it out. + expect(link.getAttribute("tabindex")).toBeNull(); +}); + +test("a pruned window tells the reader when history starts", async () => { + stubFetch((url) => { + if (url === "/api/clients") return json({ clients: CLIENTS }); + if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); + return json({ + queries: [row(20, "kept.example")], + next_before: null, + coverage: { complete: false, available_since: 1_700_000_000 }, + } satisfies QueriesPage); + }); + renderPage(); + + await screen.findByText("kept.example"); + expect(screen.getByText(/Query history is available from/)).toBeTruthy(); +}); + +test("a complete window shows no coverage notice", async () => { + renderPage(); + await screen.findByText("first.example"); + expect(screen.queryByText(/Query history is available from/)).toBeNull(); +}); + +test("a ?domain= link seeds the filter form and fetches that domain on arrival", async () => { + renderPage("/activity?domain=ads"); + + await screen.findByText("ads.example"); + expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads"); + expect(screen.queryByText("first.example")).toBeNull(); +}); + +test("history forwards exactly the six normalized filter fields and nothing else", async () => { + stubFetch((url) => { + if (url === "/api/clients") return json({ clients: CLIENTS }); + return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage); + }); + renderPage( + "/activity?mode=history&domain=%20ads%20&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600&bogus=1&limit=9999", + ); + + await screen.findByText("No queries match the current filters."); + expect(queryCalls()).toEqual([ + "/api/queries?domain=ads&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600", + ]); +}); + +test("a rejected search parameter is dropped rather than guessed at", async () => { + renderPage("/activity?since=1.5&blocked=%22true%22&domain=%20%20"); + + await screen.findByText("first.example"); + // Nothing survived validation, so the request is the unfiltered one. + expect(queryCalls()).toEqual(["/api/queries"]); + expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", ""); +}); + +test("the form draft follows the url back and forward, seconds included", async () => { + stubFetch((url) => { + if (url === "/api/clients") return json({ clients: CLIENTS }); + return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage); + }); + // A bound with non-zero seconds: the round trip has to keep them, and the + // untouched field has to carry the original number rather than re-parse. + const seeded = 1_700_000_017; + const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`); + + const domainInput = await screen.findByLabelText("Domain contains"); + expect(domainInput).toHaveProperty("value", "first"); + const sinceInput = screen.getByLabelText("Since") as HTMLInputElement; + expect(sinceInput.value).toContain(":37"); + + fireEvent.change(domainInput, { target: { value: "second" } }); + fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); + await waitFor(() => expect(history.location.search).toContain("domain=second")); + // The untouched Since bound applied as the exact second it was seeded with. + expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`); + + act(() => history.back()); + await waitFor(() => { + expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first"); + }); + + act(() => history.forward()); + await waitFor(() => { + expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second"); + }); +}); + +/** 2 a.m. on the EU spring-forward date: an hour that exists in some zones and not others. */ +const DST_WALL_TIME = "2026-03-29T02:30:00"; + +/** + * Whether that wall time names an instant in the timezone the suite runs in. + * `new Date` slides a spring-forward gap silently forward, so an hour or minute + * that comes back different from the one written *is* the gap. + */ +function wallTimeExists(text: string): boolean { + const written = /T(\d{2}):(\d{2})/.exec(text)!; + const parsed = new Date(text); + return parsed.getHours() === Number(written[1]) && parsed.getMinutes() === Number(written[2]); +} + +test("a wall-clock time the daylight-saving jump skips is refused, not silently moved", async () => { + // One expected outcome per timezone, decided here rather than accepted from + // the page: in a zone with the jump the bound must be refused outright, and + // in a zone without it the same text is an ordinary instant that applies. + const inGap = !wallTimeExists(DST_WALL_TIME); + const unix = Math.floor(new Date(DST_WALL_TIME).getTime() / 1000); + stubFetch((url) => { + if (url === `/api/queries?since=${unix}`) { + return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage); + } + return fromPages(url); + }); + + const { history } = renderPage(); + await screen.findByText("first.example"); + const callsBefore = queryCalls().length; + const searchBefore = history.location.search; + + fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } }); + fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); + + if (inGap) { + expect(screen.getByRole("alert").textContent).toContain("daylight saving"); + // Refused means refused: no navigation, and no request for the hour the + // operator did not ask for. + expect(history.location.search).toBe(searchBefore); + expect(queryCalls()).toHaveLength(callsBefore); + return; + } + + await waitFor(() => expect(history.location.search).toContain(`since=${unix}`)); + expect(queryCalls()).toContain(`/api/queries?since=${unix}`); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +test("the policy simulation is reachable from the header, with no rows to click through", async () => { + stubFetch((url) => { + if (url === "/api/clients") return json({ clients: CLIENTS }); + if (url === "/api/groups") return json({ groups: [{ id: 1, name: "default", safe_search: false }] }); + return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage); + }); + + const { history } = renderPage(); + // An empty log is exactly the case a row-borne link cannot serve. + await screen.findByText("No queries logged yet."); + + const link = screen.getByRole("link", { name: "Current policy simulation" }); + expect(link.getAttribute("href")).toBe("/activity/test"); + + fireEvent.click(link); + await screen.findByRole("heading", { level: 1, name: "Current policy simulation" }); + expect(history.location.pathname).toBe("/activity/test"); +}); + +test("Clear empties the url as well as the form", async () => { + const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true"); + await screen.findByText("ads.example"); + + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + await waitFor(() => { + expect(history.location.search).not.toContain("domain"); + }); + expect(history.location.search).not.toContain("blocked"); + expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", ""); +}); diff --git a/admin/src/features/activity/ActivityPage.tsx b/admin/src/features/activity/ActivityPage.tsx new file mode 100644 index 0000000..f4dbd07 --- /dev/null +++ b/admin/src/features/activity/ActivityPage.tsx @@ -0,0 +1,152 @@ +/** + * Activity: one surface over the queries nxdns answered, in two modes. + * + * History reads the persisted log and Live reads the stream, but they are the + * same seven columns over the same filters, and the reader moves between them + * without losing the question they were asking. The mode lives in the URL with + * the filters, so an investigation is one link — including which half of it the + * recipient should be looking at. + */ + +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; +import * as stylex from "@stylexjs/stylex"; +import { styles as shared } from "@/ui/styles"; +import { colors } from "@/ui/tokens.stylex"; +import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters"; +import HistoryActivity from "./HistoryActivity"; +import LiveActivity from "./LiveActivity"; +import type { ActivityMode } from "./search"; + +const MODES: ReadonlyArray<{ mode: ActivityMode; label: string }> = [ + { mode: "history", label: "History" }, + { mode: "live", label: "Live" }, +]; + +const styles = stylex.create({ + header: { + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: "0.75rem", + }, + heading: { + fontSize: "1.5rem", + lineHeight: "2rem", + fontWeight: 600, + }, + switch: { + display: "flex", + gap: "0.25rem", + borderRadius: "0.25rem", + borderWidth: 1, + borderStyle: "solid", + borderColor: colors.border, + padding: "0.125rem", + }, + modeButton: { + borderStyle: "none", + borderRadius: "0.1875rem", + paddingInline: "0.75rem", + paddingBlock: "0.25rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + fontWeight: 500, + cursor: "pointer", + }, + modeIdle: { + backgroundColor: { default: "transparent", ":hover": colors.surfaceHover }, + color: { default: colors.textSecondary, ":hover": colors.text }, + }, + /** The selected mode reads as a filled chip, the same weight the nav uses. */ + modeSelected: { + backgroundColor: colors.primary, + color: colors.primaryText, + }, + /** The one way into the simulation from here, so it cannot sit behind a row. */ + simulationLink: { + marginInlineStart: "auto", + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.primaryOnSurface, + textDecorationLine: "none", + }, + liveNote: { + marginTop: "0.5rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.textMuted, + }, +}); + +export default function ActivityPage() { + const search = useSearch({ from: "/shell/activity" }); + const navigate = useNavigate({ from: "/activity" }); + const live = search.mode === "live"; + + // The functional form, not a replacement object: the filters are retained + // across a mode switch on purpose, and spelling out a new search here would + // drop every one of them on the way to Live and back. + function selectMode(mode: ActivityMode) { + if (mode === search.mode) return; + void navigate({ search: (prev) => ({ ...prev, mode }) }); + } + + function apply(filters: AppliedFilters) { + void navigate({ search: { mode: search.mode, ...filters } }); + } + + return ( +
+
+

Activity

+
+ {MODES.map((option) => { + const selected = option.mode === search.mode; + return ( + + ); + })} +
+ + Current policy simulation + +
+ + {/* + * Remounted whenever the applied search changes, which is what makes + * the back button work: the draft is derived state, and the browser + * moving the URL under it has to move the form with it. + */} + apply(NO_FILTERS)} + /> + + {live ? ( + <> +

+ The stream carries every query the server answers; these filters apply to history only. +

+ + + ) : ( + + )} +
+ ); +} diff --git a/admin/src/features/activity/HistoryActivity.tsx b/admin/src/features/activity/HistoryActivity.tsx new file mode 100644 index 0000000..c53fe7e --- /dev/null +++ b/admin/src/features/activity/HistoryActivity.tsx @@ -0,0 +1,181 @@ +/** + * Activity in history mode: the persisted queries the URL's filters select, + * paged by keyset cursor. + * + * The filters arrive already applied — the URL is the applied state — so this + * only reads them. Everything about how the reader got here lives one level up. + */ + +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import * as stylex from "@stylexjs/stylex"; +import * as api from "@/lib/api"; +import CoverageNotice from "@/lib/CoverageNotice"; +import InlineError from "@/lib/InlineError"; +import { queriesInfiniteQuery } from "@/lib/queries"; +import type { QueryRow } from "@/lib/types"; +import { useClientNames } from "@/features/clients/clientNames"; +import { summarizeRow } from "@/features/queries/querySummary"; +import { styles as shared } from "@/ui/styles"; +import { colors } from "@/ui/tokens.stylex"; +import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells"; +import { queriesFilterOf, type ActivitySearch } from "./search"; + +const styles = stylex.create({ + empty: { + marginTop: "1.5rem", + color: colors.textMuted, + }, + tableWrap: { + marginTop: "1rem", + overflowX: "auto", + borderRadius: "0.25rem", + borderWidth: 1, + borderStyle: "solid", + borderColor: colors.border, + }, + table: { + width: "100%", + fontSize: "0.875rem", + lineHeight: "1.25rem", + }, + /** `divide-y`: a hairline between rows, so the first row carries none. */ + row: { + borderTopWidth: { default: 1, ":first-child": 0 }, + borderTopStyle: "solid", + borderTopColor: colors.border, + }, + footer: { + marginTop: "0.75rem", + display: "flex", + alignItems: "center", + gap: "0.75rem", + }, + note: { + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.textMuted, + }, + refetching: { + marginTop: "0.75rem", + }, + moreButton: { + fontWeight: 500, + }, + moreError: { + marginTop: "0.5rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.dangerText, + }, +}); + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export default function HistoryActivity({ search }: { search: ActivitySearch }) { + const filter = queriesFilterOf(search); + const base = useInfiniteQuery(queriesInfiniteQuery(filter)); + const clientNames = useClientNames(); + + const pages = base.data?.pages ?? []; + const rows: QueryRow[] = pages.flatMap((page) => page.queries); + const coverage = pages[0]?.coverage; + const filterActive = Object.keys(filter).length > 0; + // `base.hasNextPage` reads the query state, which is empty while placeholder + // data stands in for a filter change; derive the cursor from what is on + // screen so the button keeps its place instead of flashing "end of log". + const lastPage = pages[pages.length - 1]; + const hasMore = lastPage !== undefined && lastPage.next_before !== null; + // A 401 is already redirecting via the cache-level handleUnauthorized. + const isUnauthorized = base.error instanceof api.ApiError && base.error.status === 401; + const moreError = base.isFetchNextPageError && !isUnauthorized ? errorMessage(base.error) : null; + + function loadMore() { + if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return; + void base.fetchNextPage(); + } + + // The loader starts this fetch but does not wait for it, so both the first + // paint and a failed first page are this component's to render. + if (base.status === "error" && base.data === undefined) { + return void base.refetch()} />; + } + if (base.data === undefined) { + return ( +

+ Loading activity… +

+ ); + } + + return ( + <> + {base.isFetching && ( +

+ Loading… +

+ )} + {coverage !== undefined && } + {rows.length === 0 ? ( +

+ {filterActive ? "No queries match the current filters." : "No queries logged yet."} +

+ ) : ( + <> +
+ + + + {rows.map((row) => ( + + + id === null ? ( + children + ) : ( + + {children} + + ) + } + /> + + ))} + +
+
+
+

+ Showing {rows.length} {rows.length === 1 ? "query" : "queries"} + {hasMore ? "" : " — end of log"} +

+ {hasMore && ( + + )} +
+ {moreError !== null && ( +

+ Failed to load more: {moreError} +

+ )} + + )} + + ); +} diff --git a/admin/src/features/activity/LiveActivity.test.tsx b/admin/src/features/activity/LiveActivity.test.tsx new file mode 100644 index 0000000..6b0ad56 --- /dev/null +++ b/admin/src/features/activity/LiveActivity.test.tsx @@ -0,0 +1,399 @@ +/** + * Activity in live mode, through the real router. + * + * The EventSource is a global here rather than an injected factory: whether the + * connection exists at all is the thing under test, and that is decided by + * which subtree the URL mounts, not by a prop a caller could pass. + */ + +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { AuthProvider } from "@/auth/store"; +import { createQueryClient } from "@/lib/queryClient"; +import { createAppRouter } from "@/routes"; +import type { Client } from "@/lib/types"; +import { provenance, queryRow } from "@/features/queries/provenanceFixture"; +import { FakeEventSource } from "./fakeEventSource"; + +function client(ip: string, name: string, learnedName: string): Client { + return { + id: Number(ip.split(".").pop()), + ip, + name, + learned_name: learnedName, + group_id: 1, + group: "default", + hand_edited: name !== "", + first_seen: 1_700_000_000, + last_seen: 1_700_000_100, + }; +} + +const CLIENTS: Client[] = [ + client("192.0.2.10", "Kitchen Pi", "pi.lan"), + client("192.0.2.11", "", "laptop.lan"), + client("192.0.2.12", "", ""), +]; + +const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }; + +let sources: FakeEventSource[]; +let fetchMock: ReturnType; + +function json(payload: unknown): Response { + return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } }); +} + +function stubFetch(handler: (url: string) => Response | Promise = () => json({})) { + fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/version") return Promise.resolve(json(VERSION)); + if (url === "/api/clients") return Promise.resolve(json({ clients: CLIENTS })); + return Promise.resolve(handler(url)); + }); + vi.stubGlobal("fetch", fetchMock); +} + +beforeEach(() => { + sources = []; + vi.stubGlobal( + "EventSource", + class { + constructor(url: string) { + const source = new FakeEventSource(url); + sources.push(source); + return source as unknown as EventSource; + } + }, + ); + stubFetch(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function frame(ts: number, domain: string, sections: Parameters[0] = {}): { data: string } { + return { + data: JSON.stringify( + provenance({ + ...sections, + request: { time: ts, domain, ...sections.request }, + route: { kind: "cache", upstream: "", ...sections.route }, + }), + ), + }; +} + +function renderPage(path = "/activity?mode=live") { + const queryClient = createQueryClient(); + const history = createMemoryHistory({ initialEntries: [path] }); + const router = createAppRouter(history, queryClient); + render( + + + + + , + ); + return { history }; +} + +async function openLive(path?: string) { + const rendered = renderPage(path); + await screen.findByRole("button", { name: "Freeze" }); + act(() => sources[0]!.emit("open")); + return rendered; +} + +function queryCalls(): string[] { + return fetchMock.mock.calls + .map((call) => String(call[0])) + .filter((url) => url === "/api/queries" || url.startsWith("/api/queries?")); +} + +test("streams rows, flags blocked ones, and freezes the display", async () => { + await openLive(); + expect(screen.getByRole("status", { name: "Live" })).toBeTruthy(); + expect(screen.getByText("Waiting for queries…")).toBeTruthy(); + + act(() => { + sources[0]!.emit("query", frame(1000, "ok.example")); + sources[0]!.emit( + "query", + frame(1001, "ads.example", { + request: { qtype: 28 }, + policy: { action: "block", reason: "blocklist_wildcard" }, + route: { kind: "blocked" }, + }), + ); + }); + + expect(screen.getByText("ok.example")).toBeTruthy(); + expect(screen.getByText("AAAA")).toBeTruthy(); + const blockedRow = screen.getByText("ads.example").closest("tr")!; + expect(within(blockedRow).getAllByText("Blocked")).toHaveLength(2); + // StyleX compiles to opaque class names, so the check is structural: a blocked + // row carries every class a plain row does, plus the ones the flag adds. + const plainRow = screen.getByText("ok.example").closest("tr")!; + const blockedClasses = new Set(blockedRow.className.split(" ")); + const plainClasses = plainRow.className.split(" "); + expect(plainClasses.every((name) => blockedClasses.has(name))).toBe(true); + expect(blockedClasses.size).toBeGreaterThan(plainClasses.length); + + const freeze = screen.getByRole("button", { name: "Freeze" }); + fireEvent.click(freeze); + expect(freeze.getAttribute("aria-pressed")).toBe("true"); + + act(() => sources[0]!.emit("query", frame(1002, "later.example"))); + expect(screen.queryByText("later.example")).toBeNull(); + expect(screen.getByText(/3 in buffer/)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Resume" })); + expect(screen.getByText("later.example")).toBeTruthy(); +}); + +test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => { + await openLive(); + act(() => { + sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } })); + sources[0]!.emit("query", frame(1001, "learned.example", { request: { client: "192.0.2.11" } })); + sources[0]!.emit("query", frame(1002, "nameless.example", { request: { client: "192.0.2.12" } })); + sources[0]!.emit("query", frame(1003, "stranger.example", { request: { client: "192.0.2.99" } })); + }); + + const named = await screen.findByText("Kitchen Pi"); + expect(named.getAttribute("title")).toBe("192.0.2.10"); + expect(screen.queryByText("pi.lan")).toBeNull(); + + const learned = screen.getByText("laptop.lan"); + expect(learned.getAttribute("title")).toBe("192.0.2.11"); + expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull(); + + expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull(); + expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull(); +}); + +test("rows stream in as bare IPs while the client list is still loading", async () => { + let releaseClients: () => void = () => {}; + fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/version") return Promise.resolve(json(VERSION)); + return new Promise((resolve) => { + if (url !== "/api/clients") { + resolve(json({})); + return; + } + releaseClients = () => resolve(json({ clients: CLIENTS })); + }); + }); + vi.stubGlobal("fetch", fetchMock); + + await openLive(); + act(() => sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }))); + + expect(screen.getByText("192.0.2.10")).toBeTruthy(); + expect(screen.queryByText("Kitchen Pi")).toBeNull(); + + releaseClients(); + expect(await screen.findByText("Kitchen Pi")).toBeTruthy(); +}); + +test("repeated connection failures show the viewer-cap state with a retry button", async () => { + renderPage(); + await screen.findByRole("button", { name: "Freeze" }); + act(() => { + sources[0]!.emit("error"); + sources[0]!.emit("error"); + sources[0]!.emit("error"); + }); + expect(screen.getByRole("alert").textContent).toContain("too many live viewers"); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(sources).toHaveLength(2); + expect(screen.getByText("Connecting…")).toBeTruthy(); +}); + +test("a recovered row links to its stored detail; a streamed one opens in place instead", async () => { + stubFetch((url) => { + if (url.startsWith("/api/queries?")) { + return json({ + queries: [queryRow(88, { ts: 1001, domain: "recovered.example" })], + next_before: null, + coverage: { complete: true, available_since: 0 }, + }); + } + return json({}); + }); + + await openLive(); + act(() => sources[0]!.emit("query", frame(1000, "streamed.example"))); + act(() => sources[0]!.emit("error")); + act(() => sources[0]!.emit("open")); + + const recovered = await screen.findByRole("link", { name: "recovered.example" }); + expect(recovered.getAttribute("href")).toContain("/activity/queries/88"); + // The streamed frame precedes its own insert, so it has no row to link to — + // but it does carry its own provenance, so it still has a detail. + expect(screen.queryByRole("link", { name: "streamed.example" })).toBeNull(); + expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy(); +}); + +test("a streamed row opens its own provenance, from the keyboard as well as the pointer", 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: "" }, + }), + ), + ); + + const trigger = screen.getByRole("button", { name: "streamed.example" }); + // A real + + + } + /> + + ); +} + +export default function LiveActivity({ origin }: { origin: ActivitySearch }) { + const live = useLiveQueries(); + const clientNames = useClientNames(); + const [selected, setSelected] = useState(null); + const trigger = useRef(null); + + function open(row: StreamedRow, from: HTMLButtonElement) { + trigger.current = from; + setSelected(row); + } + + // The row that opened the panel takes focus back, unless the ring has + // already evicted it: a detached button cannot be focused, and the browser + // falls back to the document, which is the best available answer. + function close() { + setSelected(null); + trigger.current?.focus(); + trigger.current = null; + } + + return ( + <>
-

Live

+

+ ); +} + function ActiveCard({ event, now }: { event: DiagnosticEvent; now: number }) { const copy = copyFor(event.code); return ( @@ -327,9 +362,7 @@ export default function DiagnosticsPage() { const navigate = useNavigate({ from: "/diagnostics" }); const state = search.state ?? "all"; - const base: DiagnosticsFilter = {}; - if (search.severity !== undefined) base.severity = search.severity; - if (search.component !== undefined) base.component = search.component; + const base = diagnosticsFilterOf(search); const active = useInfiniteQuery(diagnosticsInfiniteQuery({ ...base, state: "active" }, state !== "resolved")); const history = useInfiniteQuery(diagnosticsInfiniteQuery({ ...base, state: "resolved" }, state !== "active")); @@ -368,6 +401,8 @@ export default function DiagnosticsPage() { repeats, and closes when the subject recovers.

+ +
setDomain(event.target.value)} - {...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)} - /> - - - setSince(event.target.value)} - {...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)} - /> - - -
- - - {base.isFetching && ( - - Loading… - - )} -
- - - {coverage !== undefined && } - - {base.data === undefined ? ( -

- Loading query log… -

- ) : rows.length === 0 ? ( -

- {filterActive ? "No queries match the current filters." : "No queries logged yet."} -

- ) : ( - <> -
- - - - {rows.map((row) => ( - - - - ))} - -
-
-
-

- Showing {rows.length} {rows.length === 1 ? "query" : "queries"} - {hasMore ? "" : " — end of log"} -

- {hasMore && ( - - )} -
- {moreError !== null && ( -

- Failed to load more: {moreError} -

- )} - - )} - - ); -} diff --git a/admin/src/features/queries/provenanceCopy.ts b/admin/src/features/queries/provenanceCopy.ts index 28d4585..02ce970 100644 --- a/admin/src/features/queries/provenanceCopy.ts +++ b/admin/src/features/queries/provenanceCopy.ts @@ -52,15 +52,6 @@ export function routeKindLabel(kind: RouteKind): string { return ROUTE_KIND_LABELS[kind]; } -/** - * `no_match` is the answer for the overwhelming majority of allowed queries and - * says nothing an operator scanning a table wants to read, so the status column - * leaves it blank. Every other reason names a decision worth seeing. - */ -export function isUninformativeReason(reason: PolicyReason): boolean { - return reason === "no_match"; -} - /** The enum value sets, for tests that prove the maps exhaustive at runtime too. */ export const ENUM_VALUES = { policyAction: POLICY_ACTIONS, @@ -83,6 +74,15 @@ const RCODE_NAMES: Record = { 16: "BADVERS", }; +/** + * The bare mnemonic, for a table cell with no room for the number. An + * unassigned code has no mnemonic to shorten, so it keeps the same `RCODE ` + * shape the long form falls back to. + */ +export function rcodeShortName(rcode: number): string { + return RCODE_NAMES[rcode] ?? `RCODE ${rcode}`; +} + /** The twelve-bit extended code as `NXDOMAIN (3)`; an unassigned code keeps its number. */ export function rcodeName(rcode: number): string { const name = RCODE_NAMES[rcode]; diff --git a/admin/src/features/queries/provenanceFixture.ts b/admin/src/features/queries/provenanceFixture.ts index 22d2c65..5e44da8 100644 --- a/admin/src/features/queries/provenanceFixture.ts +++ b/admin/src/features/queries/provenanceFixture.ts @@ -2,7 +2,7 @@ import type { Provenance, QueryRow } from "@/lib/types"; /** * Fixture builders for the provenance shapes, shared by the query-log, detail - * and live-stream tests the way `features/live/fakeEventSource.ts` is shared. + * and live-stream tests the way `features/activity/fakeEventSource.ts` is shared. * * The defaults describe the dullest possible query — an allowed name nothing * matched, answered upstream — so each test states only the fields it is about. diff --git a/admin/src/features/queries/querySummary.ts b/admin/src/features/queries/querySummary.ts index 988142e..b2574a9 100644 --- a/admin/src/features/queries/querySummary.ts +++ b/admin/src/features/queries/querySummary.ts @@ -18,6 +18,9 @@ export interface QuerySummary { qtype: number | null; blocked: boolean; policy_reason: PolicyReason; + /** The twelve-bit extended code the client saw, including a synthesized SERVFAIL. */ + rcode: number; + route_kind: RouteKind; response_time_us: number | null; cache_hit: boolean | null; upstream: string; @@ -53,6 +56,8 @@ export function summarizeRow(row: QueryRow): QuerySummary { qtype: row.qtype, blocked: row.blocked, policy_reason: row.policy_reason, + rcode: row.rcode, + route_kind: row.route_kind, response_time_us: row.response_time_us, cache_hit: row.cache_hit, upstream: row.upstream, @@ -74,6 +79,8 @@ export function summarizeEvent(event: LiveQueryEvent): QuerySummary { qtype: event.request.qtype, blocked: event.policy.action === "block", policy_reason: event.policy.reason, + rcode: event.response.rcode, + route_kind: event.route.kind, response_time_us: event.response.duration_us, cache_hit: cacheHitFor(event.route.kind), upstream: event.route.upstream, diff --git a/admin/src/routes.tsx b/admin/src/routes.tsx index 4bb7f40..0e426bb 100644 --- a/admin/src/routes.tsx +++ b/admin/src/routes.tsx @@ -12,7 +12,14 @@ import { import AppShell from "@/shell/AppShell"; import { ApiError } from "@/lib/api"; import { createQueryClient } from "@/lib/queryClient"; -import type { DiagnosticSeverity, DiagnosticState, DiagnosticsFilter, QueriesFilter } from "@/lib/types"; +import { diagnosticsFilterOf, type DiagnosticsSearch } from "@/features/diagnostics/filter"; +import { + queriesFilterOf, + validateActivitySearch, + validateText, + validateTimestamp, + type ActivitySearch, +} from "@/features/activity/search"; import { blocklistsQuery, clientPrefixesQuery, @@ -140,46 +147,71 @@ const dashboardRoute = createRoute({ }); /** - * `domain` and `client` seed the filter form, so a detail page can link to - * "every query for this domain". Anything else in the search object is dropped: - * an unknown value would reach the api as a parameter the handler 400s. + * Activity. The URL is the applied state: mode, the five filters, and nothing + * else. Everything is validated by `activity/search.ts`, so a hand-typed or + * stale parameter becomes `undefined` here rather than reaching the API as a + * value it answers 400 to. */ -const queriesRoute = createRoute({ +const activityRoute = createRoute({ getParentRoute: () => shellRoute, - path: "/queries", - validateSearch: (search: Record): { domain?: string; client?: string } => { - const domain = search["domain"]; - const client = search["client"]; - return { - domain: typeof domain === "string" && domain !== "" ? domain : undefined, - client: typeof client === "string" && client !== "" ? client : undefined, - }; - }, - loaderDeps: ({ search }) => search, + path: "/activity", + validateSearch: validateActivitySearch, + // An explicit projection, not `search` itself: the router hands the loader + // whatever else the URL carried, and an unknown key would make two + // otherwise-identical loads look like different deps. + loaderDeps: ({ search }): ActivitySearch => ({ + mode: search.mode, + since: search.since, + until: search.until, + domain: search.domain, + client: search.client, + blocked: search.blocked, + }), + /** + * Starts the first page in parallel with the component chunk, and does not + * wait for it. Awaiting would make every Apply a blocking navigation, which + * throws away the `keepPreviousData` placeholder the list is built on: the + * reader would lose the rows they were reading to a pending page instead of + * watching them be replaced. The page owns the loading and error surfaces, + * so the rejection is caught here only to keep it from going unhandled. + * + * Live mode reads the SSE stream and nothing else. Prefetching the log for + * it would spend a request per navigation on rows the page never renders, + * with the retained filters attached to make it look deliberate. + */ loader: ({ context, deps }) => { - const filter: QueriesFilter = {}; - if (deps.domain !== undefined) filter.domain = deps.domain; - if (deps.client !== undefined) filter.client = deps.client; - return context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery(filter)); + if (deps.mode !== "history") return; + void context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery(queriesFilterOf(deps))).catch(() => {}); }, - component: lazyRouteComponent(() => import("@/features/queries/QueryLogPage")), + component: lazyRouteComponent(() => import("@/features/activity/ActivityPage")), }); -const queryDetailRoute = createRoute({ +/** + * One logged query. Its search is the Activity search the reader arrived from, + * validated by the same functions, so the back link and every related action + * restore the exact investigation instead of a default view of it. + */ +const activityDetailRoute = createRoute({ getParentRoute: () => shellRoute, - path: "/queries/$id", + path: "/activity/queries/$id", + validateSearch: validateActivitySearch, // Swallowed on purpose, as the diagnostics detail route does: a row // retention has pruned is a 404 the page explains, with the way back to the // log. The whole-page error component would call it a request failure. loader: ({ context, params }) => context.queryClient.ensureQueryData(queryDetailQuery(Number(params.id))).catch(() => undefined), - component: lazyRouteComponent(() => import("@/features/queries/QueryDetailPage")), + component: lazyRouteComponent(() => import("@/features/activity/ActivityDetailPage")), }); -const liveRoute = createRoute({ +/** `domain` prefills and runs the simulation, so a query detail can link into it. */ +const activityTestRoute = createRoute({ getParentRoute: () => shellRoute, - path: "/live", - component: lazyRouteComponent(() => import("@/features/live/LiveLogPage")), + path: "/activity/test", + validateSearch: (search: Record): { domain?: string } => ({ + domain: validateText(search["domain"]), + }), + loader: ({ context }) => context.queryClient.ensureQueryData(groupsQuery()), + component: lazyRouteComponent(() => import("@/features/activity/PolicyTestPage")), }); const clientsRoute = createRoute({ @@ -241,45 +273,38 @@ const upstreamsRoute = createRoute({ component: lazyRouteComponent(() => import("@/features/upstreams/UpstreamsPage")), }); -/** `domain` prefills and runs the lookup, so a query detail page can link into it. */ -const lookupRoute = createRoute({ - getParentRoute: () => shellRoute, - path: "/lookup", - validateSearch: (search: Record): { domain?: string } => { - const domain = search["domain"]; - return { domain: typeof domain === "string" && domain !== "" ? domain : undefined }; - }, - loader: ({ context }) => context.queryClient.ensureQueryData(groupsQuery()), - component: lazyRouteComponent(() => import("@/features/lookup/LookupPage")), -}); - /** - * The three filters live in the url so an episode can be linked to as it was - * read. Anything else in the search object is dropped: an unknown value would - * reach the api as a query parameter the handler rejects with a 400. + * The filters and the window live in the url so an episode can be linked to as + * it was read — a query detail links here with an absolute five-minute window + * around one query, which only means anything if the page applies it. Anything + * else in the search object is dropped: an unknown value would reach the api as + * a query parameter the handler rejects with a 400. */ const diagnosticsRoute = createRoute({ getParentRoute: () => shellRoute, path: "/diagnostics", - validateSearch: ( - search: Record, - ): { state?: DiagnosticState; severity?: DiagnosticSeverity; component?: string } => { + validateSearch: (search: Record): DiagnosticsSearch => { const state = search["state"]; const severity = search["severity"]; - const component = search["component"]; return { state: state === "active" || state === "resolved" ? state : undefined, severity: severity === "warning" || severity === "error" ? severity : undefined, - component: typeof component === "string" && component !== "" ? component : undefined, + component: validateText(search["component"]), + since: validateTimestamp(search["since"]), + until: validateTimestamp(search["until"]), }; }, - loaderDeps: ({ search }) => search, + loaderDeps: ({ search }): DiagnosticsSearch => ({ + state: search.state, + severity: search.severity, + component: search.component, + since: search.since, + until: search.until, + }), // allSettled: the two sections render their own state, and the resolved // history failing must not replace the active list with the error page. loader: ({ context, deps }) => { - const base: DiagnosticsFilter = {}; - if (deps.severity !== undefined) base.severity = deps.severity; - if (deps.component !== undefined) base.component = deps.component; + const base = diagnosticsFilterOf(deps); return Promise.allSettled([ context.queryClient.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state: "active" })), context.queryClient.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state: "resolved" })), @@ -310,16 +335,15 @@ const routeTree = rootRoute.addChildren([ loginRoute, shellRoute.addChildren([ dashboardRoute, - queriesRoute, - queryDetailRoute, - liveRoute, + activityRoute, + activityDetailRoute, + activityTestRoute, clientsRoute, groupsRoute, blocklistsRoute, rulesRoute, localDnsRoute, upstreamsRoute, - lookupRoute, diagnosticsRoute, diagnosticDetailRoute, settingsRoute, diff --git a/admin/src/shell/AppShell.test.tsx b/admin/src/shell/AppShell.test.tsx index 68808cb..08c978d 100644 --- a/admin/src/shell/AppShell.test.tsx +++ b/admin/src/shell/AppShell.test.tsx @@ -7,15 +7,13 @@ import { createAppRouter } from "@/routes"; const NAV_LABELS = [ "Dashboard", - "Query Log", - "Live", + "Activity", "Clients", "Groups", "Blocklists", "Rules", "Local DNS", "Upstreams", - "Lookup", "Diagnostics", "Settings", ]; diff --git a/admin/src/shell/AppShell.tsx b/admin/src/shell/AppShell.tsx index c3016eb..3e9aa1f 100644 --- a/admin/src/shell/AppShell.tsx +++ b/admin/src/shell/AppShell.tsx @@ -17,15 +17,13 @@ const DARK = "@media (prefers-color-scheme: dark)"; const NAV_ITEMS = [ { to: "/", label: "Dashboard" }, - { to: "/queries", label: "Query Log" }, - { to: "/live", label: "Live" }, + { to: "/activity", label: "Activity" }, { to: "/clients", label: "Clients" }, { to: "/groups", label: "Groups" }, { to: "/blocklists", label: "Blocklists" }, { to: "/rules", label: "Rules" }, { to: "/local-dns", label: "Local DNS" }, { to: "/upstreams", label: "Upstreams" }, - { to: "/lookup", label: "Lookup" }, { to: "/diagnostics", label: "Diagnostics" }, { to: "/settings", label: "Settings" }, ] as const; diff --git a/admin/src/ui/Select.test.tsx b/admin/src/ui/Select.test.tsx new file mode 100644 index 0000000..2c1f5b9 --- /dev/null +++ b/admin/src/ui/Select.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import Select from "./Select"; + +const OPTIONS = [ + { value: "any", label: "All" }, + { value: "blocked", label: "Blocked only" }, +]; + +/** RAC opens a Select from the keyboard as readily as from a pointer. */ +function open(trigger: HTMLElement) { + fireEvent.keyDown(trigger, { key: "Enter" }); + fireEvent.keyUp(trigger, { key: "Enter" }); +} + +test("a disabled select keeps its value on screen but takes no input", () => { + const onChange = vi.fn(); + render(); + open(screen.getByRole("button")); + expect(screen.getByRole("listbox")).toBeTruthy(); +}); diff --git a/admin/src/ui/Select.tsx b/admin/src/ui/Select.tsx index 9ab400c..3c9aa6c 100644 --- a/admin/src/ui/Select.tsx +++ b/admin/src/ui/Select.tsx @@ -32,6 +32,8 @@ interface Props { * dialog uses, `inline` a control sitting in a row of other controls. */ variant?: "field" | "compactField" | "inline"; + /** Visible but inert, keeping its value on screen; RAC also drops it from the tab order. */ + isDisabled?: boolean; } const styles = stylex.create({ @@ -50,7 +52,10 @@ const styles = stylex.create({ justifyContent: "space-between", gap: "0.5rem", textAlign: "left", - cursor: "pointer", + // RAC renders a real `