milestone 29: activity — history, live and policy simulation on one surface
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
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.
This commit is contained in:
+9
-2
@@ -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-<unix seconds>` 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`.
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
+81
-16
@@ -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<typeof provenance>[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(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -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<string, string> {
|
||||
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",
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Link to="/activity" search={origin} {...stylex.props(styles.back, shared.focusRing)}>
|
||||
← Activity
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
|
||||
Loading query…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<section>
|
||||
<BackLink origin={origin} />
|
||||
<InlineError error={error} onRetry={() => void refetch()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const detail: QueryDetail = data;
|
||||
const { domain, client, time } = detail.request;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<BackLink origin={origin} />
|
||||
<ProvenanceDetail
|
||||
provenance={detail}
|
||||
persistedId={detail.id}
|
||||
relatedActions={<RelatedActions domain={domain} client={client} ts={time} origin={origin} />}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<ActivitySearch, "mode">;
|
||||
|
||||
/** 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>(() => datetimeField(applied.since));
|
||||
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<>
|
||||
<form onSubmit={submit} {...stylex.props(styles.grid)}>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Domain contains
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Client (exact)
|
||||
<input
|
||||
type="text"
|
||||
value={client}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setClient(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
variant="compactField"
|
||||
label="Result"
|
||||
value={blocked}
|
||||
isDisabled={isDisabled}
|
||||
onChange={setBlocked}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Since
|
||||
<input
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={since.text}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Until
|
||||
<input
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={until.text}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
>
|
||||
Apply filters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
disabled={isDisabled}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{error !== null && (
|
||||
<p role="alert" {...stylex.props(styles.error)}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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> = {}): 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<QueryRow>;
|
||||
|
||||
const PAGES: Record<string, QueriesPage> = {
|
||||
"/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<typeof vi.fn>;
|
||||
|
||||
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<Response>) {
|
||||
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(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
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<Response>((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<Response>((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<string, QueriesPage> = {
|
||||
"/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<string, QueriesPage> = {
|
||||
"/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 <a href> 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", "");
|
||||
});
|
||||
@@ -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 (
|
||||
<section>
|
||||
<div {...stylex.props(styles.header)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Activity</h1>
|
||||
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}>
|
||||
{MODES.map((option) => {
|
||||
const selected = option.mode === search.mode;
|
||||
return (
|
||||
<button
|
||||
key={option.mode}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => selectMode(option.mode)}
|
||||
{...stylex.props(
|
||||
styles.modeButton,
|
||||
selected ? styles.modeSelected : styles.modeIdle,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
|
||||
Current policy simulation
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* 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.
|
||||
*/}
|
||||
<ActivityFilters
|
||||
key={`${search.domain ?? ""}|${search.client ?? ""}|${String(search.blocked)}|${String(search.since)}|${String(search.until)}`}
|
||||
applied={search}
|
||||
isDisabled={live}
|
||||
onApply={apply}
|
||||
onClear={() => apply(NO_FILTERS)}
|
||||
/>
|
||||
|
||||
{live ? (
|
||||
<>
|
||||
<p {...stylex.props(styles.liveNote)}>
|
||||
The stream carries every query the server answers; these filters apply to history only.
|
||||
</p>
|
||||
<LiveActivity origin={search} />
|
||||
</>
|
||||
) : (
|
||||
<HistoryActivity search={search} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 <InlineError error={base.error} onRetry={() => void base.refetch()} />;
|
||||
}
|
||||
if (base.data === undefined) {
|
||||
return (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
Loading activity…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{base.isFetching && (
|
||||
<p {...stylex.props(styles.note, styles.refetching)} role="status">
|
||||
Loading…
|
||||
</p>
|
||||
)}
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
|
||||
{rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} {...stylex.props(styles.row)}>
|
||||
<ActivityCells
|
||||
row={summarizeRow(row)}
|
||||
clientNames={clientNames}
|
||||
renderDomain={(id, children) =>
|
||||
id === null ? (
|
||||
children
|
||||
) : (
|
||||
<Link
|
||||
to="/activity/queries/$id"
|
||||
params={{ id: String(id) }}
|
||||
search={search}
|
||||
{...stylex.props(activityDomainLink, shared.focusRing)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div {...stylex.props(styles.footer)}>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
||||
{...stylex.props(shared.button, styles.moreButton, shared.focusRing)}
|
||||
>
|
||||
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{moreError !== null && (
|
||||
<p role="alert" {...stylex.props(styles.moreError)}>
|
||||
Failed to load more: {moreError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof vi.fn>;
|
||||
|
||||
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<Response> = () => 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<typeof provenance>[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(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
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<Response>((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 <button> is in the tab order and activates on Enter and Space; the
|
||||
// only way to lose that is to opt out of it, which nothing here may do.
|
||||
expect(trigger.tagName).toBe("BUTTON");
|
||||
expect(trigger.getAttribute("tabindex")).toBeNull();
|
||||
act(() => trigger.focus());
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
|
||||
fireEvent.click(trigger);
|
||||
const heading = screen.getByRole("heading", { level: 1, name: "streamed.example" });
|
||||
expect(heading).toBeTruthy();
|
||||
const panel = heading.closest("div")!.parentElement!;
|
||||
expect(within(panel).getByText("Blocked locally")).toBeTruthy();
|
||||
expect(panel.textContent).toContain("the query log may not have written it yet");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("heading", { level: 1, name: "streamed.example" })).toBeNull();
|
||||
});
|
||||
|
||||
test("the detail takes focus when a row opens it and hands it back when it closes", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "streamed.example" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||
|
||||
// A native button activates on Enter and Space; jsdom does not synthesize
|
||||
// the click those keys fire, so the click is the activation.
|
||||
act(() => trigger.focus());
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(trigger.getAttribute("aria-controls")).toBe(panel.id);
|
||||
// The panel is inserted above the table, behind the trigger in tab order, so
|
||||
// the only thing that keeps a forward tab inside it is focus moving in.
|
||||
expect(panel.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(document.activeElement).toBe(panel);
|
||||
expect(panel.contains(screen.getByRole("button", { name: "Close" }))).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("group", { name: "Streamed query" })).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||
});
|
||||
|
||||
test("opening a second row moves the expanded state and the focus with it", async () => {
|
||||
await openLive();
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "first.example"));
|
||||
sources[0]!.emit("query", frame(1001, "second.example"));
|
||||
});
|
||||
|
||||
const first = screen.getByRole("button", { name: "first.example" });
|
||||
const second = screen.getByRole("button", { name: "second.example" });
|
||||
fireEvent.click(first);
|
||||
fireEvent.click(second);
|
||||
|
||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
||||
expect(within(panel).getByRole("heading", { level: 1, name: "second.example" })).toBeTruthy();
|
||||
expect(document.activeElement).toBe(panel);
|
||||
expect(first.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(second.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(document.activeElement).toBe(second);
|
||||
});
|
||||
|
||||
test("an open streamed detail survives the row being evicted from the ring buffer", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
|
||||
// 500 more queries: the ring keeps the newest 500, so the selected row is
|
||||
// gone from the table. The detail is a snapshot, not a lookup into the ring.
|
||||
act(() => {
|
||||
for (let index = 0; index < 500; index += 1) {
|
||||
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
|
||||
}
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an open streamed detail survives Freeze and Resume", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the filter row stays visible, keeps its values, and is out of the tab order", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
|
||||
|
||||
const domain = screen.getByLabelText("Domain contains") as HTMLInputElement;
|
||||
expect(domain.value).toBe("ads");
|
||||
expect(domain.disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Client (exact)") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Since") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Until") as HTMLInputElement).disabled).toBe(true);
|
||||
|
||||
const form = domain.closest("form")!;
|
||||
const controls = [...form.querySelectorAll("input, button, select, textarea, a[href], [tabindex]")];
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
for (const control of controls) {
|
||||
// A disabled form control is skipped by the browser's tab order, and RAC
|
||||
// pins its own trigger out of it as well. Nothing in the row may
|
||||
// reintroduce itself with a reachable tabindex.
|
||||
expect(control.hasAttribute("disabled")).toBe(true);
|
||||
const tabindex = control.getAttribute("tabindex");
|
||||
expect(tabindex === null || tabindex === "-1").toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("live mode asks for no query pages, whatever filters the url retained", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true&since=1700000000&bogus=1");
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
|
||||
expect(queryCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "History" }));
|
||||
await screen.findByRole("button", { name: "Apply filters" });
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0]!.closed).toBe(true);
|
||||
// The filters came along, which is the point of switching rather than
|
||||
// navigating: the reader keeps the question they were asking.
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads");
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Live" }));
|
||||
await screen.findByRole("button", { name: "Freeze" });
|
||||
expect(sources).toHaveLength(2);
|
||||
expect(sources[1]!.closed).toBe(false);
|
||||
});
|
||||
+174
-18
@@ -1,25 +1,41 @@
|
||||
/**
|
||||
* Activity in live mode: the SSE stream, its bounded ring buffer, and the
|
||||
* in-place detail a streamed row opens.
|
||||
*
|
||||
* This subtree is mounted only while the URL says `mode=live`, which is what
|
||||
* closes the EventSource on the way back to history: the connection is a
|
||||
* server-side resource capped per address, so a page that kept it open while
|
||||
* showing something else would spend a viewer slot on nothing.
|
||||
*
|
||||
* Freeze and Follow are display state and stay out of the URL. They describe
|
||||
* what the screen is doing right now, not what it is showing, so a shared link
|
||||
* would carry a frozen moment the recipient never saw fill.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useClientNames } from "@/features/clients/clientNames";
|
||||
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
|
||||
import { RING_CAPACITY, summaryOf } from "./ringBuffer";
|
||||
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
|
||||
import { summarizeEvent } from "@/features/queries/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||
import ProvenanceDetail from "./ProvenanceDetail";
|
||||
import RelatedActions from "./RelatedActions";
|
||||
import { RING_CAPACITY, summaryOf, type StreamedRow } from "./ringBuffer";
|
||||
import type { ActivitySearch } from "./search";
|
||||
import { useLiveQueries, type StreamStatus } from "./useLiveQueries";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const styles = stylex.create({
|
||||
toolbar: {
|
||||
marginTop: "1rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
toolbarButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
@@ -134,6 +150,40 @@ const styles = stylex.create({
|
||||
[DARK]: "oklch(25.8% 0.092 26.042 / 0.4)",
|
||||
},
|
||||
},
|
||||
/** A streamed row opens its detail here rather than at a route, so it is a button that looks like the link beside it. */
|
||||
domainButton: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
font: "inherit",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
textDecorationLine: "underline",
|
||||
textDecorationStyle: "dotted",
|
||||
},
|
||||
detailPanel: {
|
||||
marginTop: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surface,
|
||||
padding: "1rem",
|
||||
},
|
||||
detailBar: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
detailLabel: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
footnote: {
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
@@ -142,6 +192,10 @@ const styles = stylex.create({
|
||||
},
|
||||
});
|
||||
|
||||
/** One panel at a time, so the trigger that opened it can name it in `aria-controls`. */
|
||||
const DETAIL_PANEL_ID = "live-query-detail";
|
||||
const DETAIL_LABEL_ID = "live-query-detail-label";
|
||||
|
||||
const PILL_LABELS: Record<StreamStatus, string> = {
|
||||
connecting: "Connecting…",
|
||||
open: "Live",
|
||||
@@ -165,16 +219,85 @@ function StatusPill({ status }: { status: StreamStatus }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Freeze is display-only: the stream stays open and the 500-row ring buffer keeps
|
||||
* filling; Resume shows the current buffer (anything pushed out meanwhile is gone). */
|
||||
export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) {
|
||||
const live = useLiveQueries({ createEventSource });
|
||||
const clientNames = useClientNames();
|
||||
/**
|
||||
* The open detail, held as the selected row itself rather than as a key into
|
||||
* the buffer.
|
||||
*
|
||||
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by
|
||||
* key would go stale under the reader while they were still reading it, and the
|
||||
* panel would blank out for no reason they could see. The snapshot is the whole
|
||||
* fact — a streamed frame carries its own provenance — so it survives eviction,
|
||||
* a merge and a Freeze/Resume, and closes only when the reader closes it or
|
||||
* leaves live mode.
|
||||
*/
|
||||
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
|
||||
const summary = summarizeEvent(row.event);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The panel opens above the table, behind the trigger in tab order, so a
|
||||
// forward tab from the row would walk past it. Focus moves in on open —
|
||||
// keyed on the row, so choosing a second row moves it again — and the
|
||||
// closer puts it back on the trigger.
|
||||
useEffect(() => {
|
||||
panel.current?.focus();
|
||||
}, [row.key]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div
|
||||
ref={panel}
|
||||
id={DETAIL_PANEL_ID}
|
||||
tabIndex={-1}
|
||||
role="group"
|
||||
aria-labelledby={DETAIL_LABEL_ID}
|
||||
{...stylex.props(styles.detailPanel)}
|
||||
>
|
||||
<div {...stylex.props(styles.detailBar)}>
|
||||
<span id={DETAIL_LABEL_ID} {...stylex.props(styles.detailLabel)}>
|
||||
Streamed query
|
||||
</span>
|
||||
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<ProvenanceDetail
|
||||
provenance={row.event}
|
||||
persistedId={null}
|
||||
relatedActions={
|
||||
<RelatedActions
|
||||
domain={summary.domain}
|
||||
client={summary.client_ip}
|
||||
ts={summary.ts}
|
||||
origin={origin}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LiveActivity({ origin }: { origin: ActivitySearch }) {
|
||||
const live = useLiveQueries();
|
||||
const clientNames = useClientNames();
|
||||
const [selected, setSelected] = useState<StreamedRow | null>(null);
|
||||
const trigger = useRef<HTMLButtonElement | null>(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 (
|
||||
<>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Live</h1>
|
||||
<StatusPill status={live.status} />
|
||||
<button
|
||||
type="button"
|
||||
@@ -228,6 +351,8 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
|
||||
|
||||
{live.rows.length === 0 ? (
|
||||
live.status !== "capped" && (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
@@ -238,7 +363,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<QueryTableHead />
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
{live.rows.map((row) => {
|
||||
const summary = summaryOf(row);
|
||||
@@ -247,7 +372,38 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
key={row.key}
|
||||
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
|
||||
>
|
||||
<QueryCells row={summary} clientNames={clientNames} />
|
||||
<ActivityCells
|
||||
row={summary}
|
||||
clientNames={clientNames}
|
||||
renderDomain={(_id, children) =>
|
||||
row.kind === "streamed" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={selected?.key === row.key}
|
||||
aria-controls={
|
||||
selected?.key === row.key ? DETAIL_PANEL_ID : undefined
|
||||
}
|
||||
onClick={(event) => open(row, event.currentTarget)}
|
||||
{...stylex.props(
|
||||
styles.domainButton,
|
||||
activityDomainLink,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/activity/queries/$id"
|
||||
params={{ id: String(row.row.id) }}
|
||||
search={origin}
|
||||
{...stylex.props(activityDomainLink, shared.focusRing)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -260,6 +416,6 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { LookupResult } from "@/lib/types";
|
||||
|
||||
const BLOCKED: LookupResult = {
|
||||
domain: "ads.example",
|
||||
group_id: 1,
|
||||
local_records: false,
|
||||
forward_zone: null,
|
||||
blocked: true,
|
||||
reason: "blocklist_domain",
|
||||
matched: "ads.example",
|
||||
source_url: "https://lists.test/a",
|
||||
safe_search_rewrite: null,
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof createFetchMock>;
|
||||
/** What `/api/lookup` answers, so a test can make it fail without rebuilding the mock. */
|
||||
let lookup: (url: string) => Response;
|
||||
|
||||
function json(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function createFetchMock() {
|
||||
return vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/groups") {
|
||||
return json({
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url.startsWith("/api/lookup")) return lookup(url);
|
||||
return json({ error: "not stubbed" }, 404);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lookup = (url) =>
|
||||
url === "/api/lookup?domain=ads.example&group_id=1" ? json(BLOCKED) : json({ error: "not stubbed" }, 404);
|
||||
fetchMock = createFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/**
|
||||
* `retry: false` for the failure tests: the shared client retries a 5xx twice
|
||||
* and a 429 after its Retry-After, so the surfaced error is what the page does
|
||||
* once the client has given up, not something a test should sit out in real
|
||||
* time.
|
||||
*/
|
||||
function renderPage(path = "/activity/test", { retry = true } = {}) {
|
||||
const queryClient = createQueryClient();
|
||||
if (!retry) {
|
||||
const defaults = queryClient.getDefaultOptions();
|
||||
queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
|
||||
}
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function lookupCalls(): string[] {
|
||||
return fetchMock.mock.calls.map(([input]) => String(input)).filter((url) => url.startsWith("/api/lookup"));
|
||||
}
|
||||
|
||||
test("fetches nothing until submit, then renders the blocked verdict", async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByRole("heading", { name: "Current policy simulation" });
|
||||
await screen.findByLabelText("Group");
|
||||
expect(lookupCalls()).toEqual([]);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||
expect(lookupCalls()).toEqual([]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocked" });
|
||||
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
|
||||
|
||||
expect(screen.getByText("blocklist_domain")).toBeTruthy();
|
||||
const link = screen.getByRole("link", { name: "https://lists.test/a" }) as HTMLAnchorElement;
|
||||
expect(link.href).toBe("https://lists.test/a");
|
||||
expect(screen.getByText("Queries for this name get a blocked response.")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a ?domain= link asks the question on arrival instead of leaving a filled-in form", async () => {
|
||||
renderPage("/activity/test?domain=ads.example");
|
||||
|
||||
// No submit here: the link is the question, so the verdict is what arrives.
|
||||
await screen.findByRole("heading", { name: "Blocked" });
|
||||
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
|
||||
expect(screen.getByLabelText("Domain")).toHaveProperty("value", "ads.example");
|
||||
});
|
||||
|
||||
test("no filter snapshot reads as a server that is starting, not as a verdict", async () => {
|
||||
lookup = () => json({ error: "no snapshot" }, 503);
|
||||
renderPage("/activity/test", { retry: false });
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("No filter snapshot is loaded yet");
|
||||
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
|
||||
// Nothing may read as an answer while the lookup has none.
|
||||
expect(screen.queryByRole("heading", { name: "Blocked" })).toBeNull();
|
||||
expect(screen.queryByText("Simulating…")).toBeNull();
|
||||
});
|
||||
|
||||
test("a rate limit says how long to wait, from the server's own Retry-After", async () => {
|
||||
lookup = () => json({ error: "rate limited" }, 429, { "retry-after": "12" });
|
||||
renderPage("/activity/test", { retry: false });
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 12s.");
|
||||
});
|
||||
|
||||
test("resubmitting the same domain and group refetches rather than showing a stale verdict", async () => {
|
||||
renderPage();
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||
await screen.findByRole("heading", { name: "Blocked" });
|
||||
expect(lookupCalls()).toHaveLength(1);
|
||||
|
||||
// The policy can change between two identical questions, so the second one
|
||||
// has to reach the server even though the query key has not moved.
|
||||
lookup = () => json({ ...BLOCKED, blocked: false, reason: "no_match", matched: "", source_url: null });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||
|
||||
await screen.findByRole("heading", { name: "Allowed" });
|
||||
expect(lookupCalls()).toEqual([
|
||||
"/api/lookup?domain=ads.example&group_id=1",
|
||||
"/api/lookup?domain=ads.example&group_id=1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("defaults the group select to the default group (id 1)", async () => {
|
||||
renderPage();
|
||||
// A RAC Select names its trigger with the current value and then the label, so
|
||||
// the selected group's name is the only thing the trigger shows.
|
||||
const trigger = await screen.findByRole("button", { name: /Group$/ });
|
||||
expect(trigger.textContent).toContain("default");
|
||||
});
|
||||
|
||||
test("the framing is forward-tense, so it cannot be read as an account of a past query", async () => {
|
||||
renderPage();
|
||||
await screen.findByRole("heading", { name: "Current policy simulation" });
|
||||
|
||||
const intro = screen.getByRole("heading", { name: "Current policy simulation" }).nextElementSibling;
|
||||
expect(intro?.textContent).toContain("would");
|
||||
expect(intro?.textContent).toContain("right now");
|
||||
// Nothing on the page may claim to explain a query that already happened.
|
||||
expect(document.body.textContent).not.toContain("Look up");
|
||||
});
|
||||
+11
-9
@@ -250,13 +250,13 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
|
||||
);
|
||||
}
|
||||
|
||||
export default function LookupPage() {
|
||||
export default function PolicyTestPage() {
|
||||
const groups = useSuspenseQuery(groupsQuery()).data;
|
||||
const preselectedGroupId = defaultGroupId(groups);
|
||||
|
||||
// A `?domain=` link (from a query's detail page) arrives already asking the
|
||||
// question, so it runs the lookup rather than leaving a filled-in form.
|
||||
const search = useSearch({ from: "/shell/lookup" });
|
||||
// question, so it runs the simulation rather than leaving a filled-in form.
|
||||
const search = useSearch({ from: "/shell/activity/test" });
|
||||
const [domain, setDomain] = useState(search.domain ?? "");
|
||||
const [groupId, setGroupId] = useState(preselectedGroupId);
|
||||
const [submitted, setSubmitted] = useState<Submitted | null>(
|
||||
@@ -281,17 +281,19 @@ export default function LookupPage() {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Lookup</h1>
|
||||
<h1 {...stylex.props(styles.heading)}>Current policy simulation</h1>
|
||||
<p {...stylex.props(styles.intro)}>
|
||||
What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
|
||||
What the pipeline <em>would</em> do with a domain right now: local records, forward zones, block
|
||||
decision, safe search. This reads the configuration in force at this moment, so it explains nothing
|
||||
about a query already answered — a detail page does that.
|
||||
</p>
|
||||
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
||||
<div {...stylex.props(styles.domainField)}>
|
||||
<label htmlFor="lookup-domain" {...stylex.props(styles.fieldLabel)}>
|
||||
<label htmlFor="policy-test-domain" {...stylex.props(styles.fieldLabel)}>
|
||||
Domain
|
||||
</label>
|
||||
<input
|
||||
id="lookup-domain"
|
||||
id="policy-test-domain"
|
||||
required
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
@@ -312,10 +314,10 @@ export default function LookupPage() {
|
||||
disabled={lookup.isFetching}
|
||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||
>
|
||||
Look up
|
||||
Simulate
|
||||
</button>
|
||||
</form>
|
||||
{lookup.isFetching && <p {...stylex.props(styles.note)}>Looking up…</p>}
|
||||
{lookup.isFetching && <p {...stylex.props(styles.note)}>Simulating…</p>}
|
||||
{!lookup.isFetching && lookup.isError && (
|
||||
<p role="alert" {...stylex.props(styles.error)}>
|
||||
{errorMessage(lookup.error)}
|
||||
+41
-70
@@ -1,24 +1,30 @@
|
||||
/**
|
||||
* One query, explained in the order it met the pipeline.
|
||||
*
|
||||
* This is the body of the detail surface, with no route in it, because two
|
||||
* surfaces show it: the persisted detail page, which fetched the row by id, and
|
||||
* a live row, whose provenance arrived in the stream frame and which SQLite may
|
||||
* not have written yet. The related actions are links into routes, so the
|
||||
* caller passes them in already built.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import { queryDetailQuery } from "@/lib/queries";
|
||||
import type { PolicyReason, QueryDetail } from "@/lib/types";
|
||||
import type { PolicyReason, Provenance } from "@/lib/types";
|
||||
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
|
||||
import { policyActionLabel, policyReasonLabel, qclassName, rcodeName, routeKindLabel } from "./provenanceCopy";
|
||||
import { qtypeName } from "./qtype";
|
||||
import {
|
||||
policyActionLabel,
|
||||
policyReasonLabel,
|
||||
qclassName,
|
||||
rcodeName,
|
||||
routeKindLabel,
|
||||
} from "@/features/queries/provenanceCopy";
|
||||
import { qtypeName } from "@/features/queries/qtype";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
back: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
heading: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "1.5rem",
|
||||
@@ -109,12 +115,11 @@ const styles = stylex.create({
|
||||
link: {
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
loading: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
/** The look of one related action, so every caller's links match. */
|
||||
export const provenanceRelatedLink = styles.link;
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div {...stylex.props(styles.section)}>
|
||||
@@ -158,39 +163,25 @@ function unmatchedLabel(reason: PolicyReason): string {
|
||||
return reason === "no_match" ? "Nothing matched" : "The matcher never ran";
|
||||
}
|
||||
|
||||
export default function QueryDetailPage() {
|
||||
const { id } = useParams({ from: "/shell/queries/$id" });
|
||||
const rowId = Number(id);
|
||||
const { data, error, isPending, refetch } = useQuery(queryDetailQuery(rowId));
|
||||
interface Props {
|
||||
provenance: Provenance;
|
||||
/**
|
||||
* The log row this explains, or null for a frame read straight off the
|
||||
* stream. Null is the same fact `QuerySummary.id` carries: the event
|
||||
* precedes its own insert, so there is no row to name and none is invented.
|
||||
*/
|
||||
persistedId: number | null;
|
||||
/** The related-action links, built by whichever surface owns the routes. */
|
||||
relatedActions: ReactNode;
|
||||
}
|
||||
|
||||
export default function ProvenanceDetail({ provenance, persistedId, relatedActions }: Props) {
|
||||
const { request, group, policy, rewrites, route, response } = provenance;
|
||||
const clientNames = useClientNames();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
|
||||
Loading query…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<section>
|
||||
<Link to="/queries" {...stylex.props(styles.back, shared.focusRing)}>
|
||||
← Query log
|
||||
</Link>
|
||||
<InlineError error={error} onRetry={() => void refetch()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const detail: QueryDetail = data;
|
||||
const { request, group, policy, rewrites, route, response } = detail;
|
||||
const currentClient = clientLabel(request.client, clientNames);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Link to="/queries" {...stylex.props(styles.back, shared.focusRing)}>
|
||||
← Query log
|
||||
</Link>
|
||||
<>
|
||||
<h1 {...stylex.props(styles.heading, shared.mono)}>{request.domain}</h1>
|
||||
<p {...stylex.props(styles.subtitle)}>
|
||||
{formatTime(request.time)} — {policyActionLabel(policy.action)}
|
||||
@@ -202,6 +193,8 @@ export default function QueryDetailPage() {
|
||||
<p {...stylex.props(styles.recordNote)}>
|
||||
What was recorded when this query was answered. Group and blocklist names are the ones in force at
|
||||
that moment; they may have been renamed or deleted since.
|
||||
{persistedId === null &&
|
||||
" This is the event as it was streamed; the query log may not have written it yet."}
|
||||
</p>
|
||||
|
||||
<Section title="Request">
|
||||
@@ -300,30 +293,8 @@ export default function QueryDetailPage() {
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div {...stylex.props(styles.relatedList)}>
|
||||
<Link
|
||||
to="/lookup"
|
||||
search={{ domain: request.domain }}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
Look up this domain now
|
||||
</Link>
|
||||
<Link
|
||||
to="/queries"
|
||||
search={{ domain: request.domain }}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
All queries for this domain
|
||||
</Link>
|
||||
<Link
|
||||
to="/queries"
|
||||
search={{ client: request.client }}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
All queries from this client
|
||||
</Link>
|
||||
<div {...stylex.props(styles.relatedList)}>{relatedActions}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* The links out of one query's detail, shared by the persisted detail page and
|
||||
* the in-place detail a streamed row opens.
|
||||
*
|
||||
* Both surfaces answer the same four follow-up questions, and both are read
|
||||
* from an investigation that has a time range. Every link therefore carries
|
||||
* absolute bounds: a link that said "recently" would show a different set of
|
||||
* queries every time it was opened, which is the opposite of what linking to an
|
||||
* incident is for.
|
||||
*/
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { provenanceRelatedLink } from "./ProvenanceDetail";
|
||||
import { diagnosticsBounds, relatedBounds } from "./relatedLinks";
|
||||
import type { ActivitySearch } from "./search";
|
||||
|
||||
interface Props {
|
||||
domain: string;
|
||||
client: string;
|
||||
/** The second this query was answered, which every window is centred on. */
|
||||
ts: number;
|
||||
/** The Activity search the reader came from; its bounds win over the defaults. */
|
||||
origin: Pick<ActivitySearch, "since" | "until">;
|
||||
}
|
||||
|
||||
export default function RelatedActions({ domain, client, ts, origin }: Props) {
|
||||
const bounds = relatedBounds(ts, origin);
|
||||
const window = diagnosticsBounds(ts);
|
||||
return (
|
||||
<>
|
||||
<Link to="/activity/test" search={{ domain }} {...stylex.props(provenanceRelatedLink, shared.focusRing)}>
|
||||
Test this domain against current policy
|
||||
</Link>
|
||||
<Link
|
||||
to="/activity"
|
||||
search={{ mode: "history", domain, client: undefined, blocked: undefined, ...bounds }}
|
||||
{...stylex.props(provenanceRelatedLink, shared.focusRing)}
|
||||
>
|
||||
All activity for this domain
|
||||
</Link>
|
||||
<Link
|
||||
to="/activity"
|
||||
search={{ mode: "history", client, domain: undefined, blocked: undefined, ...bounds }}
|
||||
{...stylex.props(provenanceRelatedLink, shared.focusRing)}
|
||||
>
|
||||
All activity from this client
|
||||
</Link>
|
||||
<Link
|
||||
to="/diagnostics"
|
||||
search={{ since: window.since, until: window.until }}
|
||||
{...stylex.props(provenanceRelatedLink, shared.focusRing)}
|
||||
>
|
||||
Diagnostics around this query
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { QueryRow } from "@/lib/types";
|
||||
import type { ClientNames } from "@/features/clients/clientNames";
|
||||
import { queryRow } from "@/features/queries/provenanceFixture";
|
||||
import { summarizeRow, type QuerySummary } from "@/features/queries/querySummary";
|
||||
import { ACTIVITY_COLUMNS, ActivityCells, ActivityTableHead, resultLabel, routeLabel } from "./cells";
|
||||
|
||||
const noNames: ClientNames = new Map();
|
||||
|
||||
function renderRow(overrides: Partial<QueryRow> = {}): HTMLTableRowElement {
|
||||
const row: QuerySummary = summarizeRow(queryRow(1, overrides));
|
||||
render(
|
||||
<table>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
<tr data-testid="row">
|
||||
<ActivityCells
|
||||
row={row}
|
||||
clientNames={noNames}
|
||||
renderDomain={(id, children) => <a href={`/activity/queries/${id}`}>{children}</a>}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
return screen.getByTestId("row") as HTMLTableRowElement;
|
||||
}
|
||||
|
||||
/** The cell under a header, read by its column name rather than its index. */
|
||||
function cell(row: HTMLTableRowElement, column: (typeof ACTIVITY_COLUMNS)[number]): string {
|
||||
const index = ACTIVITY_COLUMNS.indexOf(column);
|
||||
return row.cells[index]?.textContent ?? "";
|
||||
}
|
||||
|
||||
test("the head names the seven columns in order", () => {
|
||||
render(
|
||||
<table>
|
||||
<ActivityTableHead />
|
||||
</table>,
|
||||
);
|
||||
const headers = screen.getAllByRole("columnheader").map((header) => header.textContent);
|
||||
expect(headers).toEqual(["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"]);
|
||||
});
|
||||
|
||||
test("an allowed NOERROR row reads as the answer it got, with no badge", () => {
|
||||
const row = renderRow({ blocked: false, rcode: 0, route_kind: "upstream", response_time_us: 1234 });
|
||||
expect(cell(row, "Result")).toBe("NOERROR");
|
||||
expect(cell(row, "Route")).toBe("Upstream");
|
||||
expect(cell(row, "Duration")).toBe("1.2 ms");
|
||||
expect(cell(row, "Type")).toBe("A");
|
||||
});
|
||||
|
||||
test("a blocked row reads Blocked even though the client got NOERROR", () => {
|
||||
const row = renderRow({ blocked: true, rcode: 0, route_kind: "blocked", policy_reason: "blocklist_domain" });
|
||||
expect(cell(row, "Result")).toBe("Blocked");
|
||||
expect(cell(row, "Route")).toBe("Blocked");
|
||||
});
|
||||
|
||||
test("a SERVFAIL row names the code", () => {
|
||||
const row = renderRow({ blocked: false, rcode: 2, route_kind: "upstream", response_time_us: null });
|
||||
expect(cell(row, "Result")).toBe("SERVFAIL");
|
||||
expect(cell(row, "Duration")).toBe("—");
|
||||
});
|
||||
|
||||
test("a cache hit names the cache as the route", () => {
|
||||
const row = renderRow({ blocked: false, rcode: 0, route_kind: "cache", cache_hit: true, upstream: "" });
|
||||
expect(cell(row, "Route")).toBe("Cache");
|
||||
expect(cell(row, "Result")).toBe("NOERROR");
|
||||
});
|
||||
|
||||
test("an unassigned extended rcode keeps the numeric fallback, without the long form's parentheses", () => {
|
||||
const row = renderRow({ blocked: false, rcode: 3841 });
|
||||
expect(cell(row, "Result")).toBe("RCODE 3841");
|
||||
});
|
||||
|
||||
test("a persisted row links its domain to the detail the caller chose", () => {
|
||||
renderRow({ domain: "ads.example" });
|
||||
expect(screen.getByRole("link", { name: "ads.example" }).getAttribute("href")).toBe("/activity/queries/1");
|
||||
});
|
||||
|
||||
test("a streamed row reaches the renderer with a null id, and can render as plain text", () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr data-testid="row">
|
||||
<ActivityCells
|
||||
row={{ ...summarizeRow(queryRow(1)), id: null }}
|
||||
clientNames={noNames}
|
||||
renderDomain={(id, children) => {
|
||||
expect(id).toBeNull();
|
||||
return children;
|
||||
}}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
expect(screen.getByTestId("row").textContent).toContain("example.com");
|
||||
});
|
||||
|
||||
test("every route kind has a compact label", () => {
|
||||
expect(routeLabel("blocked")).toBe("Blocked");
|
||||
expect(routeLabel("local")).toBe("Local");
|
||||
expect(routeLabel("forward_zone")).toBe("Forward zone");
|
||||
expect(routeLabel("upstream")).toBe("Upstream");
|
||||
expect(routeLabel("cache")).toBe("Cache");
|
||||
expect(routeLabel("rejected")).toBe("Rejected");
|
||||
});
|
||||
|
||||
test("resultLabel is the pure form of the Result cell", () => {
|
||||
expect(resultLabel({ blocked: true, rcode: 2 })).toBe("Blocked");
|
||||
expect(resultLabel({ blocked: false, rcode: 5 })).toBe("REFUSED");
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* The seven columns of the Activity table: Time, Domain, Client, Type, Result,
|
||||
* Route and Duration.
|
||||
*
|
||||
* The labels here are the compact forms a scanned table needs. The detail page
|
||||
* keeps `provenanceCopy`'s long forms, which spell out the same facts with room
|
||||
* for the rcode number and the "answered by" phrasing.
|
||||
*
|
||||
* The cells render both a stored row and a streamed event, so they know nothing
|
||||
* about routes: the caller renders the Domain cell's contents and decides what,
|
||||
* if anything, a row opens. A streamed row has no id — the frame precedes its
|
||||
* own insert — and only the caller knows whether it has a surface for one.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import type { RouteKind } from "@/lib/types";
|
||||
import { ClientName, type ClientNames } from "@/features/clients/clientNames";
|
||||
import { rcodeShortName } from "@/features/queries/provenanceCopy";
|
||||
import { qtypeName } from "@/features/queries/qtype";
|
||||
import type { QuerySummary } from "@/features/queries/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const styles = stylex.create({
|
||||
head: {
|
||||
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||
textAlign: "left",
|
||||
},
|
||||
th: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
cell: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
nowrap: {
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
breakAll: {
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
small: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
},
|
||||
muted: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
domainLink: {
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
/**
|
||||
* The badge shape and its weight are the signal; the tint only says which
|
||||
* kind of unhappy answer this was. A monochrome or colour-blind reading of
|
||||
* the table still separates a blocked or failed row from a plain NOERROR
|
||||
* one, which a hue alone would not.
|
||||
*/
|
||||
badge: {
|
||||
display: "inline-block",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
blockedBadge: {
|
||||
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
|
||||
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
||||
},
|
||||
faultBadge: {
|
||||
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(41.4% 0.112 45.904)" },
|
||||
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(90.1% 0.076 70.697)" },
|
||||
},
|
||||
});
|
||||
|
||||
const ROUTE_LABELS: Record<RouteKind, string> = {
|
||||
blocked: "Blocked",
|
||||
local: "Local",
|
||||
forward_zone: "Forward zone",
|
||||
upstream: "Upstream",
|
||||
cache: "Cache",
|
||||
rejected: "Rejected",
|
||||
};
|
||||
|
||||
/** The compact Route label. `Record` over the union, so a new kind fails `tsc`. */
|
||||
export function routeLabel(kind: RouteKind): string {
|
||||
return ROUTE_LABELS[kind];
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact Result label. A block is the answer the operator asked nxdns for,
|
||||
* so it wins over the rcode it was delivered as — a blocked name answered with
|
||||
* NOERROR and a zero address is still "Blocked". Everything else reads as the
|
||||
* code the client saw.
|
||||
*/
|
||||
export function resultLabel(row: Pick<QuerySummary, "blocked" | "rcode">): string {
|
||||
return row.blocked ? "Blocked" : rcodeShortName(row.rcode);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a row's domain is wrapped in. The caller owns the routes, so it builds
|
||||
* the element; the look stays here, as `activityDomainLink`, which the caller
|
||||
* spreads onto the control itself — a link's own colour beats one inherited
|
||||
* from a wrapper. `id` is null for a streamed row, which history has no surface
|
||||
* for and live opens from memory.
|
||||
*/
|
||||
export type DomainRenderer = (id: number | null, children: ReactNode) => ReactNode;
|
||||
|
||||
export const activityDomainLink = styles.domainLink;
|
||||
|
||||
export function ResultCellContent({ row }: { row: Pick<QuerySummary, "blocked" | "rcode"> }) {
|
||||
const label = resultLabel(row);
|
||||
if (row.blocked) return <span {...stylex.props(styles.badge, styles.blockedBadge)}>{label}</span>;
|
||||
if (row.rcode !== 0) return <span {...stylex.props(styles.badge, styles.faultBadge)}>{label}</span>;
|
||||
return <span {...stylex.props(styles.small, styles.muted)}>{label}</span>;
|
||||
}
|
||||
|
||||
export function ActivityCells({
|
||||
row,
|
||||
clientNames,
|
||||
renderDomain,
|
||||
}: {
|
||||
row: QuerySummary;
|
||||
clientNames: ClientNames;
|
||||
renderDomain: DomainRenderer;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||
{renderDomain(row.id, row.domain)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
||||
<ClientName ip={row.client_ip} names={clientNames} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||
<ResultCellContent row={row} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{routeLabel(row.route_kind)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const ACTIVITY_COLUMNS = ["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"] as const;
|
||||
|
||||
export function ActivityTableHead() {
|
||||
return (
|
||||
<thead {...stylex.props(styles.head)}>
|
||||
<tr>
|
||||
{ACTIVITY_COLUMNS.map((column) => (
|
||||
<th key={column} {...stylex.props(styles.th)}>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
datetimeField,
|
||||
datetimeLocalToUnix,
|
||||
editDatetimeField,
|
||||
resolveDatetimeField,
|
||||
unixToDatetimeLocal,
|
||||
} from "./datetime";
|
||||
|
||||
/**
|
||||
* Every assertion here is about local time, so the zone has to be pinned. New
|
||||
* York is the zone the DST cases are written for: the fold is 2024-11-03 01:30
|
||||
* and the gap is 2024-03-10 02:30.
|
||||
*/
|
||||
// The app never reads `process`, so `src` is typed without node's globals; the
|
||||
// test host is node, where assigning `TZ` re-reads the zone for `Date`.
|
||||
declare const process: { env: Record<string, string | undefined> };
|
||||
|
||||
const originalTz = process.env["TZ"];
|
||||
beforeAll(() => {
|
||||
process.env["TZ"] = "America/New_York";
|
||||
});
|
||||
afterAll(() => {
|
||||
process.env["TZ"] = originalTz;
|
||||
});
|
||||
|
||||
/** 2024-06-01T12:34:56 EDT. */
|
||||
const SUMMER = 1_717_259_696;
|
||||
|
||||
test("a non-zero-second instant round trips", () => {
|
||||
expect(unixToDatetimeLocal(SUMMER)).toBe("2024-06-01T12:34:56");
|
||||
expect(datetimeLocalToUnix("2024-06-01T12:34:56")).toBe(SUMMER);
|
||||
});
|
||||
|
||||
test("text without seconds parses as :00", () => {
|
||||
expect(datetimeLocalToUnix("2024-06-01T12:34")).toBe(datetimeLocalToUnix("2024-06-01T12:34:00"));
|
||||
});
|
||||
|
||||
test("text that is not a datetime-local value names no instant", () => {
|
||||
expect(datetimeLocalToUnix("")).toBeUndefined();
|
||||
expect(datetimeLocalToUnix("yesterday")).toBeUndefined();
|
||||
expect(datetimeLocalToUnix("2024-06-01")).toBeUndefined();
|
||||
expect(datetimeLocalToUnix("2024-13-01T00:00:00")).toBeUndefined();
|
||||
});
|
||||
|
||||
/** 2024-11-03 01:30 EDT and 01:30 EST: two instants, one wall clock. */
|
||||
const FOLD_FIRST = 1_730_611_800;
|
||||
const FOLD_SECOND = 1_730_615_400;
|
||||
|
||||
test("the fall-back fold gives two instants the same text", () => {
|
||||
expect(unixToDatetimeLocal(FOLD_FIRST)).toBe("2024-11-03T01:30:00");
|
||||
expect(unixToDatetimeLocal(FOLD_SECOND)).toBe("2024-11-03T01:30:00");
|
||||
expect(datetimeLocalToUnix("2024-11-03T01:30:00")).toBe(FOLD_FIRST);
|
||||
});
|
||||
|
||||
test("an untouched fold bound applies the instant it was seeded with, not a re-parse of its text", () => {
|
||||
const field = datetimeField(FOLD_SECOND);
|
||||
expect(field.text).toBe("2024-11-03T01:30:00");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: FOLD_SECOND });
|
||||
});
|
||||
|
||||
test("an edited fold bound resolves to the first of the two instants, which is what its text says", () => {
|
||||
const field = editDatetimeField(datetimeField(FOLD_SECOND), "2024-11-03T01:30:00");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: FOLD_FIRST });
|
||||
});
|
||||
|
||||
test("a spring-forward time that exists on no clock is rejected rather than slid forward an hour", () => {
|
||||
const field = editDatetimeField(datetimeField(undefined), "2024-03-10T02:30:00");
|
||||
expect(datetimeLocalToUnix("2024-03-10T02:30:00")).toBe(1_710_055_800);
|
||||
expect(unixToDatetimeLocal(1_710_055_800)).toBe("2024-03-10T03:30:00");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: false, reason: "nonexistent" });
|
||||
});
|
||||
|
||||
test("an edited bound round trips with non-zero seconds", () => {
|
||||
const field = editDatetimeField(datetimeField(undefined), "2024-06-01T12:34:56");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: SUMMER });
|
||||
});
|
||||
|
||||
test("clearing an edited bound drops the filter", () => {
|
||||
const field = editDatetimeField(datetimeField(SUMMER), "");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: undefined });
|
||||
});
|
||||
|
||||
test("an unparseable edit is reported, never silently dropped", () => {
|
||||
const field = editDatetimeField(datetimeField(undefined), "2024-06-32T99:99");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: false, reason: "unparseable" });
|
||||
});
|
||||
|
||||
test("an unset bound seeds an empty field that stays unset", () => {
|
||||
const field = datetimeField(undefined);
|
||||
expect(field.text).toBe("");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: undefined });
|
||||
});
|
||||
|
||||
test("a fractional part on the seconds is parsed and dropped, not rejected", () => {
|
||||
// jsdom, and any engine that sanitizes to the full grammar, hands the input
|
||||
// back with milliseconds attached; a bound is a whole second either way.
|
||||
const field = editDatetimeField(datetimeField(undefined), "2023-11-14T23:13:37.000");
|
||||
const resolved = resolveDatetimeField(field);
|
||||
expect(resolved).toEqual({ ok: true, value: datetimeLocalToUnix("2023-11-14T23:13:37") });
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* The bridge between a `datetime-local` input and the unix seconds the URL and
|
||||
* the API speak.
|
||||
*
|
||||
* Local wall-clock text is lossy in a way unix seconds are not. Twice a year a
|
||||
* fall-back fold gives two instants the same text, and a spring-forward gap
|
||||
* gives an hour of text no instant at all. So the text is never the authority:
|
||||
* a bound the operator did not touch is carried through as the number it
|
||||
* already was, and a bound they did edit is accepted only when it survives a
|
||||
* round trip unchanged.
|
||||
*/
|
||||
|
||||
/** Zero-padded to the width the `datetime-local` grammar requires. */
|
||||
function pad(value: number, width: number): string {
|
||||
return String(value).padStart(width, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix seconds → the local wall-clock text a `datetime-local` input holds,
|
||||
* always with seconds, because the inputs run at `step={1}`.
|
||||
*/
|
||||
export function unixToDatetimeLocal(unix: number): string {
|
||||
const date = new Date(unix * 1000);
|
||||
const day = `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1, 2)}-${pad(date.getDate(), 2)}`;
|
||||
const time = `${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad(date.getSeconds(), 2)}`;
|
||||
return `${day}T${time}`;
|
||||
}
|
||||
|
||||
const DATETIME_LOCAL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?$/;
|
||||
|
||||
/**
|
||||
* The text with its seconds spelled out, or undefined when it is not a
|
||||
* `datetime-local` value at all. A browser omits `:00` seconds even at
|
||||
* `step={1}`, so the canonical form is what a round trip compares against.
|
||||
*
|
||||
* The grammar allows a fractional part after the seconds and some engines emit
|
||||
* one; a bound is a whole second here and on the wire, so it is parsed and then
|
||||
* dropped rather than treated as text we do not recognise.
|
||||
*/
|
||||
function canonicalize(value: string): string | undefined {
|
||||
const match = DATETIME_LOCAL.exec(value);
|
||||
if (match === null) return undefined;
|
||||
return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6] ?? "00"}`;
|
||||
}
|
||||
|
||||
/** Local wall-clock text → unix seconds, or undefined when it names no instant. */
|
||||
export function datetimeLocalToUnix(value: string): number | undefined {
|
||||
const canonical = canonicalize(value);
|
||||
if (canonical === undefined) return undefined;
|
||||
const ms = new Date(canonical).getTime();
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One bound of the filter form: what the input shows, what the applied search
|
||||
* carried, and whether the operator has touched it since.
|
||||
*/
|
||||
export interface DatetimeField {
|
||||
text: string;
|
||||
/** The applied value this field was seeded from, reused while `dirty` is false. */
|
||||
original: number | undefined;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export type DatetimeResolution =
|
||||
{ ok: true; value: number | undefined } | { ok: false; reason: "unparseable" | "nonexistent" };
|
||||
|
||||
export function datetimeField(original: number | undefined): DatetimeField {
|
||||
return { text: original === undefined ? "" : unixToDatetimeLocal(original), original, dirty: false };
|
||||
}
|
||||
|
||||
export function editDatetimeField(field: DatetimeField, text: string): DatetimeField {
|
||||
return { ...field, text, dirty: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The unix value this bound applies.
|
||||
*
|
||||
* An untouched field resolves to the number it was seeded with, never to a
|
||||
* re-parse of its own text: the text of a fall-back instant names two of them,
|
||||
* and re-parsing would silently move a bound the operator never edited.
|
||||
*
|
||||
* An edited field is parsed, then formatted back. A wall-clock time inside the
|
||||
* spring-forward gap exists on no clock, and `Date` quietly slides it forward
|
||||
* an hour; the round trip catches that and the caller reports it instead of
|
||||
* filtering on an hour nobody asked for.
|
||||
*/
|
||||
export function resolveDatetimeField(field: DatetimeField): DatetimeResolution {
|
||||
if (!field.dirty) return { ok: true, value: field.original };
|
||||
if (field.text.trim() === "") return { ok: true, value: undefined };
|
||||
const unix = datetimeLocalToUnix(field.text);
|
||||
if (unix === undefined) return { ok: false, reason: "unparseable" };
|
||||
if (unixToDatetimeLocal(unix) !== canonicalize(field.text)) return { ok: false, reason: "nonexistent" };
|
||||
return { ok: true, value: unix };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The absolute bounds an investigation link carries.
|
||||
*
|
||||
* Every link out of a query detail is time-scoped on purpose: a relative window
|
||||
* would answer a different question tomorrow than it does today, and the whole
|
||||
* point of linking to an episode is that the link keeps showing that episode.
|
||||
*/
|
||||
|
||||
import type { ActivitySearch } from "./search";
|
||||
|
||||
/** The five-minute window the redesign puts around one query. */
|
||||
export const RELATED_WINDOW_SECONDS = 300;
|
||||
|
||||
export interface Bounds {
|
||||
since: number;
|
||||
until: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bounds for "all activity for this domain/client", decided per bound.
|
||||
*
|
||||
* When the reader arrived from a bounded investigation, that bound is the one
|
||||
* they are working in and it carries over. A bound they never set falls back to
|
||||
* the five-minute window around this query — never to no bound at all, which
|
||||
* would answer with the whole retained history and lose the episode in it. The
|
||||
* two bounds are decided separately, so a half-bounded origin keeps its half.
|
||||
*/
|
||||
export function relatedBounds(ts: number, origin: Pick<ActivitySearch, "since" | "until">): Bounds {
|
||||
return {
|
||||
since: origin.since ?? ts - RELATED_WINDOW_SECONDS,
|
||||
until: origin.until ?? ts + RELATED_WINDOW_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Diagnostics window around one query. Fixed at five minutes either side of
|
||||
* the query, not inherited: the reader is asking what else was failing while
|
||||
* this query was answered, which is a question about the query's own moment.
|
||||
*/
|
||||
export function diagnosticsBounds(ts: number): Bounds {
|
||||
return { since: ts - RELATED_WINDOW_SECONDS, until: ts + RELATED_WINDOW_SECONDS };
|
||||
}
|
||||
+2
@@ -43,6 +43,8 @@ describe("summaryOf", () => {
|
||||
qtype: 28,
|
||||
blocked: true,
|
||||
policy_reason: "blocklist_wildcard",
|
||||
rcode: 0,
|
||||
route_kind: "blocked",
|
||||
response_time_us: 42,
|
||||
cache_hit: null,
|
||||
upstream: "",
|
||||
@@ -15,6 +15,9 @@ export type LiveRow = { key: number } & (
|
||||
{ kind: "streamed"; event: LiveQueryEvent } | { kind: "recovered"; row: QueryRow }
|
||||
);
|
||||
|
||||
/** The arm that carries its own provenance, and so its own detail surface. */
|
||||
export type StreamedRow = Extract<LiveRow, { kind: "streamed" }>;
|
||||
|
||||
/** The flat cells both arms render, and the shared identity for gap dedupe. */
|
||||
export function summaryOf(row: LiveRow): QuerySummary {
|
||||
return row.kind === "streamed" ? summarizeEvent(row.event) : summarizeRow(row.row);
|
||||
@@ -41,7 +44,7 @@ export function pushRow(rows: LiveRow[], row: LiveRow, capacity: number = RING_C
|
||||
* consume its occurrence, duplicating one query and losing the other.
|
||||
*
|
||||
* `QuerySummary` is the wrong basis for that: it is what the table renders, and
|
||||
* it drops qclass, rcode, policy_action and route_kind. `Omit<QueryRow, "id">`
|
||||
* it drops qclass and policy_action. `Omit<QueryRow, "id">`
|
||||
* instead makes the compiler demand a derivation for every stored column, so a
|
||||
* column added to the row cannot quietly fall out of the identity.
|
||||
*/
|
||||
@@ -0,0 +1,113 @@
|
||||
import { validateActivitySearch, validateBlocked, validateMode, validateText, validateTimestamp } from "./search";
|
||||
|
||||
test("mode is the two-value union, defaulting to history", () => {
|
||||
expect(validateMode("live")).toBe("live");
|
||||
expect(validateMode("history")).toBe("history");
|
||||
expect(validateMode(undefined)).toBe("history");
|
||||
expect(validateMode("Live")).toBe("history");
|
||||
expect(validateMode("")).toBe("history");
|
||||
expect(validateMode(0)).toBe("history");
|
||||
expect(validateMode(["live"])).toBe("history");
|
||||
});
|
||||
|
||||
test("a bound is a safe integer or nothing at all", () => {
|
||||
expect(validateTimestamp(1_700_000_000)).toBe(1_700_000_000);
|
||||
expect(validateTimestamp(0)).toBe(0);
|
||||
expect(validateTimestamp(-1)).toBe(-1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["a fraction", 1_700_000_000.5],
|
||||
["Infinity", Number.POSITIVE_INFINITY],
|
||||
["-Infinity", Number.NEGATIVE_INFINITY],
|
||||
["NaN", Number.NaN],
|
||||
["past the safe range", Number.MAX_SAFE_INTEGER + 1],
|
||||
["1e21", 1e21],
|
||||
["a numeric string", "1700000000"],
|
||||
["an empty string", ""],
|
||||
["null", null],
|
||||
["undefined", undefined],
|
||||
["a boolean", true],
|
||||
["an array", [1_700_000_000]],
|
||||
["a bigint", 1_700_000_000n],
|
||||
])("a bound rejects %s", (_name, value) => {
|
||||
expect(validateTimestamp(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("blocked keeps false, which is the allowed-only filter", () => {
|
||||
expect(validateBlocked(true)).toBe(true);
|
||||
expect(validateBlocked(false)).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["the string true", "true"],
|
||||
["the string false", "false"],
|
||||
["1", 1],
|
||||
["0", 0],
|
||||
["null", null],
|
||||
["undefined", undefined],
|
||||
])("blocked rejects %s", (_name, value) => {
|
||||
expect(validateBlocked(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a text filter is trimmed, and an empty one is no filter", () => {
|
||||
expect(validateText("ads.example")).toBe("ads.example");
|
||||
expect(validateText(" ads.example ")).toBe("ads.example");
|
||||
expect(validateText("")).toBeUndefined();
|
||||
expect(validateText(" ")).toBeUndefined();
|
||||
expect(validateText("\t\n")).toBeUndefined();
|
||||
expect(validateText(42)).toBeUndefined();
|
||||
expect(validateText(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a whole search normalizes every field and drops nothing else in", () => {
|
||||
expect(
|
||||
validateActivitySearch({
|
||||
mode: "live",
|
||||
since: 1_700_000_000,
|
||||
until: 1_700_000_600,
|
||||
domain: " ads.example ",
|
||||
client: "192.0.2.10",
|
||||
blocked: false,
|
||||
unknown: "kept out",
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "live",
|
||||
since: 1_700_000_000,
|
||||
until: 1_700_000_600,
|
||||
domain: "ads.example",
|
||||
client: "192.0.2.10",
|
||||
blocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("an empty search is history with no filters", () => {
|
||||
expect(validateActivitySearch({})).toEqual({
|
||||
mode: "history",
|
||||
since: undefined,
|
||||
until: undefined,
|
||||
domain: undefined,
|
||||
client: undefined,
|
||||
blocked: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("a search of junk applies nothing", () => {
|
||||
expect(
|
||||
validateActivitySearch({
|
||||
mode: "HISTORY ",
|
||||
since: "1700000000",
|
||||
until: Number.POSITIVE_INFINITY,
|
||||
domain: " ",
|
||||
client: null,
|
||||
blocked: "true",
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "history",
|
||||
since: undefined,
|
||||
until: undefined,
|
||||
domain: undefined,
|
||||
client: undefined,
|
||||
blocked: undefined,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The Activity search parameters, validated as pure functions so the route's
|
||||
* `validateSearch` stays a one-liner and every rejection is testable without a
|
||||
* router.
|
||||
*
|
||||
* A search value arrives from a URL, from history state, or from a hand-typed
|
||||
* link, so nothing about its type is given. Anything that is not exactly the
|
||||
* value the API can filter on becomes `undefined`: an unbounded page is honest,
|
||||
* a page filtered on a coerced guess is not.
|
||||
*/
|
||||
|
||||
import type { QueriesFilter } from "@/lib/types";
|
||||
|
||||
export const ACTIVITY_MODES = ["history", "live"] as const;
|
||||
export type ActivityMode = (typeof ACTIVITY_MODES)[number];
|
||||
|
||||
export interface ActivitySearch {
|
||||
mode: ActivityMode;
|
||||
since: number | undefined;
|
||||
until: number | undefined;
|
||||
domain: string | undefined;
|
||||
client: string | undefined;
|
||||
blocked: boolean | undefined;
|
||||
}
|
||||
|
||||
/** History is the surface a bare `/activity` should open on: it answers questions. */
|
||||
export function validateMode(value: unknown): ActivityMode {
|
||||
return value === "live" ? "live" : "history";
|
||||
}
|
||||
|
||||
/**
|
||||
* A unix-second bound. `Number.isSafeInteger` is the whole test: it rejects a
|
||||
* fraction, an infinity, a NaN and a magnitude past 2^53 in one step, and a
|
||||
* string never passes, so `?since=now` cannot reach the API as garbage.
|
||||
*/
|
||||
export function validateTimestamp(value: unknown): number | undefined {
|
||||
return Number.isSafeInteger(value) ? (value as number) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The blocked filter. `false` is a real filter — "allowed only" — so it must
|
||||
* survive; only a genuine boolean does, because `"false"` out of a URL parser
|
||||
* that did not decode JSON would otherwise read as true.
|
||||
*/
|
||||
export function validateBlocked(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A text filter, trimmed. An empty result becomes `undefined` rather than `""`:
|
||||
* the server treats an empty filter as no filter, and a URL that showed
|
||||
* `domain=` as applied state would claim a filter that is not filtering.
|
||||
*/
|
||||
export function validateText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The API filter for a validated search, built field by field.
|
||||
*
|
||||
* Only the fields that are actually set are written, so an unfiltered request
|
||||
* carries no keys at all: `GET /api/queries` rejects a parameter it does not
|
||||
* know, and the infinite query's cache key is the filter object, so a key
|
||||
* present-but-undefined and a key absent must not be two different windows onto
|
||||
* the same rows. `mode` never appears — it selects the surface, not the rows.
|
||||
*/
|
||||
export function queriesFilterOf(search: Omit<ActivitySearch, "mode">): QueriesFilter {
|
||||
const filter: QueriesFilter = {};
|
||||
if (search.domain !== undefined) filter.domain = search.domain;
|
||||
if (search.client !== undefined) filter.client = search.client;
|
||||
if (search.blocked !== undefined) filter.blocked = search.blocked;
|
||||
if (search.since !== undefined) filter.since = search.since;
|
||||
if (search.until !== undefined) filter.until = search.until;
|
||||
return filter;
|
||||
}
|
||||
|
||||
export function validateActivitySearch(search: Record<string, unknown>): ActivitySearch {
|
||||
return {
|
||||
mode: validateMode(search["mode"]),
|
||||
since: validateTimestamp(search["since"]),
|
||||
until: validateTimestamp(search["until"]),
|
||||
domain: validateText(search["domain"]),
|
||||
client: validateText(search["client"]),
|
||||
blocked: validateBlocked(search["blocked"]),
|
||||
};
|
||||
}
|
||||
+4
-4
@@ -10,7 +10,7 @@ afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function stubLocationAssign() {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { pathname: "/live", search: "", assign });
|
||||
vi.stubGlobal("location", { pathname: "/activity", search: "?mode=live", assign });
|
||||
return assign;
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ test("a 401 gap re-sync redirects to login instead of setting resyncFailed", asy
|
||||
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
|
||||
act(() => sources[0]!.emit("error"));
|
||||
act(() => sources[0]!.emit("open"));
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
|
||||
expect(hook.result.current.resyncFailed).toBe(false);
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ test("cap trip with an expired session redirects to login", async () => {
|
||||
act(() => {
|
||||
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
|
||||
});
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -183,7 +183,7 @@ test("a fatal rejection with an expired session redirects to login", async () =>
|
||||
|
||||
act(() => sources[0]!.failFatal());
|
||||
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -337,3 +337,38 @@ test("an episode links to its own detail page", async () => {
|
||||
const link = await screen.findByRole("link", { name: "Blocklist source failed to update" });
|
||||
expect(link.getAttribute("href")).toBe("/diagnostics/42");
|
||||
});
|
||||
|
||||
test("an absolute window reaches both requests and is stated on the page", async () => {
|
||||
const bounded = "since=1699999700&until=1700000300";
|
||||
responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE;
|
||||
responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED;
|
||||
renderRoute(`/diagnostics?${bounded}`);
|
||||
|
||||
await screen.findByRole("heading", { name: "Active" });
|
||||
expect(requested).toContain(`/api/diagnostics?${bounded}&state=active`);
|
||||
expect(requested).toContain(`/api/diagnostics?${bounded}&state=resolved`);
|
||||
// An empty section inside a five-minute window means something different
|
||||
// from an empty section over the whole history, so the page has to say so.
|
||||
expect(screen.getByText(/Showing events that overlap/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("clearing the range drops both bounds from the url", async () => {
|
||||
const bounded = "since=1699999700&until=1700000300";
|
||||
responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE;
|
||||
responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED;
|
||||
const router = renderRoute(`/diagnostics?${bounded}`);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Clear the time range" }));
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.search).not.toContain("since");
|
||||
});
|
||||
expect(router.state.location.search).not.toContain("until");
|
||||
});
|
||||
|
||||
test("a bound that is not a whole second is dropped, leaving the page unbounded", async () => {
|
||||
renderRoute("/diagnostics?since=1.5&until=Infinity");
|
||||
|
||||
await screen.findByRole("heading", { name: "Active" });
|
||||
expect(requested).toContain("/api/diagnostics?state=active");
|
||||
expect(screen.queryByText(/Showing events that overlap/)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -12,18 +12,13 @@ import * as api from "@/lib/api";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { formatDuration, formatTime } from "@/lib/format";
|
||||
import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries";
|
||||
import type {
|
||||
DiagnosticEvent,
|
||||
DiagnosticSeverity,
|
||||
DiagnosticState,
|
||||
DiagnosticsFilter,
|
||||
DiagnosticsPage as Page,
|
||||
} from "@/lib/types";
|
||||
import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import SeverityBadge from "./SeverityBadge";
|
||||
import { diagnosticsFilterOf } from "./filter";
|
||||
import { DIAGNOSTIC_COMPONENTS, componentLabel, copyFor } from "./eventCopy";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
@@ -138,6 +133,19 @@ const styles = stylex.create({
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
rangeNotice: {
|
||||
marginTop: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceHover,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "0.75rem",
|
||||
overflowX: "auto",
|
||||
@@ -263,6 +271,33 @@ function MoreButton({ section }: { section: Section }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The window the page is bounded to, whenever it is bounded.
|
||||
*
|
||||
* A link from a query detail arrives with an absolute five-minute window, and
|
||||
* an empty Active section inside it means something very different from an
|
||||
* empty Active section over the whole history. The page has to say which it is
|
||||
* showing, and offer the way out of it.
|
||||
*/
|
||||
function RangeNotice({ since, until }: { since?: number; until?: number }) {
|
||||
const navigate = useNavigate({ from: "/diagnostics" });
|
||||
if (since === undefined && until === undefined) return null;
|
||||
const from = since === undefined ? "the start of the history" : formatTime(since);
|
||||
const to = until === undefined ? "now" : formatTime(until);
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.rangeNotice)}>
|
||||
Showing events that overlap {from} to {to}.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate({ search: (prev) => ({ ...prev, since: undefined, until: undefined }) })}
|
||||
{...stylex.props(shared.linkButton, shared.focusRing)}
|
||||
>
|
||||
Clear the time range
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
</p>
|
||||
|
||||
<RangeNotice since={search.since} until={search.until} />
|
||||
|
||||
<div {...stylex.props(styles.filterGrid)}>
|
||||
<Select
|
||||
variant="compactField"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The Diagnostics search parameters and the API filter they build.
|
||||
*
|
||||
* The route, the page and the two infinite queries all have to agree on what
|
||||
* the URL asked for — the page renders the same window the loader prefetched —
|
||||
* so the projection lives in one place rather than being spelled out at each.
|
||||
*/
|
||||
|
||||
import type { DiagnosticSeverity, DiagnosticState, DiagnosticsFilter } from "@/lib/types";
|
||||
|
||||
export interface DiagnosticsSearch {
|
||||
state?: DiagnosticState;
|
||||
severity?: DiagnosticSeverity;
|
||||
component?: string;
|
||||
/** Unix seconds, inclusive. An episode qualifies when its interval overlaps. */
|
||||
since?: number;
|
||||
until?: number;
|
||||
}
|
||||
|
||||
/** Field by field, so an unset filter is an absent key rather than `undefined`. */
|
||||
export function diagnosticsFilterOf(search: DiagnosticsSearch): DiagnosticsFilter {
|
||||
const filter: DiagnosticsFilter = {};
|
||||
if (search.severity !== undefined) filter.severity = search.severity;
|
||||
if (search.component !== undefined) filter.component = search.component;
|
||||
if (search.since !== undefined) filter.since = search.since;
|
||||
if (search.until !== undefined) filter.until = search.until;
|
||||
return filter;
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
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";
|
||||
import LiveLogPage from "./LiveLogPage";
|
||||
|
||||
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", "", ""),
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) !== "/api/clients") {
|
||||
return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
}
|
||||
return new Response(JSON.stringify({ clients: CLIENTS }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function frame(ts: number, domain: string, sections: Parameters<typeof provenance>[0] = {}): { data: string } {
|
||||
const payload = provenance({
|
||||
...sections,
|
||||
request: { time: ts, domain, ...sections.request },
|
||||
route: { kind: "cache", upstream: "", ...sections.route },
|
||||
});
|
||||
return { data: JSON.stringify(payload) };
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const sources: FakeEventSource[] = [];
|
||||
const createEventSource = (url: string) => {
|
||||
const es = new FakeEventSource(url);
|
||||
sources.push(es);
|
||||
return es;
|
||||
};
|
||||
render(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<LiveLogPage createEventSource={createEventSource} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return sources;
|
||||
}
|
||||
|
||||
test("streams rows, flags blocked ones, and freezes the display", () => {
|
||||
const sources = renderPage();
|
||||
expect(screen.getByText("Connecting…")).toBeTruthy();
|
||||
|
||||
act(() => sources[0]!.emit("open"));
|
||||
expect(screen.getByRole("status", { name: "Live" })).toBeTruthy();
|
||||
expect(screen.getByText("Waiting for queries…")).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "ok.example"));
|
||||
sources[0]!.emit(
|
||||
"query",
|
||||
frame(1001, "ads.example", {
|
||||
request: { qtype: 28 },
|
||||
policy: { action: "block", reason: "blocklist_wildcard" },
|
||||
route: { kind: "blocked" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText("ok.example")).toBeTruthy();
|
||||
expect(screen.getByText("Blocked")).toBeTruthy();
|
||||
expect(screen.getByText("Blocklist (wildcard)")).toBeTruthy();
|
||||
expect(screen.getByText("AAAA")).toBeTruthy();
|
||||
// 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 blockedRow = screen.getByText("ads.example").closest("tr");
|
||||
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 () => {
|
||||
const sources = renderPage();
|
||||
act(() => sources[0]!.emit("open"));
|
||||
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" } }));
|
||||
});
|
||||
|
||||
// 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.
|
||||
const nameless = screen.getByText("192.0.2.12");
|
||||
expect(nameless.getAttribute("title")).toBeNull();
|
||||
const stranger = screen.getByText("192.0.2.99");
|
||||
expect(stranger.getAttribute("title")).toBeNull();
|
||||
expect(screen.getByText("stranger.example").closest("tr")?.textContent).toContain("192.0.2.99");
|
||||
});
|
||||
|
||||
test("rows stream in as bare IPs while the client list is still loading", async () => {
|
||||
let releaseClients: () => void = () => {};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
(input: RequestInfo | URL) =>
|
||||
new Promise<Response>((resolve) => {
|
||||
if (String(input) !== "/api/clients") {
|
||||
resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||
return;
|
||||
}
|
||||
releaseClients = () =>
|
||||
resolve(
|
||||
new Response(JSON.stringify({ clients: CLIENTS }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const sources = renderPage();
|
||||
act(() => sources[0]!.emit("open"));
|
||||
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", () => {
|
||||
const sources = renderPage();
|
||||
act(() => {
|
||||
sources[0]!.emit("error");
|
||||
sources[0]!.emit("error");
|
||||
sources[0]!.emit("error");
|
||||
});
|
||||
expect(screen.getByRole("alert").textContent).toContain("too many live viewers");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(sources).toHaveLength(2);
|
||||
expect(screen.getByText("Connecting…")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a row recovered by the reconnect fetch links to its stored detail; a streamed one cannot", async () => {
|
||||
const sources: FakeEventSource[] = [];
|
||||
vi.stubGlobal(
|
||||
"EventSource",
|
||||
class {
|
||||
constructor(url: string) {
|
||||
const es = new FakeEventSource(url);
|
||||
sources.push(es);
|
||||
return es as unknown as EventSource;
|
||||
}
|
||||
},
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
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({ version: "0.0.0-test", git_commit: "0", zig_version: "0.16.0", uptime_seconds: 1 });
|
||||
}),
|
||||
);
|
||||
|
||||
// The route, not the bare page: only a recovered row renders a link, so the
|
||||
// test needs the router the link resolves against.
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/live"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
await screen.findByRole("button", { name: "Freeze" });
|
||||
const source = sources[0]!;
|
||||
act(() => source.emit("open"));
|
||||
act(() => source.emit("query", frame(1000, "streamed.example")));
|
||||
act(() => source.emit("error"));
|
||||
act(() => source.emit("open"));
|
||||
|
||||
const recovered = await screen.findByRole("link", { name: "recovered.example" });
|
||||
expect(recovered.getAttribute("href")).toBe("/queries/88");
|
||||
// The streamed frame precedes its own insert, so it has no row to link to.
|
||||
expect(screen.queryByRole("link", { name: "streamed.example" })).toBeNull();
|
||||
expect(screen.getByText("streamed.example")).toBeTruthy();
|
||||
});
|
||||
@@ -1,95 +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";
|
||||
import type { LookupResult } from "@/lib/types";
|
||||
|
||||
const BLOCKED: LookupResult = {
|
||||
domain: "ads.example",
|
||||
group_id: 1,
|
||||
local_records: false,
|
||||
forward_zone: null,
|
||||
blocked: true,
|
||||
reason: "blocklist_domain",
|
||||
matched: "ads.example",
|
||||
source_url: "https://lists.test/a",
|
||||
safe_search_rewrite: null,
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof createFetchMock>;
|
||||
|
||||
function json(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function createFetchMock() {
|
||||
return vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/groups") {
|
||||
return json({
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url === "/api/lookup?domain=ads.example&group_id=1") return json(BLOCKED);
|
||||
return json({ error: "not stubbed" }, 404);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = createFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/lookup"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function lookupCalls(): string[] {
|
||||
return fetchMock.mock.calls.map(([input]) => String(input)).filter((url) => url.startsWith("/api/lookup"));
|
||||
}
|
||||
|
||||
test("fetches nothing until submit, then renders the blocked verdict", async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByRole("heading", { name: "Lookup" });
|
||||
await screen.findByLabelText("Group");
|
||||
expect(lookupCalls()).toEqual([]);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||
expect(lookupCalls()).toEqual([]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Look up" }));
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocked" });
|
||||
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
|
||||
|
||||
expect(screen.getByText("blocklist_domain")).toBeTruthy();
|
||||
const link = screen.getByRole("link", { name: "https://lists.test/a" }) as HTMLAnchorElement;
|
||||
expect(link.href).toBe("https://lists.test/a");
|
||||
expect(screen.getByText("Queries for this name get a blocked response.")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("defaults the group select to the default group (id 1)", async () => {
|
||||
renderPage();
|
||||
// A RAC Select names its trigger with the current value and then the label, so
|
||||
// the selected group's name is the only thing the trigger shows.
|
||||
const trigger = await screen.findByRole("button", { name: /Group$/ });
|
||||
expect(trigger.textContent).toContain("default");
|
||||
});
|
||||
@@ -1,452 +0,0 @@
|
||||
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 "./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> = {}): 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<QueryRow>;
|
||||
|
||||
const PAGES: Record<string, QueriesPage> = {
|
||||
"/api/queries": {
|
||||
queries: [
|
||||
row(20, "first.example", { qtype: 65, cache_hit: true }),
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
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 new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/**
|
||||
* The whole router, not the bare component: the rows link into `/queries/$id`
|
||||
* and the filter form seeds itself from the url, so both need real routing.
|
||||
*/
|
||||
function renderPage(path = "/queries") {
|
||||
const client = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), client);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={client}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
test("renders the first page with type names, blocked badge, and formatted cells", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
expect(screen.getByText("HTTPS")).toBeTruthy();
|
||||
expect(screen.getByText("A")).toBeTruthy();
|
||||
expect(screen.getByText("Blocked")).toBeTruthy();
|
||||
expect(screen.getByText("Blocklist (wildcard)")).toBeTruthy();
|
||||
expect(screen.getByText("1.2 ms")).toBeTruthy();
|
||||
expect(screen.getByText("hit")).toBeTruthy();
|
||||
expect(screen.getByText("udp://9.9.9.9:53")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
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 refetches and resets the accumulated list", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("older.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
expect(screen.getByText("ads.example")).toBeTruthy();
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
});
|
||||
|
||||
test("a load-more that resolves after a filter change is discarded", async () => {
|
||||
let releaseLoadMore: () => void = () => {};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/queries?before=19") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
releaseLoadMore = () => {
|
||||
resolve(
|
||||
new Response(JSON.stringify(PAGES["/api/queries?before=19"]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
}
|
||||
const payload = PAGES[url];
|
||||
if (payload === undefined)
|
||||
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
|
||||
releaseLoadMore();
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("load more is disabled while a filter change shows placeholder data, then uses the fresh cursor", async () => {
|
||||
let releaseFiltered: () => void = () => {};
|
||||
const filteredPage: QueriesPage = {
|
||||
queries: [row(19, "ads.example", BLOCKED)],
|
||||
next_before: 7,
|
||||
coverage: COMPLETE,
|
||||
};
|
||||
const filteredOlderPage: QueriesPage = {
|
||||
queries: [row(3, "ads.older.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
};
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/queries?domain=ads") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
releaseFiltered = () => {
|
||||
resolve(
|
||||
new Response(JSON.stringify(filteredPage), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
}
|
||||
const payload = url === "/api/queries?domain=ads&before=7" ? filteredOlderPage : PAGES[url];
|
||||
if (payload === undefined)
|
||||
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
|
||||
const staleButton = screen.getByRole("button", { name: "Load more" });
|
||||
expect(staleButton).toHaveProperty("disabled", true);
|
||||
fireEvent.click(staleButton);
|
||||
expect(fetchMock.mock.calls.map((call) => String(call[0]))).not.toContain("/api/queries?domain=ads&before=19");
|
||||
|
||||
releaseFiltered();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
});
|
||||
|
||||
const freshButton = screen.getByRole("button", { name: "Load more" });
|
||||
expect(freshButton).toHaveProperty("disabled", false);
|
||||
fireEvent.click(freshButton);
|
||||
await screen.findByText("ads.older.example");
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toContain("/api/queries?domain=ads&before=7");
|
||||
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a 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<string, QueriesPage> = {
|
||||
"/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<string, QueriesPage> = {
|
||||
"/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;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const payload = live[String(input)];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
const client = 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 client.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: "/queries", search: "", assign });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/queries?before=19") {
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const payload = PAGES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await waitFor(() => {
|
||||
expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/queries")}`);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByText(/Failed to load more/)).toBeNull();
|
||||
});
|
||||
|
||||
test("each row links into its own detail page by domain, reachable from the keyboard", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
const link = screen.getByRole("link", { name: "first.example" });
|
||||
expect(link.getAttribute("href")).toBe("/queries/20");
|
||||
// An <a href> is in the tab order by default; nothing here may opt it out.
|
||||
expect(link.getAttribute("tabindex")).toBeNull();
|
||||
});
|
||||
|
||||
test("an allowed query names the rule that allowed it; an unremarkable one stays blank", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
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, "allowed.example", { policy_reason: "rule_allow_exact" }), row(19, "plain.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
} satisfies QueriesPage);
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("allowed.example");
|
||||
expect(screen.getByText("Allow rule (exact)")).toBeTruthy();
|
||||
expect(screen.queryByText("Blocked")).toBeNull();
|
||||
expect(within(screen.getByText("plain.example").closest("tr")!).getByText("—")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a pruned window tells the reader when history starts", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
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 and fetches that domain on arrival", async () => {
|
||||
renderPage("/queries?domain=ads");
|
||||
|
||||
await screen.findByText("ads.example");
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
});
|
||||
@@ -1,422 +0,0 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link, useSearch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import * as api from "@/lib/api";
|
||||
import CoverageNotice from "@/lib/CoverageNotice";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
||||
import { ClientName, useClientNames, type ClientNames } from "@/features/clients/clientNames";
|
||||
import { isUninformativeReason, policyReasonLabel } from "./provenanceCopy";
|
||||
import { summarizeRow, type QuerySummary } from "./querySummary";
|
||||
import { qtypeName } from "./qtype";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "any", label: "All" },
|
||||
{ value: "blocked", label: "Blocked only" },
|
||||
{ value: "allowed", label: "Allowed only" },
|
||||
];
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
/** One column on a phone, two from `sm`, five from `lg`, as before. */
|
||||
filterGrid: {
|
||||
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))",
|
||||
},
|
||||
},
|
||||
filterLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
filterInput: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
},
|
||||
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,
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
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",
|
||||
},
|
||||
/** The header tint is a shade off the ground in each scheme, not a token role. */
|
||||
head: {
|
||||
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||
textAlign: "left",
|
||||
},
|
||||
th: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||
row: {
|
||||
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
cell: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
nowrap: {
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
breakAll: {
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
/** The row's way into the detail page; a real link, so tab and enter reach it. */
|
||||
domainLink: {
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
small: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
},
|
||||
muted: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
blockedWrap: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
blockedBadge: {
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 500,
|
||||
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
|
||||
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
||||
},
|
||||
footer: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
function datetimeLocalToUnix(value: string): number | undefined {
|
||||
if (value === "") return undefined;
|
||||
const ms = new Date(value).getTime();
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the policy decided, and why. The reason is the stored enum rather than
|
||||
* the old free-text block reason, so an allowed query that a rule or a blocklist
|
||||
* exception explains says so too — only `no_match`, the answer for most allowed
|
||||
* queries, stays blank.
|
||||
*/
|
||||
export function StatusCell({ row }: { row: Pick<QuerySummary, "blocked" | "policy_reason"> }) {
|
||||
const reason = policyReasonLabel(row.policy_reason);
|
||||
if (!row.blocked) {
|
||||
if (isUninformativeReason(row.policy_reason)) return <span {...stylex.props(styles.muted)}>—</span>;
|
||||
return <span {...stylex.props(styles.small, styles.muted)}>{reason}</span>;
|
||||
}
|
||||
return (
|
||||
<span {...stylex.props(styles.blockedWrap)}>
|
||||
<span {...stylex.props(styles.blockedBadge)}>Blocked</span>
|
||||
<span {...stylex.props(styles.small, styles.muted)}>{reason}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The eight shared cells. `row.id` is null for a live frame the server has not
|
||||
* written yet, which is the one case with no detail page to link to.
|
||||
*/
|
||||
export function QueryCells({ row, clientNames }: { row: QuerySummary; clientNames: ClientNames }) {
|
||||
const id = row.id;
|
||||
return (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||
{id === null ? (
|
||||
row.domain
|
||||
) : (
|
||||
<Link
|
||||
to="/queries/$id"
|
||||
params={{ id: String(id) }}
|
||||
{...stylex.props(styles.domainLink, shared.focusRing)}
|
||||
>
|
||||
{row.domain}
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
||||
<ClientName ip={row.client_ip} names={clientNames} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<StatusCell row={row} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||
{row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||
{row.upstream === "" ? "—" : row.upstream}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function QueryTableHead() {
|
||||
return (
|
||||
<thead {...stylex.props(styles.head)}>
|
||||
<tr>
|
||||
<th {...stylex.props(styles.th)}>Time</th>
|
||||
<th {...stylex.props(styles.th)}>Domain</th>
|
||||
<th {...stylex.props(styles.th)}>Client</th>
|
||||
<th {...stylex.props(styles.th)}>Type</th>
|
||||
<th {...stylex.props(styles.th)}>Status</th>
|
||||
<th {...stylex.props(styles.th)}>Response</th>
|
||||
<th {...stylex.props(styles.th)}>Cache</th>
|
||||
<th {...stylex.props(styles.th)}>Upstream</th>
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QueryLogPage() {
|
||||
// The two url filters exist so a detail page can link back to "every query
|
||||
// for this domain". They seed the form once; typing from here on is local
|
||||
// state, as the other three filters always were.
|
||||
const search = useSearch({ from: "/shell/queries" });
|
||||
const [domain, setDomain] = useState(search.domain ?? "");
|
||||
const [client, setClient] = useState(search.client ?? "");
|
||||
const [blocked, setBlocked] = useState("any");
|
||||
const [since, setSince] = useState("");
|
||||
const [until, setUntil] = useState("");
|
||||
|
||||
const [applied, setApplied] = useState<QueriesFilter>(() => {
|
||||
const initial: QueriesFilter = {};
|
||||
if (search.domain !== undefined) initial.domain = search.domain;
|
||||
if (search.client !== undefined) initial.client = search.client;
|
||||
return initial;
|
||||
});
|
||||
|
||||
const base = useInfiniteQuery(queriesInfiniteQuery(applied));
|
||||
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(applied).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 applyFilters(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const filter: QueriesFilter = {};
|
||||
if (domain.trim() !== "") filter.domain = domain.trim();
|
||||
if (client.trim() !== "") filter.client = client.trim();
|
||||
if (blocked === "blocked") filter.blocked = true;
|
||||
if (blocked === "allowed") filter.blocked = false;
|
||||
const sinceTs = datetimeLocalToUnix(since);
|
||||
if (sinceTs !== undefined) filter.since = sinceTs;
|
||||
const untilTs = datetimeLocalToUnix(until);
|
||||
if (untilTs !== undefined) filter.until = untilTs;
|
||||
setApplied(filter);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setDomain("");
|
||||
setClient("");
|
||||
setBlocked("any");
|
||||
setSince("");
|
||||
setUntil("");
|
||||
setApplied({});
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return;
|
||||
void base.fetchNextPage();
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Query Log</h1>
|
||||
|
||||
<form onSubmit={applyFilters} {...stylex.props(styles.filterGrid)}>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Domain contains
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Client (exact)
|
||||
<input
|
||||
type="text"
|
||||
value={client}
|
||||
onChange={(event) => setClient(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
variant="compactField"
|
||||
label="Status"
|
||||
value={blocked}
|
||||
onChange={setBlocked}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Since
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={since}
|
||||
onChange={(event) => setSince(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Until
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={until}
|
||||
onChange={(event) => setUntil(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button type="submit" {...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}>
|
||||
Apply filters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{base.isFetching && (
|
||||
<span {...stylex.props(styles.note)} role="status">
|
||||
Loading…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
|
||||
|
||||
{base.data === undefined ? (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
Loading query log…
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<QueryTableHead />
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} {...stylex.props(styles.row)}>
|
||||
<QueryCells row={summarizeRow(row)} clientNames={clientNames} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div {...stylex.props(styles.footer)}>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
>
|
||||
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{moreError !== null && (
|
||||
<p role="alert" {...stylex.props(styles.moreError)}>
|
||||
Failed to load more: {moreError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<number, string> = {
|
||||
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 <n>`
|
||||
* 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];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+78
-54
@@ -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<string, unknown>): { 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<string, unknown>): { 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<string, unknown>): { 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<string, unknown>,
|
||||
): { state?: DiagnosticState; severity?: DiagnosticSeverity; component?: string } => {
|
||||
validateSearch: (search: Record<string, unknown>): 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,
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(<Select label="Status" options={OPTIONS} value="blocked" onChange={onChange} isDisabled />);
|
||||
|
||||
const trigger = screen.getByRole("button");
|
||||
expect(trigger.textContent).toContain("Blocked only");
|
||||
// A disabled button is out of the tab order by definition, so the filter row
|
||||
// cannot be reached by keyboard while live mode owns it.
|
||||
expect(trigger).toHaveProperty("disabled", true);
|
||||
|
||||
open(trigger);
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("an enabled select still opens", () => {
|
||||
render(<Select label="Status" options={OPTIONS} value="any" onChange={vi.fn()} />);
|
||||
open(screen.getByRole("button"));
|
||||
expect(screen.getByRole("listbox")).toBeTruthy();
|
||||
});
|
||||
+16
-2
@@ -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 `<button disabled>`, so the state is reachable as a
|
||||
// pseudo-class rather than needing a second style object.
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
opacity: { default: null, ":disabled": 0.55 },
|
||||
},
|
||||
compact: {
|
||||
marginTop: "0.25rem",
|
||||
@@ -105,12 +110,21 @@ const styles = stylex.create({
|
||||
},
|
||||
});
|
||||
|
||||
export default function Select({ options, value, onChange, label, "aria-label": ariaLabel, variant = "field" }: Props) {
|
||||
export default function Select({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
"aria-label": ariaLabel,
|
||||
variant = "field",
|
||||
isDisabled = false,
|
||||
}: Props) {
|
||||
const base = variant === "field" ? shared.input : shared.smallInput;
|
||||
const block = variant === "compactField" ? styles.compact : null;
|
||||
return (
|
||||
<AriaSelect
|
||||
aria-label={ariaLabel}
|
||||
isDisabled={isDisabled}
|
||||
value={value}
|
||||
onChange={(key) => onChange(String(key ?? ""))}
|
||||
{...stylex.props(styles.root)}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Milestone 29: Activity consolidation
|
||||
|
||||
Redesign step 3 of specs/ui-redesign.md ("Activity", "Time scoping", "Deletions and their cost", build-sequence step 3). Query Log, Live and Lookup merge into one Activity surface; the three old routes and their code are removed in the same change. Admin-only — no Zig, wire or openapi changes; if a session believes it needs one, that is a spec bug to report, not code to write. Codex design review folded in (thread 01a02857); its corrections are marked where they changed the shape.
|
||||
|
||||
## Sessions
|
||||
|
||||
S1: route-independent primitives only — it must not touch routes.tsx, AppShell.tsx, or move any route-bound page (Codex: the original move-then-delete split could not keep the tree shippable between sessions). S2: one atomic landable session — routes, page assembly, moves, deletion, retargeting, test migration, smoke. Sequential.
|
||||
|
||||
---
|
||||
|
||||
## Session S1: primitives (no route changes)
|
||||
|
||||
### S1.1 Summary projection and cells
|
||||
|
||||
- `QuerySummary` (querySummary.ts:13) gains `rcode` and `route_kind` only. **Result derives from the existing `blocked` projection** (which already folds live `policy.action` — Codex: the summary has no `policy_action` and does not need one), else the rcode.
|
||||
- New `admin/src/features/activity/cells.tsx`: the seven-column set. The Domain cell takes a **route-neutral link-renderer prop** (Codex: S1 cannot reference the not-yet-existing typed route without failing typecheck); S2 supplies the typed `Link` to `/activity/queries/$id`. — Time, Domain, Client, Type, Result, Route, Duration. Table-only compact labels, defined and tested exactly here (the detail page keeps `provenanceCopy`'s long forms): Result → `Blocked` | `NOERROR` | `SERVFAIL` | … (bare rcode name, no numeric suffix); Route → `Blocked` | `Local` | `Forward zone` | `Upstream` | `Cache` | `Rejected`. Non-NOERROR result carries a non-color signal (weight/icon), same treatment as Blocked. Duration formats `response_time_us`, em dash when null.
|
||||
- Tests: fixtures for blocked, allowed-NOERROR (**explicitly `blocked=false`** — Codex: the allowed case must be pinned, not implied), SERVFAIL, cache hit; each cell's exact text, including an unassigned extended rcode preserving provenanceCopy's `RCODE <n>` fallback shape (compact form without the parenthesized number).
|
||||
|
||||
### S1.2 Shared provenance-detail renderer (Codex critical: streamed live rows must keep a detail surface)
|
||||
|
||||
Extract the ordered-explanation body of QueryDetailPage (request / group / policy / rewrites / route / response sections, historical-vs-current separation, the honesty wordings) into `admin/src/features/activity/ProvenanceDetail.tsx` taking a `Provenance` plus optional persisted id. QueryDetailPage becomes a thin route wrapper around it (page stays at its current route in S1 — only the body moves to a route-independent component). The renderer takes related-action links as props so S1 stays route-agnostic.
|
||||
|
||||
### S1.3 Form/URL plumbing primitives
|
||||
|
||||
- `Select` (ui/Select.tsx:23) gains `isDisabled`, passed through to React Aria (Codex: the filter row cannot otherwise be disabled).
|
||||
- Datetime conversion becomes bidirectional and second-exact: `datetimeLocalToUnix` gets its inverse (`unixToDatetimeLocal`, local time), inputs use `step={1}`. **DST makes local text lossy** (Codex: a fall-back fold maps two instants to one string; a spring-forward gap silently normalizes): the form keeps the original unix value plus a per-field dirty flag, reuses the original unless the operator edited that field, and rejects an edited value that does not format back identically after parsing. Tests: non-zero-second round trips, a DST-fold instant, a nonexistent spring-forward time.
|
||||
- Search-param validators as pure functions in `activity/search.ts`: `since`/`until` accepted only via `Number.isSafeInteger`; `blocked` only when `typeof === "boolean"`; `mode` in the two-value union defaulting `"history"`; `domain`/`client` trimmed, with the empty string normalized to `undefined` (Codex: `domain=` must not persist as applied state the server treats as no filter). Tests: fractions, Infinity, overflow, quoted booleans, `false`, empty and whitespace strings.
|
||||
|
||||
### S1.4 Acceptance (S1)
|
||||
|
||||
- [ ] Typecheck, vitest, oxlint, format clean; existing pages still work untouched (only additive files plus the QueryDetailPage body extraction and Select prop).
|
||||
|
||||
## Session S2: the Activity surface, atomically
|
||||
|
||||
### S2.1 Routes (routes.tsx) — created and deleted in one change
|
||||
|
||||
- `/activity` → ActivityPage, `validateSearch` from S1's validators. `loaderDeps` returns an **explicit object** of `{mode, since, until, domain, client, blocked}` — never the whole search (Codex: unknown keys create spurious matches without `search.strict`); the history loader builds `QueriesFilter` **field-by-field** and only calls `ensureInfiniteQueryData` in history mode.
|
||||
- `/activity/queries/$id` → the detail wrapper (loader unchanged, incl. the deliberate 404 swallow). The route's search carries the originating Activity search (validated by the same functions), so the back link restores the exact investigation view (Codex: a bare `/activity` back link discards context and violates absolute time scoping).
|
||||
- `/activity/test` → policy simulation, search `domain`, `?domain=` auto-run preserved.
|
||||
- `/queries`, `/queries/$id`, `/live`, `/lookup` deleted from the route tree in the same edit. No aliases, no redirects; unknown paths fall to the router's existing not-found handling (verify sane, build nothing).
|
||||
|
||||
### S2.2 ActivityPage
|
||||
|
||||
- **Mode switch is URL-controlled** (the uncontrolled Tabs at ui/Tabs.tsx:76 does not fit — a controlled switch or controlled-Tabs variant), updating via the functional form `search: prev => ({...prev, mode})` so filters survive (Codex: object-replacement navigate loses them). The Live subtree (`LiveActivity`) mounts **only** in live mode so no SSE connection lingers in history. Tests: switching to history closes the EventSource; switching back creates exactly one fresh source.
|
||||
- History mode: URL is the applied state. The form draft resets whenever the applied search changes (back/forward, pasted URL — Codex: the current seed-once pattern fails back/forward; test both, with non-zero seconds in the bounds). Apply = navigate with the explicit search object; keyset/infinite mechanics (`next_before`, `isPlaceholderData` guard, `hasMore` derivation) carry over; CoverageNotice stays.
|
||||
- Live mode: follow-by-default, Freeze/Resume ephemeral (never URL). The live stack moves as-is: useLiveQueries (freeze snapshot, gap re-sync, 401 probe, cap threshold, stale-source guards), ringBuffer (LiveRow union, occurrence-counting mergeGap, rationale comment verbatim), fakeEventSource injection, status pill, `aria-pressed` Freeze, missed-recovered note, resync-failed alert, capped alert + Retry, blocked-row tint, footnote count.
|
||||
- **Live detail**: a streamed row (id null) opens `ProvenanceDetail` in-place from its in-memory `event` — no invented correlation id, no persisted fetch (ui-redesign.md:177). Recovered rows link to `/activity/queries/$id`. **Retention contract** (Codex): selection stores the selected `LiveRow` snapshot itself; the open detail survives ring eviction, gap merges, and Freeze/Resume, closing only on explicit close or mode unmount. Tests pin: opening a streamed row's detail, keyboard access, survival across capacity eviction (501+ events) and across Freeze/Resume.
|
||||
- Filter row in live mode: disabled via S1's `isDisabled` (visible, value-preserving), including native inputs and Apply/Clear; keyboard/tab-order test proves nothing in the row is tabbable while disabled.
|
||||
- Detail-only facts, stated per what the wire actually carries (Codex: no source URL or upstream error text exists on `QueryDetail`): matched pattern, historical source label, selected upstream, rcode. Underlying failure text lives in Diagnostics — the detail page's related actions gain the accepted absolute-window Diagnostics link: `/diagnostics?since=<ts-300>&until=<ts+300>` (the redesign's five-minute window around the query, ui-redesign.md:173).
|
||||
- Related domain/client links carry absolute bounds: `/activity?mode=history&domain=…&since=…&until=…` with **`since = origin.since ?? ts - 300` and `until = origin.until ?? ts + 300`** per bound independently (Codex: the fallback is product contract, not implementation choice). Tests cover zero-, one-, and two-bound origins.
|
||||
- **Diagnostics bounds must actually apply** (Codex: `/diagnostics` neither validates nor uses `since`/`until` at HEAD, so the ±300s link would render an unbounded page): the diagnostics route gains safe-integer `since`/`until` search validation and explicit loaderDeps, DiagnosticsPage applies them to both the active and resolved queries, and the page shows a visible range indication when bounded. Tests assert the outgoing `/api/diagnostics` requests carry the bounds.
|
||||
|
||||
### S2.3 Policy simulation
|
||||
|
||||
LookupPage moves to `activity/` with the "Current policy simulation" framing (forward-tense property preserved); verdict rendering, 503/429 handling, group select, same-pair refetch carry over. Reached from an Activity action, not primary nav.
|
||||
|
||||
### S2.4 Navigation, deletion, migration
|
||||
|
||||
- `NAV_ITEMS`: Query Log/Live/Lookup out, `{to: "/activity", label: "Activity"}` in their position.
|
||||
- Old page files, their cells/stylex exports, and dead helpers deleted. One home per module under `activity/`; no re-export shims.
|
||||
- Test migration with the no-shrink rule: every behavior pinned by the old tests is re-pinned at the new home or listed in the report as intentionally dead with its page. Files: QueryLogPage.test, LiveLogPage.test, useLiveQueries.test, ringBuffer.test, LookupPage.test, QueryDetailPage.test, AppShell.test, LoginPage safeRedirect samples (cosmetic).
|
||||
- Sweep: no import, string or test references `/queries`, `/live`, `/lookup` as routes (API paths `/api/queries*` stay).
|
||||
- **CHANGELOG.md is S2-owned** (Codex): retarget the still-Unreleased m28 entry's `/queries/{id}` mention to the new route, add the removal notice (bookmarks break) and the Activity surface + Result column.
|
||||
|
||||
### S2.5 Smoke + screenshots
|
||||
|
||||
Rebuild bundle (`npm run build`, `zig build -Dadmin-dist=admin/dist`), run the real binary with the smoke28 scratchpad config: URL-driven history filters (paste a full URL, screenshot the reproduced view), live streaming with freeze and an open streamed-row detail, the SERVFAIL row distinguishable in the list, detail via recovered/history row with the context-preserving back link, policy simulation, five-minute Diagnostics link landing on the bounded window, mobile-width nav drawer.
|
||||
|
||||
### S2.6 Acceptance (S2)
|
||||
|
||||
- [ ] Typecheck, vitest, oxlint, format clean; `zig build -Dadmin-dist=admin/dist` succeeds; `zig build test` untouched-green.
|
||||
- [ ] The S2.2 test list green, including EventSource lifecycle, back/forward, disabled-row tab order, streamed-row detail.
|
||||
- [ ] Router-level loader regression (Codex): `/activity?mode=live` — including with retained filters and unknown search keys — makes no `/api/queries` request; history mode forwards exactly the six normalized filter fields and nothing else.
|
||||
- [ ] Screenshots per S2.5.
|
||||
|
||||
## File ownership
|
||||
|
||||
Sequential; S2 starts only after S1's gates are green. S1: additive files under activity/, querySummary.ts, ui/Select.tsx, the QueryDetailPage body extraction, their tests. S2: everything else listed, admin/src/* (including the diagnostics route/page bounds work) plus CHANGELOG.md.
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No server/API/openapi changes; no golden regeneration.
|
||||
- No route aliases, redirects, or bookmark shims; no custom 404.
|
||||
- No filter push-down into the SSE stream.
|
||||
- No Overview changes (step 4), no configuration-page changes (step 5).
|
||||
- No new dependencies; no virtualization.
|
||||
|
||||
## Acceptance (milestone complete)
|
||||
|
||||
- [ ] All admin gates clean; zig suites untouched-green; bundled build succeeds; screenshots per S2.5.
|
||||
- [ ] CHANGELOG updated per S2.4.
|
||||
Reference in New Issue
Block a user