admin: activity history filters become a live-apply toolbar
Gates / frontend (push) Failing after 1m4s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / test (push) Successful in 2m33s
Gates / test-aarch64 (push) Successful in 7m33s
CI / gates (push) Failing after 10m6s

the five-field form with its apply button is gone. one row holds a search-shaped domain field and a client ip field that debounce into the url with enter flushing at once, result segments and a time menu that commit instantly, and a clear that appears only when a filter is active without reflowing the row. presets freeze both absolute bounds at click time so a bookmark describes the same investigation later, custom ranges apply atomically through set range with inline validation, and echo queues keep in-flight commits from clobbering newer typing or newer picks. live mode renders no toolbar, the availability banner became a footer note, previous rows stay visible during refetch, and every control carries a 44px hit target.
This commit is contained in:
2026-08-29 14:56:40 +02:00
parent c65d92d8f8
commit 039eeeda96
7 changed files with 1107 additions and 233 deletions
+167 -17
View File
@@ -136,6 +136,22 @@ function queryCalls(): string[] {
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
}
/** The toolbar's search field, which is how a domain filter is entered now. */
function domainInput(): HTMLInputElement {
return screen.getByLabelText("Filter domains") as HTMLInputElement;
}
/** Enter in a text field: the debounce's escape hatch, and the fast path here. */
function submitFilters() {
fireEvent.submit(domainInput().closest("form")!);
}
/** The custom range lives behind the Time menu; the two bounds only exist there. */
function openCustomRange() {
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
}
test("renders the first page with the seven columns filled in", async () => {
renderPage();
await screen.findByText("first.example");
@@ -220,8 +236,8 @@ test("applying a filter puts it in the url, refetches, and resets the accumulate
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" }));
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
await screen.findByText(/Showing 1 query /);
expect(history.location.search).toContain("domain=ads");
@@ -246,8 +262,8 @@ test("a load-more that resolves after a filter change is discarded", async () =>
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" }));
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
await screen.findByText(/Showing 1 query /);
releaseLoadMore();
@@ -285,8 +301,8 @@ test("load more is disabled while a filter change shows placeholder data, then u
renderPage();
await screen.findByText("first.example");
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
const staleButton = await screen.findByRole("button", { name: "Load more" });
expect(staleButton).toHaveProperty("disabled", true);
@@ -420,7 +436,7 @@ test("a ?domain= link seeds the filter form and fetches that domain on arrival",
renderPage("/activity?domain=ads");
await screen.findByText("ads.example");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
expect(domainInput()).toHaveProperty("value", "ads");
expect(screen.queryByText("first.example")).toBeNull();
});
@@ -445,7 +461,7 @@ test("a rejected search parameter is dropped rather than guessed at", async () =
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", "");
expect(domainInput()).toHaveProperty("value", "");
});
test("the form draft follows the url back and forward, seconds included", async () => {
@@ -458,25 +474,33 @@ test("the form draft follows the url back and forward, seconds included", async
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");
await screen.findByLabelText("Filter domains");
expect(domainInput()).toHaveProperty("value", "first");
// A seeded custom range opens its row, so the link's bounds are visible.
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" }));
fireEvent.change(domainInput(), { target: { value: "second" } });
submitFilters();
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}`);
// A pasted link, then the buttons over it: the draft is derived from the URL,
// so whichever way the browser moves it the field has to move with it.
act(() => history.push(`/activity?mode=history&domain=third&since=${seeded}`));
await waitFor(() => {
expect(domainInput()).toHaveProperty("value", "third");
});
act(() => history.back());
await waitFor(() => {
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
expect(domainInput()).toHaveProperty("value", "second");
});
act(() => history.forward());
await waitFor(() => {
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
expect(domainInput()).toHaveProperty("value", "third");
});
});
@@ -512,8 +536,9 @@ test("a wall-clock time the daylight-saving jump skips is refused, not silently
const callsBefore = queryCalls().length;
const searchBefore = history.location.search;
openCustomRange();
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
if (inGap) {
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
@@ -579,7 +604,7 @@ test("the mode switch is a tab list whose selection is the url, and it keeps the
selected: true,
}),
).toBeTruthy();
expect(await screen.findByText(/these filters apply to history only/)).toBeTruthy();
expect(await screen.findByText(/the History filters apply to history only/)).toBeTruthy();
});
test("Clear empties the url as well as the form", async () => {
@@ -591,5 +616,130 @@ test("Clear empties the url as well as the form", async () => {
expect(history.location.search).not.toContain("domain");
});
expect(history.location.search).not.toContain("blocked");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
expect(domainInput()).toHaveProperty("value", "");
});
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { history } = renderPage();
await screen.findByText("first.example");
fireEvent.change(domainInput(), { target: { value: "a" } });
fireEvent.change(domainInput(), { target: { value: "ad" } });
fireEvent.change(domainInput(), { target: { value: "ads" } });
// Mid-word the URL has not moved: three keystrokes are one investigation,
// not three, and each one would otherwise be a request and a history entry.
expect(history.location.search).not.toContain("domain");
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(history.location.search).toContain("domain=ads");
// Replaced, not pushed: Back leaves the page, it does not retype the word.
expect(history.length).toBe(1);
fireEvent.change(domainInput(), { target: { value: "first" } });
submitFilters();
await waitFor(() => expect(history.location.search).toContain("domain=first"));
} finally {
vi.useRealTimers();
}
});
test("the field being typed in keeps the focus when the debounce commits", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { history } = renderPage();
await screen.findByText("first.example");
const input = domainInput();
input.focus();
fireEvent.change(input, { target: { value: "ads" } });
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
await waitFor(() => expect(history.location.search).toContain("domain=ads"));
// The same node, still focused, still holding the caret: a toolbar that
// remounted on the URL it just wrote would drop the next keystroke.
expect(domainInput()).toBe(input);
expect(document.activeElement).toBe(input);
} finally {
vi.useRealTimers();
}
});
test("a result segment commits on the click, with no wait and no button", async () => {
const { history } = renderPage("/activity?domain=ads");
await screen.findByText("ads.example");
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
await waitFor(() => expect(history.location.search).toContain("blocked=true"));
expect(history.location.search).toContain("domain=ads");
});
test("a time preset writes the second it resolved to, not a rolling window", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
const { history } = renderPage();
await screen.findByText("No queries logged yet.");
fireEvent.click(screen.getByRole("button", { name: "Time: Any time" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
// Both bounds are concrete, so the link names a closed hour rather than one
// that keeps growing at the top as the log does.
const since = now / 1000 - 3600;
const until = now / 1000;
await waitFor(() => expect(history.location.search).toContain(`since=${since}`));
expect(history.location.search).toContain(`until=${until}`);
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
expect(queryCalls()).toContain(`/api/queries?since=${since}&until=${until}`);
vi.restoreAllMocks();
});
test("Live shows no toolbar at all", async () => {
vi.stubGlobal(
"EventSource",
class {
constructor(url: string) {
return new FakeEventSource(url) as unknown as EventSource;
}
},
);
renderPage("/activity?mode=live&domain=ads");
await screen.findByText(/the History filters apply to history only/);
// The stream is unfiltered, so a control here would promise filtering that is
// not happening; the filters are still in the URL, waiting for History.
expect(screen.queryByLabelText("Filter domains")).toBeNull();
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
});
test("the coverage watermark reads under the results, never over them", 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");
const watermark = screen.getByText(/Query history is available from/);
const count = screen.getByText(/Showing 1 query/);
// After the count in document order, which is what "footer" means here.
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});