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

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:
2026-08-22 10:52:56 +02:00
parent 0fd6bbd312
commit fa323c7ed4
42 changed files with 3225 additions and 1426 deletions
@@ -0,0 +1,312 @@
import { 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 { QueryDetail } from "@/lib/types";
import { provenance } from "@/features/queries/provenanceFixture";
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
return { id, ...provenance(sections) };
}
let responses: Record<string, unknown>;
beforeEach(() => {
responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const payload = responses[String(input)];
if (payload === undefined) {
return new Response(JSON.stringify({ error: "no such query" }), {
status: 404,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => vi.unstubAllGlobals());
function renderDetail(id: number, search = "") {
const queryClient = createQueryClient();
const router = createAppRouter(
createMemoryHistory({ initialEntries: [`/activity/queries/${id}${search}`] }),
queryClient,
);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
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);
const value = term.nextElementSibling;
return value?.textContent ?? "";
}
/** The line under the domain, which is what a page is read as at a glance. */
function subtitle(domain: string): string {
const heading = screen.getByRole("heading", { name: domain });
return heading.nextElementSibling?.textContent ?? "";
}
test("a blocked query explains itself in the six sections, in pipeline order", async () => {
responses["/api/queries/42"] = detail(42, {
request: { time: 1_700_000_000, domain: "ads.example", client: "192.0.2.11", qtype: 28, qclass: 1 },
group: { id: 3, name: "kids" },
policy: {
action: "block",
reason: "blocklist_wildcard",
matched: "||tracker.example^",
source_id: 5,
source_name: "StevenBlack",
},
rewrites: { cname_target: "cdn.tracker.example" },
route: { kind: "blocked", upstream: "" },
response: { rcode: 0, duration_us: 1234 },
});
renderDetail(42);
await screen.findByRole("heading", { name: "ads.example" });
const headings = screen.getAllByRole("heading", { level: 2 }).map((node) => node.textContent);
expect(headings).toEqual(["Request", "Group", "Policy", "Rewrites", "Route", "Response", "Related"]);
expect(factValue("Client")).toBe("192.0.2.11");
expect(factValue("Type")).toBe("AAAA");
expect(factValue("Class")).toBe("IN (1)");
expect(factValue("Name")).toBe("kids");
expect(factValue("Id")).toBe("3");
expect(factValue("Decision")).toBe("Blocked");
expect(factValue("Reason")).toBe("Blocklist (wildcard)");
expect(factValue("Matched")).toBe("||tracker.example^");
expect(factValue("Blocklist")).toBe("StevenBlack (#5)");
expect(factValue("CNAME target")).toBe("cdn.tracker.example");
expect(factValue("Answered by")).toBe("Blocked locally");
expect(factValue("Upstream")).toBe("No upstream exchange");
expect(factValue("Result")).toBe("NOERROR (0)");
expect(factValue("Took")).toBe("1.2 ms");
// NOERROR is the ordinary case and adds nothing to the verdict.
expect(subtitle("ads.example")).toMatch(/ — Blocked$/);
});
test("an upstream SERVFAIL names the resolver that failed and the code the client saw", async () => {
responses["/api/queries/7"] = detail(7, {
request: { domain: "news.example" },
route: { kind: "upstream", upstream: "https://dns.example/dns-query" },
response: { rcode: 2, duration_us: null },
});
renderDetail(7);
await screen.findByRole("heading", { name: "news.example" });
expect(factValue("Answered by")).toBe("Upstream resolver");
expect(factValue("Upstream")).toBe("https://dns.example/dns-query");
expect(factValue("Result")).toBe("SERVFAIL (2)");
expect(factValue("Took")).toBe("Not measured");
// The policy allowed the query; the client still got nothing, and the
// headline has to say so rather than reading as a success.
expect(subtitle("news.example")).toMatch(/ — Allowed — SERVFAIL \(2\)$/);
});
test("a forward-zone answer says the matcher never ran, not that nothing matched", async () => {
responses["/api/queries/14"] = detail(14, {
request: { domain: "nas.lan.home" },
policy: { action: "allow", reason: "forward_zone", matched: "" },
route: { kind: "forward_zone", forward_zone: "lan.home", upstream: "udp://192.168.1.1:53" },
});
renderDetail(14);
await screen.findByRole("heading", { name: "nas.lan.home" });
expect(factValue("Reason")).toBe("Forward zone");
expect(factValue("Matched")).toBe("The matcher never ran");
});
test("a query the matcher did evaluate keeps the honest empty verdict", async () => {
responses["/api/queries/15"] = detail(15, {
policy: { action: "allow", reason: "no_match", matched: "" },
});
renderDetail(15);
await screen.findByRole("heading", { name: "example.com" });
expect(factValue("Reason")).toBe("No match");
expect(factValue("Matched")).toBe("Nothing matched");
});
test("empty text fields read as absent facts, never as blank values", async () => {
responses["/api/queries/8"] = detail(8, {
group: { id: null, name: "" },
policy: { action: "not_evaluated", reason: "paused", matched: "", source_id: null, source_name: "" },
route: { kind: "upstream", forward_zone: "", upstream: "udp://9.9.9.9:53" },
});
renderDetail(8);
await screen.findByRole("heading", { name: "example.com" });
expect(factValue("Name")).toBe("No group recorded");
expect(factValue("Matched")).toBe("The matcher never ran");
expect(factValue("Blocklist")).toBe("Not a blocklist decision");
expect(factValue("Safe search")).toBe("No rewrite");
expect(factValue("Reason")).toBe("Filtering paused");
});
test("a log with hidden domains renders the server's marker, with nothing invented around it", async () => {
responses["/api/queries/9"] = detail(9, {
request: { domain: "hidden" },
policy: { action: "block", reason: "blocklist_domain", matched: "hidden", source_name: "StevenBlack" },
rewrites: { cname_target: "hidden", safe_search_target: "hidden" },
route: { kind: "blocked", upstream: "" },
});
renderDetail(9);
await screen.findByRole("heading", { name: "hidden" });
expect(factValue("Domain")).toBe("hidden");
expect(factValue("Matched")).toBe("hidden");
expect(factValue("CNAME target")).toBe("hidden");
expect(factValue("Safe search")).toBe("hidden");
// The client is governed by its own flag and stays visible here.
expect(factValue("Client")).toBe("192.0.2.10");
});
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: /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 }) {
return {
clients: [
{
id: 1,
group_id: 1,
group: "default",
hand_edited: client.name !== "",
first_seen: 1_700_000_000,
last_seen: 1_700_000_100,
...client,
},
],
};
}
/** The related section, whose text is read whole because it is prose, not facts. */
async function relatedText(expected: string) {
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
await waitFor(() => expect(related.textContent?.replace(/\s+/g, " ")).toContain(expected));
}
test("the record keeps the address the query came from, and Related carries the name it has now", async () => {
responses["/api/queries/12"] = detail(12, { request: { domain: "shop.example", client: "192.0.2.12" } });
responses["/api/clients"] = clientList({ ip: "192.0.2.12", name: "Kids iPad", learned_name: "ipad.lan" });
renderDetail(12);
await screen.findByRole("heading", { name: "shop.example" });
await relatedText("The client list currently names 192.0.2.12 “Kids iPad”.");
expect(factValue("Client")).toBe("192.0.2.12");
});
test("a learned name is told as the reverse-DNS lookup it is, never as a recorded fact", async () => {
responses["/api/queries/13"] = detail(13, { request: { client: "192.0.2.13" } });
responses["/api/clients"] = clientList({ ip: "192.0.2.13", name: "", learned_name: "printer.lan" });
renderDetail(13);
await screen.findByRole("heading", { name: "example.com" });
await relatedText("Reverse DNS currently resolves 192.0.2.13 to printer.lan.");
expect(factValue("Client")).toBe("192.0.2.13");
});
test("a row retention has pruned explains the 404 and keeps the way back to the log", async () => {
renderDetail(404, "?mode=history&domain=gone");
await screen.findByRole("alert");
expect(screen.getByText(/no such query/)).toBeTruthy();
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);
});
@@ -0,0 +1,421 @@
/**
* 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 { 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",
},
toolbarButton: {
fontWeight: 500,
},
pill: {
borderRadius: "9999px",
paddingInline: "0.625rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
},
/** Four stream states need four tints; only two of them map onto a token role. */
pillConnecting: {
backgroundColor: { default: "oklch(96.7% 0.001 286.375)", [DARK]: "oklch(27.4% 0.006 286.033)" },
color: { default: "oklch(37% 0.013 285.805)", [DARK]: "oklch(87.1% 0.006 286.286)" },
},
pillOpen: {
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(39.3% 0.095 152.535)" },
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(92.5% 0.084 155.995)" },
},
pillRetrying: {
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(92.4% 0.12 95.746)" },
},
pillCapped: {
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)" },
},
note: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
/** Informational, neither a warning nor a failure, so the blue ramp stands alone. */
resumed: {
marginTop: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.75rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: { default: "oklch(80.9% 0.105 251.813)", [DARK]: "oklch(37.9% 0.146 265.522)" },
backgroundColor: { default: "oklch(97% 0.014 254.604)", [DARK]: "oklch(28.2% 0.091 267.935)" },
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(88.2% 0.059 254.128)" },
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
dismiss: {
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
color: "inherit",
fontSize: "inherit",
fontWeight: 500,
textDecorationLine: "underline",
},
failureNote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
cappedBox: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
backgroundColor: colors.dangerSurface,
padding: "1rem",
},
cappedHeading: {
fontWeight: 600,
color: colors.dangerText,
},
cappedDetail: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
},
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,
},
rowBlocked: {
backgroundColor: {
default: "oklch(97.1% 0.013 17.38)",
[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",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
/** 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",
retrying: "Reconnecting…",
capped: "Disconnected",
};
function pillStyle(status: StreamStatus) {
if (status === "open") return styles.pillOpen;
if (status === "retrying") return styles.pillRetrying;
if (status === "capped") return styles.pillCapped;
return styles.pillConnecting;
}
function StatusPill({ status }: { status: StreamStatus }) {
const label = PILL_LABELS[status];
return (
<span role="status" aria-label={label} {...stylex.props(styles.pill, pillStyle(status))}>
{label}
</span>
);
}
/**
* 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 (
<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)}>
<StatusPill status={live.status} />
<button
type="button"
onClick={live.toggleFreeze}
aria-pressed={live.frozen}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
>
{live.frozen ? "Resume" : "Freeze"}
</button>
</div>
{live.frozen && (
<p {...stylex.props(styles.note)} role="status">
Display frozen new queries keep buffering ({live.liveCount} in buffer, newest {RING_CAPACITY}{" "}
kept).
</p>
)}
{live.missed !== null && (
<div role="status" {...stylex.props(styles.resumed)}>
<span>
Stream resumed {" "}
{live.missed === 0 ? "no queries missed" : `${live.missed} missed queries recovered`}.
</span>
<button
type="button"
onClick={live.dismissMissed}
{...stylex.props(styles.dismiss, shared.focusRing)}
>
Dismiss
</button>
</div>
)}
{live.resyncFailed && (
<p role="alert" {...stylex.props(styles.failureNote)}>
Stream resumed, but re-syncing the gap failed some queries may be missing here.
</p>
)}
{live.status === "capped" && (
<div role="alert" {...stylex.props(styles.cappedBox)}>
<h2 {...stylex.props(styles.cappedHeading)}>Live stream unavailable</h2>
<p {...stylex.props(styles.cappedDetail)}>
The connection failed repeatedly possibly too many live viewers (the server caps streams per
address), or the server is unreachable.
</p>
<button type="button" onClick={live.retry} {...stylex.props(shared.retryButton, shared.focusRing)}>
Retry
</button>
</div>
)}
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
{live.rows.length === 0 ? (
live.status !== "capped" && (
<p {...stylex.props(styles.empty)}>
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
</p>
)
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
{live.rows.map((row) => {
const summary = summaryOf(row);
return (
<tr
key={row.key}
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
>
<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>
);
})}
</tbody>
</table>
</div>
<p {...stylex.props(styles.footnote)}>
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
{RING_CAPACITY} kept).
</p>
</>
)}
</>
);
}
@@ -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");
});
@@ -0,0 +1,331 @@
import { useState, type FormEvent, type ReactNode } from "react";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { ApiError } from "@/lib/api";
import { groupsQuery, lookupQuery } from "@/lib/queries";
import type { Group, LookupResult } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
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)";
interface Submitted {
domain: string;
groupId: number;
}
interface Verdict {
label: string;
tone: "local" | "blocked" | "forwarded" | "allowed";
description: string;
}
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.5rem",
color: colors.textMuted,
},
form: {
marginTop: "1.5rem",
display: "flex",
maxWidth: "42rem",
flexWrap: "wrap",
alignItems: "flex-end",
gap: "0.75rem",
},
domainField: {
minWidth: "14rem",
flexGrow: 1,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
note: {
marginTop: "1.5rem",
color: colors.textMuted,
},
error: {
marginTop: "1.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
card: {
marginTop: "1.5rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
},
banner: {
borderStartStartRadius: "0.25rem",
borderStartEndRadius: "0.25rem",
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
/** Four verdicts need four tints; only "blocked" maps onto a token role. */
local: {
backgroundColor: { default: "oklch(93.2% 0.032 255.585)", [DARK]: "oklch(28.2% 0.091 267.935)" },
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(80.9% 0.105 251.813)" },
},
blocked: {
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
},
forwarded: {
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
},
allowed: {
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(26.6% 0.065 152.934)" },
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(87.1% 0.15 154.449)" },
},
verdictLabel: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
verdictDescription: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
details: {
paddingInline: "1rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** `divide-y`: a hairline between rows, so the first row carries none. */
detailRow: {
display: "flex",
gap: "1rem",
paddingBlock: "0.5rem",
borderTopWidth: { default: 1, ":first-child": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
},
detailTerm: {
width: "10rem",
flexShrink: 0,
color: colors.textMuted,
},
detailValue: {
minWidth: 0,
overflowWrap: "break-word",
},
sourceLink: {
color: colors.primaryOnSurface,
textDecorationLine: "underline",
},
});
function toneStyle(tone: Verdict["tone"]) {
if (tone === "local") return styles.local;
if (tone === "blocked") return styles.blocked;
return tone === "forwarded" ? styles.forwarded : styles.allowed;
}
/**
* Header priority follows the pipeline order the lookup handler documents
* (PLAN §6): local records answer first, then the block decision, then
* forward zones, then plain forwarding to the upstream pool.
*/
export function verdictOf(result: LookupResult): Verdict {
if (result.local_records) {
return {
label: "Local answer",
tone: "local",
description: "A local record answers this name directly.",
};
}
if (result.blocked) {
return {
label: "Blocked",
tone: "blocked",
description: "Queries for this name get a blocked response.",
};
}
if (result.forward_zone !== null) {
return {
label: "Forwarded",
tone: "forwarded",
description: `Queries go to the resolver for zone ${result.forward_zone}.`,
};
}
return {
label: "Allowed",
tone: "allowed",
description: "Queries resolve through the upstream pool.",
};
}
function errorMessage(error: unknown): string {
if (error instanceof ApiError) {
if (error.status === 503) {
return "No filter snapshot is loaded yet — the server is starting or degraded. Try again shortly.";
}
if (error.status === 429) {
return error.retryAfter !== undefined
? `Rate limited. Try again in ${error.retryAfter}s.`
: "Rate limited. Try again shortly.";
}
return error.message;
}
return "Could not reach the server.";
}
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div {...stylex.props(styles.detailRow)}>
<dt {...stylex.props(styles.detailTerm)}>{label}</dt>
<dd {...stylex.props(styles.detailValue)}>{children}</dd>
</div>
);
}
function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[] }) {
const verdict = verdictOf(result);
const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`;
return (
<div {...stylex.props(styles.card)}>
<div {...stylex.props(styles.banner, toneStyle(verdict.tone))}>
<h2 {...stylex.props(styles.verdictLabel)}>{verdict.label}</h2>
<p {...stylex.props(styles.verdictDescription)}>{verdict.description}</p>
</div>
<dl {...stylex.props(styles.details)}>
<DetailRow label="Domain">
<span {...stylex.props(shared.mono)}>{result.domain}</span>
</DetailRow>
<DetailRow label="Group">{groupName}</DetailRow>
<DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow>
<DetailRow label="Forward zone">
{result.forward_zone !== null ? (
<span {...stylex.props(shared.mono)}>{result.forward_zone}</span>
) : (
"—"
)}
</DetailRow>
<DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow>
<DetailRow label="Reason">
<span {...stylex.props(shared.mono)}>{result.reason}</span>
</DetailRow>
<DetailRow label="Matched pattern">
{result.matched !== "" ? <span {...stylex.props(shared.mono)}>{result.matched}</span> : "—"}
</DetailRow>
<DetailRow label="Blocklist source">
{result.source_url !== null ? (
<a
href={result.source_url}
target="_blank"
rel="noreferrer"
{...stylex.props(styles.sourceLink, shared.focusRing)}
>
{result.source_url}
</a>
) : (
"—"
)}
</DetailRow>
<DetailRow label="Safe search rewrite">
{result.safe_search_rewrite !== null ? (
<span {...stylex.props(shared.mono)}>{result.safe_search_rewrite}</span>
) : (
"—"
)}
</DetailRow>
</dl>
</div>
);
}
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 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>(
search.domain === undefined ? null : { domain: search.domain, groupId: preselectedGroupId },
);
const lookup = useQuery({
...lookupQuery(submitted?.domain ?? "", submitted?.groupId),
enabled: submitted !== null,
});
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = domain.trim();
if (trimmed === "") return;
if (submitted !== null && submitted.domain === trimmed && submitted.groupId === groupId) {
void lookup.refetch();
return;
}
setSubmitted({ domain: trimmed, groupId });
}
return (
<section>
<h1 {...stylex.props(styles.heading)}>Current policy simulation</h1>
<p {...stylex.props(styles.intro)}>
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="policy-test-domain" {...stylex.props(styles.fieldLabel)}>
Domain
</label>
<input
id="policy-test-domain"
required
value={domain}
onChange={(event) => setDomain(event.target.value)}
placeholder="ads.example.com"
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<Select
label="Group"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
</div>
<button
type="submit"
disabled={lookup.isFetching}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Simulate
</button>
</form>
{lookup.isFetching && <p {...stylex.props(styles.note)}>Simulating</p>}
{!lookup.isFetching && lookup.isError && (
<p role="alert" {...stylex.props(styles.error)}>
{errorMessage(lookup.error)}
</p>
)}
{!lookup.isFetching && lookup.data !== undefined && !lookup.isError && (
<VerdictCard result={lookup.data} groups={groups} />
)}
</section>
);
}
@@ -0,0 +1,300 @@
/**
* 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 * as stylex from "@stylexjs/stylex";
import { formatMicros, formatTime } from "@/lib/format";
import type { PolicyReason, Provenance } from "@/lib/types";
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
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({
heading: {
marginTop: "0.5rem",
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
wordBreak: "break-all",
},
subtitle: {
marginTop: "0.25rem",
color: colors.textSecondary,
},
/** The recorded facts, fenced off from the live links below them. */
record: {
marginTop: "1rem",
maxWidth: "48rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
padding: "1rem",
},
recordNote: {
fontSize: "0.8125rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
section: {
marginTop: "1rem",
borderTopWidth: { default: 1, ":first-of-type": 0 },
borderTopStyle: "solid",
borderTopColor: colors.border,
paddingTop: { default: "1rem", ":first-of-type": 0 },
},
sectionHeading: {
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 600,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: colors.textMuted,
},
facts: {
marginTop: "0.5rem",
marginBottom: 0,
display: "grid",
gap: "0.375rem 1rem",
gridTemplateColumns: {
default: "auto",
"@media (min-width: 640px)": "max-content 1fr",
},
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
term: {
color: colors.textMuted,
},
value: {
margin: 0,
wordBreak: "break-all",
},
muted: {
color: colors.textMuted,
},
related: {
marginTop: "1.5rem",
maxWidth: "48rem",
},
relatedHeading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
relatedNote: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
relatedList: {
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
gap: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
link: {
color: colors.primaryOnSurface,
},
});
/** 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)}>
<h2 {...stylex.props(styles.sectionHeading)}>{title}</h2>
<dl {...stylex.props(styles.facts)}>{children}</dl>
</div>
);
}
function Fact({ label, children }: { label: string; children: ReactNode }) {
return (
<>
<dt {...stylex.props(styles.term)}>{label}</dt>
<dd {...stylex.props(styles.value)}>{children}</dd>
</>
);
}
/**
* A recorded name, in the face the rest of the interface gives to names. It
* wraps the value rather than the row, so the prose that stands in for a
* missing one is not set in the same typewriter face.
*/
function Mono({ children }: { children: string }) {
return <span {...stylex.props(shared.mono)}>{children}</span>;
}
/** An empty text field means the server recorded nothing there, never an empty value. */
function Absent({ children }: { children: string }) {
return <span {...stylex.props(styles.muted)}>{children}</span>;
}
/**
* What an empty `matched` means. `no_match` is the only reason the matcher
* itself records with nothing to show; every other empty one names a pipeline
* step that answered before filtering — a local record, a forward zone, a pause
* (see `PolicyReason` in src/storage/provenance.zig) — where "nothing matched"
* would claim an evaluation that never happened.
*/
function unmatchedLabel(reason: PolicyReason): string {
return reason === "no_match" ? "Nothing matched" : "The matcher never ran";
}
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();
const currentClient = clientLabel(request.client, clientNames);
return (
<>
<h1 {...stylex.props(styles.heading, shared.mono)}>{request.domain}</h1>
<p {...stylex.props(styles.subtitle)}>
{formatTime(request.time)} {policyActionLabel(policy.action)}
{/* The verdict alone reads as a success; a non-NOERROR answer says otherwise. */}
{response.rcode !== 0 && `${rcodeName(response.rcode)}`}
</p>
<div {...stylex.props(styles.record)}>
<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">
<Fact label="Time">{formatTime(request.time)}</Fact>
<Fact label="Domain">
<Mono>{request.domain}</Mono>
</Fact>
<Fact label="Client">
<Mono>{request.client}</Mono>
</Fact>
<Fact label="Type">{qtypeName(request.qtype)}</Fact>
<Fact label="Class">{qclassName(request.qclass)}</Fact>
</Section>
<Section title="Group">
<Fact label="Name">{group.name === "" ? <Absent>No group recorded</Absent> : group.name}</Fact>
<Fact label="Id">{group.id === null ? <Absent></Absent> : group.id}</Fact>
</Section>
<Section title="Policy">
<Fact label="Decision">{policyActionLabel(policy.action)}</Fact>
<Fact label="Reason">{policyReasonLabel(policy.reason)}</Fact>
<Fact label="Matched">
{policy.matched === "" ? (
<Absent>{unmatchedLabel(policy.reason)}</Absent>
) : (
<Mono>{policy.matched}</Mono>
)}
</Fact>
<Fact label="Blocklist">
{policy.source_name === "" ? (
<Absent>Not a blocklist decision</Absent>
) : policy.source_id === null ? (
policy.source_name
) : (
`${policy.source_name} (#${policy.source_id})`
)}
</Fact>
</Section>
<Section title="Rewrites">
<Fact label="CNAME target">
{rewrites.cname_target === "" ? (
<Absent>The queried name was decided directly</Absent>
) : (
<Mono>{rewrites.cname_target}</Mono>
)}
</Fact>
<Fact label="Safe search">
{rewrites.safe_search_target === "" ? (
<Absent>No rewrite</Absent>
) : (
<Mono>{rewrites.safe_search_target}</Mono>
)}
</Fact>
</Section>
<Section title="Route">
<Fact label="Answered by">{routeKindLabel(route.kind)}</Fact>
<Fact label="Forward zone">
{route.forward_zone === "" ? <Absent></Absent> : <Mono>{route.forward_zone}</Mono>}
</Fact>
<Fact label="Upstream">
{route.upstream === "" ? <Absent>No upstream exchange</Absent> : <Mono>{route.upstream}</Mono>}
</Fact>
</Section>
<Section title="Response">
<Fact label="Result">{rcodeName(response.rcode)}</Fact>
<Fact label="Took">
{response.duration_us === null ? (
<Absent>Not measured</Absent>
) : (
formatMicros(response.duration_us)
)}
</Fact>
</Section>
</div>
<div {...stylex.props(styles.related)}>
<h2 {...stylex.props(styles.relatedHeading)}>Related</h2>
<p {...stylex.props(styles.relatedNote)}>
These read the current configuration, which may no longer be the one that decided this query.
</p>
{currentClient !== null && (
<p {...stylex.props(styles.relatedNote)}>
{currentClient.learned ? (
<>
Reverse DNS currently resolves <Mono>{request.client}</Mono> to{" "}
<Mono>{currentClient.text}</Mono>.
</>
) : (
<>
The client list currently names <Mono>{request.client}</Mono> {currentClient.text}.
</>
)}
</p>
)}
<div {...stylex.props(styles.relatedList)}>{relatedActions}</div>
</div>
</>
);
}
@@ -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>
</>
);
}
+114
View File
@@ -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");
});
+171
View File
@@ -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") });
});
+95
View File
@@ -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,41 @@
import { EVENT_SOURCE_CLOSED, type EventSourceLike } from "./useLiveQueries";
const CONNECTING = 0;
const OPEN = 1;
/** Test double for the injected EventSource constructor. */
export class FakeEventSource implements EventSourceLike {
readonly url: string;
closed = false;
readyState: number = CONNECTING;
private listeners = new Map<string, Array<(event: { data?: unknown }) => void>>();
constructor(url: string) {
this.url = url;
}
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void {
const existing = this.listeners.get(type) ?? [];
existing.push(listener);
this.listeners.set(type, existing);
}
close(): void {
this.closed = true;
this.readyState = EVENT_SOURCE_CLOSED;
}
emit(type: string, event: { data?: unknown } = {}): void {
if (type === "open") this.readyState = OPEN;
for (const listener of this.listeners.get(type) ?? []) listener(event);
}
/**
* A non-200 response: the browser closes the source, then dispatches one
* error event and never retries.
*/
failFatal(): void {
this.readyState = EVENT_SOURCE_CLOSED;
this.emit("error");
}
}
@@ -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 };
}
@@ -0,0 +1,248 @@
import type { Provenance, QueryRow } from "@/lib/types";
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
import { RING_CAPACITY, mergeGap, pushRow, summaryOf, type LiveRow } from "./ringBuffer";
function streamed(key: number, ts: number, domain: string, sections: Parameters<typeof provenance>[0] = {}): LiveRow {
return {
kind: "streamed",
key,
event: provenance({
...sections,
request: { time: ts, domain, ...sections.request },
route: { upstream: "udp://9.9.9.9:53", ...sections.route },
}),
};
}
function fetchedRow(id: number, ts: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
return queryRow(id, { ts, domain, upstream: "udp://9.9.9.9:53", ...overrides });
}
function counter(start = 100): () => number {
let n = start;
return () => ++n;
}
function domains(rows: LiveRow[]): string[] {
return rows.map((row) => summaryOf(row).domain);
}
describe("summaryOf", () => {
test("a streamed frame projects every summary field from the provenance it carries", () => {
const event: Provenance = provenance({
request: { time: 1700, domain: "ads.example", client: "192.0.2.11", qtype: 28 },
policy: { action: "block", reason: "blocklist_wildcard" },
route: { kind: "blocked", upstream: "" },
response: { duration_us: 42 },
});
expect(summaryOf({ kind: "streamed", key: 1, event })).toEqual({
id: null,
ts: 1700,
domain: "ads.example",
client_ip: "192.0.2.11",
qtype: 28,
blocked: true,
policy_reason: "blocklist_wildcard",
rcode: 0,
route_kind: "blocked",
response_time_us: 42,
cache_hit: null,
upstream: "",
});
});
test("a recovered row projects its stored fields and keeps its id", () => {
const row = queryRow(77, { domain: "news.example", cache_hit: true, policy_reason: "rule_allow_exact" });
expect(summaryOf({ kind: "recovered", key: 2, row })).toMatchObject({
id: 77,
domain: "news.example",
cache_hit: true,
policy_reason: "rule_allow_exact",
});
});
/**
* The guard the discriminated union exists for: a field added to the wire
* DTO must be either projected into the summary or consciously left to the
* detail page. A silent addition fails here rather than going unrendered.
*/
test("every provenance field is either projected or knowingly detail-only", () => {
const projected = [
"request.time",
"request.domain",
"request.client",
"request.qtype",
"policy.action",
"policy.reason",
"route.kind",
"route.upstream",
"response.duration_us",
];
const detailOnly = [
"request.qclass",
"group.id",
"group.name",
"policy.matched",
"policy.source_id",
"policy.source_name",
"rewrites.cname_target",
"rewrites.safe_search_target",
"route.forward_zone",
"response.rcode",
];
const leaves = Object.entries(provenance()).flatMap(([section, fields]) =>
Object.keys(fields as Record<string, unknown>).map((field) => `${section}.${field}`),
);
expect(leaves.sort()).toEqual([...projected, ...detailOnly].sort());
});
});
describe("pushRow", () => {
test("prepends newest-first", () => {
let rows: LiveRow[] = [];
rows = pushRow(rows, streamed(1, 10, "a.example"));
rows = pushRow(rows, streamed(2, 11, "b.example"));
expect(domains(rows)).toEqual(["b.example", "a.example"]);
});
test("drops the oldest beyond capacity", () => {
let rows: LiveRow[] = [];
for (let i = 0; i < 5; i++) rows = pushRow(rows, streamed(i, i, `d${i}.example`), 3);
expect(rows).toHaveLength(3);
expect(rows.map((r) => r.key)).toEqual([4, 3, 2]);
});
test("default capacity is 500", () => {
let rows: LiveRow[] = [];
for (let i = 0; i < RING_CAPACITY + 10; i++) rows = pushRow(rows, streamed(i, i, "x.example"));
expect(rows).toHaveLength(RING_CAPACITY);
});
});
describe("mergeGap", () => {
test("skips rows already in the buffer and counts only new ones", () => {
const buffer = [streamed(2, 100, "seen.example"), streamed(1, 99, "old.example")];
const fetched = [
fetchedRow(30, 102, "gap2.example"),
fetchedRow(29, 101, "gap1.example"),
fetchedRow(28, 100, "seen.example"),
];
const { rows, missed } = mergeGap(buffer, fetched, counter());
expect(missed).toBe(2);
expect(domains(rows)).toEqual(["gap2.example", "gap1.example", "seen.example", "old.example"]);
});
test("no additions returns the buffer unchanged with missed 0", () => {
const buffer = [streamed(1, 100, "seen.example")];
const { rows, missed } = mergeGap(buffer, [fetchedRow(5, 100, "seen.example")], counter());
expect(missed).toBe(0);
expect(rows).toBe(buffer);
});
/**
* A household repeats itself: one client, one name, three lookups inside the
* same second. The stream delivered one of them before the connection broke,
* so the gap fetch must recover the other two rather than let the one row in
* the buffer stand for all three.
*/
test("repeated identical queries drop only as many rows as the buffer already holds", () => {
const buffer = [streamed(1, 100, "dup.example")];
const fetched = [
fetchedRow(12, 100, "dup.example"),
fetchedRow(11, 100, "dup.example"),
fetchedRow(10, 100, "dup.example"),
];
const { rows, missed } = mergeGap(buffer, fetched, counter());
expect(missed).toBe(2);
expect(domains(rows)).toEqual(["dup.example", "dup.example", "dup.example"]);
const recoveredIds = rows.flatMap((row) => (row.kind === "recovered" ? [row.row.id] : []));
expect(new Set(recoveredIds).size).toBe(2);
});
test("a gap fetch that repeats the whole buffer adds nothing", () => {
const buffer = [streamed(2, 100, "dup.example"), streamed(1, 100, "dup.example")];
const fetched = [fetchedRow(12, 100, "dup.example"), fetchedRow(11, 100, "dup.example")];
const { rows, missed } = mergeGap(buffer, fetched, counter());
expect(missed).toBe(0);
expect(rows).toBe(buffer);
});
/**
* Two queries of the same name from the same client in the same second are
* still separate facts when any stored column differs — the record type or
* class, the response code, the policy that decided them, how long they took,
* the route taken. The gap fetch here returns the differing row *first* and
* the one the buffer already holds second, so an identity blind to the column
* would let the differing row consume the buffered occurrence: the buffered
* query would come back duplicated and the other would vanish, at an
* unchanged `missed`. Order is what exposes that — the count alone is 1
* either way.
*
* `blocked` and `cache_hit` have no case of their own: the server derives
* them from `policy_action` and `route_kind`, so they cannot differ while
* everything else holds, and the two columns they follow are covered here.
*/
test.each([
{ column: "qtype", sections: { request: { qtype: 1 } }, held: { qtype: 1 }, differing: { qtype: 28 } },
{ column: "qclass", sections: { request: { qclass: 1 } }, held: { qclass: 1 }, differing: { qclass: 3 } },
{ column: "rcode", sections: { response: { rcode: 0 } }, held: { rcode: 0 }, differing: { rcode: 2 } },
{
column: "response_time_us",
sections: { response: { duration_us: 1234 } },
held: { response_time_us: 1234 },
differing: { response_time_us: 9999 },
},
{
column: "route_kind",
sections: { route: { kind: "upstream" } },
held: { route_kind: "upstream" },
differing: { route_kind: "forward_zone" },
},
{
column: "policy_action",
sections: { policy: { action: "allow" } },
held: { policy_action: "allow" },
differing: { policy_action: "not_evaluated" },
},
{
column: "policy_reason",
sections: { policy: { reason: "no_match" } },
held: { policy_reason: "no_match" },
differing: { policy_reason: "rule_allow_exact" },
},
] satisfies readonly {
column: string;
sections: Parameters<typeof provenance>[0];
held: Partial<QueryRow>;
differing: Partial<QueryRow>;
}[])("rows differing only in $column survive the gap merge", ({ sections, held, differing }) => {
const buffer = [streamed(1, 100, "dual.example", sections)];
const fetched = [fetchedRow(6, 100, "dual.example", differing), fetchedRow(5, 100, "dual.example", held)];
const { rows, missed } = mergeGap(buffer, fetched, counter());
expect(missed).toBe(1);
expect(rows).toEqual([{ kind: "recovered", key: expect.any(Number), row: fetched[0] }, buffer[0]]);
});
test("recovered rows keep their id and take a fresh key", () => {
const { rows } = mergeGap([], [fetchedRow(77, 100, "gap.example")], counter(200));
const recovered = rows[0];
expect(recovered?.key).toBe(201);
expect(recovered?.kind).toBe("recovered");
expect(recovered !== undefined && recovered.kind === "recovered" ? recovered.row.id : null).toBe(77);
});
test("result is capped at capacity, keeping the newest", () => {
const buffer = [streamed(3, 300, "live.example")];
const fetched = [fetchedRow(2, 302, "g2.example"), fetchedRow(1, 301, "g1.example")];
const { rows, missed } = mergeGap(buffer, fetched, counter(), 2);
expect(missed).toBe(2);
expect(domains(rows)).toEqual(["g2.example", "g1.example"]);
});
test("merged rows stay sorted newest-first by ts", () => {
const buffer = [streamed(4, 105, "after-reopen.example"), streamed(3, 100, "before.example")];
const fetched = [fetchedRow(9, 103, "gap.example")];
const { rows } = mergeGap(buffer, fetched, counter());
expect(rows.map((row) => summaryOf(row).ts)).toEqual([105, 103, 100]);
});
});
+147
View File
@@ -0,0 +1,147 @@
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
import { summarizeEvent, summarizeRow, type QuerySummary } from "@/features/queries/querySummary";
/**
* A row in the live buffer. `key` is a client-side monotonic counter, because
* neither arm has a stable identity of its own on arrival.
*
* The two arms are genuinely different facts, not two encodings of one. A
* streamed frame carries the full provenance of a query the server has not
* written yet; a row recovered by the reconnect gap-fetch is the stored summary
* of a query that *was* written, and cannot fabricate the provenance it never
* received. Only the recovered arm has a row id to link to.
*/
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);
}
export const RING_CAPACITY = 500;
/** Prepend `row` (rows are newest-first) and drop the oldest beyond `capacity`. */
export function pushRow(rows: LiveRow[], row: LiveRow, capacity: number = RING_CAPACITY): LiveRow[] {
const next = [row, ...rows];
return next.length > capacity ? next.slice(0, capacity) : next;
}
/**
* What the gap merge compares two queries by: the whole stored row bar its id.
*
* `since` on GET /api/queries is inclusive, so the re-sync fetch returns the
* last-seen row(s) again and the merge has to recognise them. The id cannot
* serve as the identity — a streamed frame precedes its own insert and has none
* — so the comparison is by value, and every stored column has to take part.
* Two queries alike in name, client and second but differing in class, rcode,
* the policy that decided them or the route taken are separate facts; if they
* hashed alike, the fetched row that does *not* match the buffered one would
* 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 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.
*/
type GapIdentity = Omit<QueryRow, "id">;
function identityOfRow(row: QueryRow): GapIdentity {
return {
ts: row.ts,
domain: row.domain,
client_ip: row.client_ip,
qtype: row.qtype,
qclass: row.qclass,
rcode: row.rcode,
blocked: row.blocked,
response_time_us: row.response_time_us,
cache_hit: row.cache_hit,
upstream: row.upstream,
policy_action: row.policy_action,
policy_reason: row.policy_reason,
route_kind: row.route_kind,
};
}
/**
* The same identity out of a live frame, which carries every stored column in
* its provenance. The columns the server derives rather than sends — `blocked`
* and `cache_hit` — come through `summarizeEvent` so that derivation keeps
* living in exactly one place.
*/
function identityOfEvent(event: LiveQueryEvent): GapIdentity {
const summary = summarizeEvent(event);
return {
ts: summary.ts,
domain: summary.domain,
client_ip: summary.client_ip,
qtype: summary.qtype,
qclass: event.request.qclass,
rcode: event.response.rcode,
blocked: summary.blocked,
response_time_us: summary.response_time_us,
cache_hit: summary.cache_hit,
upstream: summary.upstream,
policy_action: event.policy.action,
policy_reason: summary.policy_reason,
route_kind: event.route.kind,
};
}
function identityOf(row: LiveRow): GapIdentity {
return row.kind === "streamed" ? identityOfEvent(row.event) : identityOfRow(row.row);
}
/** Sorted keys so the hash cannot depend on the order the two arms happen to build their literals in. */
function signature(identity: GapIdentity): string {
return JSON.stringify(identity, Object.keys(identity).sort());
}
/**
* How many times each signature is already in the buffer. A signature is not
* unique: one client asking for one name twice within the same second is an
* ordinary household pattern, and the two queries are separate facts. Counting
* the occurrences lets the merge drop exactly as many fetched rows as the
* buffer already holds, instead of letting one buffered row hide all of them.
*/
function occurrences(rows: LiveRow[]): Map<string, number> {
const counts = new Map<string, number>();
for (const row of rows) {
const key = signature(identityOf(row));
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}
/**
* Merge rows fetched for a reconnect gap (newest-first, from GET /api/queries)
* into the buffer. Each fetched row consumes one buffered occurrence of its
* signature and is skipped; the rest are genuinely missed and `missed` counts
* them. The result stays newest-first (stable sort by ts) and capped.
*/
export function mergeGap(
rows: LiveRow[],
fetched: QueryRow[],
nextKey: () => number,
capacity: number = RING_CAPACITY,
): { rows: LiveRow[]; missed: number } {
const buffered = occurrences(rows);
const added: LiveRow[] = [];
for (const row of fetched) {
const key = signature(identityOfRow(row));
const count = buffered.get(key) ?? 0;
if (count > 0) {
buffered.set(key, count - 1);
continue;
}
added.push({ kind: "recovered", row, key: nextKey() });
}
if (added.length === 0) return { rows, missed: 0 };
const merged = [...added, ...rows].sort((a, b) => summaryOf(b).ts - summaryOf(a).ts).slice(0, capacity);
return { rows: merged, missed: added.length };
}
+113
View File
@@ -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,
});
});
+88
View File
@@ -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"]),
};
}
@@ -0,0 +1,244 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { ApiError } from "@/lib/api";
import type { QueriesPage, QueryRow } from "@/lib/types";
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
import { summaryOf, type LiveRow } from "./ringBuffer";
import { FakeEventSource } from "./fakeEventSource";
import { CAP_ERROR_THRESHOLD, useLiveQueries } from "./useLiveQueries";
afterEach(() => vi.unstubAllGlobals());
function stubLocationAssign() {
const assign = vi.fn();
vi.stubGlobal("location", { pathname: "/activity", search: "?mode=live", assign });
return assign;
}
function frame(ts: number, domain: string): { data: string } {
const payload = provenance({
request: { time: ts, domain },
route: { upstream: "udp://9.9.9.9:53" },
});
return { data: JSON.stringify(payload) };
}
function fetchedRow(id: number, ts: number, domain: string): QueryRow {
return queryRow(id, { ts, domain, upstream: "udp://9.9.9.9:53" });
}
function domains(rows: LiveRow[]): string[] {
return rows.map((row) => summaryOf(row).domain);
}
const FULL_COVERAGE = { complete: true, available_since: 0 };
function setup(fetchSince?: (since: number) => Promise<QueriesPage>, probeSession?: () => Promise<unknown>) {
const sources: FakeEventSource[] = [];
const createEventSource = (url: string) => {
const es = new FakeEventSource(url);
sources.push(es);
return es;
};
const probe = probeSession ?? (() => Promise.resolve());
const hook = renderHook(() => useLiveQueries({ createEventSource, fetchSince, probeSession: probe }));
return { sources, hook };
}
test("open then frames: rows newest-first with increasing keys", () => {
const { sources, hook } = setup();
expect(sources).toHaveLength(1);
expect(hook.result.current.status).toBe("connecting");
act(() => sources[0]!.emit("open"));
expect(hook.result.current.status).toBe("open");
act(() => {
sources[0]!.emit("query", frame(1000, "a.example"));
sources[0]!.emit("query", frame(1001, "b.example"));
});
const rows = hook.result.current.rows;
expect(domains(rows)).toEqual(["b.example", "a.example"]);
expect(rows[0]!.key).toBeGreaterThan(rows[1]!.key);
});
test("malformed and non-string frames are ignored", () => {
const { sources, hook } = setup();
act(() => {
sources[0]!.emit("open");
sources[0]!.emit("query", { data: "{not json" });
sources[0]!.emit("query", {});
});
expect(hook.result.current.rows).toHaveLength(0);
});
test("error then reopen re-syncs the gap since the last seen ts", async () => {
const fetchSince = vi.fn((since: number): Promise<QueriesPage> => {
return Promise.resolve({
queries: [fetchedRow(9, 1002, "gap.example"), fetchedRow(8, since, "a.example")],
next_before: null,
coverage: FULL_COVERAGE,
});
});
const { sources, hook } = setup(fetchSince);
act(() => sources[0]!.emit("open"));
expect(fetchSince).not.toHaveBeenCalled();
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
act(() => sources[0]!.emit("error"));
expect(hook.result.current.status).toBe("retrying");
act(() => sources[0]!.emit("open"));
expect(hook.result.current.status).toBe("open");
expect(fetchSince).toHaveBeenCalledWith(1000);
await waitFor(() => expect(hook.result.current.missed).toBe(1));
expect(domains(hook.result.current.rows)).toEqual(["gap.example", "a.example"]);
act(() => hook.result.current.dismissMissed());
expect(hook.result.current.missed).toBeNull();
});
test("failed re-sync sets resyncFailed", async () => {
const fetchSince = vi.fn((): Promise<QueriesPage> => Promise.reject(new Error("boom")));
const { sources, hook } = setup(fetchSince);
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
act(() => sources[0]!.emit("error"));
act(() => sources[0]!.emit("open"));
await waitFor(() => expect(hook.result.current.resyncFailed).toBe(true));
});
test("a 401 gap re-sync redirects to login instead of setting resyncFailed", async () => {
const assign = stubLocationAssign();
const fetchSince = vi.fn((): Promise<QueriesPage> => Promise.reject(new ApiError(401, "unauthorized")));
const { sources, hook } = setup(fetchSince);
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
act(() => sources[0]!.emit("error"));
act(() => sources[0]!.emit("open"));
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
expect(hook.result.current.resyncFailed).toBe(false);
});
test("cap trip with a valid session probes once and stays capped", async () => {
const assign = stubLocationAssign();
const probeSession = vi.fn((): Promise<unknown> => Promise.resolve({}));
const { sources, hook } = setup(undefined, probeSession);
act(() => {
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
});
expect(hook.result.current.status).toBe("capped");
expect(probeSession).toHaveBeenCalledTimes(1);
await act(async () => {});
expect(assign).not.toHaveBeenCalled();
expect(hook.result.current.status).toBe("capped");
});
test("cap trip with an expired session redirects to login", async () => {
const assign = stubLocationAssign();
const probeSession = vi.fn((): Promise<unknown> => Promise.reject(new ApiError(401, "unauthorized")));
const { sources } = setup(undefined, probeSession);
act(() => {
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
});
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
expect(probeSession).toHaveBeenCalledTimes(1);
});
test("repeated errors without open hit the cap state; retry reconnects", () => {
const { sources, hook } = setup();
act(() => {
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
});
expect(hook.result.current.status).toBe("capped");
expect(sources[0]!.closed).toBe(true);
act(() => hook.result.current.retry());
expect(sources).toHaveLength(2);
expect(hook.result.current.status).toBe("connecting");
act(() => sources[1]!.emit("open"));
expect(hook.result.current.status).toBe("open");
});
test("a fatal rejection caps on the first error event and probes the session", async () => {
const assign = stubLocationAssign();
const probeSession = vi.fn((): Promise<unknown> => Promise.resolve({}));
const { sources, hook } = setup(undefined, probeSession);
act(() => sources[0]!.failFatal());
expect(hook.result.current.status).toBe("capped");
expect(probeSession).toHaveBeenCalledTimes(1);
expect(sources[0]!.closed).toBe(true);
await act(async () => {});
expect(assign).not.toHaveBeenCalled();
});
test("a fatal rejection with an expired session redirects to login", async () => {
const assign = stubLocationAssign();
const probeSession = vi.fn((): Promise<unknown> => Promise.reject(new ApiError(401, "unauthorized")));
const { sources } = setup(undefined, probeSession);
act(() => sources[0]!.failFatal());
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
expect(probeSession).toHaveBeenCalledTimes(1);
});
test("a transient error leaves the source open and still takes three to cap", () => {
const probeSession = vi.fn((): Promise<unknown> => Promise.resolve({}));
const { sources, hook } = setup(undefined, probeSession);
for (let i = 0; i < CAP_ERROR_THRESHOLD - 1; i++) {
act(() => sources[0]!.emit("error"));
expect(hook.result.current.status).toBe("retrying");
expect(sources[0]!.closed).toBe(false);
expect(probeSession).not.toHaveBeenCalled();
}
act(() => sources[0]!.emit("error"));
expect(hook.result.current.status).toBe("capped");
expect(probeSession).toHaveBeenCalledTimes(1);
});
test("a successful open resets the consecutive error count", () => {
const { sources, hook } = setup();
act(() => sources[0]!.emit("error"));
act(() => sources[0]!.emit("error"));
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("error"));
expect(hook.result.current.status).toBe("retrying");
expect(sources[0]!.closed).toBe(false);
});
test("freeze keeps the display fixed while the buffer keeps filling", () => {
const { sources, hook } = setup();
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
act(() => hook.result.current.toggleFreeze());
expect(hook.result.current.frozen).toBe(true);
act(() => {
sources[0]!.emit("query", frame(1001, "b.example"));
sources[0]!.emit("query", frame(1002, "c.example"));
});
expect(domains(hook.result.current.rows)).toEqual(["a.example"]);
expect(hook.result.current.liveCount).toBe(3);
act(() => hook.result.current.toggleFreeze());
expect(hook.result.current.frozen).toBe(false);
expect(domains(hook.result.current.rows)).toEqual(["c.example", "b.example", "a.example"]);
});
test("stale sources are ignored after retry and closed on unmount", () => {
const { sources, hook } = setup();
act(() => hook.result.current.retry());
act(() => sources[0]!.emit("query", frame(1000, "stale.example")));
expect(hook.result.current.rows).toHaveLength(0);
hook.unmount();
expect(sources[1]!.closed).toBe(true);
});
@@ -0,0 +1,191 @@
import { useCallback, useEffect, useRef, useState } from "react";
import * as api from "@/lib/api";
import { handleUnauthorized } from "@/lib/queryClient";
import type { LiveQueryEvent, QueriesPage } from "@/lib/types";
import { RING_CAPACITY, mergeGap, pushRow, type LiveRow } from "./ringBuffer";
export type StreamStatus = "connecting" | "open" | "retrying" | "capped";
/** Minimal EventSource surface so tests can inject a fake. */
export interface EventSourceLike {
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void;
close(): void;
readyState: number;
}
/** `EventSource.CLOSED`: the browser gave up and will not retry. */
export const EVENT_SOURCE_CLOSED = 2;
export type EventSourceFactory = (url: string) => EventSourceLike;
export interface LiveQueriesOptions {
url?: string;
createEventSource?: EventSourceFactory;
fetchSince?: (since: number) => Promise<QueriesPage>;
/** Cheap session-gated GET fired once on entering capped, to distinguish an expired session from a real cap. */
probeSession?: () => Promise<unknown>;
}
// A transient drop is invisible to EventSource beyond a bare `error` event;
// this many consecutive errors without an intervening `open` (the browser
// retries every 3s per the server's `retry: 3000`) stops the stream and
// surfaces a manual-retry state. A non-200 response instead fails the source
// permanently after one error event, and is handled by readyState below.
export const CAP_ERROR_THRESHOLD = 3;
const defaultEventSource: EventSourceFactory = (url) => new EventSource(url);
const defaultFetchSince = (since: number): Promise<QueriesPage> => api.getQueries({ since, limit: RING_CAPACITY });
const defaultProbeSession = (): Promise<unknown> => api.getPause();
function isUnauthorized(error: unknown): boolean {
return error instanceof api.ApiError && error.status === 401;
}
export interface LiveQueries {
/** Newest-first; the freeze-time snapshot while frozen. */
rows: LiveRow[];
/** Size of the live buffer, which keeps filling while frozen. */
liveCount: number;
status: StreamStatus;
/** Rows recovered by the reconnect re-sync; null until a re-sync happens or after dismissal. */
missed: number | null;
resyncFailed: boolean;
frozen: boolean;
toggleFreeze: () => void;
retry: () => void;
dismissMissed: () => void;
}
export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
const [rows, setRows] = useState<LiveRow[]>([]);
const [status, setStatus] = useState<StreamStatus>("connecting");
const [missed, setMissed] = useState<number | null>(null);
const [resyncFailed, setResyncFailed] = useState(false);
const [frozen, setFrozen] = useState(false);
const [frozenRows, setFrozenRows] = useState<LiveRow[]>([]);
const bufferRef = useRef<LiveRow[]>([]);
const keyRef = useRef(0);
const lastSeenTsRef = useRef<number | null>(null);
const everOpenRef = useRef(false);
const errorsRef = useRef(0);
const esRef = useRef<EventSourceLike | null>(null);
const optionsRef = useRef(options);
optionsRef.current = options;
const connect = useCallback(() => {
esRef.current?.close();
errorsRef.current = 0;
setStatus("connecting");
const opts = optionsRef.current;
const fetchSince = opts?.fetchSince ?? defaultFetchSince;
const probeSession = opts?.probeSession ?? defaultProbeSession;
const es = (opts?.createEventSource ?? defaultEventSource)(opts?.url ?? api.liveQueriesUrl);
esRef.current = es;
es.addEventListener("open", () => {
if (esRef.current !== es) return;
errorsRef.current = 0;
setStatus("open");
const since = lastSeenTsRef.current;
if (everOpenRef.current && since !== null) {
setResyncFailed(false);
fetchSince(since).then(
(page) => {
if (esRef.current !== es) return;
const merged = mergeGap(bufferRef.current, page.queries, () => ++keyRef.current);
bufferRef.current = merged.rows;
setRows(merged.rows);
setMissed(merged.missed);
},
(error: unknown) => {
if (esRef.current !== es) return;
if (isUnauthorized(error)) {
handleUnauthorized(error);
return;
}
setResyncFailed(true);
},
);
}
everOpenRef.current = true;
});
es.addEventListener("query", (event) => {
if (esRef.current !== es) return;
if (typeof event.data !== "string") return;
let payload: LiveQueryEvent;
try {
payload = JSON.parse(event.data) as LiveQueryEvent;
} catch {
return;
}
lastSeenTsRef.current = payload.request.time;
bufferRef.current = pushRow(bufferRef.current, {
kind: "streamed",
event: payload,
key: ++keyRef.current,
});
setRows(bufferRef.current);
});
const giveUp = () => {
es.close();
setStatus("capped");
// EventSource cannot surface a 401; an expired session looks
// identical to the cap. Probe once on entering capped so the
// user lands on login instead of a misleading capped message.
probeSession().catch(handleUnauthorized);
};
es.addEventListener("error", () => {
if (esRef.current !== es) return;
// A 429 or 401 closes the source outright — no retry follows, so
// the consecutive-error counter would never reach its threshold.
if (es.readyState === EVENT_SOURCE_CLOSED) {
errorsRef.current = CAP_ERROR_THRESHOLD;
giveUp();
return;
}
errorsRef.current += 1;
if (errorsRef.current >= CAP_ERROR_THRESHOLD) {
giveUp();
} else {
setStatus("retrying");
}
});
}, []);
useEffect(() => {
connect();
return () => {
esRef.current?.close();
esRef.current = null;
};
}, [connect]);
const toggleFreeze = () => {
if (frozen) {
setFrozen(false);
} else {
setFrozen(true);
setFrozenRows(bufferRef.current);
}
};
return {
rows: frozen ? frozenRows : rows,
liveCount: rows.length,
status,
missed,
resyncFailed,
frozen,
toggleFreeze,
retry: connect,
dismissMissed: () => {
setMissed(null);
setResyncFailed(false);
},
};
}