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
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user