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