milestone 29: activity — history, live and policy simulation on one surface
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
query log, live and lookup merge into /activity. history filters live in the url, so a pasted link or back/forward reproduces the exact view; the result column separates servfail and nxdomain from success in the list. live is follow-by-default with freeze, and a streamed row opens its in-memory provenance detail — no correlation invented for rows sqlite has not written. lookup survives as the current policy simulation under /activity/test. investigation links carry absolute bounds, and the diagnostics page now honors since/until instead of ignoring them. the old routes are gone without aliases.
This commit is contained in:
@@ -87,8 +87,8 @@ test("429 login shows a ticking countdown and keeps submit disabled until it end
|
||||
|
||||
test("safeRedirect only allows same-origin absolute paths", () => {
|
||||
expect(safeRedirect(undefined)).toBe("/");
|
||||
expect(safeRedirect("/queries")).toBe("/queries");
|
||||
expect(safeRedirect("/queries?x=1")).toBe("/queries?x=1");
|
||||
expect(safeRedirect("/activity")).toBe("/activity");
|
||||
expect(safeRedirect("/activity?x=1")).toBe("/activity?x=1");
|
||||
expect(safeRedirect("//evil.example")).toBe("/");
|
||||
expect(safeRedirect("https://evil.example")).toBe("/");
|
||||
expect(safeRedirect("/\\evil.example")).toBe("/");
|
||||
|
||||
+81
-16
@@ -5,7 +5,7 @@ import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { QueryDetail } from "@/lib/types";
|
||||
import { provenance } from "./provenanceFixture";
|
||||
import { provenance } from "@/features/queries/provenanceFixture";
|
||||
|
||||
function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
|
||||
return { id, ...provenance(sections) };
|
||||
@@ -37,9 +37,12 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function renderDetail(id: number) {
|
||||
function renderDetail(id: number, search = "") {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [`/queries/${id}`] }), queryClient);
|
||||
const router = createAppRouter(
|
||||
createMemoryHistory({ initialEntries: [`/activity/queries/${id}${search}`] }),
|
||||
queryClient,
|
||||
);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -50,6 +53,12 @@ function renderDetail(id: number) {
|
||||
return router;
|
||||
}
|
||||
|
||||
/** The search parameters a link carries, so an assertion states them by name. */
|
||||
function hrefSearch(link: HTMLElement): Record<string, string> {
|
||||
const query = link.getAttribute("href")?.split("?")[1] ?? "";
|
||||
return Object.fromEntries(new URLSearchParams(query));
|
||||
}
|
||||
|
||||
/** The value beside a term, so a section's facts are read as pairs. */
|
||||
function factValue(label: string): string {
|
||||
const term = screen.getByText(label);
|
||||
@@ -179,21 +188,74 @@ test("a log with hidden domains renders the server's marker, with nothing invent
|
||||
expect(factValue("Client")).toBe("192.0.2.10");
|
||||
});
|
||||
|
||||
test("the related actions carry the query's own domain and client into the live pages", async () => {
|
||||
responses["/api/queries/11"] = detail(11, { request: { domain: "shop.example", client: "192.0.2.12" } });
|
||||
test("the related actions carry absolute bounds around the query, and the domain into the simulation", async () => {
|
||||
responses["/api/queries/11"] = detail(11, {
|
||||
request: { time: 1_700_000_000, domain: "shop.example", client: "192.0.2.12" },
|
||||
});
|
||||
renderDetail(11);
|
||||
|
||||
await screen.findByRole("heading", { name: "shop.example" });
|
||||
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
|
||||
expect(within(related).getByRole("link", { name: "Look up this domain now" }).getAttribute("href")).toBe(
|
||||
"/lookup?domain=shop.example",
|
||||
);
|
||||
expect(within(related).getByRole("link", { name: "All queries for this domain" }).getAttribute("href")).toBe(
|
||||
"/queries?domain=shop.example",
|
||||
);
|
||||
expect(within(related).getByRole("link", { name: "All queries from this client" }).getAttribute("href")).toBe(
|
||||
"/queries?client=192.0.2.12",
|
||||
);
|
||||
expect(
|
||||
within(related)
|
||||
.getByRole("link", { name: /Test this domain/ })
|
||||
.getAttribute("href"),
|
||||
).toBe("/activity/test?domain=shop.example");
|
||||
// No origin bound at all: five minutes either side of the query itself.
|
||||
expect(hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }))).toEqual({
|
||||
mode: "history",
|
||||
domain: "shop.example",
|
||||
since: "1699999700",
|
||||
until: "1700000300",
|
||||
});
|
||||
expect(hrefSearch(within(related).getByRole("link", { name: "All activity from this client" }))).toEqual({
|
||||
mode: "history",
|
||||
client: "192.0.2.12",
|
||||
since: "1699999700",
|
||||
until: "1700000300",
|
||||
});
|
||||
// The diagnostics window is the query's own moment, never the origin's.
|
||||
expect(hrefSearch(within(related).getByRole("link", { name: /Diagnostics around/ }))).toEqual({
|
||||
since: "1699999700",
|
||||
until: "1700000300",
|
||||
});
|
||||
});
|
||||
|
||||
test("an origin bound wins over the default window, one bound at a time", async () => {
|
||||
responses["/api/queries/17"] = detail(17, {
|
||||
request: { time: 1_700_000_000, domain: "shop.example", client: "192.0.2.12" },
|
||||
});
|
||||
renderDetail(17, "?mode=history&since=1600000000");
|
||||
|
||||
await screen.findByRole("heading", { name: "shop.example" });
|
||||
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
|
||||
const link = hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }));
|
||||
expect(link["since"]).toBe("1600000000");
|
||||
expect(link["until"]).toBe("1700000300");
|
||||
});
|
||||
|
||||
test("both origin bounds carry through untouched", async () => {
|
||||
responses["/api/queries/18"] = detail(18, { request: { time: 1_700_000_000, domain: "shop.example" } });
|
||||
renderDetail(18, "?mode=history&since=1600000000&until=1600000060");
|
||||
|
||||
await screen.findByRole("heading", { name: "shop.example" });
|
||||
const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
|
||||
const link = hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }));
|
||||
expect(link["since"]).toBe("1600000000");
|
||||
expect(link["until"]).toBe("1600000060");
|
||||
});
|
||||
|
||||
test("the back link restores the investigation the reader came from", async () => {
|
||||
responses["/api/queries/19"] = detail(19, { request: { domain: "shop.example" } });
|
||||
renderDetail(19, "?mode=history&domain=shop&since=1600000000&blocked=true");
|
||||
|
||||
await screen.findByRole("heading", { name: "shop.example" });
|
||||
expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
|
||||
mode: "history",
|
||||
domain: "shop",
|
||||
since: "1600000000",
|
||||
blocked: "true",
|
||||
});
|
||||
});
|
||||
|
||||
function clientList(client: { ip: string; name: string; learned_name: string }) {
|
||||
@@ -239,9 +301,12 @@ test("a learned name is told as the reverse-DNS lookup it is, never as a recorde
|
||||
});
|
||||
|
||||
test("a row retention has pruned explains the 404 and keeps the way back to the log", async () => {
|
||||
renderDetail(404);
|
||||
renderDetail(404, "?mode=history&domain=gone");
|
||||
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.getByText(/no such query/)).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "← Query log" }).getAttribute("href")).toBe("/queries");
|
||||
expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
|
||||
mode: "history",
|
||||
domain: "gone",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams, useSearch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { queryDetailQuery } from "@/lib/queries";
|
||||
import type { QueryDetail } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import ProvenanceDetail from "./ProvenanceDetail";
|
||||
import RelatedActions from "./RelatedActions";
|
||||
import type { ActivitySearch } from "./search";
|
||||
|
||||
const styles = stylex.create({
|
||||
back: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
loading: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The way back to the investigation, not to a bare list. The originating
|
||||
* Activity search rides in this route's own search, so the reader returns to
|
||||
* the mode, the filters and the absolute window they left — a plain `/activity`
|
||||
* would silently widen the range they had chosen.
|
||||
*/
|
||||
function BackLink({ origin }: { origin: ActivitySearch }) {
|
||||
return (
|
||||
<Link to="/activity" search={origin} {...stylex.props(styles.back, shared.focusRing)}>
|
||||
← Activity
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ActivityDetailPage() {
|
||||
const { id } = useParams({ from: "/shell/activity/queries/$id" });
|
||||
const origin = useSearch({ from: "/shell/activity/queries/$id" });
|
||||
const rowId = Number(id);
|
||||
const { data, error, isPending, refetch } = useQuery(queryDetailQuery(rowId));
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
|
||||
Loading query…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<section>
|
||||
<BackLink origin={origin} />
|
||||
<InlineError error={error} onRetry={() => void refetch()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const detail: QueryDetail = data;
|
||||
const { domain, client, time } = detail.request;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<BackLink origin={origin} />
|
||||
<ProvenanceDetail
|
||||
provenance={detail}
|
||||
persistedId={detail.id}
|
||||
relatedActions={<RelatedActions domain={domain} client={client} ts={time} origin={origin} />}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* The filter row over the Activity table.
|
||||
*
|
||||
* The applied state is the URL, never this form: what the reader sees is what
|
||||
* the link they can paste to a housemate will show. So this holds a draft only,
|
||||
* and the page remounts it whenever the applied search changes — a back button
|
||||
* or a pasted URL has to move the form with it, and a form that seeded itself
|
||||
* once would keep showing the previous investigation's filters.
|
||||
*
|
||||
* In live mode the row stays visible and disabled rather than disappearing: the
|
||||
* filters are retained in the URL and apply again the moment history comes
|
||||
* back, and hiding them would read as having lost them. The stream itself is
|
||||
* unfiltered — the server sends every query — so a row that looked usable here
|
||||
* would promise filtering that is not happening.
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
|
||||
import type { ActivitySearch } from "./search";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "any", label: "All" },
|
||||
{ value: "blocked", label: "Blocked only" },
|
||||
{ value: "allowed", label: "Allowed only" },
|
||||
];
|
||||
|
||||
const styles = stylex.create({
|
||||
/** One column on a phone, two from `sm`, five from `lg`. */
|
||||
grid: {
|
||||
marginTop: "1rem",
|
||||
display: "grid",
|
||||
gap: "0.75rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(1, minmax(0, 1fr))",
|
||||
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
||||
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
|
||||
},
|
||||
},
|
||||
label: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
input: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
// A disabled native input keeps its value legible but reads as inert,
|
||||
// matching what RAC does to the Select trigger beside it.
|
||||
cursor: { default: null, ":disabled": "not-allowed" },
|
||||
opacity: { default: null, ":disabled": 0.55 },
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
gap: "0.5rem",
|
||||
gridColumn: {
|
||||
default: null,
|
||||
"@media (min-width: 640px)": "span 2 / span 2",
|
||||
"@media (min-width: 1024px)": "span 5 / span 5",
|
||||
},
|
||||
},
|
||||
toolbarButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
error: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.dangerText,
|
||||
},
|
||||
});
|
||||
|
||||
/** The applied filters, with `mode` left to the page that owns the switch. */
|
||||
export type AppliedFilters = Omit<ActivitySearch, "mode">;
|
||||
|
||||
/** Every filter off — what Clear applies, and the loader's empty-filter case. */
|
||||
export const NO_FILTERS: AppliedFilters = {
|
||||
domain: undefined,
|
||||
client: undefined,
|
||||
blocked: undefined,
|
||||
since: undefined,
|
||||
until: undefined,
|
||||
};
|
||||
|
||||
function blockedOption(blocked: boolean | undefined): string {
|
||||
if (blocked === undefined) return "any";
|
||||
return blocked ? "blocked" : "allowed";
|
||||
}
|
||||
|
||||
function optionBlocked(value: string): boolean | undefined {
|
||||
if (value === "blocked") return true;
|
||||
return value === "allowed" ? false : undefined;
|
||||
}
|
||||
|
||||
function boundError(label: string, reason: "unparseable" | "nonexistent"): string {
|
||||
return reason === "unparseable"
|
||||
? `${label} is not a complete date and time.`
|
||||
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
applied: AppliedFilters;
|
||||
isDisabled: boolean;
|
||||
onApply: (filters: AppliedFilters) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function ActivityFilters({ applied, isDisabled, onApply, onClear }: Props) {
|
||||
const [domain, setDomain] = useState(applied.domain ?? "");
|
||||
const [client, setClient] = useState(applied.client ?? "");
|
||||
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
|
||||
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
|
||||
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const sinceValue = resolveDatetimeField(since);
|
||||
if (!sinceValue.ok) {
|
||||
setError(boundError("Since", sinceValue.reason));
|
||||
return;
|
||||
}
|
||||
const untilValue = resolveDatetimeField(until);
|
||||
if (!untilValue.ok) {
|
||||
setError(boundError("Until", untilValue.reason));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onApply({
|
||||
domain: domain.trim() === "" ? undefined : domain.trim(),
|
||||
client: client.trim() === "" ? undefined : client.trim(),
|
||||
blocked: optionBlocked(blocked),
|
||||
since: sinceValue.value,
|
||||
until: untilValue.value,
|
||||
});
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setDomain("");
|
||||
setClient("");
|
||||
setBlocked("any");
|
||||
setSince(datetimeField(undefined));
|
||||
setUntil(datetimeField(undefined));
|
||||
setError(null);
|
||||
onClear();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={submit} {...stylex.props(styles.grid)}>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Domain contains
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Client (exact)
|
||||
<input
|
||||
type="text"
|
||||
value={client}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setClient(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
variant="compactField"
|
||||
label="Result"
|
||||
value={blocked}
|
||||
isDisabled={isDisabled}
|
||||
onChange={setBlocked}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Since
|
||||
<input
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={since.text}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Until
|
||||
<input
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={until.text}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
>
|
||||
Apply filters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
disabled={isDisabled}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{error !== null && (
|
||||
<p role="alert" {...stylex.props(styles.error)}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* Activity in history mode, through the real router: the URL is the applied
|
||||
* state, so nothing here can be checked by rendering the page on its own.
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
||||
import { queryRow } from "@/features/queries/provenanceFixture";
|
||||
|
||||
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
id,
|
||||
ip,
|
||||
name,
|
||||
learned_name: learnedName,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: name !== "",
|
||||
first_seen: 1_700_000_000,
|
||||
last_seen: 1_700_000_100,
|
||||
};
|
||||
}
|
||||
|
||||
const CLIENTS: Client[] = [
|
||||
client(1, "192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||
client(2, "192.0.2.11", "", "laptop.lan"),
|
||||
client(3, "192.0.2.12", "", ""),
|
||||
];
|
||||
|
||||
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
|
||||
return queryRow(id, { ts: 1_700_000_000 + id, domain, upstream: "udp://9.9.9.9:53", ...overrides });
|
||||
}
|
||||
|
||||
const COMPLETE: Coverage = { complete: true, available_since: 1_600_000_000 };
|
||||
|
||||
/** The blocked row every page fixture reuses. */
|
||||
const BLOCKED = {
|
||||
blocked: true,
|
||||
policy_action: "block",
|
||||
policy_reason: "blocklist_wildcard",
|
||||
route_kind: "blocked",
|
||||
upstream: "",
|
||||
} as const satisfies Partial<QueryRow>;
|
||||
|
||||
const PAGES: Record<string, QueriesPage> = {
|
||||
"/api/queries": {
|
||||
queries: [
|
||||
row(20, "first.example", { qtype: 65, cache_hit: true, route_kind: "cache" }),
|
||||
row(19, "ads.example", { ...BLOCKED, response_time_us: null, cache_hit: null }),
|
||||
],
|
||||
next_before: 19,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?before=19": {
|
||||
queries: [row(5, "older.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?domain=ads": {
|
||||
queries: [row(19, "ads.example", BLOCKED)],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?domain=ads&blocked=true": {
|
||||
queries: [row(19, "ads.example", BLOCKED)],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?since=1700000000": {
|
||||
queries: [row(20, "first.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
|
||||
/** The shell's own requests, which every test serves the same way. */
|
||||
function stubFetch(handler: (url: string) => Response | Promise<Response>) {
|
||||
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
||||
return Promise.resolve(handler(url));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
}
|
||||
|
||||
function fromPages(url: string): Response {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
const payload = PAGES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json(payload);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubFetch(fromPages);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderPage(path = "/activity") {
|
||||
const queryClient = createQueryClient();
|
||||
const history = createMemoryHistory({ initialEntries: [path] });
|
||||
const router = createAppRouter(history, queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return { queryClient, history };
|
||||
}
|
||||
|
||||
/** Every `/api/queries` URL the run asked for, list pages only. */
|
||||
function queryCalls(): string[] {
|
||||
return fetchMock.mock.calls
|
||||
.map((call) => String(call[0]))
|
||||
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
|
||||
}
|
||||
|
||||
test("renders the first page with the seven columns filled in", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([
|
||||
"Time",
|
||||
"Domain",
|
||||
"Client",
|
||||
"Type",
|
||||
"Result",
|
||||
"Route",
|
||||
"Duration",
|
||||
]);
|
||||
const first = screen.getByText("first.example").closest("tr")!;
|
||||
expect(within(first).getByText("HTTPS")).toBeTruthy();
|
||||
expect(within(first).getByText("NOERROR")).toBeTruthy();
|
||||
expect(within(first).getByText("Cache")).toBeTruthy();
|
||||
expect(within(first).getByText("1.2 ms")).toBeTruthy();
|
||||
|
||||
const blocked = screen.getByText("ads.example").closest("tr")!;
|
||||
// The Result cell says Blocked even though the client saw NOERROR, and the
|
||||
// Route cell says how: this is the pair the old Status column could not show.
|
||||
expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
|
||||
expect(within(blocked).getByText("—")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json({
|
||||
queries: [
|
||||
row(20, "named.example", { client_ip: "192.0.2.10" }),
|
||||
row(19, "learned.example", { client_ip: "192.0.2.11" }),
|
||||
row(18, "nameless.example", { client_ip: "192.0.2.12" }),
|
||||
row(17, "stranger.example", { client_ip: "192.0.2.99" }),
|
||||
],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
} satisfies QueriesPage);
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
// A hand-typed name wins outright; the learned name never surfaces for it.
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||
|
||||
// A learned name reads muted and nothing more here: the "learned" tag would
|
||||
// repeat on every row of the table, so the Clients page carries it instead.
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
// A known client with neither name, and a client the loaded list has never
|
||||
// seen, both fall back to the bare address with no tooltip standing in.
|
||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
||||
});
|
||||
|
||||
test("load more appends the next page and stops at the end of the log", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("older.example");
|
||||
|
||||
expect(screen.getByText("first.example")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
|
||||
});
|
||||
|
||||
test("applying a filter puts it in the url, refetches, and resets the accumulated list", async () => {
|
||||
const { history } = renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("older.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
expect(screen.getByText("ads.example")).toBeTruthy();
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
});
|
||||
|
||||
test("a load-more that resolves after a filter change is discarded", async () => {
|
||||
let releaseLoadMore: () => void = () => {};
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/queries?before=19") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
releaseLoadMore = () => resolve(json(PAGES["/api/queries?before=19"]));
|
||||
});
|
||||
}
|
||||
return fromPages(url);
|
||||
});
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
|
||||
releaseLoadMore();
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("load more is disabled while a filter change shows placeholder data, then uses the fresh cursor", async () => {
|
||||
let releaseFiltered: () => void = () => {};
|
||||
const filteredPage: QueriesPage = {
|
||||
queries: [row(19, "ads.example", BLOCKED)],
|
||||
next_before: 7,
|
||||
coverage: COMPLETE,
|
||||
};
|
||||
const filteredOlderPage: QueriesPage = {
|
||||
queries: [row(3, "ads.older.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
};
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/queries?domain=ads") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
releaseFiltered = () => resolve(json(filteredPage));
|
||||
});
|
||||
}
|
||||
if (url === "/api/queries?domain=ads&before=7") return json(filteredOlderPage);
|
||||
return fromPages(url);
|
||||
});
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
|
||||
const staleButton = await screen.findByRole("button", { name: "Load more" });
|
||||
expect(staleButton).toHaveProperty("disabled", true);
|
||||
fireEvent.click(staleButton);
|
||||
expect(queryCalls()).not.toContain("/api/queries?domain=ads&before=19");
|
||||
|
||||
releaseFiltered();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
});
|
||||
|
||||
const freshButton = screen.getByRole("button", { name: "Load more" });
|
||||
expect(freshButton).toHaveProperty("disabled", false);
|
||||
fireEvent.click(freshButton);
|
||||
await screen.findByText("ads.older.example");
|
||||
|
||||
expect(queryCalls()).toContain("/api/queries?domain=ads&before=7");
|
||||
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
|
||||
// The newest-100 window moves up while the reader has a second page open.
|
||||
// Refetching only the first page would drop n20 and n19 out of the middle
|
||||
// of the table; the second page must be replayed from the fresh cursor.
|
||||
const before: Record<string, QueriesPage> = {
|
||||
"/api/queries": {
|
||||
queries: [row(20, "n20.example"), row(19, "n19.example")],
|
||||
next_before: 19,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?before=19": {
|
||||
queries: [row(18, "n18.example"), row(17, "n17.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
};
|
||||
const after: Record<string, QueriesPage> = {
|
||||
"/api/queries": {
|
||||
queries: [row(22, "n22.example"), row(21, "n21.example")],
|
||||
next_before: 21,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?before=21": {
|
||||
queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
};
|
||||
let live = before;
|
||||
stubFetch((url) => {
|
||||
const payload = live[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json(payload);
|
||||
});
|
||||
|
||||
const { queryClient } = renderPage();
|
||||
await screen.findByText("n20.example");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("n17.example");
|
||||
|
||||
live = after;
|
||||
await act(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["queries"] });
|
||||
});
|
||||
|
||||
await screen.findByText("n22.example");
|
||||
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
|
||||
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
|
||||
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { pathname: "/activity", search: "", assign });
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/queries?before=19") {
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return fromPages(url);
|
||||
});
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await waitFor(() => {
|
||||
expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/activity")}`);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByText(/Failed to load more/)).toBeNull();
|
||||
});
|
||||
|
||||
test("each row links into its own detail page, carrying the investigation with it", async () => {
|
||||
renderPage("/activity?mode=history&since=1700000000");
|
||||
await screen.findByText("first.example");
|
||||
|
||||
const link = screen.getByRole("link", { name: "first.example" });
|
||||
expect(link.getAttribute("href")).toContain("/activity/queries/20");
|
||||
expect(link.getAttribute("href")).toContain("since=1700000000");
|
||||
// An <a href> is in the tab order by default; nothing here may opt it out.
|
||||
expect(link.getAttribute("tabindex")).toBeNull();
|
||||
});
|
||||
|
||||
test("a pruned window tells the reader when history starts", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json({
|
||||
queries: [row(20, "kept.example")],
|
||||
next_before: null,
|
||||
coverage: { complete: false, available_since: 1_700_000_000 },
|
||||
} satisfies QueriesPage);
|
||||
});
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("kept.example");
|
||||
expect(screen.getByText(/Query history is available from/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a complete window shows no coverage notice", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
expect(screen.queryByText(/Query history is available from/)).toBeNull();
|
||||
});
|
||||
|
||||
test("a ?domain= link seeds the filter form and fetches that domain on arrival", async () => {
|
||||
renderPage("/activity?domain=ads");
|
||||
|
||||
await screen.findByText("ads.example");
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
});
|
||||
|
||||
test("history forwards exactly the six normalized filter fields and nothing else", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
renderPage(
|
||||
"/activity?mode=history&domain=%20ads%20&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600&bogus=1&limit=9999",
|
||||
);
|
||||
|
||||
await screen.findByText("No queries match the current filters.");
|
||||
expect(queryCalls()).toEqual([
|
||||
"/api/queries?domain=ads&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a rejected search parameter is dropped rather than guessed at", async () => {
|
||||
renderPage("/activity?since=1.5&blocked=%22true%22&domain=%20%20");
|
||||
|
||||
await screen.findByText("first.example");
|
||||
// Nothing survived validation, so the request is the unfiltered one.
|
||||
expect(queryCalls()).toEqual(["/api/queries"]);
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
||||
});
|
||||
|
||||
test("the form draft follows the url back and forward, seconds included", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
// A bound with non-zero seconds: the round trip has to keep them, and the
|
||||
// untouched field has to carry the original number rather than re-parse.
|
||||
const seeded = 1_700_000_017;
|
||||
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
|
||||
|
||||
const domainInput = await screen.findByLabelText("Domain contains");
|
||||
expect(domainInput).toHaveProperty("value", "first");
|
||||
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
expect(sinceInput.value).toContain(":37");
|
||||
|
||||
fireEvent.change(domainInput, { target: { value: "second" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
await waitFor(() => expect(history.location.search).toContain("domain=second"));
|
||||
// The untouched Since bound applied as the exact second it was seeded with.
|
||||
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);
|
||||
|
||||
act(() => history.back());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
|
||||
});
|
||||
|
||||
act(() => history.forward());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
|
||||
});
|
||||
});
|
||||
|
||||
/** 2 a.m. on the EU spring-forward date: an hour that exists in some zones and not others. */
|
||||
const DST_WALL_TIME = "2026-03-29T02:30:00";
|
||||
|
||||
/**
|
||||
* Whether that wall time names an instant in the timezone the suite runs in.
|
||||
* `new Date` slides a spring-forward gap silently forward, so an hour or minute
|
||||
* that comes back different from the one written *is* the gap.
|
||||
*/
|
||||
function wallTimeExists(text: string): boolean {
|
||||
const written = /T(\d{2}):(\d{2})/.exec(text)!;
|
||||
const parsed = new Date(text);
|
||||
return parsed.getHours() === Number(written[1]) && parsed.getMinutes() === Number(written[2]);
|
||||
}
|
||||
|
||||
test("a wall-clock time the daylight-saving jump skips is refused, not silently moved", async () => {
|
||||
// One expected outcome per timezone, decided here rather than accepted from
|
||||
// the page: in a zone with the jump the bound must be refused outright, and
|
||||
// in a zone without it the same text is an ordinary instant that applies.
|
||||
const inGap = !wallTimeExists(DST_WALL_TIME);
|
||||
const unix = Math.floor(new Date(DST_WALL_TIME).getTime() / 1000);
|
||||
stubFetch((url) => {
|
||||
if (url === `/api/queries?since=${unix}`) {
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
}
|
||||
return fromPages(url);
|
||||
});
|
||||
|
||||
const { history } = renderPage();
|
||||
await screen.findByText("first.example");
|
||||
const callsBefore = queryCalls().length;
|
||||
const searchBefore = history.location.search;
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
|
||||
if (inGap) {
|
||||
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
|
||||
// Refused means refused: no navigation, and no request for the hour the
|
||||
// operator did not ask for.
|
||||
expect(history.location.search).toBe(searchBefore);
|
||||
expect(queryCalls()).toHaveLength(callsBefore);
|
||||
return;
|
||||
}
|
||||
|
||||
await waitFor(() => expect(history.location.search).toContain(`since=${unix}`));
|
||||
expect(queryCalls()).toContain(`/api/queries?since=${unix}`);
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("the policy simulation is reachable from the header, with no rows to click through", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url === "/api/groups") return json({ groups: [{ id: 1, name: "default", safe_search: false }] });
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
|
||||
const { history } = renderPage();
|
||||
// An empty log is exactly the case a row-borne link cannot serve.
|
||||
await screen.findByText("No queries logged yet.");
|
||||
|
||||
const link = screen.getByRole("link", { name: "Current policy simulation" });
|
||||
expect(link.getAttribute("href")).toBe("/activity/test");
|
||||
|
||||
fireEvent.click(link);
|
||||
await screen.findByRole("heading", { level: 1, name: "Current policy simulation" });
|
||||
expect(history.location.pathname).toBe("/activity/test");
|
||||
});
|
||||
|
||||
test("Clear empties the url as well as the form", async () => {
|
||||
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
||||
await screen.findByText("ads.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
await waitFor(() => {
|
||||
expect(history.location.search).not.toContain("domain");
|
||||
});
|
||||
expect(history.location.search).not.toContain("blocked");
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Activity: one surface over the queries nxdns answered, in two modes.
|
||||
*
|
||||
* History reads the persisted log and Live reads the stream, but they are the
|
||||
* same seven columns over the same filters, and the reader moves between them
|
||||
* without losing the question they were asking. The mode lives in the URL with
|
||||
* the filters, so an investigation is one link — including which half of it the
|
||||
* recipient should be looking at.
|
||||
*/
|
||||
|
||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
||||
import HistoryActivity from "./HistoryActivity";
|
||||
import LiveActivity from "./LiveActivity";
|
||||
import type { ActivityMode } from "./search";
|
||||
|
||||
const MODES: ReadonlyArray<{ mode: ActivityMode; label: string }> = [
|
||||
{ mode: "history", label: "History" },
|
||||
{ mode: "live", label: "Live" },
|
||||
];
|
||||
|
||||
const styles = stylex.create({
|
||||
header: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
switch: {
|
||||
display: "flex",
|
||||
gap: "0.25rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
padding: "0.125rem",
|
||||
},
|
||||
modeButton: {
|
||||
borderStyle: "none",
|
||||
borderRadius: "0.1875rem",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
},
|
||||
modeIdle: {
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: { default: colors.textSecondary, ":hover": colors.text },
|
||||
},
|
||||
/** The selected mode reads as a filled chip, the same weight the nav uses. */
|
||||
modeSelected: {
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
},
|
||||
/** The one way into the simulation from here, so it cannot sit behind a row. */
|
||||
simulationLink: {
|
||||
marginInlineStart: "auto",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
liveNote: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
export default function ActivityPage() {
|
||||
const search = useSearch({ from: "/shell/activity" });
|
||||
const navigate = useNavigate({ from: "/activity" });
|
||||
const live = search.mode === "live";
|
||||
|
||||
// The functional form, not a replacement object: the filters are retained
|
||||
// across a mode switch on purpose, and spelling out a new search here would
|
||||
// drop every one of them on the way to Live and back.
|
||||
function selectMode(mode: ActivityMode) {
|
||||
if (mode === search.mode) return;
|
||||
void navigate({ search: (prev) => ({ ...prev, mode }) });
|
||||
}
|
||||
|
||||
function apply(filters: AppliedFilters) {
|
||||
void navigate({ search: { mode: search.mode, ...filters } });
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div {...stylex.props(styles.header)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Activity</h1>
|
||||
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}>
|
||||
{MODES.map((option) => {
|
||||
const selected = option.mode === search.mode;
|
||||
return (
|
||||
<button
|
||||
key={option.mode}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => selectMode(option.mode)}
|
||||
{...stylex.props(
|
||||
styles.modeButton,
|
||||
selected ? styles.modeSelected : styles.modeIdle,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
|
||||
Current policy simulation
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Remounted whenever the applied search changes, which is what makes
|
||||
* the back button work: the draft is derived state, and the browser
|
||||
* moving the URL under it has to move the form with it.
|
||||
*/}
|
||||
<ActivityFilters
|
||||
key={`${search.domain ?? ""}|${search.client ?? ""}|${String(search.blocked)}|${String(search.since)}|${String(search.until)}`}
|
||||
applied={search}
|
||||
isDisabled={live}
|
||||
onApply={apply}
|
||||
onClear={() => apply(NO_FILTERS)}
|
||||
/>
|
||||
|
||||
{live ? (
|
||||
<>
|
||||
<p {...stylex.props(styles.liveNote)}>
|
||||
The stream carries every query the server answers; these filters apply to history only.
|
||||
</p>
|
||||
<LiveActivity origin={search} />
|
||||
</>
|
||||
) : (
|
||||
<HistoryActivity search={search} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Activity in history mode: the persisted queries the URL's filters select,
|
||||
* paged by keyset cursor.
|
||||
*
|
||||
* The filters arrive already applied — the URL is the applied state — so this
|
||||
* only reads them. Everything about how the reader got here lives one level up.
|
||||
*/
|
||||
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import * as api from "@/lib/api";
|
||||
import CoverageNotice from "@/lib/CoverageNotice";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||
import type { QueryRow } from "@/lib/types";
|
||||
import { useClientNames } from "@/features/clients/clientNames";
|
||||
import { summarizeRow } from "@/features/queries/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||
import { queriesFilterOf, type ActivitySearch } from "./search";
|
||||
|
||||
const styles = stylex.create({
|
||||
empty: {
|
||||
marginTop: "1.5rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||
row: {
|
||||
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
footer: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
refetching: {
|
||||
marginTop: "0.75rem",
|
||||
},
|
||||
moreButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
moreError: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.dangerText,
|
||||
},
|
||||
});
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export default function HistoryActivity({ search }: { search: ActivitySearch }) {
|
||||
const filter = queriesFilterOf(search);
|
||||
const base = useInfiniteQuery(queriesInfiniteQuery(filter));
|
||||
const clientNames = useClientNames();
|
||||
|
||||
const pages = base.data?.pages ?? [];
|
||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||
const coverage = pages[0]?.coverage;
|
||||
const filterActive = Object.keys(filter).length > 0;
|
||||
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
||||
// data stands in for a filter change; derive the cursor from what is on
|
||||
// screen so the button keeps its place instead of flashing "end of log".
|
||||
const lastPage = pages[pages.length - 1];
|
||||
const hasMore = lastPage !== undefined && lastPage.next_before !== null;
|
||||
// A 401 is already redirecting via the cache-level handleUnauthorized.
|
||||
const isUnauthorized = base.error instanceof api.ApiError && base.error.status === 401;
|
||||
const moreError = base.isFetchNextPageError && !isUnauthorized ? errorMessage(base.error) : null;
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return;
|
||||
void base.fetchNextPage();
|
||||
}
|
||||
|
||||
// The loader starts this fetch but does not wait for it, so both the first
|
||||
// paint and a failed first page are this component's to render.
|
||||
if (base.status === "error" && base.data === undefined) {
|
||||
return <InlineError error={base.error} onRetry={() => void base.refetch()} />;
|
||||
}
|
||||
if (base.data === undefined) {
|
||||
return (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
Loading activity…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{base.isFetching && (
|
||||
<p {...stylex.props(styles.note, styles.refetching)} role="status">
|
||||
Loading…
|
||||
</p>
|
||||
)}
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
|
||||
{rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} {...stylex.props(styles.row)}>
|
||||
<ActivityCells
|
||||
row={summarizeRow(row)}
|
||||
clientNames={clientNames}
|
||||
renderDomain={(id, children) =>
|
||||
id === null ? (
|
||||
children
|
||||
) : (
|
||||
<Link
|
||||
to="/activity/queries/$id"
|
||||
params={{ id: String(id) }}
|
||||
search={search}
|
||||
{...stylex.props(activityDomainLink, shared.focusRing)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div {...stylex.props(styles.footer)}>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
||||
{...stylex.props(shared.button, styles.moreButton, shared.focusRing)}
|
||||
>
|
||||
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{moreError !== null && (
|
||||
<p role="alert" {...stylex.props(styles.moreError)}>
|
||||
Failed to load more: {moreError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Activity in live mode, through the real router.
|
||||
*
|
||||
* The EventSource is a global here rather than an injected factory: whether the
|
||||
* connection exists at all is the thing under test, and that is decided by
|
||||
* which subtree the URL mounts, not by a prop a caller could pass.
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { Client } from "@/lib/types";
|
||||
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
|
||||
function client(ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
id: Number(ip.split(".").pop()),
|
||||
ip,
|
||||
name,
|
||||
learned_name: learnedName,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: name !== "",
|
||||
first_seen: 1_700_000_000,
|
||||
last_seen: 1_700_000_100,
|
||||
};
|
||||
}
|
||||
|
||||
const CLIENTS: Client[] = [
|
||||
client("192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||
client("192.0.2.11", "", "laptop.lan"),
|
||||
client("192.0.2.12", "", ""),
|
||||
];
|
||||
|
||||
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
|
||||
let sources: FakeEventSource[];
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: string) => Response | Promise<Response> = () => json({})) {
|
||||
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
||||
if (url === "/api/clients") return Promise.resolve(json({ clients: CLIENTS }));
|
||||
return Promise.resolve(handler(url));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sources = [];
|
||||
vi.stubGlobal(
|
||||
"EventSource",
|
||||
class {
|
||||
constructor(url: string) {
|
||||
const source = new FakeEventSource(url);
|
||||
sources.push(source);
|
||||
return source as unknown as EventSource;
|
||||
}
|
||||
},
|
||||
);
|
||||
stubFetch();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function frame(ts: number, domain: string, sections: Parameters<typeof provenance>[0] = {}): { data: string } {
|
||||
return {
|
||||
data: JSON.stringify(
|
||||
provenance({
|
||||
...sections,
|
||||
request: { time: ts, domain, ...sections.request },
|
||||
route: { kind: "cache", upstream: "", ...sections.route },
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function renderPage(path = "/activity?mode=live") {
|
||||
const queryClient = createQueryClient();
|
||||
const history = createMemoryHistory({ initialEntries: [path] });
|
||||
const router = createAppRouter(history, queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return { history };
|
||||
}
|
||||
|
||||
async function openLive(path?: string) {
|
||||
const rendered = renderPage(path);
|
||||
await screen.findByRole("button", { name: "Freeze" });
|
||||
act(() => sources[0]!.emit("open"));
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function queryCalls(): string[] {
|
||||
return fetchMock.mock.calls
|
||||
.map((call) => String(call[0]))
|
||||
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
|
||||
}
|
||||
|
||||
test("streams rows, flags blocked ones, and freezes the display", async () => {
|
||||
await openLive();
|
||||
expect(screen.getByRole("status", { name: "Live" })).toBeTruthy();
|
||||
expect(screen.getByText("Waiting for queries…")).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "ok.example"));
|
||||
sources[0]!.emit(
|
||||
"query",
|
||||
frame(1001, "ads.example", {
|
||||
request: { qtype: 28 },
|
||||
policy: { action: "block", reason: "blocklist_wildcard" },
|
||||
route: { kind: "blocked" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText("ok.example")).toBeTruthy();
|
||||
expect(screen.getByText("AAAA")).toBeTruthy();
|
||||
const blockedRow = screen.getByText("ads.example").closest("tr")!;
|
||||
expect(within(blockedRow).getAllByText("Blocked")).toHaveLength(2);
|
||||
// StyleX compiles to opaque class names, so the check is structural: a blocked
|
||||
// row carries every class a plain row does, plus the ones the flag adds.
|
||||
const plainRow = screen.getByText("ok.example").closest("tr")!;
|
||||
const blockedClasses = new Set(blockedRow.className.split(" "));
|
||||
const plainClasses = plainRow.className.split(" ");
|
||||
expect(plainClasses.every((name) => blockedClasses.has(name))).toBe(true);
|
||||
expect(blockedClasses.size).toBeGreaterThan(plainClasses.length);
|
||||
|
||||
const freeze = screen.getByRole("button", { name: "Freeze" });
|
||||
fireEvent.click(freeze);
|
||||
expect(freeze.getAttribute("aria-pressed")).toBe("true");
|
||||
|
||||
act(() => sources[0]!.emit("query", frame(1002, "later.example")));
|
||||
expect(screen.queryByText("later.example")).toBeNull();
|
||||
expect(screen.getByText(/3 in buffer/)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
||||
expect(screen.getByText("later.example")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||
await openLive();
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
|
||||
sources[0]!.emit("query", frame(1001, "learned.example", { request: { client: "192.0.2.11" } }));
|
||||
sources[0]!.emit("query", frame(1002, "nameless.example", { request: { client: "192.0.2.12" } }));
|
||||
sources[0]!.emit("query", frame(1003, "stranger.example", { request: { client: "192.0.2.99" } }));
|
||||
});
|
||||
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
||||
});
|
||||
|
||||
test("rows stream in as bare IPs while the client list is still loading", async () => {
|
||||
let releaseClients: () => void = () => {};
|
||||
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
||||
return new Promise<Response>((resolve) => {
|
||||
if (url !== "/api/clients") {
|
||||
resolve(json({}));
|
||||
return;
|
||||
}
|
||||
releaseClients = () => resolve(json({ clients: CLIENTS }));
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } })));
|
||||
|
||||
expect(screen.getByText("192.0.2.10")).toBeTruthy();
|
||||
expect(screen.queryByText("Kitchen Pi")).toBeNull();
|
||||
|
||||
releaseClients();
|
||||
expect(await screen.findByText("Kitchen Pi")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("repeated connection failures show the viewer-cap state with a retry button", async () => {
|
||||
renderPage();
|
||||
await screen.findByRole("button", { name: "Freeze" });
|
||||
act(() => {
|
||||
sources[0]!.emit("error");
|
||||
sources[0]!.emit("error");
|
||||
sources[0]!.emit("error");
|
||||
});
|
||||
expect(screen.getByRole("alert").textContent).toContain("too many live viewers");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(sources).toHaveLength(2);
|
||||
expect(screen.getByText("Connecting…")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a recovered row links to its stored detail; a streamed one opens in place instead", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url.startsWith("/api/queries?")) {
|
||||
return json({
|
||||
queries: [queryRow(88, { ts: 1001, domain: "recovered.example" })],
|
||||
next_before: null,
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
});
|
||||
}
|
||||
return json({});
|
||||
});
|
||||
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
act(() => sources[0]!.emit("error"));
|
||||
act(() => sources[0]!.emit("open"));
|
||||
|
||||
const recovered = await screen.findByRole("link", { name: "recovered.example" });
|
||||
expect(recovered.getAttribute("href")).toContain("/activity/queries/88");
|
||||
// The streamed frame precedes its own insert, so it has no row to link to —
|
||||
// but it does carry its own provenance, so it still has a detail.
|
||||
expect(screen.queryByRole("link", { name: "streamed.example" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a streamed row opens its own provenance, from the keyboard as well as the pointer", async () => {
|
||||
await openLive();
|
||||
act(() =>
|
||||
sources[0]!.emit(
|
||||
"query",
|
||||
frame(1000, "streamed.example", {
|
||||
policy: { action: "block", reason: "blocklist_domain", matched: "streamed.example" },
|
||||
route: { kind: "blocked", upstream: "" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "streamed.example" });
|
||||
// A real <button> is in the tab order and activates on Enter and Space; the
|
||||
// only way to lose that is to opt out of it, which nothing here may do.
|
||||
expect(trigger.tagName).toBe("BUTTON");
|
||||
expect(trigger.getAttribute("tabindex")).toBeNull();
|
||||
act(() => trigger.focus());
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
|
||||
fireEvent.click(trigger);
|
||||
const heading = screen.getByRole("heading", { level: 1, name: "streamed.example" });
|
||||
expect(heading).toBeTruthy();
|
||||
const panel = heading.closest("div")!.parentElement!;
|
||||
expect(within(panel).getByText("Blocked locally")).toBeTruthy();
|
||||
expect(panel.textContent).toContain("the query log may not have written it yet");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("heading", { level: 1, name: "streamed.example" })).toBeNull();
|
||||
});
|
||||
|
||||
test("the detail takes focus when a row opens it and hands it back when it closes", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "streamed.example" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||
|
||||
// A native button activates on Enter and Space; jsdom does not synthesize
|
||||
// the click those keys fire, so the click is the activation.
|
||||
act(() => trigger.focus());
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(trigger.getAttribute("aria-controls")).toBe(panel.id);
|
||||
// The panel is inserted above the table, behind the trigger in tab order, so
|
||||
// the only thing that keeps a forward tab inside it is focus moving in.
|
||||
expect(panel.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(document.activeElement).toBe(panel);
|
||||
expect(panel.contains(screen.getByRole("button", { name: "Close" }))).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("group", { name: "Streamed query" })).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||
});
|
||||
|
||||
test("opening a second row moves the expanded state and the focus with it", async () => {
|
||||
await openLive();
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "first.example"));
|
||||
sources[0]!.emit("query", frame(1001, "second.example"));
|
||||
});
|
||||
|
||||
const first = screen.getByRole("button", { name: "first.example" });
|
||||
const second = screen.getByRole("button", { name: "second.example" });
|
||||
fireEvent.click(first);
|
||||
fireEvent.click(second);
|
||||
|
||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
||||
expect(within(panel).getByRole("heading", { level: 1, name: "second.example" })).toBeTruthy();
|
||||
expect(document.activeElement).toBe(panel);
|
||||
expect(first.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(second.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(document.activeElement).toBe(second);
|
||||
});
|
||||
|
||||
test("an open streamed detail survives the row being evicted from the ring buffer", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
|
||||
// 500 more queries: the ring keeps the newest 500, so the selected row is
|
||||
// gone from the table. The detail is a snapshot, not a lookup into the ring.
|
||||
act(() => {
|
||||
for (let index = 0; index < 500; index += 1) {
|
||||
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
|
||||
}
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an open streamed detail survives Freeze and Resume", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the filter row stays visible, keeps its values, and is out of the tab order", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
|
||||
|
||||
const domain = screen.getByLabelText("Domain contains") as HTMLInputElement;
|
||||
expect(domain.value).toBe("ads");
|
||||
expect(domain.disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Client (exact)") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Since") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Until") as HTMLInputElement).disabled).toBe(true);
|
||||
|
||||
const form = domain.closest("form")!;
|
||||
const controls = [...form.querySelectorAll("input, button, select, textarea, a[href], [tabindex]")];
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
for (const control of controls) {
|
||||
// A disabled form control is skipped by the browser's tab order, and RAC
|
||||
// pins its own trigger out of it as well. Nothing in the row may
|
||||
// reintroduce itself with a reachable tabindex.
|
||||
expect(control.hasAttribute("disabled")).toBe(true);
|
||||
const tabindex = control.getAttribute("tabindex");
|
||||
expect(tabindex === null || tabindex === "-1").toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("live mode asks for no query pages, whatever filters the url retained", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true&since=1700000000&bogus=1");
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
|
||||
expect(queryCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "History" }));
|
||||
await screen.findByRole("button", { name: "Apply filters" });
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0]!.closed).toBe(true);
|
||||
// The filters came along, which is the point of switching rather than
|
||||
// navigating: the reader keeps the question they were asking.
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads");
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Live" }));
|
||||
await screen.findByRole("button", { name: "Freeze" });
|
||||
expect(sources).toHaveLength(2);
|
||||
expect(sources[1]!.closed).toBe(false);
|
||||
});
|
||||
+174
-18
@@ -1,25 +1,41 @@
|
||||
/**
|
||||
* Activity in live mode: the SSE stream, its bounded ring buffer, and the
|
||||
* in-place detail a streamed row opens.
|
||||
*
|
||||
* This subtree is mounted only while the URL says `mode=live`, which is what
|
||||
* closes the EventSource on the way back to history: the connection is a
|
||||
* server-side resource capped per address, so a page that kept it open while
|
||||
* showing something else would spend a viewer slot on nothing.
|
||||
*
|
||||
* Freeze and Follow are display state and stay out of the URL. They describe
|
||||
* what the screen is doing right now, not what it is showing, so a shared link
|
||||
* would carry a frozen moment the recipient never saw fill.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useClientNames } from "@/features/clients/clientNames";
|
||||
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
|
||||
import { RING_CAPACITY, summaryOf } from "./ringBuffer";
|
||||
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
|
||||
import { summarizeEvent } from "@/features/queries/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||
import ProvenanceDetail from "./ProvenanceDetail";
|
||||
import RelatedActions from "./RelatedActions";
|
||||
import { RING_CAPACITY, summaryOf, type StreamedRow } from "./ringBuffer";
|
||||
import type { ActivitySearch } from "./search";
|
||||
import { useLiveQueries, type StreamStatus } from "./useLiveQueries";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const styles = stylex.create({
|
||||
toolbar: {
|
||||
marginTop: "1rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
toolbarButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
@@ -134,6 +150,40 @@ const styles = stylex.create({
|
||||
[DARK]: "oklch(25.8% 0.092 26.042 / 0.4)",
|
||||
},
|
||||
},
|
||||
/** A streamed row opens its detail here rather than at a route, so it is a button that looks like the link beside it. */
|
||||
domainButton: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
font: "inherit",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
textDecorationLine: "underline",
|
||||
textDecorationStyle: "dotted",
|
||||
},
|
||||
detailPanel: {
|
||||
marginTop: "1rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surface,
|
||||
padding: "1rem",
|
||||
},
|
||||
detailBar: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
detailLabel: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
footnote: {
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
@@ -142,6 +192,10 @@ const styles = stylex.create({
|
||||
},
|
||||
});
|
||||
|
||||
/** One panel at a time, so the trigger that opened it can name it in `aria-controls`. */
|
||||
const DETAIL_PANEL_ID = "live-query-detail";
|
||||
const DETAIL_LABEL_ID = "live-query-detail-label";
|
||||
|
||||
const PILL_LABELS: Record<StreamStatus, string> = {
|
||||
connecting: "Connecting…",
|
||||
open: "Live",
|
||||
@@ -165,16 +219,85 @@ function StatusPill({ status }: { status: StreamStatus }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Freeze is display-only: the stream stays open and the 500-row ring buffer keeps
|
||||
* filling; Resume shows the current buffer (anything pushed out meanwhile is gone). */
|
||||
export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) {
|
||||
const live = useLiveQueries({ createEventSource });
|
||||
const clientNames = useClientNames();
|
||||
/**
|
||||
* The open detail, held as the selected row itself rather than as a key into
|
||||
* the buffer.
|
||||
*
|
||||
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by
|
||||
* key would go stale under the reader while they were still reading it, and the
|
||||
* panel would blank out for no reason they could see. The snapshot is the whole
|
||||
* fact — a streamed frame carries its own provenance — so it survives eviction,
|
||||
* a merge and a Freeze/Resume, and closes only when the reader closes it or
|
||||
* leaves live mode.
|
||||
*/
|
||||
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
|
||||
const summary = summarizeEvent(row.event);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The panel opens above the table, behind the trigger in tab order, so a
|
||||
// forward tab from the row would walk past it. Focus moves in on open —
|
||||
// keyed on the row, so choosing a second row moves it again — and the
|
||||
// closer puts it back on the trigger.
|
||||
useEffect(() => {
|
||||
panel.current?.focus();
|
||||
}, [row.key]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div
|
||||
ref={panel}
|
||||
id={DETAIL_PANEL_ID}
|
||||
tabIndex={-1}
|
||||
role="group"
|
||||
aria-labelledby={DETAIL_LABEL_ID}
|
||||
{...stylex.props(styles.detailPanel)}
|
||||
>
|
||||
<div {...stylex.props(styles.detailBar)}>
|
||||
<span id={DETAIL_LABEL_ID} {...stylex.props(styles.detailLabel)}>
|
||||
Streamed query
|
||||
</span>
|
||||
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<ProvenanceDetail
|
||||
provenance={row.event}
|
||||
persistedId={null}
|
||||
relatedActions={
|
||||
<RelatedActions
|
||||
domain={summary.domain}
|
||||
client={summary.client_ip}
|
||||
ts={summary.ts}
|
||||
origin={origin}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LiveActivity({ origin }: { origin: ActivitySearch }) {
|
||||
const live = useLiveQueries();
|
||||
const clientNames = useClientNames();
|
||||
const [selected, setSelected] = useState<StreamedRow | null>(null);
|
||||
const trigger = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
function open(row: StreamedRow, from: HTMLButtonElement) {
|
||||
trigger.current = from;
|
||||
setSelected(row);
|
||||
}
|
||||
|
||||
// The row that opened the panel takes focus back, unless the ring has
|
||||
// already evicted it: a detached button cannot be focused, and the browser
|
||||
// falls back to the document, which is the best available answer.
|
||||
function close() {
|
||||
setSelected(null);
|
||||
trigger.current?.focus();
|
||||
trigger.current = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Live</h1>
|
||||
<StatusPill status={live.status} />
|
||||
<button
|
||||
type="button"
|
||||
@@ -228,6 +351,8 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
|
||||
|
||||
{live.rows.length === 0 ? (
|
||||
live.status !== "capped" && (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
@@ -238,7 +363,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<QueryTableHead />
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
{live.rows.map((row) => {
|
||||
const summary = summaryOf(row);
|
||||
@@ -247,7 +372,38 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
key={row.key}
|
||||
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
|
||||
>
|
||||
<QueryCells row={summary} clientNames={clientNames} />
|
||||
<ActivityCells
|
||||
row={summary}
|
||||
clientNames={clientNames}
|
||||
renderDomain={(_id, children) =>
|
||||
row.kind === "streamed" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={selected?.key === row.key}
|
||||
aria-controls={
|
||||
selected?.key === row.key ? DETAIL_PANEL_ID : undefined
|
||||
}
|
||||
onClick={(event) => open(row, event.currentTarget)}
|
||||
{...stylex.props(
|
||||
styles.domainButton,
|
||||
activityDomainLink,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/activity/queries/$id"
|
||||
params={{ id: String(row.row.id) }}
|
||||
search={origin}
|
||||
{...stylex.props(activityDomainLink, shared.focusRing)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -260,6 +416,6 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { LookupResult } from "@/lib/types";
|
||||
|
||||
const BLOCKED: LookupResult = {
|
||||
domain: "ads.example",
|
||||
group_id: 1,
|
||||
local_records: false,
|
||||
forward_zone: null,
|
||||
blocked: true,
|
||||
reason: "blocklist_domain",
|
||||
matched: "ads.example",
|
||||
source_url: "https://lists.test/a",
|
||||
safe_search_rewrite: null,
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof createFetchMock>;
|
||||
/** What `/api/lookup` answers, so a test can make it fail without rebuilding the mock. */
|
||||
let lookup: (url: string) => Response;
|
||||
|
||||
function json(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function createFetchMock() {
|
||||
return vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/groups") {
|
||||
return json({
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url.startsWith("/api/lookup")) return lookup(url);
|
||||
return json({ error: "not stubbed" }, 404);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lookup = (url) =>
|
||||
url === "/api/lookup?domain=ads.example&group_id=1" ? json(BLOCKED) : json({ error: "not stubbed" }, 404);
|
||||
fetchMock = createFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/**
|
||||
* `retry: false` for the failure tests: the shared client retries a 5xx twice
|
||||
* and a 429 after its Retry-After, so the surfaced error is what the page does
|
||||
* once the client has given up, not something a test should sit out in real
|
||||
* time.
|
||||
*/
|
||||
function renderPage(path = "/activity/test", { retry = true } = {}) {
|
||||
const queryClient = createQueryClient();
|
||||
if (!retry) {
|
||||
const defaults = queryClient.getDefaultOptions();
|
||||
queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
|
||||
}
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function lookupCalls(): string[] {
|
||||
return fetchMock.mock.calls.map(([input]) => String(input)).filter((url) => url.startsWith("/api/lookup"));
|
||||
}
|
||||
|
||||
test("fetches nothing until submit, then renders the blocked verdict", async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByRole("heading", { name: "Current policy simulation" });
|
||||
await screen.findByLabelText("Group");
|
||||
expect(lookupCalls()).toEqual([]);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||
expect(lookupCalls()).toEqual([]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocked" });
|
||||
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
|
||||
|
||||
expect(screen.getByText("blocklist_domain")).toBeTruthy();
|
||||
const link = screen.getByRole("link", { name: "https://lists.test/a" }) as HTMLAnchorElement;
|
||||
expect(link.href).toBe("https://lists.test/a");
|
||||
expect(screen.getByText("Queries for this name get a blocked response.")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a ?domain= link asks the question on arrival instead of leaving a filled-in form", async () => {
|
||||
renderPage("/activity/test?domain=ads.example");
|
||||
|
||||
// No submit here: the link is the question, so the verdict is what arrives.
|
||||
await screen.findByRole("heading", { name: "Blocked" });
|
||||
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
|
||||
expect(screen.getByLabelText("Domain")).toHaveProperty("value", "ads.example");
|
||||
});
|
||||
|
||||
test("no filter snapshot reads as a server that is starting, not as a verdict", async () => {
|
||||
lookup = () => json({ error: "no snapshot" }, 503);
|
||||
renderPage("/activity/test", { retry: false });
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("No filter snapshot is loaded yet");
|
||||
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
|
||||
// Nothing may read as an answer while the lookup has none.
|
||||
expect(screen.queryByRole("heading", { name: "Blocked" })).toBeNull();
|
||||
expect(screen.queryByText("Simulating…")).toBeNull();
|
||||
});
|
||||
|
||||
test("a rate limit says how long to wait, from the server's own Retry-After", async () => {
|
||||
lookup = () => json({ error: "rate limited" }, 429, { "retry-after": "12" });
|
||||
renderPage("/activity/test", { retry: false });
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 12s.");
|
||||
});
|
||||
|
||||
test("resubmitting the same domain and group refetches rather than showing a stale verdict", async () => {
|
||||
renderPage();
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||
await screen.findByRole("heading", { name: "Blocked" });
|
||||
expect(lookupCalls()).toHaveLength(1);
|
||||
|
||||
// The policy can change between two identical questions, so the second one
|
||||
// has to reach the server even though the query key has not moved.
|
||||
lookup = () => json({ ...BLOCKED, blocked: false, reason: "no_match", matched: "", source_url: null });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Simulate" }));
|
||||
|
||||
await screen.findByRole("heading", { name: "Allowed" });
|
||||
expect(lookupCalls()).toEqual([
|
||||
"/api/lookup?domain=ads.example&group_id=1",
|
||||
"/api/lookup?domain=ads.example&group_id=1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("defaults the group select to the default group (id 1)", async () => {
|
||||
renderPage();
|
||||
// A RAC Select names its trigger with the current value and then the label, so
|
||||
// the selected group's name is the only thing the trigger shows.
|
||||
const trigger = await screen.findByRole("button", { name: /Group$/ });
|
||||
expect(trigger.textContent).toContain("default");
|
||||
});
|
||||
|
||||
test("the framing is forward-tense, so it cannot be read as an account of a past query", async () => {
|
||||
renderPage();
|
||||
await screen.findByRole("heading", { name: "Current policy simulation" });
|
||||
|
||||
const intro = screen.getByRole("heading", { name: "Current policy simulation" }).nextElementSibling;
|
||||
expect(intro?.textContent).toContain("would");
|
||||
expect(intro?.textContent).toContain("right now");
|
||||
// Nothing on the page may claim to explain a query that already happened.
|
||||
expect(document.body.textContent).not.toContain("Look up");
|
||||
});
|
||||
+11
-9
@@ -250,13 +250,13 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
|
||||
);
|
||||
}
|
||||
|
||||
export default function LookupPage() {
|
||||
export default function PolicyTestPage() {
|
||||
const groups = useSuspenseQuery(groupsQuery()).data;
|
||||
const preselectedGroupId = defaultGroupId(groups);
|
||||
|
||||
// A `?domain=` link (from a query's detail page) arrives already asking the
|
||||
// question, so it runs the lookup rather than leaving a filled-in form.
|
||||
const search = useSearch({ from: "/shell/lookup" });
|
||||
// question, so it runs the simulation rather than leaving a filled-in form.
|
||||
const search = useSearch({ from: "/shell/activity/test" });
|
||||
const [domain, setDomain] = useState(search.domain ?? "");
|
||||
const [groupId, setGroupId] = useState(preselectedGroupId);
|
||||
const [submitted, setSubmitted] = useState<Submitted | null>(
|
||||
@@ -281,17 +281,19 @@ export default function LookupPage() {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Lookup</h1>
|
||||
<h1 {...stylex.props(styles.heading)}>Current policy simulation</h1>
|
||||
<p {...stylex.props(styles.intro)}>
|
||||
What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
|
||||
What the pipeline <em>would</em> do with a domain right now: local records, forward zones, block
|
||||
decision, safe search. This reads the configuration in force at this moment, so it explains nothing
|
||||
about a query already answered — a detail page does that.
|
||||
</p>
|
||||
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
||||
<div {...stylex.props(styles.domainField)}>
|
||||
<label htmlFor="lookup-domain" {...stylex.props(styles.fieldLabel)}>
|
||||
<label htmlFor="policy-test-domain" {...stylex.props(styles.fieldLabel)}>
|
||||
Domain
|
||||
</label>
|
||||
<input
|
||||
id="lookup-domain"
|
||||
id="policy-test-domain"
|
||||
required
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
@@ -312,10 +314,10 @@ export default function LookupPage() {
|
||||
disabled={lookup.isFetching}
|
||||
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||
>
|
||||
Look up
|
||||
Simulate
|
||||
</button>
|
||||
</form>
|
||||
{lookup.isFetching && <p {...stylex.props(styles.note)}>Looking up…</p>}
|
||||
{lookup.isFetching && <p {...stylex.props(styles.note)}>Simulating…</p>}
|
||||
{!lookup.isFetching && lookup.isError && (
|
||||
<p role="alert" {...stylex.props(styles.error)}>
|
||||
{errorMessage(lookup.error)}
|
||||
+41
-70
@@ -1,24 +1,30 @@
|
||||
/**
|
||||
* One query, explained in the order it met the pipeline.
|
||||
*
|
||||
* This is the body of the detail surface, with no route in it, because two
|
||||
* surfaces show it: the persisted detail page, which fetched the row by id, and
|
||||
* a live row, whose provenance arrived in the stream frame and which SQLite may
|
||||
* not have written yet. The related actions are links into routes, so the
|
||||
* caller passes them in already built.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import { queryDetailQuery } from "@/lib/queries";
|
||||
import type { PolicyReason, QueryDetail } from "@/lib/types";
|
||||
import type { PolicyReason, Provenance } from "@/lib/types";
|
||||
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
|
||||
import { policyActionLabel, policyReasonLabel, qclassName, rcodeName, routeKindLabel } from "./provenanceCopy";
|
||||
import { qtypeName } from "./qtype";
|
||||
import {
|
||||
policyActionLabel,
|
||||
policyReasonLabel,
|
||||
qclassName,
|
||||
rcodeName,
|
||||
routeKindLabel,
|
||||
} from "@/features/queries/provenanceCopy";
|
||||
import { qtypeName } from "@/features/queries/qtype";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
back: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
heading: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "1.5rem",
|
||||
@@ -109,12 +115,11 @@ const styles = stylex.create({
|
||||
link: {
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
loading: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
/** The look of one related action, so every caller's links match. */
|
||||
export const provenanceRelatedLink = styles.link;
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div {...stylex.props(styles.section)}>
|
||||
@@ -158,39 +163,25 @@ function unmatchedLabel(reason: PolicyReason): string {
|
||||
return reason === "no_match" ? "Nothing matched" : "The matcher never ran";
|
||||
}
|
||||
|
||||
export default function QueryDetailPage() {
|
||||
const { id } = useParams({ from: "/shell/queries/$id" });
|
||||
const rowId = Number(id);
|
||||
const { data, error, isPending, refetch } = useQuery(queryDetailQuery(rowId));
|
||||
interface Props {
|
||||
provenance: Provenance;
|
||||
/**
|
||||
* The log row this explains, or null for a frame read straight off the
|
||||
* stream. Null is the same fact `QuerySummary.id` carries: the event
|
||||
* precedes its own insert, so there is no row to name and none is invented.
|
||||
*/
|
||||
persistedId: number | null;
|
||||
/** The related-action links, built by whichever surface owns the routes. */
|
||||
relatedActions: ReactNode;
|
||||
}
|
||||
|
||||
export default function ProvenanceDetail({ provenance, persistedId, relatedActions }: Props) {
|
||||
const { request, group, policy, rewrites, route, response } = provenance;
|
||||
const clientNames = useClientNames();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<p {...stylex.props(styles.loading, shared.pulse)} role="status">
|
||||
Loading query…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (data === undefined) {
|
||||
return (
|
||||
<section>
|
||||
<Link to="/queries" {...stylex.props(styles.back, shared.focusRing)}>
|
||||
← Query log
|
||||
</Link>
|
||||
<InlineError error={error} onRetry={() => void refetch()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const detail: QueryDetail = data;
|
||||
const { request, group, policy, rewrites, route, response } = detail;
|
||||
const currentClient = clientLabel(request.client, clientNames);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Link to="/queries" {...stylex.props(styles.back, shared.focusRing)}>
|
||||
← Query log
|
||||
</Link>
|
||||
<>
|
||||
<h1 {...stylex.props(styles.heading, shared.mono)}>{request.domain}</h1>
|
||||
<p {...stylex.props(styles.subtitle)}>
|
||||
{formatTime(request.time)} — {policyActionLabel(policy.action)}
|
||||
@@ -202,6 +193,8 @@ export default function QueryDetailPage() {
|
||||
<p {...stylex.props(styles.recordNote)}>
|
||||
What was recorded when this query was answered. Group and blocklist names are the ones in force at
|
||||
that moment; they may have been renamed or deleted since.
|
||||
{persistedId === null &&
|
||||
" This is the event as it was streamed; the query log may not have written it yet."}
|
||||
</p>
|
||||
|
||||
<Section title="Request">
|
||||
@@ -300,30 +293,8 @@ export default function QueryDetailPage() {
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div {...stylex.props(styles.relatedList)}>
|
||||
<Link
|
||||
to="/lookup"
|
||||
search={{ domain: request.domain }}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
Look up this domain now
|
||||
</Link>
|
||||
<Link
|
||||
to="/queries"
|
||||
search={{ domain: request.domain }}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
All queries for this domain
|
||||
</Link>
|
||||
<Link
|
||||
to="/queries"
|
||||
search={{ client: request.client }}
|
||||
{...stylex.props(styles.link, shared.focusRing)}
|
||||
>
|
||||
All queries from this client
|
||||
</Link>
|
||||
</div>
|
||||
<div {...stylex.props(styles.relatedList)}>{relatedActions}</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* The links out of one query's detail, shared by the persisted detail page and
|
||||
* the in-place detail a streamed row opens.
|
||||
*
|
||||
* Both surfaces answer the same four follow-up questions, and both are read
|
||||
* from an investigation that has a time range. Every link therefore carries
|
||||
* absolute bounds: a link that said "recently" would show a different set of
|
||||
* queries every time it was opened, which is the opposite of what linking to an
|
||||
* incident is for.
|
||||
*/
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { provenanceRelatedLink } from "./ProvenanceDetail";
|
||||
import { diagnosticsBounds, relatedBounds } from "./relatedLinks";
|
||||
import type { ActivitySearch } from "./search";
|
||||
|
||||
interface Props {
|
||||
domain: string;
|
||||
client: string;
|
||||
/** The second this query was answered, which every window is centred on. */
|
||||
ts: number;
|
||||
/** The Activity search the reader came from; its bounds win over the defaults. */
|
||||
origin: Pick<ActivitySearch, "since" | "until">;
|
||||
}
|
||||
|
||||
export default function RelatedActions({ domain, client, ts, origin }: Props) {
|
||||
const bounds = relatedBounds(ts, origin);
|
||||
const window = diagnosticsBounds(ts);
|
||||
return (
|
||||
<>
|
||||
<Link to="/activity/test" search={{ domain }} {...stylex.props(provenanceRelatedLink, shared.focusRing)}>
|
||||
Test this domain against current policy
|
||||
</Link>
|
||||
<Link
|
||||
to="/activity"
|
||||
search={{ mode: "history", domain, client: undefined, blocked: undefined, ...bounds }}
|
||||
{...stylex.props(provenanceRelatedLink, shared.focusRing)}
|
||||
>
|
||||
All activity for this domain
|
||||
</Link>
|
||||
<Link
|
||||
to="/activity"
|
||||
search={{ mode: "history", client, domain: undefined, blocked: undefined, ...bounds }}
|
||||
{...stylex.props(provenanceRelatedLink, shared.focusRing)}
|
||||
>
|
||||
All activity from this client
|
||||
</Link>
|
||||
<Link
|
||||
to="/diagnostics"
|
||||
search={{ since: window.since, until: window.until }}
|
||||
{...stylex.props(provenanceRelatedLink, shared.focusRing)}
|
||||
>
|
||||
Diagnostics around this query
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { QueryRow } from "@/lib/types";
|
||||
import type { ClientNames } from "@/features/clients/clientNames";
|
||||
import { queryRow } from "@/features/queries/provenanceFixture";
|
||||
import { summarizeRow, type QuerySummary } from "@/features/queries/querySummary";
|
||||
import { ACTIVITY_COLUMNS, ActivityCells, ActivityTableHead, resultLabel, routeLabel } from "./cells";
|
||||
|
||||
const noNames: ClientNames = new Map();
|
||||
|
||||
function renderRow(overrides: Partial<QueryRow> = {}): HTMLTableRowElement {
|
||||
const row: QuerySummary = summarizeRow(queryRow(1, overrides));
|
||||
render(
|
||||
<table>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
<tr data-testid="row">
|
||||
<ActivityCells
|
||||
row={row}
|
||||
clientNames={noNames}
|
||||
renderDomain={(id, children) => <a href={`/activity/queries/${id}`}>{children}</a>}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
return screen.getByTestId("row") as HTMLTableRowElement;
|
||||
}
|
||||
|
||||
/** The cell under a header, read by its column name rather than its index. */
|
||||
function cell(row: HTMLTableRowElement, column: (typeof ACTIVITY_COLUMNS)[number]): string {
|
||||
const index = ACTIVITY_COLUMNS.indexOf(column);
|
||||
return row.cells[index]?.textContent ?? "";
|
||||
}
|
||||
|
||||
test("the head names the seven columns in order", () => {
|
||||
render(
|
||||
<table>
|
||||
<ActivityTableHead />
|
||||
</table>,
|
||||
);
|
||||
const headers = screen.getAllByRole("columnheader").map((header) => header.textContent);
|
||||
expect(headers).toEqual(["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"]);
|
||||
});
|
||||
|
||||
test("an allowed NOERROR row reads as the answer it got, with no badge", () => {
|
||||
const row = renderRow({ blocked: false, rcode: 0, route_kind: "upstream", response_time_us: 1234 });
|
||||
expect(cell(row, "Result")).toBe("NOERROR");
|
||||
expect(cell(row, "Route")).toBe("Upstream");
|
||||
expect(cell(row, "Duration")).toBe("1.2 ms");
|
||||
expect(cell(row, "Type")).toBe("A");
|
||||
});
|
||||
|
||||
test("a blocked row reads Blocked even though the client got NOERROR", () => {
|
||||
const row = renderRow({ blocked: true, rcode: 0, route_kind: "blocked", policy_reason: "blocklist_domain" });
|
||||
expect(cell(row, "Result")).toBe("Blocked");
|
||||
expect(cell(row, "Route")).toBe("Blocked");
|
||||
});
|
||||
|
||||
test("a SERVFAIL row names the code", () => {
|
||||
const row = renderRow({ blocked: false, rcode: 2, route_kind: "upstream", response_time_us: null });
|
||||
expect(cell(row, "Result")).toBe("SERVFAIL");
|
||||
expect(cell(row, "Duration")).toBe("—");
|
||||
});
|
||||
|
||||
test("a cache hit names the cache as the route", () => {
|
||||
const row = renderRow({ blocked: false, rcode: 0, route_kind: "cache", cache_hit: true, upstream: "" });
|
||||
expect(cell(row, "Route")).toBe("Cache");
|
||||
expect(cell(row, "Result")).toBe("NOERROR");
|
||||
});
|
||||
|
||||
test("an unassigned extended rcode keeps the numeric fallback, without the long form's parentheses", () => {
|
||||
const row = renderRow({ blocked: false, rcode: 3841 });
|
||||
expect(cell(row, "Result")).toBe("RCODE 3841");
|
||||
});
|
||||
|
||||
test("a persisted row links its domain to the detail the caller chose", () => {
|
||||
renderRow({ domain: "ads.example" });
|
||||
expect(screen.getByRole("link", { name: "ads.example" }).getAttribute("href")).toBe("/activity/queries/1");
|
||||
});
|
||||
|
||||
test("a streamed row reaches the renderer with a null id, and can render as plain text", () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr data-testid="row">
|
||||
<ActivityCells
|
||||
row={{ ...summarizeRow(queryRow(1)), id: null }}
|
||||
clientNames={noNames}
|
||||
renderDomain={(id, children) => {
|
||||
expect(id).toBeNull();
|
||||
return children;
|
||||
}}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
expect(screen.getByTestId("row").textContent).toContain("example.com");
|
||||
});
|
||||
|
||||
test("every route kind has a compact label", () => {
|
||||
expect(routeLabel("blocked")).toBe("Blocked");
|
||||
expect(routeLabel("local")).toBe("Local");
|
||||
expect(routeLabel("forward_zone")).toBe("Forward zone");
|
||||
expect(routeLabel("upstream")).toBe("Upstream");
|
||||
expect(routeLabel("cache")).toBe("Cache");
|
||||
expect(routeLabel("rejected")).toBe("Rejected");
|
||||
});
|
||||
|
||||
test("resultLabel is the pure form of the Result cell", () => {
|
||||
expect(resultLabel({ blocked: true, rcode: 2 })).toBe("Blocked");
|
||||
expect(resultLabel({ blocked: false, rcode: 5 })).toBe("REFUSED");
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* The seven columns of the Activity table: Time, Domain, Client, Type, Result,
|
||||
* Route and Duration.
|
||||
*
|
||||
* The labels here are the compact forms a scanned table needs. The detail page
|
||||
* keeps `provenanceCopy`'s long forms, which spell out the same facts with room
|
||||
* for the rcode number and the "answered by" phrasing.
|
||||
*
|
||||
* The cells render both a stored row and a streamed event, so they know nothing
|
||||
* about routes: the caller renders the Domain cell's contents and decides what,
|
||||
* if anything, a row opens. A streamed row has no id — the frame precedes its
|
||||
* own insert — and only the caller knows whether it has a surface for one.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import type { RouteKind } from "@/lib/types";
|
||||
import { ClientName, type ClientNames } from "@/features/clients/clientNames";
|
||||
import { rcodeShortName } from "@/features/queries/provenanceCopy";
|
||||
import { qtypeName } from "@/features/queries/qtype";
|
||||
import type { QuerySummary } from "@/features/queries/querySummary";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const styles = stylex.create({
|
||||
head: {
|
||||
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||
textAlign: "left",
|
||||
},
|
||||
th: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
cell: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
nowrap: {
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
breakAll: {
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
small: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
},
|
||||
muted: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
domainLink: {
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
/**
|
||||
* The badge shape and its weight are the signal; the tint only says which
|
||||
* kind of unhappy answer this was. A monochrome or colour-blind reading of
|
||||
* the table still separates a blocked or failed row from a plain NOERROR
|
||||
* one, which a hue alone would not.
|
||||
*/
|
||||
badge: {
|
||||
display: "inline-block",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
blockedBadge: {
|
||||
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
|
||||
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
||||
},
|
||||
faultBadge: {
|
||||
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(41.4% 0.112 45.904)" },
|
||||
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(90.1% 0.076 70.697)" },
|
||||
},
|
||||
});
|
||||
|
||||
const ROUTE_LABELS: Record<RouteKind, string> = {
|
||||
blocked: "Blocked",
|
||||
local: "Local",
|
||||
forward_zone: "Forward zone",
|
||||
upstream: "Upstream",
|
||||
cache: "Cache",
|
||||
rejected: "Rejected",
|
||||
};
|
||||
|
||||
/** The compact Route label. `Record` over the union, so a new kind fails `tsc`. */
|
||||
export function routeLabel(kind: RouteKind): string {
|
||||
return ROUTE_LABELS[kind];
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact Result label. A block is the answer the operator asked nxdns for,
|
||||
* so it wins over the rcode it was delivered as — a blocked name answered with
|
||||
* NOERROR and a zero address is still "Blocked". Everything else reads as the
|
||||
* code the client saw.
|
||||
*/
|
||||
export function resultLabel(row: Pick<QuerySummary, "blocked" | "rcode">): string {
|
||||
return row.blocked ? "Blocked" : rcodeShortName(row.rcode);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a row's domain is wrapped in. The caller owns the routes, so it builds
|
||||
* the element; the look stays here, as `activityDomainLink`, which the caller
|
||||
* spreads onto the control itself — a link's own colour beats one inherited
|
||||
* from a wrapper. `id` is null for a streamed row, which history has no surface
|
||||
* for and live opens from memory.
|
||||
*/
|
||||
export type DomainRenderer = (id: number | null, children: ReactNode) => ReactNode;
|
||||
|
||||
export const activityDomainLink = styles.domainLink;
|
||||
|
||||
export function ResultCellContent({ row }: { row: Pick<QuerySummary, "blocked" | "rcode"> }) {
|
||||
const label = resultLabel(row);
|
||||
if (row.blocked) return <span {...stylex.props(styles.badge, styles.blockedBadge)}>{label}</span>;
|
||||
if (row.rcode !== 0) return <span {...stylex.props(styles.badge, styles.faultBadge)}>{label}</span>;
|
||||
return <span {...stylex.props(styles.small, styles.muted)}>{label}</span>;
|
||||
}
|
||||
|
||||
export function ActivityCells({
|
||||
row,
|
||||
clientNames,
|
||||
renderDomain,
|
||||
}: {
|
||||
row: QuerySummary;
|
||||
clientNames: ClientNames;
|
||||
renderDomain: DomainRenderer;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||
{renderDomain(row.id, row.domain)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
||||
<ClientName ip={row.client_ip} names={clientNames} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||
<ResultCellContent row={row} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{routeLabel(row.route_kind)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const ACTIVITY_COLUMNS = ["Time", "Domain", "Client", "Type", "Result", "Route", "Duration"] as const;
|
||||
|
||||
export function ActivityTableHead() {
|
||||
return (
|
||||
<thead {...stylex.props(styles.head)}>
|
||||
<tr>
|
||||
{ACTIVITY_COLUMNS.map((column) => (
|
||||
<th key={column} {...stylex.props(styles.th)}>
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
datetimeField,
|
||||
datetimeLocalToUnix,
|
||||
editDatetimeField,
|
||||
resolveDatetimeField,
|
||||
unixToDatetimeLocal,
|
||||
} from "./datetime";
|
||||
|
||||
/**
|
||||
* Every assertion here is about local time, so the zone has to be pinned. New
|
||||
* York is the zone the DST cases are written for: the fold is 2024-11-03 01:30
|
||||
* and the gap is 2024-03-10 02:30.
|
||||
*/
|
||||
// The app never reads `process`, so `src` is typed without node's globals; the
|
||||
// test host is node, where assigning `TZ` re-reads the zone for `Date`.
|
||||
declare const process: { env: Record<string, string | undefined> };
|
||||
|
||||
const originalTz = process.env["TZ"];
|
||||
beforeAll(() => {
|
||||
process.env["TZ"] = "America/New_York";
|
||||
});
|
||||
afterAll(() => {
|
||||
process.env["TZ"] = originalTz;
|
||||
});
|
||||
|
||||
/** 2024-06-01T12:34:56 EDT. */
|
||||
const SUMMER = 1_717_259_696;
|
||||
|
||||
test("a non-zero-second instant round trips", () => {
|
||||
expect(unixToDatetimeLocal(SUMMER)).toBe("2024-06-01T12:34:56");
|
||||
expect(datetimeLocalToUnix("2024-06-01T12:34:56")).toBe(SUMMER);
|
||||
});
|
||||
|
||||
test("text without seconds parses as :00", () => {
|
||||
expect(datetimeLocalToUnix("2024-06-01T12:34")).toBe(datetimeLocalToUnix("2024-06-01T12:34:00"));
|
||||
});
|
||||
|
||||
test("text that is not a datetime-local value names no instant", () => {
|
||||
expect(datetimeLocalToUnix("")).toBeUndefined();
|
||||
expect(datetimeLocalToUnix("yesterday")).toBeUndefined();
|
||||
expect(datetimeLocalToUnix("2024-06-01")).toBeUndefined();
|
||||
expect(datetimeLocalToUnix("2024-13-01T00:00:00")).toBeUndefined();
|
||||
});
|
||||
|
||||
/** 2024-11-03 01:30 EDT and 01:30 EST: two instants, one wall clock. */
|
||||
const FOLD_FIRST = 1_730_611_800;
|
||||
const FOLD_SECOND = 1_730_615_400;
|
||||
|
||||
test("the fall-back fold gives two instants the same text", () => {
|
||||
expect(unixToDatetimeLocal(FOLD_FIRST)).toBe("2024-11-03T01:30:00");
|
||||
expect(unixToDatetimeLocal(FOLD_SECOND)).toBe("2024-11-03T01:30:00");
|
||||
expect(datetimeLocalToUnix("2024-11-03T01:30:00")).toBe(FOLD_FIRST);
|
||||
});
|
||||
|
||||
test("an untouched fold bound applies the instant it was seeded with, not a re-parse of its text", () => {
|
||||
const field = datetimeField(FOLD_SECOND);
|
||||
expect(field.text).toBe("2024-11-03T01:30:00");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: FOLD_SECOND });
|
||||
});
|
||||
|
||||
test("an edited fold bound resolves to the first of the two instants, which is what its text says", () => {
|
||||
const field = editDatetimeField(datetimeField(FOLD_SECOND), "2024-11-03T01:30:00");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: FOLD_FIRST });
|
||||
});
|
||||
|
||||
test("a spring-forward time that exists on no clock is rejected rather than slid forward an hour", () => {
|
||||
const field = editDatetimeField(datetimeField(undefined), "2024-03-10T02:30:00");
|
||||
expect(datetimeLocalToUnix("2024-03-10T02:30:00")).toBe(1_710_055_800);
|
||||
expect(unixToDatetimeLocal(1_710_055_800)).toBe("2024-03-10T03:30:00");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: false, reason: "nonexistent" });
|
||||
});
|
||||
|
||||
test("an edited bound round trips with non-zero seconds", () => {
|
||||
const field = editDatetimeField(datetimeField(undefined), "2024-06-01T12:34:56");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: SUMMER });
|
||||
});
|
||||
|
||||
test("clearing an edited bound drops the filter", () => {
|
||||
const field = editDatetimeField(datetimeField(SUMMER), "");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: undefined });
|
||||
});
|
||||
|
||||
test("an unparseable edit is reported, never silently dropped", () => {
|
||||
const field = editDatetimeField(datetimeField(undefined), "2024-06-32T99:99");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: false, reason: "unparseable" });
|
||||
});
|
||||
|
||||
test("an unset bound seeds an empty field that stays unset", () => {
|
||||
const field = datetimeField(undefined);
|
||||
expect(field.text).toBe("");
|
||||
expect(resolveDatetimeField(field)).toEqual({ ok: true, value: undefined });
|
||||
});
|
||||
|
||||
test("a fractional part on the seconds is parsed and dropped, not rejected", () => {
|
||||
// jsdom, and any engine that sanitizes to the full grammar, hands the input
|
||||
// back with milliseconds attached; a bound is a whole second either way.
|
||||
const field = editDatetimeField(datetimeField(undefined), "2023-11-14T23:13:37.000");
|
||||
const resolved = resolveDatetimeField(field);
|
||||
expect(resolved).toEqual({ ok: true, value: datetimeLocalToUnix("2023-11-14T23:13:37") });
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* The bridge between a `datetime-local` input and the unix seconds the URL and
|
||||
* the API speak.
|
||||
*
|
||||
* Local wall-clock text is lossy in a way unix seconds are not. Twice a year a
|
||||
* fall-back fold gives two instants the same text, and a spring-forward gap
|
||||
* gives an hour of text no instant at all. So the text is never the authority:
|
||||
* a bound the operator did not touch is carried through as the number it
|
||||
* already was, and a bound they did edit is accepted only when it survives a
|
||||
* round trip unchanged.
|
||||
*/
|
||||
|
||||
/** Zero-padded to the width the `datetime-local` grammar requires. */
|
||||
function pad(value: number, width: number): string {
|
||||
return String(value).padStart(width, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix seconds → the local wall-clock text a `datetime-local` input holds,
|
||||
* always with seconds, because the inputs run at `step={1}`.
|
||||
*/
|
||||
export function unixToDatetimeLocal(unix: number): string {
|
||||
const date = new Date(unix * 1000);
|
||||
const day = `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1, 2)}-${pad(date.getDate(), 2)}`;
|
||||
const time = `${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad(date.getSeconds(), 2)}`;
|
||||
return `${day}T${time}`;
|
||||
}
|
||||
|
||||
const DATETIME_LOCAL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?$/;
|
||||
|
||||
/**
|
||||
* The text with its seconds spelled out, or undefined when it is not a
|
||||
* `datetime-local` value at all. A browser omits `:00` seconds even at
|
||||
* `step={1}`, so the canonical form is what a round trip compares against.
|
||||
*
|
||||
* The grammar allows a fractional part after the seconds and some engines emit
|
||||
* one; a bound is a whole second here and on the wire, so it is parsed and then
|
||||
* dropped rather than treated as text we do not recognise.
|
||||
*/
|
||||
function canonicalize(value: string): string | undefined {
|
||||
const match = DATETIME_LOCAL.exec(value);
|
||||
if (match === null) return undefined;
|
||||
return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6] ?? "00"}`;
|
||||
}
|
||||
|
||||
/** Local wall-clock text → unix seconds, or undefined when it names no instant. */
|
||||
export function datetimeLocalToUnix(value: string): number | undefined {
|
||||
const canonical = canonicalize(value);
|
||||
if (canonical === undefined) return undefined;
|
||||
const ms = new Date(canonical).getTime();
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One bound of the filter form: what the input shows, what the applied search
|
||||
* carried, and whether the operator has touched it since.
|
||||
*/
|
||||
export interface DatetimeField {
|
||||
text: string;
|
||||
/** The applied value this field was seeded from, reused while `dirty` is false. */
|
||||
original: number | undefined;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export type DatetimeResolution =
|
||||
{ ok: true; value: number | undefined } | { ok: false; reason: "unparseable" | "nonexistent" };
|
||||
|
||||
export function datetimeField(original: number | undefined): DatetimeField {
|
||||
return { text: original === undefined ? "" : unixToDatetimeLocal(original), original, dirty: false };
|
||||
}
|
||||
|
||||
export function editDatetimeField(field: DatetimeField, text: string): DatetimeField {
|
||||
return { ...field, text, dirty: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The unix value this bound applies.
|
||||
*
|
||||
* An untouched field resolves to the number it was seeded with, never to a
|
||||
* re-parse of its own text: the text of a fall-back instant names two of them,
|
||||
* and re-parsing would silently move a bound the operator never edited.
|
||||
*
|
||||
* An edited field is parsed, then formatted back. A wall-clock time inside the
|
||||
* spring-forward gap exists on no clock, and `Date` quietly slides it forward
|
||||
* an hour; the round trip catches that and the caller reports it instead of
|
||||
* filtering on an hour nobody asked for.
|
||||
*/
|
||||
export function resolveDatetimeField(field: DatetimeField): DatetimeResolution {
|
||||
if (!field.dirty) return { ok: true, value: field.original };
|
||||
if (field.text.trim() === "") return { ok: true, value: undefined };
|
||||
const unix = datetimeLocalToUnix(field.text);
|
||||
if (unix === undefined) return { ok: false, reason: "unparseable" };
|
||||
if (unixToDatetimeLocal(unix) !== canonicalize(field.text)) return { ok: false, reason: "nonexistent" };
|
||||
return { ok: true, value: unix };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The absolute bounds an investigation link carries.
|
||||
*
|
||||
* Every link out of a query detail is time-scoped on purpose: a relative window
|
||||
* would answer a different question tomorrow than it does today, and the whole
|
||||
* point of linking to an episode is that the link keeps showing that episode.
|
||||
*/
|
||||
|
||||
import type { ActivitySearch } from "./search";
|
||||
|
||||
/** The five-minute window the redesign puts around one query. */
|
||||
export const RELATED_WINDOW_SECONDS = 300;
|
||||
|
||||
export interface Bounds {
|
||||
since: number;
|
||||
until: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bounds for "all activity for this domain/client", decided per bound.
|
||||
*
|
||||
* When the reader arrived from a bounded investigation, that bound is the one
|
||||
* they are working in and it carries over. A bound they never set falls back to
|
||||
* the five-minute window around this query — never to no bound at all, which
|
||||
* would answer with the whole retained history and lose the episode in it. The
|
||||
* two bounds are decided separately, so a half-bounded origin keeps its half.
|
||||
*/
|
||||
export function relatedBounds(ts: number, origin: Pick<ActivitySearch, "since" | "until">): Bounds {
|
||||
return {
|
||||
since: origin.since ?? ts - RELATED_WINDOW_SECONDS,
|
||||
until: origin.until ?? ts + RELATED_WINDOW_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Diagnostics window around one query. Fixed at five minutes either side of
|
||||
* the query, not inherited: the reader is asking what else was failing while
|
||||
* this query was answered, which is a question about the query's own moment.
|
||||
*/
|
||||
export function diagnosticsBounds(ts: number): Bounds {
|
||||
return { since: ts - RELATED_WINDOW_SECONDS, until: ts + RELATED_WINDOW_SECONDS };
|
||||
}
|
||||
+2
@@ -43,6 +43,8 @@ describe("summaryOf", () => {
|
||||
qtype: 28,
|
||||
blocked: true,
|
||||
policy_reason: "blocklist_wildcard",
|
||||
rcode: 0,
|
||||
route_kind: "blocked",
|
||||
response_time_us: 42,
|
||||
cache_hit: null,
|
||||
upstream: "",
|
||||
@@ -15,6 +15,9 @@ export type LiveRow = { key: number } & (
|
||||
{ kind: "streamed"; event: LiveQueryEvent } | { kind: "recovered"; row: QueryRow }
|
||||
);
|
||||
|
||||
/** The arm that carries its own provenance, and so its own detail surface. */
|
||||
export type StreamedRow = Extract<LiveRow, { kind: "streamed" }>;
|
||||
|
||||
/** The flat cells both arms render, and the shared identity for gap dedupe. */
|
||||
export function summaryOf(row: LiveRow): QuerySummary {
|
||||
return row.kind === "streamed" ? summarizeEvent(row.event) : summarizeRow(row.row);
|
||||
@@ -41,7 +44,7 @@ export function pushRow(rows: LiveRow[], row: LiveRow, capacity: number = RING_C
|
||||
* consume its occurrence, duplicating one query and losing the other.
|
||||
*
|
||||
* `QuerySummary` is the wrong basis for that: it is what the table renders, and
|
||||
* it drops qclass, rcode, policy_action and route_kind. `Omit<QueryRow, "id">`
|
||||
* it drops qclass and policy_action. `Omit<QueryRow, "id">`
|
||||
* instead makes the compiler demand a derivation for every stored column, so a
|
||||
* column added to the row cannot quietly fall out of the identity.
|
||||
*/
|
||||
@@ -0,0 +1,113 @@
|
||||
import { validateActivitySearch, validateBlocked, validateMode, validateText, validateTimestamp } from "./search";
|
||||
|
||||
test("mode is the two-value union, defaulting to history", () => {
|
||||
expect(validateMode("live")).toBe("live");
|
||||
expect(validateMode("history")).toBe("history");
|
||||
expect(validateMode(undefined)).toBe("history");
|
||||
expect(validateMode("Live")).toBe("history");
|
||||
expect(validateMode("")).toBe("history");
|
||||
expect(validateMode(0)).toBe("history");
|
||||
expect(validateMode(["live"])).toBe("history");
|
||||
});
|
||||
|
||||
test("a bound is a safe integer or nothing at all", () => {
|
||||
expect(validateTimestamp(1_700_000_000)).toBe(1_700_000_000);
|
||||
expect(validateTimestamp(0)).toBe(0);
|
||||
expect(validateTimestamp(-1)).toBe(-1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["a fraction", 1_700_000_000.5],
|
||||
["Infinity", Number.POSITIVE_INFINITY],
|
||||
["-Infinity", Number.NEGATIVE_INFINITY],
|
||||
["NaN", Number.NaN],
|
||||
["past the safe range", Number.MAX_SAFE_INTEGER + 1],
|
||||
["1e21", 1e21],
|
||||
["a numeric string", "1700000000"],
|
||||
["an empty string", ""],
|
||||
["null", null],
|
||||
["undefined", undefined],
|
||||
["a boolean", true],
|
||||
["an array", [1_700_000_000]],
|
||||
["a bigint", 1_700_000_000n],
|
||||
])("a bound rejects %s", (_name, value) => {
|
||||
expect(validateTimestamp(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("blocked keeps false, which is the allowed-only filter", () => {
|
||||
expect(validateBlocked(true)).toBe(true);
|
||||
expect(validateBlocked(false)).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["the string true", "true"],
|
||||
["the string false", "false"],
|
||||
["1", 1],
|
||||
["0", 0],
|
||||
["null", null],
|
||||
["undefined", undefined],
|
||||
])("blocked rejects %s", (_name, value) => {
|
||||
expect(validateBlocked(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a text filter is trimmed, and an empty one is no filter", () => {
|
||||
expect(validateText("ads.example")).toBe("ads.example");
|
||||
expect(validateText(" ads.example ")).toBe("ads.example");
|
||||
expect(validateText("")).toBeUndefined();
|
||||
expect(validateText(" ")).toBeUndefined();
|
||||
expect(validateText("\t\n")).toBeUndefined();
|
||||
expect(validateText(42)).toBeUndefined();
|
||||
expect(validateText(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a whole search normalizes every field and drops nothing else in", () => {
|
||||
expect(
|
||||
validateActivitySearch({
|
||||
mode: "live",
|
||||
since: 1_700_000_000,
|
||||
until: 1_700_000_600,
|
||||
domain: " ads.example ",
|
||||
client: "192.0.2.10",
|
||||
blocked: false,
|
||||
unknown: "kept out",
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "live",
|
||||
since: 1_700_000_000,
|
||||
until: 1_700_000_600,
|
||||
domain: "ads.example",
|
||||
client: "192.0.2.10",
|
||||
blocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("an empty search is history with no filters", () => {
|
||||
expect(validateActivitySearch({})).toEqual({
|
||||
mode: "history",
|
||||
since: undefined,
|
||||
until: undefined,
|
||||
domain: undefined,
|
||||
client: undefined,
|
||||
blocked: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("a search of junk applies nothing", () => {
|
||||
expect(
|
||||
validateActivitySearch({
|
||||
mode: "HISTORY ",
|
||||
since: "1700000000",
|
||||
until: Number.POSITIVE_INFINITY,
|
||||
domain: " ",
|
||||
client: null,
|
||||
blocked: "true",
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "history",
|
||||
since: undefined,
|
||||
until: undefined,
|
||||
domain: undefined,
|
||||
client: undefined,
|
||||
blocked: undefined,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The Activity search parameters, validated as pure functions so the route's
|
||||
* `validateSearch` stays a one-liner and every rejection is testable without a
|
||||
* router.
|
||||
*
|
||||
* A search value arrives from a URL, from history state, or from a hand-typed
|
||||
* link, so nothing about its type is given. Anything that is not exactly the
|
||||
* value the API can filter on becomes `undefined`: an unbounded page is honest,
|
||||
* a page filtered on a coerced guess is not.
|
||||
*/
|
||||
|
||||
import type { QueriesFilter } from "@/lib/types";
|
||||
|
||||
export const ACTIVITY_MODES = ["history", "live"] as const;
|
||||
export type ActivityMode = (typeof ACTIVITY_MODES)[number];
|
||||
|
||||
export interface ActivitySearch {
|
||||
mode: ActivityMode;
|
||||
since: number | undefined;
|
||||
until: number | undefined;
|
||||
domain: string | undefined;
|
||||
client: string | undefined;
|
||||
blocked: boolean | undefined;
|
||||
}
|
||||
|
||||
/** History is the surface a bare `/activity` should open on: it answers questions. */
|
||||
export function validateMode(value: unknown): ActivityMode {
|
||||
return value === "live" ? "live" : "history";
|
||||
}
|
||||
|
||||
/**
|
||||
* A unix-second bound. `Number.isSafeInteger` is the whole test: it rejects a
|
||||
* fraction, an infinity, a NaN and a magnitude past 2^53 in one step, and a
|
||||
* string never passes, so `?since=now` cannot reach the API as garbage.
|
||||
*/
|
||||
export function validateTimestamp(value: unknown): number | undefined {
|
||||
return Number.isSafeInteger(value) ? (value as number) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The blocked filter. `false` is a real filter — "allowed only" — so it must
|
||||
* survive; only a genuine boolean does, because `"false"` out of a URL parser
|
||||
* that did not decode JSON would otherwise read as true.
|
||||
*/
|
||||
export function validateBlocked(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A text filter, trimmed. An empty result becomes `undefined` rather than `""`:
|
||||
* the server treats an empty filter as no filter, and a URL that showed
|
||||
* `domain=` as applied state would claim a filter that is not filtering.
|
||||
*/
|
||||
export function validateText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The API filter for a validated search, built field by field.
|
||||
*
|
||||
* Only the fields that are actually set are written, so an unfiltered request
|
||||
* carries no keys at all: `GET /api/queries` rejects a parameter it does not
|
||||
* know, and the infinite query's cache key is the filter object, so a key
|
||||
* present-but-undefined and a key absent must not be two different windows onto
|
||||
* the same rows. `mode` never appears — it selects the surface, not the rows.
|
||||
*/
|
||||
export function queriesFilterOf(search: Omit<ActivitySearch, "mode">): QueriesFilter {
|
||||
const filter: QueriesFilter = {};
|
||||
if (search.domain !== undefined) filter.domain = search.domain;
|
||||
if (search.client !== undefined) filter.client = search.client;
|
||||
if (search.blocked !== undefined) filter.blocked = search.blocked;
|
||||
if (search.since !== undefined) filter.since = search.since;
|
||||
if (search.until !== undefined) filter.until = search.until;
|
||||
return filter;
|
||||
}
|
||||
|
||||
export function validateActivitySearch(search: Record<string, unknown>): ActivitySearch {
|
||||
return {
|
||||
mode: validateMode(search["mode"]),
|
||||
since: validateTimestamp(search["since"]),
|
||||
until: validateTimestamp(search["until"]),
|
||||
domain: validateText(search["domain"]),
|
||||
client: validateText(search["client"]),
|
||||
blocked: validateBlocked(search["blocked"]),
|
||||
};
|
||||
}
|
||||
+4
-4
@@ -10,7 +10,7 @@ afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function stubLocationAssign() {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { pathname: "/live", search: "", assign });
|
||||
vi.stubGlobal("location", { pathname: "/activity", search: "?mode=live", assign });
|
||||
return assign;
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ test("a 401 gap re-sync redirects to login instead of setting resyncFailed", asy
|
||||
act(() => sources[0]!.emit("query", frame(1000, "a.example")));
|
||||
act(() => sources[0]!.emit("error"));
|
||||
act(() => sources[0]!.emit("open"));
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
|
||||
expect(hook.result.current.resyncFailed).toBe(false);
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ test("cap trip with an expired session redirects to login", async () => {
|
||||
act(() => {
|
||||
for (let i = 0; i < CAP_ERROR_THRESHOLD; i++) sources[0]!.emit("error");
|
||||
});
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -183,7 +183,7 @@ test("a fatal rejection with an expired session redirects to login", async () =>
|
||||
|
||||
act(() => sources[0]!.failFatal());
|
||||
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Flive"));
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith("/login?redirect=%2Factivity%3Fmode%3Dlive"));
|
||||
expect(probeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -337,3 +337,38 @@ test("an episode links to its own detail page", async () => {
|
||||
const link = await screen.findByRole("link", { name: "Blocklist source failed to update" });
|
||||
expect(link.getAttribute("href")).toBe("/diagnostics/42");
|
||||
});
|
||||
|
||||
test("an absolute window reaches both requests and is stated on the page", async () => {
|
||||
const bounded = "since=1699999700&until=1700000300";
|
||||
responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE;
|
||||
responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED;
|
||||
renderRoute(`/diagnostics?${bounded}`);
|
||||
|
||||
await screen.findByRole("heading", { name: "Active" });
|
||||
expect(requested).toContain(`/api/diagnostics?${bounded}&state=active`);
|
||||
expect(requested).toContain(`/api/diagnostics?${bounded}&state=resolved`);
|
||||
// An empty section inside a five-minute window means something different
|
||||
// from an empty section over the whole history, so the page has to say so.
|
||||
expect(screen.getByText(/Showing events that overlap/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("clearing the range drops both bounds from the url", async () => {
|
||||
const bounded = "since=1699999700&until=1700000300";
|
||||
responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE;
|
||||
responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED;
|
||||
const router = renderRoute(`/diagnostics?${bounded}`);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Clear the time range" }));
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.search).not.toContain("since");
|
||||
});
|
||||
expect(router.state.location.search).not.toContain("until");
|
||||
});
|
||||
|
||||
test("a bound that is not a whole second is dropped, leaving the page unbounded", async () => {
|
||||
renderRoute("/diagnostics?since=1.5&until=Infinity");
|
||||
|
||||
await screen.findByRole("heading", { name: "Active" });
|
||||
expect(requested).toContain("/api/diagnostics?state=active");
|
||||
expect(screen.queryByText(/Showing events that overlap/)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -12,18 +12,13 @@ import * as api from "@/lib/api";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { formatDuration, formatTime } from "@/lib/format";
|
||||
import { diagnosticPurgeMutation, diagnosticsInfiniteQuery, diagnosticsPurgeResolvedMutation } from "@/lib/queries";
|
||||
import type {
|
||||
DiagnosticEvent,
|
||||
DiagnosticSeverity,
|
||||
DiagnosticState,
|
||||
DiagnosticsFilter,
|
||||
DiagnosticsPage as Page,
|
||||
} from "@/lib/types";
|
||||
import type { DiagnosticEvent, DiagnosticSeverity, DiagnosticState, DiagnosticsPage as Page } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import SeverityBadge from "./SeverityBadge";
|
||||
import { diagnosticsFilterOf } from "./filter";
|
||||
import { DIAGNOSTIC_COMPONENTS, componentLabel, copyFor } from "./eventCopy";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
@@ -138,6 +133,19 @@ const styles = stylex.create({
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
rangeNotice: {
|
||||
marginTop: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceHover,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "0.75rem",
|
||||
overflowX: "auto",
|
||||
@@ -263,6 +271,33 @@ function MoreButton({ section }: { section: Section }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The window the page is bounded to, whenever it is bounded.
|
||||
*
|
||||
* A link from a query detail arrives with an absolute five-minute window, and
|
||||
* an empty Active section inside it means something very different from an
|
||||
* empty Active section over the whole history. The page has to say which it is
|
||||
* showing, and offer the way out of it.
|
||||
*/
|
||||
function RangeNotice({ since, until }: { since?: number; until?: number }) {
|
||||
const navigate = useNavigate({ from: "/diagnostics" });
|
||||
if (since === undefined && until === undefined) return null;
|
||||
const from = since === undefined ? "the start of the history" : formatTime(since);
|
||||
const to = until === undefined ? "now" : formatTime(until);
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.rangeNotice)}>
|
||||
Showing events that overlap {from} to {to}.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate({ search: (prev) => ({ ...prev, since: undefined, until: undefined }) })}
|
||||
{...stylex.props(shared.linkButton, shared.focusRing)}
|
||||
>
|
||||
Clear the time range
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveCard({ event, now }: { event: DiagnosticEvent; now: number }) {
|
||||
const copy = copyFor(event.code);
|
||||
return (
|
||||
@@ -327,9 +362,7 @@ export default function DiagnosticsPage() {
|
||||
const navigate = useNavigate({ from: "/diagnostics" });
|
||||
const state = search.state ?? "all";
|
||||
|
||||
const base: DiagnosticsFilter = {};
|
||||
if (search.severity !== undefined) base.severity = search.severity;
|
||||
if (search.component !== undefined) base.component = search.component;
|
||||
const base = diagnosticsFilterOf(search);
|
||||
|
||||
const active = useInfiniteQuery(diagnosticsInfiniteQuery({ ...base, state: "active" }, state !== "resolved"));
|
||||
const history = useInfiniteQuery(diagnosticsInfiniteQuery({ ...base, state: "resolved" }, state !== "active"));
|
||||
@@ -368,6 +401,8 @@ export default function DiagnosticsPage() {
|
||||
repeats, and closes when the subject recovers.
|
||||
</p>
|
||||
|
||||
<RangeNotice since={search.since} until={search.until} />
|
||||
|
||||
<div {...stylex.props(styles.filterGrid)}>
|
||||
<Select
|
||||
variant="compactField"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The Diagnostics search parameters and the API filter they build.
|
||||
*
|
||||
* The route, the page and the two infinite queries all have to agree on what
|
||||
* the URL asked for — the page renders the same window the loader prefetched —
|
||||
* so the projection lives in one place rather than being spelled out at each.
|
||||
*/
|
||||
|
||||
import type { DiagnosticSeverity, DiagnosticState, DiagnosticsFilter } from "@/lib/types";
|
||||
|
||||
export interface DiagnosticsSearch {
|
||||
state?: DiagnosticState;
|
||||
severity?: DiagnosticSeverity;
|
||||
component?: string;
|
||||
/** Unix seconds, inclusive. An episode qualifies when its interval overlaps. */
|
||||
since?: number;
|
||||
until?: number;
|
||||
}
|
||||
|
||||
/** Field by field, so an unset filter is an absent key rather than `undefined`. */
|
||||
export function diagnosticsFilterOf(search: DiagnosticsSearch): DiagnosticsFilter {
|
||||
const filter: DiagnosticsFilter = {};
|
||||
if (search.severity !== undefined) filter.severity = search.severity;
|
||||
if (search.component !== undefined) filter.component = search.component;
|
||||
if (search.since !== undefined) filter.since = search.since;
|
||||
if (search.until !== undefined) filter.until = search.until;
|
||||
return filter;
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { Client } from "@/lib/types";
|
||||
import { provenance, queryRow } from "@/features/queries/provenanceFixture";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
import LiveLogPage from "./LiveLogPage";
|
||||
|
||||
function client(ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
id: Number(ip.split(".").pop()),
|
||||
ip,
|
||||
name,
|
||||
learned_name: learnedName,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: name !== "",
|
||||
first_seen: 1_700_000_000,
|
||||
last_seen: 1_700_000_100,
|
||||
};
|
||||
}
|
||||
|
||||
const CLIENTS: Client[] = [
|
||||
client("192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||
client("192.0.2.11", "", "laptop.lan"),
|
||||
client("192.0.2.12", "", ""),
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) !== "/api/clients") {
|
||||
return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
}
|
||||
return new Response(JSON.stringify({ clients: CLIENTS }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function frame(ts: number, domain: string, sections: Parameters<typeof provenance>[0] = {}): { data: string } {
|
||||
const payload = provenance({
|
||||
...sections,
|
||||
request: { time: ts, domain, ...sections.request },
|
||||
route: { kind: "cache", upstream: "", ...sections.route },
|
||||
});
|
||||
return { data: JSON.stringify(payload) };
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const sources: FakeEventSource[] = [];
|
||||
const createEventSource = (url: string) => {
|
||||
const es = new FakeEventSource(url);
|
||||
sources.push(es);
|
||||
return es;
|
||||
};
|
||||
render(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<LiveLogPage createEventSource={createEventSource} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return sources;
|
||||
}
|
||||
|
||||
test("streams rows, flags blocked ones, and freezes the display", () => {
|
||||
const sources = renderPage();
|
||||
expect(screen.getByText("Connecting…")).toBeTruthy();
|
||||
|
||||
act(() => sources[0]!.emit("open"));
|
||||
expect(screen.getByRole("status", { name: "Live" })).toBeTruthy();
|
||||
expect(screen.getByText("Waiting for queries…")).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "ok.example"));
|
||||
sources[0]!.emit(
|
||||
"query",
|
||||
frame(1001, "ads.example", {
|
||||
request: { qtype: 28 },
|
||||
policy: { action: "block", reason: "blocklist_wildcard" },
|
||||
route: { kind: "blocked" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText("ok.example")).toBeTruthy();
|
||||
expect(screen.getByText("Blocked")).toBeTruthy();
|
||||
expect(screen.getByText("Blocklist (wildcard)")).toBeTruthy();
|
||||
expect(screen.getByText("AAAA")).toBeTruthy();
|
||||
// StyleX compiles to opaque class names, so the check is structural: a blocked
|
||||
// row carries every class a plain row does, plus the ones the flag adds.
|
||||
const blockedRow = screen.getByText("ads.example").closest("tr");
|
||||
const plainRow = screen.getByText("ok.example").closest("tr");
|
||||
const blockedClasses = new Set(blockedRow?.className.split(" "));
|
||||
const plainClasses = plainRow?.className.split(" ") ?? [];
|
||||
expect(plainClasses.every((name) => blockedClasses.has(name))).toBe(true);
|
||||
expect(blockedClasses.size).toBeGreaterThan(plainClasses.length);
|
||||
|
||||
const freeze = screen.getByRole("button", { name: "Freeze" });
|
||||
fireEvent.click(freeze);
|
||||
expect(freeze.getAttribute("aria-pressed")).toBe("true");
|
||||
|
||||
act(() => sources[0]!.emit("query", frame(1002, "later.example")));
|
||||
expect(screen.queryByText("later.example")).toBeNull();
|
||||
expect(screen.getByText(/3 in buffer/)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
||||
expect(screen.getByText("later.example")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||
const sources = renderPage();
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
|
||||
sources[0]!.emit("query", frame(1001, "learned.example", { request: { client: "192.0.2.11" } }));
|
||||
sources[0]!.emit("query", frame(1002, "nameless.example", { request: { client: "192.0.2.12" } }));
|
||||
sources[0]!.emit("query", frame(1003, "stranger.example", { request: { client: "192.0.2.99" } }));
|
||||
});
|
||||
|
||||
// A hand-typed name wins outright; the learned name never surfaces for it.
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||
|
||||
// A learned name reads muted and nothing more here: the "learned" tag would
|
||||
// repeat on every row of the table, so the Clients page carries it instead.
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
// A known client with neither name, and a client the loaded list has never
|
||||
// seen, both fall back to the bare address with no tooltip standing in.
|
||||
const nameless = screen.getByText("192.0.2.12");
|
||||
expect(nameless.getAttribute("title")).toBeNull();
|
||||
const stranger = screen.getByText("192.0.2.99");
|
||||
expect(stranger.getAttribute("title")).toBeNull();
|
||||
expect(screen.getByText("stranger.example").closest("tr")?.textContent).toContain("192.0.2.99");
|
||||
});
|
||||
|
||||
test("rows stream in as bare IPs while the client list is still loading", async () => {
|
||||
let releaseClients: () => void = () => {};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
(input: RequestInfo | URL) =>
|
||||
new Promise<Response>((resolve) => {
|
||||
if (String(input) !== "/api/clients") {
|
||||
resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||
return;
|
||||
}
|
||||
releaseClients = () =>
|
||||
resolve(
|
||||
new Response(JSON.stringify({ clients: CLIENTS }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const sources = renderPage();
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } })));
|
||||
|
||||
expect(screen.getByText("192.0.2.10")).toBeTruthy();
|
||||
expect(screen.queryByText("Kitchen Pi")).toBeNull();
|
||||
|
||||
releaseClients();
|
||||
expect(await screen.findByText("Kitchen Pi")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("repeated connection failures show the viewer-cap state with a retry button", () => {
|
||||
const sources = renderPage();
|
||||
act(() => {
|
||||
sources[0]!.emit("error");
|
||||
sources[0]!.emit("error");
|
||||
sources[0]!.emit("error");
|
||||
});
|
||||
expect(screen.getByRole("alert").textContent).toContain("too many live viewers");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(sources).toHaveLength(2);
|
||||
expect(screen.getByText("Connecting…")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a row recovered by the reconnect fetch links to its stored detail; a streamed one cannot", async () => {
|
||||
const sources: FakeEventSource[] = [];
|
||||
vi.stubGlobal(
|
||||
"EventSource",
|
||||
class {
|
||||
constructor(url: string) {
|
||||
const es = new FakeEventSource(url);
|
||||
sources.push(es);
|
||||
return es as unknown as EventSource;
|
||||
}
|
||||
},
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url.startsWith("/api/queries?")) {
|
||||
return json({
|
||||
queries: [queryRow(88, { ts: 1001, domain: "recovered.example" })],
|
||||
next_before: null,
|
||||
coverage: { complete: true, available_since: 0 },
|
||||
});
|
||||
}
|
||||
return json({ version: "0.0.0-test", git_commit: "0", zig_version: "0.16.0", uptime_seconds: 1 });
|
||||
}),
|
||||
);
|
||||
|
||||
// The route, not the bare page: only a recovered row renders a link, so the
|
||||
// test needs the router the link resolves against.
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/live"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
await screen.findByRole("button", { name: "Freeze" });
|
||||
const source = sources[0]!;
|
||||
act(() => source.emit("open"));
|
||||
act(() => source.emit("query", frame(1000, "streamed.example")));
|
||||
act(() => source.emit("error"));
|
||||
act(() => source.emit("open"));
|
||||
|
||||
const recovered = await screen.findByRole("link", { name: "recovered.example" });
|
||||
expect(recovered.getAttribute("href")).toBe("/queries/88");
|
||||
// The streamed frame precedes its own insert, so it has no row to link to.
|
||||
expect(screen.queryByRole("link", { name: "streamed.example" })).toBeNull();
|
||||
expect(screen.getByText("streamed.example")).toBeTruthy();
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { LookupResult } from "@/lib/types";
|
||||
|
||||
const BLOCKED: LookupResult = {
|
||||
domain: "ads.example",
|
||||
group_id: 1,
|
||||
local_records: false,
|
||||
forward_zone: null,
|
||||
blocked: true,
|
||||
reason: "blocklist_domain",
|
||||
matched: "ads.example",
|
||||
source_url: "https://lists.test/a",
|
||||
safe_search_rewrite: null,
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof createFetchMock>;
|
||||
|
||||
function json(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function createFetchMock() {
|
||||
return vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/groups") {
|
||||
return json({
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url === "/api/lookup?domain=ads.example&group_id=1") return json(BLOCKED);
|
||||
return json({ error: "not stubbed" }, 404);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = createFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/lookup"] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function lookupCalls(): string[] {
|
||||
return fetchMock.mock.calls.map(([input]) => String(input)).filter((url) => url.startsWith("/api/lookup"));
|
||||
}
|
||||
|
||||
test("fetches nothing until submit, then renders the blocked verdict", async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByRole("heading", { name: "Lookup" });
|
||||
await screen.findByLabelText("Group");
|
||||
expect(lookupCalls()).toEqual([]);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain"), { target: { value: "ads.example" } });
|
||||
expect(lookupCalls()).toEqual([]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Look up" }));
|
||||
|
||||
await screen.findByRole("heading", { name: "Blocked" });
|
||||
expect(lookupCalls()).toEqual(["/api/lookup?domain=ads.example&group_id=1"]);
|
||||
|
||||
expect(screen.getByText("blocklist_domain")).toBeTruthy();
|
||||
const link = screen.getByRole("link", { name: "https://lists.test/a" }) as HTMLAnchorElement;
|
||||
expect(link.href).toBe("https://lists.test/a");
|
||||
expect(screen.getByText("Queries for this name get a blocked response.")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("defaults the group select to the default group (id 1)", async () => {
|
||||
renderPage();
|
||||
// A RAC Select names its trigger with the current value and then the label, so
|
||||
// the selected group's name is the only thing the trigger shows.
|
||||
const trigger = await screen.findByRole("button", { name: /Group$/ });
|
||||
expect(trigger.textContent).toContain("default");
|
||||
});
|
||||
@@ -1,452 +0,0 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { createAppRouter } from "@/routes";
|
||||
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
||||
import { queryRow } from "./provenanceFixture";
|
||||
|
||||
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
id,
|
||||
ip,
|
||||
name,
|
||||
learned_name: learnedName,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: name !== "",
|
||||
first_seen: 1_700_000_000,
|
||||
last_seen: 1_700_000_100,
|
||||
};
|
||||
}
|
||||
|
||||
const CLIENTS: Client[] = [
|
||||
client(1, "192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||
client(2, "192.0.2.11", "", "laptop.lan"),
|
||||
client(3, "192.0.2.12", "", ""),
|
||||
];
|
||||
|
||||
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
|
||||
return queryRow(id, { ts: 1_700_000_000 + id, domain, upstream: "udp://9.9.9.9:53", ...overrides });
|
||||
}
|
||||
|
||||
const COMPLETE: Coverage = { complete: true, available_since: 1_600_000_000 };
|
||||
|
||||
/** The blocked row every page fixture reuses. */
|
||||
const BLOCKED = {
|
||||
blocked: true,
|
||||
policy_action: "block",
|
||||
policy_reason: "blocklist_wildcard",
|
||||
route_kind: "blocked",
|
||||
upstream: "",
|
||||
} as const satisfies Partial<QueryRow>;
|
||||
|
||||
const PAGES: Record<string, QueriesPage> = {
|
||||
"/api/queries": {
|
||||
queries: [
|
||||
row(20, "first.example", { qtype: 65, cache_hit: true }),
|
||||
row(19, "ads.example", { ...BLOCKED, response_time_us: null, cache_hit: null }),
|
||||
],
|
||||
next_before: 19,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?before=19": {
|
||||
queries: [row(5, "older.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?domain=ads": {
|
||||
queries: [row(19, "ads.example", BLOCKED)],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
const payload = PAGES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/**
|
||||
* The whole router, not the bare component: the rows link into `/queries/$id`
|
||||
* and the filter form seeds itself from the url, so both need real routing.
|
||||
*/
|
||||
function renderPage(path = "/queries") {
|
||||
const client = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), client);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={client}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
test("renders the first page with type names, blocked badge, and formatted cells", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
expect(screen.getByText("HTTPS")).toBeTruthy();
|
||||
expect(screen.getByText("A")).toBeTruthy();
|
||||
expect(screen.getByText("Blocked")).toBeTruthy();
|
||||
expect(screen.getByText("Blocklist (wildcard)")).toBeTruthy();
|
||||
expect(screen.getByText("1.2 ms")).toBeTruthy();
|
||||
expect(screen.getByText("hit")).toBeTruthy();
|
||||
expect(screen.getByText("udp://9.9.9.9:53")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json({
|
||||
queries: [
|
||||
row(20, "named.example", { client_ip: "192.0.2.10" }),
|
||||
row(19, "learned.example", { client_ip: "192.0.2.11" }),
|
||||
row(18, "nameless.example", { client_ip: "192.0.2.12" }),
|
||||
row(17, "stranger.example", { client_ip: "192.0.2.99" }),
|
||||
],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
} satisfies QueriesPage);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
// A hand-typed name wins outright; the learned name never surfaces for it.
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||
|
||||
// A learned name reads muted and nothing more here: the "learned" tag would
|
||||
// repeat on every row of the table, so the Clients page carries it instead.
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
// A known client with neither name, and a client the loaded list has never
|
||||
// seen, both fall back to the bare address with no tooltip standing in.
|
||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
||||
});
|
||||
|
||||
test("load more appends the next page and stops at the end of the log", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("older.example");
|
||||
|
||||
expect(screen.getByText("first.example")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
|
||||
});
|
||||
|
||||
test("applying a filter refetches and resets the accumulated list", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("older.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
expect(screen.getByText("ads.example")).toBeTruthy();
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
});
|
||||
|
||||
test("a load-more that resolves after a filter change is discarded", async () => {
|
||||
let releaseLoadMore: () => void = () => {};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/queries?before=19") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
releaseLoadMore = () => {
|
||||
resolve(
|
||||
new Response(JSON.stringify(PAGES["/api/queries?before=19"]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
}
|
||||
const payload = PAGES[url];
|
||||
if (payload === undefined)
|
||||
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
|
||||
releaseLoadMore();
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("load more is disabled while a filter change shows placeholder data, then uses the fresh cursor", async () => {
|
||||
let releaseFiltered: () => void = () => {};
|
||||
const filteredPage: QueriesPage = {
|
||||
queries: [row(19, "ads.example", BLOCKED)],
|
||||
next_before: 7,
|
||||
coverage: COMPLETE,
|
||||
};
|
||||
const filteredOlderPage: QueriesPage = {
|
||||
queries: [row(3, "ads.older.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
};
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/queries?domain=ads") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
releaseFiltered = () => {
|
||||
resolve(
|
||||
new Response(JSON.stringify(filteredPage), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
}
|
||||
const payload = url === "/api/queries?domain=ads&before=7" ? filteredOlderPage : PAGES[url];
|
||||
if (payload === undefined)
|
||||
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
|
||||
const staleButton = screen.getByRole("button", { name: "Load more" });
|
||||
expect(staleButton).toHaveProperty("disabled", true);
|
||||
fireEvent.click(staleButton);
|
||||
expect(fetchMock.mock.calls.map((call) => String(call[0]))).not.toContain("/api/queries?domain=ads&before=19");
|
||||
|
||||
releaseFiltered();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
});
|
||||
|
||||
const freshButton = screen.getByRole("button", { name: "Load more" });
|
||||
expect(freshButton).toHaveProperty("disabled", false);
|
||||
fireEvent.click(freshButton);
|
||||
await screen.findByText("ads.older.example");
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toContain("/api/queries?domain=ads&before=7");
|
||||
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
|
||||
// The newest-100 window moves up while the reader has a second page open.
|
||||
// Refetching only the first page would drop n20 and n19 out of the middle
|
||||
// of the table; the second page must be replayed from the fresh cursor.
|
||||
const before: Record<string, QueriesPage> = {
|
||||
"/api/queries": {
|
||||
queries: [row(20, "n20.example"), row(19, "n19.example")],
|
||||
next_before: 19,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?before=19": {
|
||||
queries: [row(18, "n18.example"), row(17, "n17.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
};
|
||||
const after: Record<string, QueriesPage> = {
|
||||
"/api/queries": {
|
||||
queries: [row(22, "n22.example"), row(21, "n21.example")],
|
||||
next_before: 21,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?before=21": {
|
||||
queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
};
|
||||
let live = before;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const payload = live[String(input)];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
const client = renderPage();
|
||||
await screen.findByText("n20.example");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("n17.example");
|
||||
|
||||
live = after;
|
||||
await act(async () => {
|
||||
await client.invalidateQueries({ queryKey: ["queries"] });
|
||||
});
|
||||
|
||||
await screen.findByText("n22.example");
|
||||
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
|
||||
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
|
||||
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { pathname: "/queries", search: "", assign });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/queries?before=19") {
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const payload = PAGES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await waitFor(() => {
|
||||
expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/queries")}`);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByText(/Failed to load more/)).toBeNull();
|
||||
});
|
||||
|
||||
test("each row links into its own detail page by domain, reachable from the keyboard", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
const link = screen.getByRole("link", { name: "first.example" });
|
||||
expect(link.getAttribute("href")).toBe("/queries/20");
|
||||
// An <a href> is in the tab order by default; nothing here may opt it out.
|
||||
expect(link.getAttribute("tabindex")).toBeNull();
|
||||
});
|
||||
|
||||
test("an allowed query names the rule that allowed it; an unremarkable one stays blank", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json({
|
||||
queries: [row(20, "allowed.example", { policy_reason: "rule_allow_exact" }), row(19, "plain.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
} satisfies QueriesPage);
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("allowed.example");
|
||||
expect(screen.getByText("Allow rule (exact)")).toBeTruthy();
|
||||
expect(screen.queryByText("Blocked")).toBeNull();
|
||||
expect(within(screen.getByText("plain.example").closest("tr")!).getByText("—")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a pruned window tells the reader when history starts", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json({
|
||||
queries: [row(20, "kept.example")],
|
||||
next_before: null,
|
||||
coverage: { complete: false, available_since: 1_700_000_000 },
|
||||
} satisfies QueriesPage);
|
||||
}),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("kept.example");
|
||||
expect(screen.getByText(/Query history is available from/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a complete window shows no coverage notice", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
expect(screen.queryByText(/Query history is available from/)).toBeNull();
|
||||
});
|
||||
|
||||
test("a ?domain= link seeds the filter and fetches that domain on arrival", async () => {
|
||||
renderPage("/queries?domain=ads");
|
||||
|
||||
await screen.findByText("ads.example");
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
});
|
||||
@@ -1,422 +0,0 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link, useSearch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import * as api from "@/lib/api";
|
||||
import CoverageNotice from "@/lib/CoverageNotice";
|
||||
import { formatMicros, formatTime } from "@/lib/format";
|
||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
||||
import { ClientName, useClientNames, type ClientNames } from "@/features/clients/clientNames";
|
||||
import { isUninformativeReason, policyReasonLabel } from "./provenanceCopy";
|
||||
import { summarizeRow, type QuerySummary } from "./querySummary";
|
||||
import { qtypeName } from "./qtype";
|
||||
import Select from "@/ui/Select";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "any", label: "All" },
|
||||
{ value: "blocked", label: "Blocked only" },
|
||||
{ value: "allowed", label: "Allowed only" },
|
||||
];
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
/** One column on a phone, two from `sm`, five from `lg`, as before. */
|
||||
filterGrid: {
|
||||
marginTop: "1rem",
|
||||
display: "grid",
|
||||
gap: "0.75rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(1, minmax(0, 1fr))",
|
||||
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
||||
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
|
||||
},
|
||||
},
|
||||
filterLabel: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
filterInput: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
gap: "0.5rem",
|
||||
gridColumn: {
|
||||
default: null,
|
||||
"@media (min-width: 640px)": "span 2 / span 2",
|
||||
"@media (min-width: 1024px)": "span 5 / span 5",
|
||||
},
|
||||
},
|
||||
toolbarButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1.5rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
tableWrap: {
|
||||
marginTop: "1rem",
|
||||
overflowX: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** The header tint is a shade off the ground in each scheme, not a token role. */
|
||||
head: {
|
||||
backgroundColor: { default: "oklch(98.5% 0 none)", [DARK]: "oklch(21% 0.006 285.885)" },
|
||||
textAlign: "left",
|
||||
},
|
||||
th: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||
row: {
|
||||
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
cell: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
nowrap: {
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
breakAll: {
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
/** The row's way into the detail page; a real link, so tab and enter reach it. */
|
||||
domainLink: {
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
small: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
},
|
||||
muted: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
blockedWrap: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
blockedBadge: {
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 500,
|
||||
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(39.6% 0.141 25.723)" },
|
||||
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(88.5% 0.062 18.334)" },
|
||||
},
|
||||
footer: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
moreError: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.dangerText,
|
||||
},
|
||||
});
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function datetimeLocalToUnix(value: string): number | undefined {
|
||||
if (value === "") return undefined;
|
||||
const ms = new Date(value).getTime();
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the policy decided, and why. The reason is the stored enum rather than
|
||||
* the old free-text block reason, so an allowed query that a rule or a blocklist
|
||||
* exception explains says so too — only `no_match`, the answer for most allowed
|
||||
* queries, stays blank.
|
||||
*/
|
||||
export function StatusCell({ row }: { row: Pick<QuerySummary, "blocked" | "policy_reason"> }) {
|
||||
const reason = policyReasonLabel(row.policy_reason);
|
||||
if (!row.blocked) {
|
||||
if (isUninformativeReason(row.policy_reason)) return <span {...stylex.props(styles.muted)}>—</span>;
|
||||
return <span {...stylex.props(styles.small, styles.muted)}>{reason}</span>;
|
||||
}
|
||||
return (
|
||||
<span {...stylex.props(styles.blockedWrap)}>
|
||||
<span {...stylex.props(styles.blockedBadge)}>Blocked</span>
|
||||
<span {...stylex.props(styles.small, styles.muted)}>{reason}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The eight shared cells. `row.id` is null for a live frame the server has not
|
||||
* written yet, which is the one case with no detail page to link to.
|
||||
*/
|
||||
export function QueryCells({ row, clientNames }: { row: QuerySummary; clientNames: ClientNames }) {
|
||||
const id = row.id;
|
||||
return (
|
||||
<>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||
{id === null ? (
|
||||
row.domain
|
||||
) : (
|
||||
<Link
|
||||
to="/queries/$id"
|
||||
params={{ id: String(id) }}
|
||||
{...stylex.props(styles.domainLink, shared.focusRing)}
|
||||
>
|
||||
{row.domain}
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
|
||||
<ClientName ip={row.client_ip} names={clientNames} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
<StatusCell row={row} />
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap, shared.tabularNums)}>
|
||||
{row.response_time_us === null ? "—" : formatMicros(row.response_time_us)}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.nowrap)}>
|
||||
{row.cache_hit === null ? "—" : row.cache_hit ? "hit" : "miss"}
|
||||
</td>
|
||||
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>
|
||||
{row.upstream === "" ? "—" : row.upstream}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function QueryTableHead() {
|
||||
return (
|
||||
<thead {...stylex.props(styles.head)}>
|
||||
<tr>
|
||||
<th {...stylex.props(styles.th)}>Time</th>
|
||||
<th {...stylex.props(styles.th)}>Domain</th>
|
||||
<th {...stylex.props(styles.th)}>Client</th>
|
||||
<th {...stylex.props(styles.th)}>Type</th>
|
||||
<th {...stylex.props(styles.th)}>Status</th>
|
||||
<th {...stylex.props(styles.th)}>Response</th>
|
||||
<th {...stylex.props(styles.th)}>Cache</th>
|
||||
<th {...stylex.props(styles.th)}>Upstream</th>
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QueryLogPage() {
|
||||
// The two url filters exist so a detail page can link back to "every query
|
||||
// for this domain". They seed the form once; typing from here on is local
|
||||
// state, as the other three filters always were.
|
||||
const search = useSearch({ from: "/shell/queries" });
|
||||
const [domain, setDomain] = useState(search.domain ?? "");
|
||||
const [client, setClient] = useState(search.client ?? "");
|
||||
const [blocked, setBlocked] = useState("any");
|
||||
const [since, setSince] = useState("");
|
||||
const [until, setUntil] = useState("");
|
||||
|
||||
const [applied, setApplied] = useState<QueriesFilter>(() => {
|
||||
const initial: QueriesFilter = {};
|
||||
if (search.domain !== undefined) initial.domain = search.domain;
|
||||
if (search.client !== undefined) initial.client = search.client;
|
||||
return initial;
|
||||
});
|
||||
|
||||
const base = useInfiniteQuery(queriesInfiniteQuery(applied));
|
||||
const clientNames = useClientNames();
|
||||
|
||||
const pages = base.data?.pages ?? [];
|
||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||
const coverage = pages[0]?.coverage;
|
||||
const filterActive = Object.keys(applied).length > 0;
|
||||
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
||||
// data stands in for a filter change; derive the cursor from what is on
|
||||
// screen so the button keeps its place instead of flashing "end of log".
|
||||
const lastPage = pages[pages.length - 1];
|
||||
const hasMore = lastPage !== undefined && lastPage.next_before !== null;
|
||||
// A 401 is already redirecting via the cache-level handleUnauthorized.
|
||||
const isUnauthorized = base.error instanceof api.ApiError && base.error.status === 401;
|
||||
const moreError = base.isFetchNextPageError && !isUnauthorized ? errorMessage(base.error) : null;
|
||||
|
||||
function applyFilters(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const filter: QueriesFilter = {};
|
||||
if (domain.trim() !== "") filter.domain = domain.trim();
|
||||
if (client.trim() !== "") filter.client = client.trim();
|
||||
if (blocked === "blocked") filter.blocked = true;
|
||||
if (blocked === "allowed") filter.blocked = false;
|
||||
const sinceTs = datetimeLocalToUnix(since);
|
||||
if (sinceTs !== undefined) filter.since = sinceTs;
|
||||
const untilTs = datetimeLocalToUnix(until);
|
||||
if (untilTs !== undefined) filter.until = untilTs;
|
||||
setApplied(filter);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setDomain("");
|
||||
setClient("");
|
||||
setBlocked("any");
|
||||
setSince("");
|
||||
setUntil("");
|
||||
setApplied({});
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore || base.isFetchingNextPage || base.isPlaceholderData) return;
|
||||
void base.fetchNextPage();
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Query Log</h1>
|
||||
|
||||
<form onSubmit={applyFilters} {...stylex.props(styles.filterGrid)}>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Domain contains
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Client (exact)
|
||||
<input
|
||||
type="text"
|
||||
value={client}
|
||||
onChange={(event) => setClient(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
variant="compactField"
|
||||
label="Status"
|
||||
value={blocked}
|
||||
onChange={setBlocked}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Since
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={since}
|
||||
onChange={(event) => setSince(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.filterLabel)}>
|
||||
Until
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={until}
|
||||
onChange={(event) => setUntil(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.filterInput, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button type="submit" {...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}>
|
||||
Apply filters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{base.isFetching && (
|
||||
<span {...stylex.props(styles.note)} role="status">
|
||||
Loading…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
|
||||
|
||||
{base.data === undefined ? (
|
||||
<p {...stylex.props(styles.empty, shared.pulse)} role="status">
|
||||
Loading query log…
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<QueryTableHead />
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} {...stylex.props(styles.row)}>
|
||||
<QueryCells row={summarizeRow(row)} clientNames={clientNames} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div {...stylex.props(styles.footer)}>
|
||||
<p {...stylex.props(styles.note)}>
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={base.isFetchingNextPage || base.isPlaceholderData}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
>
|
||||
{base.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{moreError !== null && (
|
||||
<p role="alert" {...stylex.props(styles.moreError)}>
|
||||
Failed to load more: {moreError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -52,15 +52,6 @@ export function routeKindLabel(kind: RouteKind): string {
|
||||
return ROUTE_KIND_LABELS[kind];
|
||||
}
|
||||
|
||||
/**
|
||||
* `no_match` is the answer for the overwhelming majority of allowed queries and
|
||||
* says nothing an operator scanning a table wants to read, so the status column
|
||||
* leaves it blank. Every other reason names a decision worth seeing.
|
||||
*/
|
||||
export function isUninformativeReason(reason: PolicyReason): boolean {
|
||||
return reason === "no_match";
|
||||
}
|
||||
|
||||
/** The enum value sets, for tests that prove the maps exhaustive at runtime too. */
|
||||
export const ENUM_VALUES = {
|
||||
policyAction: POLICY_ACTIONS,
|
||||
@@ -83,6 +74,15 @@ const RCODE_NAMES: Record<number, string> = {
|
||||
16: "BADVERS",
|
||||
};
|
||||
|
||||
/**
|
||||
* The bare mnemonic, for a table cell with no room for the number. An
|
||||
* unassigned code has no mnemonic to shorten, so it keeps the same `RCODE <n>`
|
||||
* shape the long form falls back to.
|
||||
*/
|
||||
export function rcodeShortName(rcode: number): string {
|
||||
return RCODE_NAMES[rcode] ?? `RCODE ${rcode}`;
|
||||
}
|
||||
|
||||
/** The twelve-bit extended code as `NXDOMAIN (3)`; an unassigned code keeps its number. */
|
||||
export function rcodeName(rcode: number): string {
|
||||
const name = RCODE_NAMES[rcode];
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Provenance, QueryRow } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Fixture builders for the provenance shapes, shared by the query-log, detail
|
||||
* and live-stream tests the way `features/live/fakeEventSource.ts` is shared.
|
||||
* and live-stream tests the way `features/activity/fakeEventSource.ts` is shared.
|
||||
*
|
||||
* The defaults describe the dullest possible query — an allowed name nothing
|
||||
* matched, answered upstream — so each test states only the fields it is about.
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface QuerySummary {
|
||||
qtype: number | null;
|
||||
blocked: boolean;
|
||||
policy_reason: PolicyReason;
|
||||
/** The twelve-bit extended code the client saw, including a synthesized SERVFAIL. */
|
||||
rcode: number;
|
||||
route_kind: RouteKind;
|
||||
response_time_us: number | null;
|
||||
cache_hit: boolean | null;
|
||||
upstream: string;
|
||||
@@ -53,6 +56,8 @@ export function summarizeRow(row: QueryRow): QuerySummary {
|
||||
qtype: row.qtype,
|
||||
blocked: row.blocked,
|
||||
policy_reason: row.policy_reason,
|
||||
rcode: row.rcode,
|
||||
route_kind: row.route_kind,
|
||||
response_time_us: row.response_time_us,
|
||||
cache_hit: row.cache_hit,
|
||||
upstream: row.upstream,
|
||||
@@ -74,6 +79,8 @@ export function summarizeEvent(event: LiveQueryEvent): QuerySummary {
|
||||
qtype: event.request.qtype,
|
||||
blocked: event.policy.action === "block",
|
||||
policy_reason: event.policy.reason,
|
||||
rcode: event.response.rcode,
|
||||
route_kind: event.route.kind,
|
||||
response_time_us: event.response.duration_us,
|
||||
cache_hit: cacheHitFor(event.route.kind),
|
||||
upstream: event.route.upstream,
|
||||
|
||||
+78
-54
@@ -12,7 +12,14 @@ import {
|
||||
import AppShell from "@/shell/AppShell";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import type { DiagnosticSeverity, DiagnosticState, DiagnosticsFilter, QueriesFilter } from "@/lib/types";
|
||||
import { diagnosticsFilterOf, type DiagnosticsSearch } from "@/features/diagnostics/filter";
|
||||
import {
|
||||
queriesFilterOf,
|
||||
validateActivitySearch,
|
||||
validateText,
|
||||
validateTimestamp,
|
||||
type ActivitySearch,
|
||||
} from "@/features/activity/search";
|
||||
import {
|
||||
blocklistsQuery,
|
||||
clientPrefixesQuery,
|
||||
@@ -140,46 +147,71 @@ const dashboardRoute = createRoute({
|
||||
});
|
||||
|
||||
/**
|
||||
* `domain` and `client` seed the filter form, so a detail page can link to
|
||||
* "every query for this domain". Anything else in the search object is dropped:
|
||||
* an unknown value would reach the api as a parameter the handler 400s.
|
||||
* Activity. The URL is the applied state: mode, the five filters, and nothing
|
||||
* else. Everything is validated by `activity/search.ts`, so a hand-typed or
|
||||
* stale parameter becomes `undefined` here rather than reaching the API as a
|
||||
* value it answers 400 to.
|
||||
*/
|
||||
const queriesRoute = createRoute({
|
||||
const activityRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/queries",
|
||||
validateSearch: (search: Record<string, unknown>): { domain?: string; client?: string } => {
|
||||
const domain = search["domain"];
|
||||
const client = search["client"];
|
||||
return {
|
||||
domain: typeof domain === "string" && domain !== "" ? domain : undefined,
|
||||
client: typeof client === "string" && client !== "" ? client : undefined,
|
||||
};
|
||||
},
|
||||
loaderDeps: ({ search }) => search,
|
||||
path: "/activity",
|
||||
validateSearch: validateActivitySearch,
|
||||
// An explicit projection, not `search` itself: the router hands the loader
|
||||
// whatever else the URL carried, and an unknown key would make two
|
||||
// otherwise-identical loads look like different deps.
|
||||
loaderDeps: ({ search }): ActivitySearch => ({
|
||||
mode: search.mode,
|
||||
since: search.since,
|
||||
until: search.until,
|
||||
domain: search.domain,
|
||||
client: search.client,
|
||||
blocked: search.blocked,
|
||||
}),
|
||||
/**
|
||||
* Starts the first page in parallel with the component chunk, and does not
|
||||
* wait for it. Awaiting would make every Apply a blocking navigation, which
|
||||
* throws away the `keepPreviousData` placeholder the list is built on: the
|
||||
* reader would lose the rows they were reading to a pending page instead of
|
||||
* watching them be replaced. The page owns the loading and error surfaces,
|
||||
* so the rejection is caught here only to keep it from going unhandled.
|
||||
*
|
||||
* Live mode reads the SSE stream and nothing else. Prefetching the log for
|
||||
* it would spend a request per navigation on rows the page never renders,
|
||||
* with the retained filters attached to make it look deliberate.
|
||||
*/
|
||||
loader: ({ context, deps }) => {
|
||||
const filter: QueriesFilter = {};
|
||||
if (deps.domain !== undefined) filter.domain = deps.domain;
|
||||
if (deps.client !== undefined) filter.client = deps.client;
|
||||
return context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery(filter));
|
||||
if (deps.mode !== "history") return;
|
||||
void context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery(queriesFilterOf(deps))).catch(() => {});
|
||||
},
|
||||
component: lazyRouteComponent(() => import("@/features/queries/QueryLogPage")),
|
||||
component: lazyRouteComponent(() => import("@/features/activity/ActivityPage")),
|
||||
});
|
||||
|
||||
const queryDetailRoute = createRoute({
|
||||
/**
|
||||
* One logged query. Its search is the Activity search the reader arrived from,
|
||||
* validated by the same functions, so the back link and every related action
|
||||
* restore the exact investigation instead of a default view of it.
|
||||
*/
|
||||
const activityDetailRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/queries/$id",
|
||||
path: "/activity/queries/$id",
|
||||
validateSearch: validateActivitySearch,
|
||||
// Swallowed on purpose, as the diagnostics detail route does: a row
|
||||
// retention has pruned is a 404 the page explains, with the way back to the
|
||||
// log. The whole-page error component would call it a request failure.
|
||||
loader: ({ context, params }) =>
|
||||
context.queryClient.ensureQueryData(queryDetailQuery(Number(params.id))).catch(() => undefined),
|
||||
component: lazyRouteComponent(() => import("@/features/queries/QueryDetailPage")),
|
||||
component: lazyRouteComponent(() => import("@/features/activity/ActivityDetailPage")),
|
||||
});
|
||||
|
||||
const liveRoute = createRoute({
|
||||
/** `domain` prefills and runs the simulation, so a query detail can link into it. */
|
||||
const activityTestRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/live",
|
||||
component: lazyRouteComponent(() => import("@/features/live/LiveLogPage")),
|
||||
path: "/activity/test",
|
||||
validateSearch: (search: Record<string, unknown>): { domain?: string } => ({
|
||||
domain: validateText(search["domain"]),
|
||||
}),
|
||||
loader: ({ context }) => context.queryClient.ensureQueryData(groupsQuery()),
|
||||
component: lazyRouteComponent(() => import("@/features/activity/PolicyTestPage")),
|
||||
});
|
||||
|
||||
const clientsRoute = createRoute({
|
||||
@@ -241,45 +273,38 @@ const upstreamsRoute = createRoute({
|
||||
component: lazyRouteComponent(() => import("@/features/upstreams/UpstreamsPage")),
|
||||
});
|
||||
|
||||
/** `domain` prefills and runs the lookup, so a query detail page can link into it. */
|
||||
const lookupRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/lookup",
|
||||
validateSearch: (search: Record<string, unknown>): { domain?: string } => {
|
||||
const domain = search["domain"];
|
||||
return { domain: typeof domain === "string" && domain !== "" ? domain : undefined };
|
||||
},
|
||||
loader: ({ context }) => context.queryClient.ensureQueryData(groupsQuery()),
|
||||
component: lazyRouteComponent(() => import("@/features/lookup/LookupPage")),
|
||||
});
|
||||
|
||||
/**
|
||||
* The three filters live in the url so an episode can be linked to as it was
|
||||
* read. Anything else in the search object is dropped: an unknown value would
|
||||
* reach the api as a query parameter the handler rejects with a 400.
|
||||
* The filters and the window live in the url so an episode can be linked to as
|
||||
* it was read — a query detail links here with an absolute five-minute window
|
||||
* around one query, which only means anything if the page applies it. Anything
|
||||
* else in the search object is dropped: an unknown value would reach the api as
|
||||
* a query parameter the handler rejects with a 400.
|
||||
*/
|
||||
const diagnosticsRoute = createRoute({
|
||||
getParentRoute: () => shellRoute,
|
||||
path: "/diagnostics",
|
||||
validateSearch: (
|
||||
search: Record<string, unknown>,
|
||||
): { state?: DiagnosticState; severity?: DiagnosticSeverity; component?: string } => {
|
||||
validateSearch: (search: Record<string, unknown>): DiagnosticsSearch => {
|
||||
const state = search["state"];
|
||||
const severity = search["severity"];
|
||||
const component = search["component"];
|
||||
return {
|
||||
state: state === "active" || state === "resolved" ? state : undefined,
|
||||
severity: severity === "warning" || severity === "error" ? severity : undefined,
|
||||
component: typeof component === "string" && component !== "" ? component : undefined,
|
||||
component: validateText(search["component"]),
|
||||
since: validateTimestamp(search["since"]),
|
||||
until: validateTimestamp(search["until"]),
|
||||
};
|
||||
},
|
||||
loaderDeps: ({ search }) => search,
|
||||
loaderDeps: ({ search }): DiagnosticsSearch => ({
|
||||
state: search.state,
|
||||
severity: search.severity,
|
||||
component: search.component,
|
||||
since: search.since,
|
||||
until: search.until,
|
||||
}),
|
||||
// allSettled: the two sections render their own state, and the resolved
|
||||
// history failing must not replace the active list with the error page.
|
||||
loader: ({ context, deps }) => {
|
||||
const base: DiagnosticsFilter = {};
|
||||
if (deps.severity !== undefined) base.severity = deps.severity;
|
||||
if (deps.component !== undefined) base.component = deps.component;
|
||||
const base = diagnosticsFilterOf(deps);
|
||||
return Promise.allSettled([
|
||||
context.queryClient.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state: "active" })),
|
||||
context.queryClient.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state: "resolved" })),
|
||||
@@ -310,16 +335,15 @@ const routeTree = rootRoute.addChildren([
|
||||
loginRoute,
|
||||
shellRoute.addChildren([
|
||||
dashboardRoute,
|
||||
queriesRoute,
|
||||
queryDetailRoute,
|
||||
liveRoute,
|
||||
activityRoute,
|
||||
activityDetailRoute,
|
||||
activityTestRoute,
|
||||
clientsRoute,
|
||||
groupsRoute,
|
||||
blocklistsRoute,
|
||||
rulesRoute,
|
||||
localDnsRoute,
|
||||
upstreamsRoute,
|
||||
lookupRoute,
|
||||
diagnosticsRoute,
|
||||
diagnosticDetailRoute,
|
||||
settingsRoute,
|
||||
|
||||
@@ -7,15 +7,13 @@ import { createAppRouter } from "@/routes";
|
||||
|
||||
const NAV_LABELS = [
|
||||
"Dashboard",
|
||||
"Query Log",
|
||||
"Live",
|
||||
"Activity",
|
||||
"Clients",
|
||||
"Groups",
|
||||
"Blocklists",
|
||||
"Rules",
|
||||
"Local DNS",
|
||||
"Upstreams",
|
||||
"Lookup",
|
||||
"Diagnostics",
|
||||
"Settings",
|
||||
];
|
||||
|
||||
@@ -17,15 +17,13 @@ const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/", label: "Dashboard" },
|
||||
{ to: "/queries", label: "Query Log" },
|
||||
{ to: "/live", label: "Live" },
|
||||
{ to: "/activity", label: "Activity" },
|
||||
{ to: "/clients", label: "Clients" },
|
||||
{ to: "/groups", label: "Groups" },
|
||||
{ to: "/blocklists", label: "Blocklists" },
|
||||
{ to: "/rules", label: "Rules" },
|
||||
{ to: "/local-dns", label: "Local DNS" },
|
||||
{ to: "/upstreams", label: "Upstreams" },
|
||||
{ to: "/lookup", label: "Lookup" },
|
||||
{ to: "/diagnostics", label: "Diagnostics" },
|
||||
{ to: "/settings", label: "Settings" },
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import Select from "./Select";
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "any", label: "All" },
|
||||
{ value: "blocked", label: "Blocked only" },
|
||||
];
|
||||
|
||||
/** RAC opens a Select from the keyboard as readily as from a pointer. */
|
||||
function open(trigger: HTMLElement) {
|
||||
fireEvent.keyDown(trigger, { key: "Enter" });
|
||||
fireEvent.keyUp(trigger, { key: "Enter" });
|
||||
}
|
||||
|
||||
test("a disabled select keeps its value on screen but takes no input", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Select label="Status" options={OPTIONS} value="blocked" onChange={onChange} isDisabled />);
|
||||
|
||||
const trigger = screen.getByRole("button");
|
||||
expect(trigger.textContent).toContain("Blocked only");
|
||||
// A disabled button is out of the tab order by definition, so the filter row
|
||||
// cannot be reached by keyboard while live mode owns it.
|
||||
expect(trigger).toHaveProperty("disabled", true);
|
||||
|
||||
open(trigger);
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("an enabled select still opens", () => {
|
||||
render(<Select label="Status" options={OPTIONS} value="any" onChange={vi.fn()} />);
|
||||
open(screen.getByRole("button"));
|
||||
expect(screen.getByRole("listbox")).toBeTruthy();
|
||||
});
|
||||
+16
-2
@@ -32,6 +32,8 @@ interface Props {
|
||||
* dialog uses, `inline` a control sitting in a row of other controls.
|
||||
*/
|
||||
variant?: "field" | "compactField" | "inline";
|
||||
/** Visible but inert, keeping its value on screen; RAC also drops it from the tab order. */
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
@@ -50,7 +52,10 @@ const styles = stylex.create({
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
// RAC renders a real `<button disabled>`, so the state is reachable as a
|
||||
// pseudo-class rather than needing a second style object.
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
opacity: { default: null, ":disabled": 0.55 },
|
||||
},
|
||||
compact: {
|
||||
marginTop: "0.25rem",
|
||||
@@ -105,12 +110,21 @@ const styles = stylex.create({
|
||||
},
|
||||
});
|
||||
|
||||
export default function Select({ options, value, onChange, label, "aria-label": ariaLabel, variant = "field" }: Props) {
|
||||
export default function Select({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
"aria-label": ariaLabel,
|
||||
variant = "field",
|
||||
isDisabled = false,
|
||||
}: Props) {
|
||||
const base = variant === "field" ? shared.input : shared.smallInput;
|
||||
const block = variant === "compactField" ? styles.compact : null;
|
||||
return (
|
||||
<AriaSelect
|
||||
aria-label={ariaLabel}
|
||||
isDisabled={isDisabled}
|
||||
value={value}
|
||||
onChange={(key) => onChange(String(key ?? ""))}
|
||||
{...stylex.props(styles.root)}
|
||||
|
||||
Reference in New Issue
Block a user