milestone 28: query provenance — every logged query is exactly explainable
Gates / frontend (push) Successful in 1m36s
Gates / test (push) Successful in 1m56s
Gates / test-aarch64 (push) Successful in 7m37s
Gates / package (push) Successful in 9m12s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 19m4s

query rows gain qclass, rcode, group, policy action and reason, the
matched rule or list entry with its source, cname and safe-search
targets, route kind, forward zone, and the resolver that actually
answered — the pool and local markers die. servfails are logged and
name the resolver that lost; post-parse protocol refusals become rows.
a detail page at /queries/:id renders the ordered explanation, and
coverage watermarks distinguish an empty history from a missing one.

the schema fingerprint changes: existing query history is recreated
with the old file kept aside and the reset filed as a resolved
diagnostic. fixes an oversized udp reply being rebuilt as noerror,
which handed clients a truncated nxdomain as success.
This commit is contained in:
2026-08-22 09:16:40 +02:00
parent 7e6cb507d2
commit 0fd6bbd312
65 changed files with 7036 additions and 685 deletions
+17 -7
View File
@@ -37,17 +37,27 @@ export function useClientNames(): ClientNames {
);
}
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
/**
* The name the loaded list gives this address right now, or null when it gives
* none. Callers that must distinguish "named" from "bare address" — rather than
* just render whichever applies — read this instead of re-deriving precedence.
*/
export function clientLabel(ip: string, names: ClientNames): { text: string; learned: boolean } | null {
const client = names.get(ip);
if (client === undefined || (client.name === "" && client.learned_name === "")) {
return <span {...stylex.props(shared.mono)}>{ip}</span>;
}
if (client === undefined) return null;
if (client.name !== "") return { text: client.name, learned: false };
if (client.learned_name !== "") return { text: client.learned_name, learned: true };
return null;
}
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
const label = clientLabel(ip, names);
if (label === null) return <span {...stylex.props(shared.mono)}>{ip}</span>;
// The name replaces the address on screen, so the address stays reachable
// as the tooltip rather than disappearing from the row entirely.
if (client.name !== "") return <span title={ip}>{client.name}</span>;
return (
<span title={ip} {...stylex.props(shared.learnedName)}>
{client.learned_name}
<span title={ip} {...stylex.props(label.learned && shared.learnedName)}>
{label.text}
</span>
);
}
@@ -18,6 +18,7 @@ const RESPONSES: Record<string, unknown> = {
cached: 100,
clients: 7,
avg_response_time_us: 2345,
coverage: { complete: true, available_since: 0 },
},
"/api/stats/timeseries?period=24h": {
period: "24h",
@@ -29,6 +30,7 @@ const RESPONSES: Record<string, unknown> = {
{ ts: 1800, queries: 40, blocked: 0, cached: 0 },
{ ts: 3600, queries: 0, blocked: 0, cached: 0 },
],
coverage: { complete: true, available_since: 0 },
},
"/api/stats?period=1h": {
period: "1h",
@@ -39,6 +41,7 @@ const RESPONSES: Record<string, unknown> = {
cached: 0,
clients: 2,
avg_response_time_us: null,
coverage: { complete: true, available_since: 0 },
},
"/api/stats/timeseries?period=1h": {
period: "1h",
@@ -46,6 +49,7 @@ const RESPONSES: Record<string, unknown> = {
until: 3600,
bucket_seconds: 60,
buckets: [],
coverage: { complete: true, available_since: 0 },
},
"/api/health": {
status: "degraded",
@@ -3,6 +3,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
import type { Period } from "@/lib/types";
import CoverageNotice from "@/lib/CoverageNotice";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -142,6 +143,10 @@ export default function DashboardPage() {
<StatCards stats={stats.data} />
)}
{/* One notice for the period: the chart is judged against the same
aligned lower bound as the totals, so it would say the same thing. */}
{stats.data !== undefined && <CoverageNotice coverage={stats.data.coverage} />}
<div {...stylex.props(styles.panelGrid)}>
<section {...stylex.props(styles.panel)}>
<h2 {...stylex.props(styles.panelHeading)}>Queries over time</h2>
@@ -12,6 +12,7 @@ function timeseries(bucketCount: number): StatsTimeseries {
since: SINCE,
until: SINCE + bucketCount * 1800,
bucket_seconds: 1800,
coverage: { complete: true, available_since: SINCE },
buckets: Array.from({ length: bucketCount }, (_, i) => ({
ts: SINCE + i * 1800,
queries: i + 1,
+80 -21
View File
@@ -1,7 +1,11 @@
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 type { Client, LiveQueryEvent } from "@/lib/types";
import { createAppRouter } from "@/routes";
import type { Client } from "@/lib/types";
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
import { FakeEventSource } from "./fakeEventSource";
import LiveLogPage from "./LiveLogPage";
@@ -44,19 +48,16 @@ afterEach(() => {
vi.unstubAllGlobals();
});
function frame(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): { data: string } {
const payload: LiveQueryEvent = {
ts,
domain,
client_ip: "192.0.2.10",
qtype: 1,
blocked: false,
block_reason: "",
response_time_us: 500,
cache_hit: true,
upstream: "",
...overrides,
};
function json(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
function frame(ts: number, domain: string, sections: Parameters<typeof provenance>[0] = {}): { data: string } {
const payload = provenance({
...sections,
request: { time: ts, domain, ...sections.request },
route: { kind: "cache", upstream: "", ...sections.route },
});
return { data: JSON.stringify(payload) };
}
@@ -87,13 +88,17 @@ test("streams rows, flags blocked ones, and freezes the display", () => {
sources[0]!.emit("query", frame(1000, "ok.example"));
sources[0]!.emit(
"query",
frame(1001, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack", qtype: 28 }),
frame(1001, "ads.example", {
request: { qtype: 28 },
policy: { action: "block", reason: "blocklist_wildcard" },
route: { kind: "blocked" },
}),
);
});
expect(screen.getByText("ok.example")).toBeTruthy();
expect(screen.getByText("Blocked")).toBeTruthy();
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
expect(screen.getByText("Blocklist (wildcard)")).toBeTruthy();
expect(screen.getByText("AAAA")).toBeTruthy();
// StyleX compiles to opaque class names, so the check is structural: a blocked
// row carries every class a plain row does, plus the ones the flag adds.
@@ -120,10 +125,10 @@ test("resolves each row's client to its display name, keeping the IP as the tool
const sources = renderPage();
act(() => sources[0]!.emit("open"));
act(() => {
sources[0]!.emit("query", frame(1000, "named.example", { client_ip: "192.0.2.10" }));
sources[0]!.emit("query", frame(1001, "learned.example", { client_ip: "192.0.2.11" }));
sources[0]!.emit("query", frame(1002, "nameless.example", { client_ip: "192.0.2.12" }));
sources[0]!.emit("query", frame(1003, "stranger.example", { client_ip: "192.0.2.99" }));
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
sources[0]!.emit("query", frame(1001, "learned.example", { request: { client: "192.0.2.11" } }));
sources[0]!.emit("query", frame(1002, "nameless.example", { request: { client: "192.0.2.12" } }));
sources[0]!.emit("query", frame(1003, "stranger.example", { request: { client: "192.0.2.99" } }));
});
// A hand-typed name wins outright; the learned name never surfaces for it.
@@ -170,7 +175,7 @@ test("rows stream in as bare IPs while the client list is still loading", async
const sources = renderPage();
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "named.example", { client_ip: "192.0.2.10" })));
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();
@@ -192,3 +197,57 @@ test("repeated connection failures show the viewer-cap state with a retry button
expect(sources).toHaveLength(2);
expect(screen.getByText("Connecting…")).toBeTruthy();
});
test("a row recovered by the reconnect fetch links to its stored detail; a streamed one cannot", async () => {
const sources: FakeEventSource[] = [];
vi.stubGlobal(
"EventSource",
class {
constructor(url: string) {
const es = new FakeEventSource(url);
sources.push(es);
return es as unknown as EventSource;
}
},
);
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url.startsWith("/api/queries?")) {
return json({
queries: [queryRow(88, { ts: 1001, domain: "recovered.example" })],
next_before: null,
coverage: { complete: true, available_since: 0 },
});
}
return json({ version: "0.0.0-test", git_commit: "0", zig_version: "0.16.0", uptime_seconds: 1 });
}),
);
// The route, not the bare page: only a recovered row renders a link, so the
// test needs the router the link resolves against.
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/live"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
await screen.findByRole("button", { name: "Freeze" });
const source = sources[0]!;
act(() => source.emit("open"));
act(() => source.emit("query", frame(1000, "streamed.example")));
act(() => source.emit("error"));
act(() => source.emit("open"));
const recovered = await screen.findByRole("link", { name: "recovered.example" });
expect(recovered.getAttribute("href")).toBe("/queries/88");
// The streamed frame precedes its own insert, so it has no row to link to.
expect(screen.queryByRole("link", { name: "streamed.example" })).toBeNull();
expect(screen.getByText("streamed.example")).toBeTruthy();
});
+12 -6
View File
@@ -1,7 +1,7 @@
import * as stylex from "@stylexjs/stylex";
import { useClientNames } from "@/features/clients/clientNames";
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
import { RING_CAPACITY } from "./ringBuffer";
import { RING_CAPACITY, summaryOf } from "./ringBuffer";
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -240,11 +240,17 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
<table {...stylex.props(styles.table)}>
<QueryTableHead />
<tbody>
{live.rows.map((row) => (
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
<QueryCells row={row} clientNames={clientNames} />
</tr>
))}
{live.rows.map((row) => {
const summary = summaryOf(row);
return (
<tr
key={row.key}
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
>
<QueryCells row={summary} clientNames={clientNames} />
</tr>
);
})}
</tbody>
</table>
</div>
+184 -39
View File
@@ -1,27 +1,21 @@
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
import { RING_CAPACITY, mergeGap, pushRow, type LiveRow } from "./ringBuffer";
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 event(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): LiveQueryEvent {
function streamed(key: number, ts: number, domain: string, sections: Parameters<typeof provenance>[0] = {}): LiveRow {
return {
ts,
domain,
client_ip: "192.0.2.10",
qtype: 1,
blocked: false,
block_reason: "",
response_time_us: 500,
cache_hit: false,
upstream: "udp://9.9.9.9:53",
...overrides,
kind: "streamed",
key,
event: provenance({
...sections,
request: { time: ts, domain, ...sections.request },
route: { upstream: "udp://9.9.9.9:53", ...sections.route },
}),
};
}
function liveRow(key: number, ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): LiveRow {
return { ...event(ts, domain, overrides), key };
}
function fetchedRow(id: number, ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): QueryRow {
return { id, ...event(ts, domain, overrides) };
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 {
@@ -29,31 +23,103 @@ function counter(start = 100): () => number {
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",
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, liveRow(1, 10, "a.example"));
rows = pushRow(rows, liveRow(2, 11, "b.example"));
expect(rows.map((r) => r.domain)).toEqual(["b.example", "a.example"]);
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, liveRow(i, i, `d${i}.example`), 3);
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, liveRow(i, i, "x.example"));
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 = [liveRow(2, 100, "seen.example"), liveRow(1, 99, "old.example")];
const buffer = [streamed(2, 100, "seen.example"), streamed(1, 99, "old.example")];
const fetched = [
fetchedRow(30, 102, "gap2.example"),
fetchedRow(29, 101, "gap1.example"),
@@ -61,41 +127,120 @@ describe("mergeGap", () => {
];
const { rows, missed } = mergeGap(buffer, fetched, counter());
expect(missed).toBe(2);
expect(rows.map((r) => r.domain)).toEqual(["gap2.example", "gap1.example", "seen.example", "old.example"]);
expect(domains(rows)).toEqual(["gap2.example", "gap1.example", "seen.example", "old.example"]);
});
test("no additions returns the buffer unchanged with missed 0", () => {
const buffer = [liveRow(1, 100, "seen.example")];
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);
});
test("rows differing only in qtype are not deduplicated", () => {
const buffer = [liveRow(1, 100, "dual.example", { qtype: 1 })];
const fetched = [fetchedRow(5, 100, "dual.example", { qtype: 28 })];
const { missed } = mergeGap(buffer, fetched, counter());
expect(missed).toBe(1);
/**
* 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("assigns fresh keys from the counter and drops the id", () => {
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));
expect(rows[0]?.key).toBe(201);
expect("id" in (rows[0] ?? {})).toBe(false);
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 = [liveRow(3, 300, "live.example")];
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(rows.map((r) => r.domain)).toEqual(["g2.example", "g1.example"]);
expect(domains(rows)).toEqual(["g2.example", "g1.example"]);
});
test("merged rows stay sorted newest-first by ts", () => {
const buffer = [liveRow(4, 105, "after-reopen.example"), liveRow(3, 100, "before.example")];
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((r) => r.ts)).toEqual([105, 103, 100]);
expect(rows.map((row) => summaryOf(row).ts)).toEqual([105, 103, 100]);
});
});
+115 -24
View File
@@ -1,8 +1,23 @@
import type { LiveQueryEvent, QueryRow } from "@/lib/types";
import { summarizeEvent, summarizeRow, type QuerySummary } from "@/features/queries/querySummary";
/** A live stream row; `key` is a client-side monotonic counter (SSE frames carry no id). */
export interface LiveRow extends LiveQueryEvent {
key: number;
/**
* 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 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;
@@ -13,16 +28,98 @@ export function pushRow(rows: LiveRow[], row: LiveRow, capacity: number = RING_C
return next.length > capacity ? next.slice(0, capacity) : next;
}
// `since` on GET /api/queries is inclusive, so the re-sync fetch returns the
// last-seen row(s) again; live rows have no id, so identity is this tuple.
function signature(row: LiveQueryEvent): string {
return `${row.ts}|${row.domain}|${row.client_ip}|${row.qtype ?? -1}|${row.blocked}|${row.upstream}`;
/**
* 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, rcode, policy_action and route_kind. `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. Rows already present are skipped; `missed` counts what was
* actually added. The result stays newest-first (stable sort by ts) and capped.
* 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[],
@@ -30,24 +127,18 @@ export function mergeGap(
nextKey: () => number,
capacity: number = RING_CAPACITY,
): { rows: LiveRow[]; missed: number } {
const seen = new Set(rows.map(signature));
const buffered = occurrences(rows);
const added: LiveRow[] = [];
for (const row of fetched) {
const event: LiveQueryEvent = {
ts: row.ts,
domain: row.domain,
client_ip: row.client_ip,
qtype: row.qtype,
blocked: row.blocked,
block_reason: row.block_reason,
response_time_us: row.response_time_us,
cache_hit: row.cache_hit,
upstream: row.upstream,
};
if (seen.has(signature(event))) continue;
added.push({ ...event, key: nextKey() });
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) => b.ts - a.ts).slice(0, capacity);
const merged = [...added, ...rows].sort((a, b) => summaryOf(b).ts - summaryOf(a).ts).slice(0, capacity);
return { rows: merged, missed: added.length };
}
+20 -30
View File
@@ -1,6 +1,8 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { ApiError } from "@/lib/api";
import type { LiveQueryEvent, QueriesPage, QueryRow } from "@/lib/types";
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";
@@ -12,37 +14,24 @@ function stubLocationAssign() {
return assign;
}
function frame(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): { data: string } {
const payload: LiveQueryEvent = {
ts,
domain,
client_ip: "192.0.2.10",
qtype: 1,
blocked: false,
block_reason: "",
response_time_us: 500,
cache_hit: false,
upstream: "udp://9.9.9.9:53",
...overrides,
};
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 {
id,
ts,
domain,
client_ip: "192.0.2.10",
qtype: 1,
blocked: false,
block_reason: "",
response_time_us: 500,
cache_hit: false,
upstream: "udp://9.9.9.9:53",
};
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) => {
@@ -68,7 +57,7 @@ test("open then frames: rows newest-first with increasing keys", () => {
sources[0]!.emit("query", frame(1001, "b.example"));
});
const rows = hook.result.current.rows;
expect(rows.map((r) => r.domain)).toEqual(["b.example", "a.example"]);
expect(domains(rows)).toEqual(["b.example", "a.example"]);
expect(rows[0]!.key).toBeGreaterThan(rows[1]!.key);
});
@@ -87,6 +76,7 @@ test("error then reopen re-syncs the gap since the last seen ts", async () => {
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);
@@ -103,7 +93,7 @@ test("error then reopen re-syncs the gap since the last seen ts", async () => {
expect(fetchSince).toHaveBeenCalledWith(1000);
await waitFor(() => expect(hook.result.current.missed).toBe(1));
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["gap.example", "a.example"]);
expect(domains(hook.result.current.rows)).toEqual(["gap.example", "a.example"]);
act(() => hook.result.current.dismissMissed());
expect(hook.result.current.missed).toBeNull();
@@ -235,12 +225,12 @@ test("freeze keeps the display fixed while the buffer keeps filling", () => {
sources[0]!.emit("query", frame(1001, "b.example"));
sources[0]!.emit("query", frame(1002, "c.example"));
});
expect(hook.result.current.rows.map((r) => r.domain)).toEqual(["a.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(hook.result.current.rows.map((r) => r.domain)).toEqual(["c.example", "b.example", "a.example"]);
expect(domains(hook.result.current.rows)).toEqual(["c.example", "b.example", "a.example"]);
});
test("stale sources are ignored after retry and closed on unmount", () => {
+6 -2
View File
@@ -121,8 +121,12 @@ export function useLiveQueries(options?: LiveQueriesOptions): LiveQueries {
} catch {
return;
}
lastSeenTsRef.current = payload.ts;
bufferRef.current = pushRow(bufferRef.current, { ...payload, key: ++keyRef.current });
lastSeenTsRef.current = payload.request.time;
bufferRef.current = pushRow(bufferRef.current, {
kind: "streamed",
event: payload,
key: ++keyRef.current,
});
setRows(bufferRef.current);
});
+8 -2
View File
@@ -1,5 +1,6 @@
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";
@@ -253,9 +254,14 @@ export default function LookupPage() {
const groups = useSuspenseQuery(groupsQuery()).data;
const preselectedGroupId = defaultGroupId(groups);
const [domain, setDomain] = useState("");
// A `?domain=` link (from a query's detail page) arrives already asking the
// question, so it runs the lookup rather than leaving a filled-in form.
const search = useSearch({ from: "/shell/lookup" });
const [domain, setDomain] = useState(search.domain ?? "");
const [groupId, setGroupId] = useState(preselectedGroupId);
const [submitted, setSubmitted] = useState<Submitted | null>(null);
const [submitted, setSubmitted] = useState<Submitted | null>(
search.domain === undefined ? null : { domain: search.domain, groupId: preselectedGroupId },
);
const lookup = useQuery({
...lookupQuery(submitted?.domain ?? "", submitted?.groupId),
@@ -0,0 +1,247 @@
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 "./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) {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: [`/queries/${id}`] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return router;
}
/** 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 the query's own domain and client into the live pages", async () => {
responses["/api/queries/11"] = detail(11, { request: { domain: "shop.example", client: "192.0.2.12" } });
renderDetail(11);
await screen.findByRole("heading", { name: "shop.example" });
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
expect(within(related).getByRole("link", { name: "Look up this domain now" }).getAttribute("href")).toBe(
"/lookup?domain=shop.example",
);
expect(within(related).getByRole("link", { name: "All queries for this domain" }).getAttribute("href")).toBe(
"/queries?domain=shop.example",
);
expect(within(related).getByRole("link", { name: "All queries from this client" }).getAttribute("href")).toBe(
"/queries?client=192.0.2.12",
);
});
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);
await screen.findByRole("alert");
expect(screen.getByText(/no such query/)).toBeTruthy();
expect(screen.getByRole("link", { name: "← Query log" }).getAttribute("href")).toBe("/queries");
});
@@ -0,0 +1,329 @@
import type { ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { formatMicros, formatTime } from "@/lib/format";
import { queryDetailQuery } from "@/lib/queries";
import type { PolicyReason, QueryDetail } from "@/lib/types";
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
import { policyActionLabel, policyReasonLabel, qclassName, rcodeName, routeKindLabel } from "./provenanceCopy";
import { qtypeName } from "./qtype";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
back: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
heading: {
marginTop: "0.5rem",
fontSize: "1.5rem",
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,
},
loading: {
marginTop: "1rem",
color: colors.textMuted,
},
});
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";
}
export default function QueryDetailPage() {
const { id } = useParams({ from: "/shell/queries/$id" });
const rowId = Number(id);
const { data, error, isPending, refetch } = useQuery(queryDetailQuery(rowId));
const clientNames = useClientNames();
if (isPending) {
return (
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
Loading query
</p>
);
}
if (data === undefined) {
return (
<section>
<Link to="/queries" {...stylex.props(styles.back, shared.focusRing)}>
Query log
</Link>
<InlineError error={error} onRetry={() => void refetch()} />
</section>
);
}
const detail: QueryDetail = data;
const { request, group, policy, rewrites, route, response } = detail;
const currentClient = clientLabel(request.client, clientNames);
return (
<section>
<Link to="/queries" {...stylex.props(styles.back, shared.focusRing)}>
Query log
</Link>
<h1 {...stylex.props(styles.heading, shared.mono)}>{request.domain}</h1>
<p {...stylex.props(styles.subtitle)}>
{formatTime(request.time)} {policyActionLabel(policy.action)}
{/* 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.
</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)}>
<Link
to="/lookup"
search={{ domain: request.domain }}
{...stylex.props(styles.link, shared.focusRing)}
>
Look up this domain now
</Link>
<Link
to="/queries"
search={{ domain: request.domain }}
{...stylex.props(styles.link, shared.focusRing)}
>
All queries for this domain
</Link>
<Link
to="/queries"
search={{ client: request.client }}
{...stylex.props(styles.link, shared.focusRing)}
>
All queries from this client
</Link>
</div>
</div>
</section>
);
}
+121 -32
View File
@@ -1,8 +1,11 @@
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 type { Client, QueriesPage, QueryRow } from "@/lib/types";
import QueryLogPage from "./QueryLogPage";
import { createAppRouter } from "@/routes";
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
import { queryRow } from "./provenanceFixture";
function client(id: number, ip: string, name: string, learnedName: string): Client {
return {
@@ -25,41 +28,38 @@ const CLIENTS: Client[] = [
];
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
return {
id,
ts: 1_700_000_000 + id,
domain,
client_ip: "192.0.2.10",
qtype: 1,
blocked: false,
block_reason: "",
response_time_us: 1234,
cache_hit: false,
upstream: "udp://9.9.9.9:53",
...overrides,
};
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, upstream: "" }),
row(19, "ads.example", {
blocked: true,
block_reason: "blocklist:stevenblack",
response_time_us: null,
cache_hit: null,
}),
row(20, "first.example", { qtype: 65, cache_hit: true }),
row(19, "ads.example", { ...BLOCKED, response_time_us: null, cache_hit: null }),
],
next_before: 19,
coverage: COMPLETE,
},
"/api/queries?before=19": {
queries: [row(5, "older.example")],
next_before: null,
coverage: COMPLETE,
},
"/api/queries?domain=ads": {
queries: [row(19, "ads.example", { blocked: true, block_reason: "blocklist:stevenblack" })],
queries: [row(19, "ads.example", BLOCKED)],
next_before: null,
coverage: COMPLETE,
},
};
@@ -83,12 +83,19 @@ afterEach(() => {
vi.unstubAllGlobals();
});
function renderPage() {
/**
* The whole router, not the bare component: the rows link into `/queries/$id`
* and the filter form seeds itself from the url, so both need real routing.
*/
function renderPage(path = "/queries") {
const client = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), client);
render(
<QueryClientProvider client={client}>
<QueryLogPage />
</QueryClientProvider>,
<AuthProvider>
<QueryClientProvider client={client}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
return client;
}
@@ -104,7 +111,7 @@ test("renders the first page with type names, blocked badge, and formatted cells
expect(screen.getByText("HTTPS")).toBeTruthy();
expect(screen.getByText("A")).toBeTruthy();
expect(screen.getByText("Blocked")).toBeTruthy();
expect(screen.getByText("blocklist:stevenblack")).toBeTruthy();
expect(screen.getByText("Blocklist (wildcard)")).toBeTruthy();
expect(screen.getByText("1.2 ms")).toBeTruthy();
expect(screen.getByText("hit")).toBeTruthy();
expect(screen.getByText("udp://9.9.9.9:53")).toBeTruthy();
@@ -126,6 +133,7 @@ test("resolves each row's client to its display name, keeping the IP as the tool
row(17, "stranger.example", { client_ip: "192.0.2.99" }),
],
next_before: null,
coverage: COMPLETE,
} satisfies QueriesPage);
}),
);
@@ -229,12 +237,14 @@ test("a load-more that resolves after a filter change is discarded", async () =>
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: true, block_reason: "blocklist:stevenblack" })],
queries: [row(19, "ads.example", BLOCKED)],
next_before: 7,
coverage: COMPLETE,
};
const filteredOlderPage: QueriesPage = {
queries: [row(3, "ads.older.example")],
next_before: null,
coverage: COMPLETE,
};
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
@@ -292,14 +302,27 @@ test("a background refetch after new rows arrive leaves no gap between the loade
// 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 },
"/api/queries?before=19": { queries: [row(18, "n18.example"), row(17, "n17.example")], next_before: null },
"/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 },
"/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;
@@ -361,3 +384,69 @@ test("a 401 on load more routes through handleUnauthorized instead of the inline
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByText(/Failed to load more/)).toBeNull();
});
test("each row links into its own detail page by domain, reachable from the keyboard", async () => {
renderPage();
await screen.findByText("first.example");
const link = screen.getByRole("link", { name: "first.example" });
expect(link.getAttribute("href")).toBe("/queries/20");
// An <a href> is in the tab order by default; nothing here may opt it out.
expect(link.getAttribute("tabindex")).toBeNull();
});
test("an allowed query names the rule that allowed it; an unremarkable one stays blank", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json({
queries: [row(20, "allowed.example", { policy_reason: "rule_allow_exact" }), row(19, "plain.example")],
next_before: null,
coverage: COMPLETE,
} satisfies QueriesPage);
}),
);
renderPage();
await screen.findByText("allowed.example");
expect(screen.getByText("Allow rule (exact)")).toBeTruthy();
expect(screen.queryByText("Blocked")).toBeNull();
expect(within(screen.getByText("plain.example").closest("tr")!).getByText("—")).toBeTruthy();
});
test("a pruned window tells the reader when history starts", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json({
queries: [row(20, "kept.example")],
next_before: null,
coverage: { complete: false, available_since: 1_700_000_000 },
} satisfies QueriesPage);
}),
);
renderPage();
await screen.findByText("kept.example");
expect(screen.getByText(/Query history is available from/)).toBeTruthy();
});
test("a complete window shows no coverage notice", async () => {
renderPage();
await screen.findByText("first.example");
expect(screen.queryByText(/Query history is available from/)).toBeNull();
});
test("a ?domain= link seeds the filter and fetches that domain on arrival", async () => {
renderPage("/queries?domain=ads");
await screen.findByText("ads.example");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
expect(screen.queryByText("first.example")).toBeNull();
});
+58 -10
View File
@@ -1,11 +1,15 @@
import { useState, type FormEvent } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import * as api from "@/lib/api";
import CoverageNotice from "@/lib/CoverageNotice";
import { formatMicros, formatTime } from "@/lib/format";
import { queriesInfiniteQuery } from "@/lib/queries";
import type { QueriesFilter, QueryRow } from "@/lib/types";
import { ClientName, useClientNames, type ClientNames } from "@/features/clients/clientNames";
import { isUninformativeReason, policyReasonLabel } from "./provenanceCopy";
import { summarizeRow, type QuerySummary } from "./querySummary";
import { qtypeName } from "./qtype";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
@@ -107,6 +111,11 @@ const styles = stylex.create({
breakAll: {
wordBreak: "break-all",
},
/** The row's way into the detail page; a real link, so tab and enter reach it. */
domainLink: {
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
small: {
fontSize: "0.75rem",
lineHeight: "1rem",
@@ -153,27 +162,54 @@ function datetimeLocalToUnix(value: string): number | undefined {
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
}
export function BlockedCell({ row }: { row: Pick<QueryRow, "blocked" | "block_reason"> }) {
if (!row.blocked) return <span {...stylex.props(styles.muted)}></span>;
/**
* What the policy decided, and why. The reason is the stored enum rather than
* the old free-text block reason, so an allowed query that a rule or a blocklist
* exception explains says so too only `no_match`, the answer for most allowed
* queries, stays blank.
*/
export function StatusCell({ row }: { row: Pick<QuerySummary, "blocked" | "policy_reason"> }) {
const reason = policyReasonLabel(row.policy_reason);
if (!row.blocked) {
if (isUninformativeReason(row.policy_reason)) return <span {...stylex.props(styles.muted)}></span>;
return <span {...stylex.props(styles.small, styles.muted)}>{reason}</span>;
}
return (
<span {...stylex.props(styles.blockedWrap)}>
<span {...stylex.props(styles.blockedBadge)}>Blocked</span>
{row.block_reason !== "" && <span {...stylex.props(styles.small, styles.muted)}>{row.block_reason}</span>}
<span {...stylex.props(styles.small, styles.muted)}>{reason}</span>
</span>
);
}
export function QueryCells({ row, clientNames }: { row: Omit<QueryRow, "id">; clientNames: ClientNames }) {
/**
* The eight shared cells. `row.id` is null for a live frame the server has not
* written yet, which is the one case with no detail page to link to.
*/
export function QueryCells({ row, clientNames }: { row: QuerySummary; clientNames: ClientNames }) {
const id = row.id;
return (
<>
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td>
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
{id === null ? (
row.domain
) : (
<Link
to="/queries/$id"
params={{ id: String(id) }}
{...stylex.props(styles.domainLink, shared.focusRing)}
>
{row.domain}
</Link>
)}
</td>
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
<ClientName ip={row.client_ip} names={clientNames} />
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
<td {...stylex.props(styles.cell)}>
<BlockedCell row={row} />
<StatusCell row={row} />
</td>
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
@@ -206,19 +242,29 @@ export function QueryTableHead() {
}
export default function QueryLogPage() {
const [domain, setDomain] = useState("");
const [client, setClient] = useState("");
// The two url filters exist so a detail page can link back to "every query
// for this domain". They seed the form once; typing from here on is local
// state, as the other three filters always were.
const search = useSearch({ from: "/shell/queries" });
const [domain, setDomain] = useState(search.domain ?? "");
const [client, setClient] = useState(search.client ?? "");
const [blocked, setBlocked] = useState("any");
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [applied, setApplied] = useState<QueriesFilter>({});
const [applied, setApplied] = useState<QueriesFilter>(() => {
const initial: QueriesFilter = {};
if (search.domain !== undefined) initial.domain = search.domain;
if (search.client !== undefined) initial.client = search.client;
return initial;
});
const base = useInfiniteQuery(queriesInfiniteQuery(applied));
const clientNames = useClientNames();
const pages = base.data?.pages ?? [];
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
const coverage = pages[0]?.coverage;
const filterActive = Object.keys(applied).length > 0;
// `base.hasNextPage` reads the query state, which is empty while placeholder
// data stands in for a filter change; derive the cursor from what is on
@@ -324,6 +370,8 @@ export default function QueryLogPage() {
</div>
</form>
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
{base.data === undefined ? (
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
Loading query log
@@ -340,7 +388,7 @@ export default function QueryLogPage() {
<tbody>
{rows.map((row) => (
<tr key={row.id} {...stylex.props(styles.row)}>
<QueryCells row={row} clientNames={clientNames} />
<QueryCells row={summarizeRow(row)} clientNames={clientNames} />
</tr>
))}
</tbody>
@@ -0,0 +1,39 @@
import {
ENUM_VALUES,
policyActionLabel,
policyReasonLabel,
qclassName,
rcodeName,
routeKindLabel,
} from "./provenanceCopy";
/**
* `tsc` proves the maps total over the union; this proves the union is the set
* the server actually stores, and that no entry was left as its raw tag name.
*/
test("every stored enum value has a label of its own", () => {
const labels = [
...ENUM_VALUES.policyAction.map(policyActionLabel),
...ENUM_VALUES.policyReason.map(policyReasonLabel),
...ENUM_VALUES.routeKind.map(routeKindLabel),
];
for (const label of labels) {
expect(label).not.toBe("");
expect(label).not.toMatch(/_/);
}
expect(new Set(ENUM_VALUES.policyReason.map(policyReasonLabel)).size).toBe(ENUM_VALUES.policyReason.length);
});
test("response codes read by name where one exists, by number where none does", () => {
expect(rcodeName(0)).toBe("NOERROR (0)");
expect(rcodeName(3)).toBe("NXDOMAIN (3)");
expect(rcodeName(16)).toBe("BADVERS (16)");
// The column holds the twelve-bit extended code, most of which is unassigned.
expect(rcodeName(3841)).toBe("RCODE 3841");
});
test("query classes read the same way", () => {
expect(qclassName(1)).toBe("IN (1)");
expect(qclassName(255)).toBe("ANY (255)");
expect(qclassName(42)).toBe("CLASS 42");
});
@@ -0,0 +1,103 @@
import { POLICY_ACTIONS, POLICY_REASONS, ROUTE_KINDS } from "@/lib/types";
import type { PolicyAction, PolicyReason, RouteKind } from "@/lib/types";
/**
* Display names for the three stored enums. `Record` over the union, so a value
* added to `src/storage/provenance.zig` and mirrored into `lib/types.ts` fails
* `tsc` here instead of reaching a cell as a raw tag name.
*/
const POLICY_ACTION_LABELS: Record<PolicyAction, string> = {
not_evaluated: "Not evaluated",
allow: "Allowed",
block: "Blocked",
};
const POLICY_REASON_LABELS: Record<PolicyReason, string> = {
rule_allow_exact: "Allow rule (exact)",
rule_block_exact: "Block rule (exact)",
rule_allow_wildcard: "Allow rule (wildcard)",
rule_block_wildcard: "Block rule (wildcard)",
rule_allow_regex: "Allow rule (regex)",
rule_block_regex: "Block rule (regex)",
blocklist_exception: "Blocklist exception",
blocklist_domain: "Blocklist (domain)",
blocklist_wildcard: "Blocklist (wildcard)",
local_record: "Local record",
forward_zone: "Forward zone",
non_in_class: "Not class IN",
paused: "Filtering paused",
snapshot_unavailable: "No filter snapshot",
no_match: "No match",
protocol_error: "Protocol refusal",
};
const ROUTE_KIND_LABELS: Record<RouteKind, string> = {
blocked: "Blocked locally",
local: "Local record",
forward_zone: "Forward zone",
upstream: "Upstream resolver",
cache: "Cache",
rejected: "Rejected",
};
export function policyActionLabel(action: PolicyAction): string {
return POLICY_ACTION_LABELS[action];
}
export function policyReasonLabel(reason: PolicyReason): string {
return POLICY_REASON_LABELS[reason];
}
export function routeKindLabel(kind: RouteKind): string {
return ROUTE_KIND_LABELS[kind];
}
/**
* `no_match` is the answer for the overwhelming majority of allowed queries and
* says nothing an operator scanning a table wants to read, so the status column
* leaves it blank. Every other reason names a decision worth seeing.
*/
export function isUninformativeReason(reason: PolicyReason): boolean {
return reason === "no_match";
}
/** The enum value sets, for tests that prove the maps exhaustive at runtime too. */
export const ENUM_VALUES = {
policyAction: POLICY_ACTIONS,
policyReason: POLICY_REASONS,
routeKind: ROUTE_KINDS,
} as const;
const RCODE_NAMES: Record<number, string> = {
0: "NOERROR",
1: "FORMERR",
2: "SERVFAIL",
3: "NXDOMAIN",
4: "NOTIMP",
5: "REFUSED",
6: "YXDOMAIN",
7: "YXRRSET",
8: "NXRRSET",
9: "NOTAUTH",
10: "NOTZONE",
16: "BADVERS",
};
/** The twelve-bit extended code as `NXDOMAIN (3)`; an unassigned code keeps its number. */
export function rcodeName(rcode: number): string {
const name = RCODE_NAMES[rcode];
return name === undefined ? `RCODE ${rcode}` : `${name} (${rcode})`;
}
const QCLASS_NAMES: Record<number, string> = {
1: "IN",
3: "CH",
4: "HS",
254: "NONE",
255: "ANY",
};
export function qclassName(qclass: number): string {
const name = QCLASS_NAMES[qclass];
return name === undefined ? `CLASS ${qclass}` : `${name} (${qclass})`;
}
@@ -0,0 +1,58 @@
import type { Provenance, QueryRow } from "@/lib/types";
/**
* Fixture builders for the provenance shapes, shared by the query-log, detail
* and live-stream tests the way `features/live/fakeEventSource.ts` is shared.
*
* The defaults describe the dullest possible query an allowed name nothing
* matched, answered upstream so each test states only the fields it is about.
*/
type Sections = {
[K in keyof Provenance]?: Partial<Provenance[K]>;
};
export function provenance(sections: Sections = {}): Provenance {
return {
request: {
time: 1_700_000_000,
domain: "example.com",
client: "192.0.2.10",
qtype: 1,
qclass: 1,
...sections.request,
},
group: { id: 1, name: "default", ...sections.group },
policy: {
action: "allow",
reason: "no_match",
matched: "",
source_id: null,
source_name: "",
...sections.policy,
},
rewrites: { cname_target: "", safe_search_target: "", ...sections.rewrites },
route: { kind: "upstream", forward_zone: "", upstream: "https://dns.example/dns-query", ...sections.route },
response: { rcode: 0, duration_us: 1234, ...sections.response },
};
}
/** The flat stored row of the same dull query. */
export function queryRow(id: number, overrides: Partial<QueryRow> = {}): QueryRow {
return {
id,
ts: 1_700_000_000,
domain: "example.com",
client_ip: "192.0.2.10",
qtype: 1,
qclass: 1,
rcode: 0,
blocked: false,
response_time_us: 1234,
cache_hit: false,
upstream: "https://dns.example/dns-query",
policy_action: "allow",
policy_reason: "no_match",
route_kind: "upstream",
...overrides,
};
}
@@ -0,0 +1,81 @@
import type { LiveQueryEvent, PolicyReason, QueryRow, RouteKind } from "@/lib/types";
/**
* What the query-log table renders for one row, whichever surface it came from.
*
* The stored list row and the live stream's provenance event describe the same
* query in two different shapes flat summary against nested full detail and
* both pages share one set of cells, so both project into this.
*
* `id` is null for a streamed event: the frame precedes its own insert, so no
* row exists to link to yet.
*/
export interface QuerySummary {
id: number | null;
ts: number;
domain: string;
client_ip: string;
qtype: number | null;
blocked: boolean;
policy_reason: PolicyReason;
response_time_us: number | null;
cache_hit: boolean | null;
upstream: string;
}
/**
* Whether the cache answered, or null where it never applied. Mirrors
* `Context.cacheHit` in src/server/handler.zig, which derives the stored
* `cache_hit` column from the same route: a local record, a blocked answer and
* a protocol refusal all bypass the cache, and "miss" would claim a lookup that
* never happened.
*/
export function cacheHitFor(kind: RouteKind): boolean | null {
switch (kind) {
case "cache":
return true;
case "upstream":
case "forward_zone":
return false;
case "local":
case "blocked":
case "rejected":
return null;
}
}
export function summarizeRow(row: QueryRow): QuerySummary {
return {
id: row.id,
ts: row.ts,
domain: row.domain,
client_ip: row.client_ip,
qtype: row.qtype,
blocked: row.blocked,
policy_reason: row.policy_reason,
response_time_us: row.response_time_us,
cache_hit: row.cache_hit,
upstream: row.upstream,
};
}
/**
* The same summary out of a live frame. `blocked` and `cache_hit` are derived
* rather than sent: the server derives the stored columns from exactly these
* two fields (handler.zig's `Entry.init` call), so the projection reproduces
* them instead of the DTO carrying the same fact twice.
*/
export function summarizeEvent(event: LiveQueryEvent): QuerySummary {
return {
id: null,
ts: event.request.time,
domain: event.request.domain,
client_ip: event.request.client,
qtype: event.request.qtype,
blocked: event.policy.action === "block",
policy_reason: event.policy.reason,
response_time_us: event.response.duration_us,
cache_hit: cacheHitFor(event.route.kind),
upstream: event.route.upstream,
};
}
+31
View File
@@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react";
import CoverageNotice from "./CoverageNotice";
import { formatTime } from "./format";
const WATERMARK = 1_700_000_000;
test("a window the log covers in full says nothing", () => {
const { container } = render(<CoverageNotice coverage={{ complete: true, available_since: WATERMARK }} />);
expect(container.textContent).toBe("");
});
test("a window reaching past the watermark names the instant history starts", () => {
render(<CoverageNotice coverage={{ complete: false, available_since: WATERMARK }} />);
const notice = screen.getByRole("status");
expect(notice.textContent).toContain("Query history is available from");
expect(notice.textContent).toContain(formatTime(WATERMARK));
});
/**
* The server judges completeness, not the page: an unbounded request is
* incomplete whatever the watermark reads, and the notice must follow that
* verdict rather than compare timestamps itself.
*/
test("the server's verdict decides, not the numbers beside it", () => {
const { rerender, container } = render(
<CoverageNotice coverage={{ complete: true, available_since: WATERMARK }} />,
);
expect(container.textContent).toBe("");
rerender(<CoverageNotice coverage={{ complete: false, available_since: 0 }} />);
expect(screen.getByRole("status").textContent).toContain(formatTime(0));
});
+38
View File
@@ -0,0 +1,38 @@
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { Coverage } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
notice: {
marginTop: "0.75rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceHover,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textSecondary,
},
});
/**
* How far back the numbers on this page can reach.
*
* Rendered whenever a response says its window is incomplete, which includes
* the common case of a request with no lower bound at all. The line states the
* watermark and nothing more: the same incompleteness covers a log retention
* has pruned and one that simply has not been running long enough, and the
* response does not say which.
*/
export default function CoverageNotice({ coverage }: { coverage: Coverage }) {
if (coverage.complete) return null;
return (
<p role="status" {...stylex.props(styles.notice)}>
Query history is available from {formatTime(coverage.available_since)}.
</p>
);
}
+4
View File
@@ -26,6 +26,7 @@ import type {
Period,
QueriesFilter,
QueriesPage,
QueryDetail,
Rule,
RuleEcho,
RuleInput,
@@ -112,6 +113,9 @@ export const logout = (): Promise<LogoutResponse> => request("/api/auth/logout",
export const getQueries = (filter: QueriesFilter = {}): Promise<QueriesPage> =>
request(`/api/queries${qs({ ...filter })}`);
/** Full provenance of one logged row. 404 when retention has removed it, 503 with no query log. */
export const getQueryDetail = (id: number): Promise<QueryDetail> => request(`/api/queries/${id}`);
/** `EventSource` URL for the live stream; not a fetch route. */
export const liveQueriesUrl = "/api/queries/live";
+96 -28
View File
@@ -29,6 +29,7 @@ import type {
LookupResult,
PauseState,
QueriesPage,
QueryDetail,
Rule,
RuleEcho,
SettingsEnvelope,
@@ -415,76 +416,139 @@ export const sample_get_upstream_health: UpstreamHealth = {
};
export const sample_get_queries: QueriesPage = {
coverage: {
available_since: 0,
complete: false,
},
next_before: 0,
queries: [
{
block_reason: "",
blocked: true,
cache_hit: null,
client_ip: "192.0.2.11",
domain: "shop.example",
id: 0,
policy_action: "block",
policy_reason: "blocklist_wildcard",
qclass: 0,
qtype: 0,
rcode: 0,
response_time_us: 0,
route_kind: "blocked",
ts: 0,
upstream: "",
},
{
blocked: false,
cache_hit: false,
client_ip: "192.0.2.10",
domain: "news.example",
id: 0,
policy_action: "allow",
policy_reason: "no_match",
qclass: 0,
qtype: 0,
rcode: 0,
response_time_us: 0,
route_kind: "upstream",
ts: 0,
upstream: "https://dns.example/dns-query",
},
{
blocked: false,
cache_hit: true,
client_ip: "192.0.2.10",
domain: "d24.example",
id: 0,
policy_action: "allow",
policy_reason: "no_match",
qclass: 0,
qtype: 0,
rcode: 0,
response_time_us: 0,
route_kind: "cache",
ts: 0,
upstream: "https://dns.example/dns-query",
upstream: "",
},
{
block_reason: "",
blocked: false,
cache_hit: false,
client_ip: "192.0.2.10",
domain: "d23.example",
id: 0,
policy_action: "allow",
policy_reason: "no_match",
qclass: 0,
qtype: 0,
rcode: 0,
response_time_us: 0,
route_kind: "upstream",
ts: 0,
upstream: "https://dns.example/dns-query",
},
{
block_reason: "",
blocked: false,
cache_hit: true,
client_ip: "192.0.2.10",
domain: "d22.example",
id: 0,
policy_action: "allow",
policy_reason: "no_match",
qclass: 0,
qtype: 0,
rcode: 0,
response_time_us: 0,
ts: 0,
upstream: "https://dns.example/dns-query",
},
{
block_reason: "",
blocked: false,
cache_hit: false,
client_ip: "192.0.2.10",
domain: "d21.example",
id: 0,
qtype: 0,
response_time_us: 0,
ts: 0,
upstream: "https://dns.example/dns-query",
},
{
block_reason: "blocklist_domain",
blocked: true,
cache_hit: null,
client_ip: "192.0.2.10",
domain: "d20.example",
id: 0,
qtype: 0,
response_time_us: 0,
route_kind: "cache",
ts: 0,
upstream: "",
},
],
};
export const sample_get_query_detail: QueryDetail = {
group: {
id: 0,
name: "kids",
},
id: 0,
policy: {
action: "block",
matched: "||tracker.example^",
reason: "blocklist_wildcard",
source_id: 0,
source_name: "StevenBlack",
},
request: {
client: "192.0.2.11",
domain: "shop.example",
qclass: 0,
qtype: 0,
time: 0,
},
response: {
duration_us: 0,
rcode: 0,
},
rewrites: {
cname_target: "cdn.tracker.example",
safe_search_target: "",
},
route: {
forward_zone: "",
kind: "blocked",
upstream: "",
},
};
export const sample_get_stats: StatsTotals = {
avg_response_time_us: null,
blocked: 0,
cached: 0,
clients: 0,
coverage: {
available_since: 0,
complete: true,
},
period: "1h",
queries: 0,
since: 0,
@@ -501,6 +565,10 @@ export const sample_get_stats_timeseries: StatsTimeseries = {
ts: 0,
},
],
coverage: {
available_since: 0,
complete: true,
},
period: "1h",
since: 0,
until: 0,
+4
View File
@@ -25,6 +25,7 @@ export const queryKeys = {
stats: (period: Period) => ["stats", period] as const,
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
queryDetail: (id: number) => ["queries", "detail", id] as const,
diagnosticsInfinite: (filter: DiagnosticsFilter) => ["diagnostics", "infinite", filter] as const,
diagnostic: (id: number) => ["diagnostics", "event", id] as const,
/** Prefix of every diagnostics entry, page and detail alike; the purge target. */
@@ -75,6 +76,9 @@ export const queriesInfiniteQuery = (filter: QueriesFilter = {}) =>
placeholderData: keepPreviousData,
});
export const queryDetailQuery = (id: number) =>
queryOptions({ queryKey: queryKeys.queryDetail(id), queryFn: () => api.getQueryDetail(id) });
// Keyset pagination on `next_before`, exactly as the query log pages
// (handlers/diagnostics.zig copies the /api/queries contract). The active view
// polls on healthQuery's cadence because an episode opening is the same news a
+113 -3
View File
@@ -60,25 +60,133 @@ export interface LogoutResponse {
authenticated: false;
}
/**
* The three closed enums `src/storage/provenance.zig` stores, as values rather
* than bare types: the copy maps in `features/queries/provenanceCopy.ts` have to
* be proven exhaustive at runtime as well as by `tsc`, exactly as
* `DIAGNOSTIC_CODES` below.
*/
export const POLICY_ACTIONS = ["not_evaluated", "allow", "block"] as const;
export type PolicyAction = (typeof POLICY_ACTIONS)[number];
export const POLICY_REASONS = [
"rule_allow_exact",
"rule_block_exact",
"rule_allow_wildcard",
"rule_block_wildcard",
"rule_allow_regex",
"rule_block_regex",
"blocklist_exception",
"blocklist_domain",
"blocklist_wildcard",
"local_record",
"forward_zone",
"non_in_class",
"paused",
"snapshot_unavailable",
"no_match",
"protocol_error",
] as const;
export type PolicyReason = (typeof POLICY_REASONS)[number];
export const ROUTE_KINDS = ["blocked", "local", "forward_zone", "upstream", "cache", "rejected"] as const;
export type RouteKind = (typeof ROUTE_KINDS)[number];
/** The summary projection the query-log table scans; full provenance is at `/api/queries/{id}`. */
export interface QueryRow {
id: number;
ts: number;
domain: string;
client_ip: string;
qtype: number | null;
qclass: number;
rcode: number;
blocked: boolean;
block_reason: string;
response_time_us: number | null;
cache_hit: boolean | null;
upstream: string;
policy_action: PolicyAction;
policy_reason: PolicyReason;
route_kind: RouteKind;
}
/** SSE `event: query` payload: a QueryRow minus `id` (precedes persistence). */
export type LiveQueryEvent = Omit<QueryRow, "id">;
export interface ProvenanceRequest {
time: number;
domain: string;
client: string;
qtype: number | null;
qclass: number;
}
/** The group as a historical fact: the id may name a group since renamed or deleted. */
export interface ProvenanceGroup {
id: number | null;
name: string;
}
export interface ProvenancePolicy {
action: PolicyAction;
reason: PolicyReason;
matched: string;
source_id: number | null;
source_name: string;
}
export interface ProvenanceRewrites {
cname_target: string;
safe_search_target: string;
}
export interface ProvenanceRoute {
kind: RouteKind;
forward_zone: string;
/** Non-empty only for an attempted upstream or forward-zone exchange; already redacted. */
upstream: string;
}
export interface ProvenanceResponse {
/** The twelve-bit EDNS extended code, not the four header bits alone. */
rcode: number;
duration_us: number | null;
}
/**
* One query, fully explained, in the order a query meets the pipeline
* (`src/web/provenance_view.zig`). Every text field follows the repository
* convention: `""` means absent.
*/
export interface Provenance {
request: ProvenanceRequest;
group: ProvenanceGroup;
policy: ProvenancePolicy;
rewrites: ProvenanceRewrites;
route: ProvenanceRoute;
response: ProvenanceResponse;
}
/** `GET /api/queries/{id}`: the same six groups plus the row id. */
export interface QueryDetail extends Provenance {
id: number;
}
/** SSE `event: query` payload: the full provenance, minus an id it cannot have yet. */
export type LiveQueryEvent = Provenance;
/**
* How much of the requested window the query log can answer for. Retention
* deletes rows and advances the watermark in one transaction, so an empty
* window is distinguishable from a pruned one.
*/
export interface Coverage {
complete: boolean;
/** The oldest instant the log is complete for, unix seconds. */
available_since: number;
}
export interface QueriesPage {
queries: QueryRow[];
next_before: number | null;
coverage: Coverage;
}
export interface QueriesFilter {
@@ -171,6 +279,7 @@ export interface StatsTotals {
cached: number;
clients: number;
avg_response_time_us: number | null;
coverage: Coverage;
}
export interface Bucket {
@@ -186,6 +295,7 @@ export interface StatsTimeseries {
until: number;
bucket_seconds: number;
buckets: Bucket[];
coverage: Coverage;
}
export interface LookupResult {
+39 -2
View File
@@ -12,7 +12,7 @@ import {
import AppShell from "@/shell/AppShell";
import { ApiError } from "@/lib/api";
import { createQueryClient } from "@/lib/queryClient";
import type { DiagnosticSeverity, DiagnosticState, DiagnosticsFilter } from "@/lib/types";
import type { DiagnosticSeverity, DiagnosticState, DiagnosticsFilter, QueriesFilter } from "@/lib/types";
import {
blocklistsQuery,
clientPrefixesQuery,
@@ -24,6 +24,7 @@ import {
healthQuery,
localRecordsQuery,
queriesInfiniteQuery,
queryDetailQuery,
rulesQuery,
settingsQuery,
statsQuery,
@@ -138,13 +139,43 @@ const dashboardRoute = createRoute({
component: lazyRouteComponent(() => import("@/features/dashboard/DashboardPage")),
});
/**
* `domain` and `client` seed the filter form, so a detail page can link to
* "every query for this domain". Anything else in the search object is dropped:
* an unknown value would reach the api as a parameter the handler 400s.
*/
const queriesRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/queries",
loader: ({ context }) => context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery({})),
validateSearch: (search: Record<string, unknown>): { domain?: string; client?: string } => {
const domain = search["domain"];
const client = search["client"];
return {
domain: typeof domain === "string" && domain !== "" ? domain : undefined,
client: typeof client === "string" && client !== "" ? client : undefined,
};
},
loaderDeps: ({ search }) => search,
loader: ({ context, deps }) => {
const filter: QueriesFilter = {};
if (deps.domain !== undefined) filter.domain = deps.domain;
if (deps.client !== undefined) filter.client = deps.client;
return context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery(filter));
},
component: lazyRouteComponent(() => import("@/features/queries/QueryLogPage")),
});
const queryDetailRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/queries/$id",
// Swallowed on purpose, as the diagnostics detail route does: a row
// retention has pruned is a 404 the page explains, with the way back to the
// log. The whole-page error component would call it a request failure.
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(queryDetailQuery(Number(params.id))).catch(() => undefined),
component: lazyRouteComponent(() => import("@/features/queries/QueryDetailPage")),
});
const liveRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/live",
@@ -210,9 +241,14 @@ const upstreamsRoute = createRoute({
component: lazyRouteComponent(() => import("@/features/upstreams/UpstreamsPage")),
});
/** `domain` prefills and runs the lookup, so a query detail page can link into it. */
const lookupRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/lookup",
validateSearch: (search: Record<string, unknown>): { domain?: string } => {
const domain = search["domain"];
return { domain: typeof domain === "string" && domain !== "" ? domain : undefined };
},
loader: ({ context }) => context.queryClient.ensureQueryData(groupsQuery()),
component: lazyRouteComponent(() => import("@/features/lookup/LookupPage")),
});
@@ -275,6 +311,7 @@ const routeTree = rootRoute.addChildren([
shellRoute.addChildren([
dashboardRoute,
queriesRoute,
queryDetailRoute,
liveRoute,
clientsRoute,
groupsRoute,
+9 -1
View File
@@ -30,8 +30,16 @@ const RESPONSES: Record<string, unknown> = {
cached: 0,
clients: 0,
avg_response_time_us: null,
coverage: { complete: true, available_since: 0 },
},
"/api/stats/timeseries?period=24h": {
period: "24h",
since: 0,
until: 86400,
bucket_seconds: 1800,
buckets: [],
coverage: { complete: true, available_since: 0 },
},
"/api/stats/timeseries?period=24h": { period: "24h", since: 0, until: 86400, bucket_seconds: 1800, buckets: [] },
"/api/health": {
status: "ok",
disk: { state: "ok", free_bytes: 0, db_bytes: 0, log_bytes: 0, sample_failures: 0 },