diff --git a/admin/src/features/activity/ActivityFilters.test.tsx b/admin/src/features/activity/ActivityFilters.test.tsx index 73bfdd8..07fe2d1 100644 --- a/admin/src/features/activity/ActivityFilters.test.tsx +++ b/admin/src/features/activity/ActivityFilters.test.tsx @@ -1,5 +1,6 @@ /** - * The filter row's error association. + * The filter toolbar on its own: the controls that decide what reaches the URL, + * driven through a harness that plays the part the page plays. * * The timezone is fixed because the only invalid bound reachable in jsdom is a * wall-clock time inside a spring-forward gap: jsdom applies the @@ -8,9 +9,11 @@ * the empty string, which is a bound the operator cleared. */ +import { useCallback, useState } from "react"; import { afterAll, expect, test, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; -import ActivityFilters, { NO_FILTERS } from "./ActivityFilters"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters"; +import { unixToDatetimeLocal } from "./datetime"; vi.stubEnv("TZ", "Europe/Paris"); afterAll(() => vi.unstubAllEnvs()); @@ -32,49 +35,359 @@ function describedText(input: HTMLElement): string { .join(" "); } -function renderFilters() { - const applied: { current: unknown } = { current: null }; - render( - (applied.current = filters)} - onClear={() => (applied.current = null)} - />, - ); - return applied; +/** + * The page's half of the contract: the applied state is held outside the form. + * + * `deferred` holds the patches back instead of applying them, so a test can + * land them in an order the network and the router can genuinely produce — + * an early debounce arriving after a later one, over a draft that has moved on. + */ +function renderFilters(initial: AppliedFilters = NO_FILTERS, deferred = false) { + const patches: Array> = []; + const state: { current: AppliedFilters } = { current: initial }; + const setter: { current: ((next: AppliedFilters) => void) | null } = { current: null }; + + function Harness() { + const [applied, setApplied] = useState(initial); + state.current = applied; + setter.current = setApplied; + const onApply = useCallback((patch: Partial) => { + patches.push(patch); + if (!deferred) setApplied((prev) => ({ ...prev, ...patch })); + }, []); + const onClear = useCallback(() => { + patches.push({}); + if (!deferred) setApplied(NO_FILTERS); + }, []); + return ; + } + + render(); + /** The URL moving under the form: a commit landing, or the back button. */ + function land(next: AppliedFilters) { + act(() => setter.current!(next)); + } + return { patches, state, land }; } +function openTimeMenu() { + fireEvent.click(screen.getByRole("button", { name: /^Time: / })); +} + +test("the segments commit to the applied state on the click, with no button in between", () => { + const { patches, state } = renderFilters(); + expect( + screen.getAllByRole("radio").map((radio) => radio.closest("label")?.textContent), + ).toEqual(["Any", "Blocked", "Allowed"]); + + fireEvent.click(screen.getByRole("radio", { name: "Blocked" })); + expect(patches).toEqual([{ blocked: true }]); + expect(state.current.blocked).toBe(true); + + fireEvent.click(screen.getByRole("radio", { name: "Allowed" })); + expect(state.current.blocked).toBe(false); + + fireEvent.click(screen.getByRole("radio", { name: "Any" })); + expect(state.current.blocked).toBeUndefined(); +}); + +test("a time preset writes an absolute second, and the trigger names the preset", () => { + const now = 1_800_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const { patches } = renderFilters(); + + openTimeMenu(); + expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([ + "Any time", + "Past hour", + "Past 24 hours", + "Past 7 days", + "Custom…", + ]); + + fireEvent.click(screen.getByRole("menuitem", { name: "Past 24 hours" })); + + // Both bounds, concrete: an open upper bound would keep taking in queries + // logged after the reader stopped looking, so the same link tomorrow would + // name a different day. + expect(patches).toEqual([{ since: now / 1000 - 86_400, until: now / 1000 }]); + expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy(); + + // A clock that has moved on does not move the label: the URL still holds the + // second the click resolved to. + vi.spyOn(Date, "now").mockReturnValue(now + 60_000); + fireEvent.click(screen.getByRole("radio", { name: "Blocked" })); + expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy(); + + vi.restoreAllMocks(); +}); + +test("bounds nobody picked here read as Custom, and Any time clears both", () => { + const { patches } = renderFilters({ ...NO_FILTERS, since: 1_700_000_000, until: 1_700_000_600 }); + expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy(); + + openTimeMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Any time" })); + + expect(patches).toEqual([{ since: undefined, until: undefined }]); + expect(screen.getByRole("button", { name: "Time: Any time" })).toBeTruthy(); +}); + +test("the custom range applies only through Set range, and Enter is Set range", () => { + const { patches } = renderFilters(); + openTimeMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" })); + + const since = screen.getByLabelText("Since") as HTMLInputElement; + fireEvent.change(since, { target: { value: "2026-03-29T04:30:00" } }); + // Typing a bound is not applying it: the other half may still be half-typed. + expect(patches).toEqual([]); + + const applied = { since: Math.floor(new Date("2026-03-29T04:30:00").getTime() / 1000), until: undefined }; + fireEvent.click(screen.getByRole("button", { name: "Set range" })); + expect(patches).toEqual([applied]); + + // Enter inside a bound is that bound's action, not the toolbar's submit: the + // outer form flushes the text filters and would apply neither half of this. + fireEvent.change(since, { target: { value: "2026-03-29T05:30:00" } }); + fireEvent.keyDown(since, { key: "Enter" }); + expect(patches).toHaveLength(2); + expect(patches[1]).toEqual({ + since: Math.floor(new Date("2026-03-29T05:30:00").getTime() / 1000), + until: undefined, + }); +}); + test("a rejected bound marks its own input and describes it", () => { - const applied = renderFilters(); + const { patches } = renderFilters(); + openTimeMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" })); + const since = screen.getByLabelText("Since") as HTMLInputElement; const until = screen.getByLabelText("Until") as HTMLInputElement; fireEvent.change(since, { target: { value: GAP_WALL_TIME } }); - fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); + fireEvent.click(screen.getByRole("button", { name: "Set range" })); expect(since.getAttribute("aria-invalid")).toBe("true"); expect(describedText(since)).toContain("daylight saving"); // Refused means refused, and only the offending bound carries the mark. - expect(applied.current).toBeNull(); + expect(patches).toEqual([]); expect(until.getAttribute("aria-invalid")).toBeNull(); expect(until.getAttribute("aria-describedby")).toBeNull(); }); -test("the mark moves to the other bound, and clears once both parse", () => { - renderFilters(); +test("an upper bound below the lower one is refused, against the bound that is wrong", () => { + const { patches } = renderFilters(); + openTimeMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" })); + const since = screen.getByLabelText("Since") as HTMLInputElement; const until = screen.getByLabelText("Until") as HTMLInputElement; + fireEvent.change(since, { target: { value: "2026-05-02T10:00:00" } }); + fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } }); + fireEvent.click(screen.getByRole("button", { name: "Set range" })); - fireEvent.change(until, { target: { value: GAP_WALL_TIME } }); - fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); - + expect(until.getAttribute("aria-invalid")).toBe("true"); + expect(describedText(until)).toContain("selects nothing"); expect(since.getAttribute("aria-invalid")).toBeNull(); - expect(describedText(until)).toContain("daylight saving"); + expect(patches).toEqual([]); - fireEvent.change(until, { target: { value: "2026-03-29T04:30:00" } }); - fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); + // The window is half-open, so two equal bounds are as empty as an inverted + // pair and are refused the same way. + fireEvent.change(until, { target: { value: "2026-05-02T10:00:00" } }); + fireEvent.click(screen.getByRole("button", { name: "Set range" })); + expect(until.getAttribute("aria-invalid")).toBe("true"); + expect(patches).toEqual([]); + fireEvent.change(until, { target: { value: "2026-05-02T11:00:00" } }); + fireEvent.click(screen.getByRole("button", { name: "Set range" })); expect(until.getAttribute("aria-invalid")).toBeNull(); - expect(until.getAttribute("aria-describedby")).toBeNull(); + expect(patches).toHaveLength(1); +}); + +test("Clear keeps its place while there is nothing to clear, and stays out of the tab order", () => { + const { state } = renderFilters(); + const clear = screen.getByText("Clear"); + expect(clear.getAttribute("tabindex")).toBe("-1"); + expect(clear.getAttribute("aria-hidden")).toBe("true"); + + fireEvent.click(screen.getByRole("radio", { name: "Blocked" })); + + expect(clear.getAttribute("tabindex")).toBeNull(); + expect(clear.getAttribute("aria-hidden")).toBeNull(); + + fireEvent.click(clear); + expect(state.current).toEqual(NO_FILTERS); + expect(clear.getAttribute("tabindex")).toBe("-1"); +}); + +test("Clear empties the text drafts along with the applied filters", () => { + vi.useFakeTimers(); + try { + const { state } = renderFilters(); + const domain = screen.getByLabelText("Filter domains") as HTMLInputElement; + fireEvent.change(domain, { target: { value: "ads" } }); + act(() => vi.advanceTimersByTime(400)); + expect(state.current.domain).toBe("ads"); + + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + act(() => vi.advanceTimersByTime(400)); + + expect(domain.value).toBe(""); + expect(state.current).toEqual(NO_FILTERS); + } finally { + vi.useRealTimers(); + } +}); + +test("the domain field is search-shaped, unspellchecked, and labelled without a visible label", () => { + renderFilters(); + const domain = screen.getByLabelText("Filter domains") as HTMLInputElement; + expect(domain.type).toBe("search"); + expect(domain.getAttribute("spellcheck")).toBe("false"); + expect(domain.getAttribute("autocomplete")).toBe("off"); + expect(domain.getAttribute("placeholder")).toBe("Filter domains…"); + // The magnifier is decoration over the field, never a second thing to read. + expect(document.querySelector("svg[aria-hidden='true']")).toBeTruthy(); +}); + +test("a bound change does not reset the domain draft that is still being typed", () => { + const now = 1_800_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const { state } = renderFilters(); + + const domain = screen.getByLabelText("Filter domains") as HTMLInputElement; + fireEvent.change(domain, { target: { value: "ads" } }); + + // A preset commits at once, while the typed word is still waiting out its + // debounce. The URL moves, but not the part of it this field is derived from. + openTimeMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" })); + + expect(state.current.since).toBe(now / 1000 - 3600); + expect(domain.value).toBe("ads"); + + vi.restoreAllMocks(); +}); + +test("a debounced commit landing late does not roll the field back over newer typing", () => { + vi.useFakeTimers(); + try { + const { patches, land } = renderFilters(NO_FILTERS, true); + const domain = screen.getByLabelText("Filter domains") as HTMLInputElement; + + // Two commits in flight, and the reader is a keystroke ahead of both. + fireEvent.change(domain, { target: { value: "ad" } }); + act(() => vi.advanceTimersByTime(400)); + fireEvent.change(domain, { target: { value: "ads" } }); + act(() => vi.advanceTimersByTime(400)); + expect(patches).toEqual([ + { domain: "ad", client: undefined }, + { domain: "ads", client: undefined }, + ]); + fireEvent.change(domain, { target: { value: "adsx" } }); + + // The older one lands first. It is this toolbar's own echo, two keystrokes + // stale, and seeding the field from it would delete what was typed since. + land({ ...NO_FILTERS, domain: "ad" }); + expect(domain.value).toBe("adsx"); + + land({ ...NO_FILTERS, domain: "ads" }); + expect(domain.value).toBe("adsx"); + + // An address that was never sent from here is someone else's — a pasted + // link, or the back button — and that one does move the field. + land({ ...NO_FILTERS, domain: "elsewhere" }); + expect(domain.value).toBe("elsewhere"); + } finally { + vi.useRealTimers(); + } +}); + +test("a window moved from outside re-opens the custom row over the bounds it arrived with", () => { + const now = 1_800_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const { land } = renderFilters(); + + openTimeMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" })); + // A preset supersedes the custom row, so it is shut and the label is the preset. + expect(screen.queryByLabelText("Since")).toBeNull(); + expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy(); + + // The back button, or a pasted link: a range this form did not choose. + const since = 1_700_000_000; + land({ ...NO_FILTERS, since, until: since + 600 }); + + expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy(); + // The label says Custom, so the fields it names have to be on screen holding + // that range — a Custom window with nothing to read is the label lying. + // jsdom's value sanitizer spells the milliseconds out, so the seeded text is + // the prefix rather than the whole of what the input holds. + expect((screen.getByLabelText("Since") as HTMLInputElement).value).toContain( + unixToDatetimeLocal(since), + ); + expect((screen.getByLabelText("Until") as HTMLInputElement).value).toContain( + unixToDatetimeLocal(since + 600), + ); + + vi.restoreAllMocks(); +}); + +test("a window moved from outside clears an error left over from the old one", () => { + const { land } = renderFilters(); + openTimeMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" })); + + const until = screen.getByLabelText("Until") as HTMLInputElement; + fireEvent.change(screen.getByLabelText("Since"), { target: { value: "2026-05-02T10:00:00" } }); + fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } }); + fireEvent.click(screen.getByRole("button", { name: "Set range" })); + expect(until.getAttribute("aria-invalid")).toBe("true"); + + const since = 1_700_000_000; + land({ ...NO_FILTERS, since, until: since + 600 }); + + // The bounds the message was about are gone, so the message is too. + expect((screen.getByLabelText("Until") as HTMLInputElement).getAttribute("aria-invalid")).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +test("the client field names the exact match it performs", () => { + renderFilters(); + const client = screen.getByLabelText("Client IP (exact match)") as HTMLInputElement; + expect(client.getAttribute("placeholder")).toBe("Client IP…"); +}); + +test("an earlier range commit landing late does not unseat the pick that is current", () => { + const first = 1_800_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(first); + const { patches, land } = renderFilters(NO_FILTERS, true); + + openTimeMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" })); + + // A second pick before the first has come back through the URL. + const second = first + 60_000; + vi.spyOn(Date, "now").mockReturnValue(second); + openTimeMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Past 24 hours" })); + + expect(patches).toEqual([ + { since: first / 1000 - 3600, until: first / 1000 }, + { since: second / 1000 - 86_400, until: second / 1000 }, + ]); + + // The older window lands first. It is this toolbar's own echo, so it must not + // read as a range someone else chose: doing that would clear the preset and + // throw the custom row open over a window the reader has already moved past. + land({ ...NO_FILTERS, since: first / 1000 - 3600, until: first / 1000 }); + expect(screen.queryByLabelText("Since")).toBeNull(); + + land({ ...NO_FILTERS, since: second / 1000 - 86_400, until: second / 1000 }); + expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy(); + expect(screen.queryByLabelText("Since")).toBeNull(); + + vi.restoreAllMocks(); }); diff --git a/admin/src/features/activity/ActivityFilters.tsx b/admin/src/features/activity/ActivityFilters.tsx index 10e3532..904b4a1 100644 --- a/admin/src/features/activity/ActivityFilters.tsx +++ b/admin/src/features/activity/ActivityFilters.tsx @@ -1,73 +1,198 @@ /** - * The filter row over the Activity table. + * The filter toolbar over the Activity history 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. + * the link they can paste to a housemate will show. So this holds a draft, and + * every control writes through to the URL — the segments and the time presets + * at once, the two text fields after a pause so a five-letter domain is one + * navigation rather than five. * - * 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 + * A time preset writes both bounds as absolute seconds, resolved once at the + * click. Neither half may be left open: a bookmark has to describe the same + * investigation tomorrow, and a window that slid overnight — or one that stayed + * open at the top and swallowed everything logged since — would answer a + * different question under the same link. + * + * A URL can move under this form at any time, and the draft only follows the + * part of it that actually moved. A field is re-seeded when its own URL value + * changed and the new value is not one this toolbar just wrote: the debounce + * means the URL is always a little behind the keyboard, and a landing commit + * must not roll the input back over the letters typed since. + * + * The custom range is the one control that does not live-apply. Two half-typed + * timestamps are a normal intermediate state of typing one of them, and a lower + * bound at or above an upper bound selects nothing at all, so the pair is + * validated and applied together or not at all. + * + * Live mode does not render this at all — the page mounts it inside the History + * panel. The stream is unfiltered, and a row of controls that looked usable * would promise filtering that is not happening. */ -import { useState, type FormEvent } from "react"; +import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react"; import * as stylex from "@stylexjs/stylex"; -import Select from "@/ui/Select"; +import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components"; 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" }, -]; +/** Long enough that a typed word is one navigation, short enough to feel live. */ +const DEBOUNCE_MS = 350; + +const RESULTS = [ + { value: "any", label: "Any" }, + { value: "blocked", label: "Blocked" }, + { value: "allowed", label: "Allowed" }, +] as const; + +const PRESETS = [ + { label: "Any time", seconds: null }, + { label: "Past hour", seconds: 3600 }, + { label: "Past 24 hours", seconds: 86_400 }, + { label: "Past 7 days", seconds: 604_800 }, +] as const; + +const CUSTOM_ITEM = "Custom…"; + +/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */ +const HIT_TARGET = 44; const styles = stylex.create({ - /** One column on a phone, two from `sm`, five from `lg`. */ - grid: { + toolbar: { 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))", - }, + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: "0.5rem", }, - label: { - display: "block", + /** The domain field is the one that grows; everything else keeps its size. */ + searchWrap: { + position: "relative", + flexGrow: 1, + flexShrink: 1, + flexBasis: "14rem", + display: "flex", + }, + searchIcon: { + position: "absolute", + insetInlineStart: "0.5rem", + top: "50%", + transform: "translateY(-50%)", + color: colors.textMuted, + pointerEvents: "none", + }, + /** Every control in the row is a pointer target before it is anything else. */ + field: { + minHeight: HIT_TARGET, + }, + searchInput: { + width: "100%", + paddingInlineStart: "1.875rem", + }, + clientInput: { + flexGrow: 0, + flexShrink: 1, + flexBasis: "10rem", + }, + /** A button is text-sized by default; this is the hit area around the text. */ + hitTarget: { + minHeight: HIT_TARGET, + minWidth: HIT_TARGET, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + }, + resultGroup: { + display: "flex", + gap: "0.25rem", + }, + /** The segment styling of the Overview period picker, item for item. */ + segment: { + cursor: "pointer", + borderStyle: "none", + borderRadius: "0.25rem", + paddingInline: "0.625rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + minHeight: HIT_TARGET, + minWidth: HIT_TARGET, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + }, + /** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */ + segmentFocusVisible: { + outlineWidth: 2, + outlineStyle: "solid", + outlineColor: colors.focus, + outlineOffset: 2, + }, + /** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */ + segmentSelected: { + backgroundColor: { + default: "oklch(92% 0.004 286.32)", + "@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)", + }, + color: colors.text, + fontWeight: 500, + }, + segmentIdle: { + backgroundColor: { default: "transparent", ":hover": colors.surfaceHover }, + color: colors.textSecondary, + }, + popover: { + borderRadius: "0.25rem", + borderWidth: 1, + borderStyle: "solid", + borderColor: colors.border, + backgroundColor: colors.surfaceRaised, + color: colors.text, + boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + }, + menu: { + outlineStyle: "none", + paddingBlock: "0.25rem", + }, + menuItem: { + cursor: "pointer", + paddingInline: "0.75rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + whiteSpace: "nowrap", + minHeight: HIT_TARGET, + display: "flex", + alignItems: "center", + }, + /** Inset because an item flush against the popover edge clips an outset ring. */ + menuItemFocused: { + backgroundColor: colors.primary, + color: colors.primaryText, + outlineColor: { default: null, ":focus-visible": colors.primaryText }, + }, + /** + * Clear keeps its box when there is nothing to clear. It appears the moment a + * filter is set, and a control that appeared by widening the row would move + * every other control out from under the pointer that was reaching for it. + */ + clearHidden: { + visibility: "hidden", + }, + customRow: { + marginTop: "0.5rem", + display: "flex", + flexWrap: "wrap", + alignItems: "flex-end", + gap: "0.5rem", + }, + customField: { + display: "flex", + flexDirection: "column", + gap: "0.25rem", 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, @@ -86,6 +211,22 @@ export const NO_FILTERS: AppliedFilters = { until: undefined, }; +/** The half-open window the API reads: `ts >= since` and `ts < until`. */ +interface Bounds { + since: number | undefined; + until: number | undefined; +} + +/** The two text filters as the URL spells them, with absent written as empty. */ +interface TextPair { + domain: string; + client: string; +} + +function sameBounds(a: Bounds, b: Bounds): boolean { + return a.since === b.since && a.until === b.until; +} + function blockedOption(blocked: boolean | undefined): string { if (blocked === undefined) return "any"; return blocked ? "blocked" : "allowed"; @@ -96,13 +237,19 @@ function optionBlocked(value: string): boolean | undefined { return value === "allowed" ? false : undefined; } +/** A filter value the URL can carry: trimmed, and empty means absent. */ +function textFilter(value: string): string | undefined { + const trimmed = value.trim(); + return trimmed === "" ? undefined : trimmed; +} + /** The message plus the bound it belongs to, so that input can point at it. */ interface BoundError { field: "since" | "until"; message: string; } -/** The editor is rendered once per page, so the message can hold a fixed id. */ +/** The toolbar is rendered once per page, so the message can hold a fixed id. */ const ERROR_ID = "activity-filter-error"; function boundError(field: "since" | "until", reason: "unparseable" | "nonexistent"): BoundError { @@ -116,27 +263,156 @@ function boundError(field: "since" | "until", reason: "unparseable" | "nonexiste }; } +/** The preset a label is claimed for, kept only while the URL still holds it. */ +interface ChosenPreset extends Bounds { + label: string; +} + +/** + * What this toolbar knows about the URL, and what it is still waiting to see + * come back from it. + * + * `url` and `bounds` are the last values looked at, so a change can be told + * apart field by field. `pendingText` and `pendingBounds` are every commit made + * here that the URL has not echoed yet. Both are queues rather than single + * slots: two keystrokes either side of the debounce put two text commits in + * flight, and two menu picks in quick succession do the same to the range. In + * both cases the older echo landing second must not be mistaken for someone + * else's edit — that is what would clear the preset out from under the pick + * that is actually current. + */ +interface Sync { + url: TextPair; + bounds: Bounds; + pendingText: TextPair[]; + pendingBounds: Bounds[]; +} + interface Props { applied: AppliedFilters; - isDisabled: boolean; - onApply: (filters: AppliedFilters) => void; + /** Merges a patch into the applied search. `replace` keeps typing out of history. */ + onApply: (patch: Partial, replace?: boolean) => 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)); +export default function ActivityFilters({ applied, onApply, onClear }: Props) { + const urlText: TextPair = { domain: applied.domain ?? "", client: applied.client ?? "" }; + const urlBounds: Bounds = { since: applied.since, until: applied.until }; + + const [domain, setDomain] = useState(urlText.domain); + const [client, setClient] = useState(urlText.client); + const [preset, setPreset] = useState(null); + const [showCustom, setShowCustom] = useState(applied.since !== undefined || applied.until !== undefined); const [since, setSince] = useState(() => datetimeField(applied.since)); const [until, setUntil] = useState(() => datetimeField(applied.until)); const [error, setError] = useState(null); + const [sync, setSync] = useState({ + url: urlText, + bounds: urlBounds, + pendingText: [], + pendingBounds: [], + }); - /** True for the one bound the current message is about; nothing else is marked. */ - const invalid = (field: BoundError["field"]): true | undefined => - error !== null && error.field === field ? true : undefined; + const textMoved = sync.url.domain !== urlText.domain || sync.url.client !== urlText.client; + const boundsMoved = !sameBounds(sync.bounds, urlBounds); + if (textMoved || boundsMoved) { + let pendingText = sync.pendingText; + let pendingBounds = sync.pendingBounds; - function submit(event: FormEvent) { + if (textMoved) { + // The newest commit the URL matches, and everything before it, has now + // been accounted for; an older one landing later is not new news. + const echo = pendingText.findLastIndex( + (sent) => sent.domain === urlText.domain && sent.client === urlText.client, + ); + if (echo >= 0) { + pendingText = pendingText.slice(echo + 1); + } else { + pendingText = []; + if (sync.url.domain !== urlText.domain) setDomain(urlText.domain); + if (sync.url.client !== urlText.client) setClient(urlText.client); + } + } + + if (boundsMoved) { + const echo = pendingBounds.findLastIndex((sent) => sameBounds(sent, urlBounds)); + if (echo >= 0) { + pendingBounds = pendingBounds.slice(echo + 1); + } else { + // Someone else moved the window — the back button, or a pasted link. + // Whatever this form was showing about the old one is now wrong: the + // preset it was named after, an error against bounds that are gone, + // and a custom row that is open or shut for the wrong range. + pendingBounds = []; + setPreset(null); + setError(null); + setShowCustom(urlBounds.since !== undefined || urlBounds.until !== undefined); + setSince(datetimeField(urlBounds.since)); + setUntil(datetimeField(urlBounds.until)); + } + } + + setSync({ url: urlText, bounds: urlBounds, pendingText, pendingBounds }); + } + + const dirty = textFilter(domain) !== applied.domain || textFilter(client) !== applied.client; + + const commitText = useCallback( + (replace: boolean) => { + const next = { domain: textFilter(domain), client: textFilter(client) }; + setSync((prev) => ({ + ...prev, + pendingText: [...prev.pendingText, { domain: next.domain ?? "", client: next.client ?? "" }], + })); + onApply(next, replace); + }, + [domain, client, onApply], + ); + + function commitBounds(bounds: Bounds) { + setSync((prev) => ({ ...prev, pendingBounds: [...prev.pendingBounds, bounds] })); + onApply(bounds); + } + + useEffect(() => { + if (!dirty) return; + const id = setTimeout(() => commitText(true), DEBOUNCE_MS); + return () => clearTimeout(id); + }, [dirty, commitText]); + + function flush(event: FormEvent) { event.preventDefault(); + if (dirty) commitText(true); + } + + function selectPreset(label: string) { + if (label === CUSTOM_ITEM) { + setShowCustom(true); + return; + } + const option = PRESETS.find((candidate) => candidate.label === label); + if (option === undefined) return; + setShowCustom(false); + setError(null); + if (option.seconds === null) { + setPreset(null); + setSince(datetimeField(undefined)); + setUntil(datetimeField(undefined)); + commitBounds({ since: undefined, until: undefined }); + return; + } + // Both bounds, resolved once, here. An open upper bound would keep taking + // in queries logged after the reader stopped looking, so "the past hour" + // would name a different hour every time the link was opened. + const now = Math.floor(Date.now() / 1000); + const bounds: Bounds = { since: now - option.seconds, until: now }; + setPreset({ label, ...bounds }); + setSince(datetimeField(bounds.since)); + setUntil(datetimeField(bounds.until)); + commitBounds(bounds); + } + + function setRange() { const sinceValue = resolveDatetimeField(since); if (!sinceValue.ok) { setError(boundError("since", sinceValue.reason)); @@ -147,106 +423,218 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear setError(boundError("until", untilValue.reason)); return; } + // The window is half-open — `ts >= since` and `ts < until` — so two equal + // bounds are as empty as an inverted pair, and neither is worth applying. + if ( + sinceValue.value !== undefined && + untilValue.value !== undefined && + untilValue.value <= sinceValue.value + ) { + setError({ + field: "until", + message: "Until must be after Since, or the range selects nothing.", + }); + 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, - }); + setPreset(null); + commitBounds({ since: sinceValue.value, until: untilValue.value }); } function clear() { setDomain(""); setClient(""); - setBlocked("any"); + setPreset(null); + setShowCustom(false); setSince(datetimeField(undefined)); setUntil(datetimeField(undefined)); setError(null); + setSync((prev) => ({ + ...prev, + pendingText: [], + pendingBounds: [...prev.pendingBounds, { since: undefined, until: undefined }], + })); onClear(); } - return ( - <> -
- - - set(editDatetimeField(state, event.target.value))} + onKeyDown={(event: KeyboardEvent) => { + // Enter here means this range, not the toolbar's text filters: + // the two bounds only ever apply together, and the outer form's + // submit would apply neither of them. + if (event.key !== "Enter") return; + event.preventDefault(); + setRange(); + }} + onBlur={() => { + const resolved = resolveDatetimeField(state); + if (!resolved.ok) setError(boundError(field, resolved.reason)); + else if (invalid(field)) setError(null); + }} + {...stylex.props(shared.smallInput, styles.field, shared.focusRing)} /> - - -
- + + + + setDomain(event.target.value)} + {...stylex.props(shared.smallInput, styles.field, styles.searchInput, shared.focusRing)} + /> +
+ {/* + * The client filter matches one address exactly — the server has no + * substring match for it — so the name says so rather than leaving the + * reader to discover it by typing half an address and getting nothing. + */} + setClient(event.target.value)} + {...stylex.props(shared.smallInput, styles.field, styles.clientInput, shared.focusRing)} + /> + onApply({ blocked: optionBlocked(next) })} + className={() => stylex.props(styles.resultGroup).className ?? ""} + > + {RESULTS.map((option) => ( + + stylex.props( + styles.segment, + isSelected ? styles.segmentSelected : styles.segmentIdle, + isFocusVisible && styles.segmentFocusVisible, + ).className ?? "" + } + > + {option.label} + + ))} + + + + stylex.props(styles.popover).className ?? ""}> + + {[...PRESETS.map((option) => option.label), CUSTOM_ITEM].map((label) => ( + selectPreset(label)} + className={({ isFocused }) => + stylex.props( + styles.menuItem, + shared.insetFocusRing, + isFocused && styles.menuItemFocused, + ).className ?? "" + } + > + {label} + + ))} + + + + + + {showCustom && ( +
+ {boundInput("since")} + {boundInput("until")}
- - {error !== null && ( - )} - + ); } diff --git a/admin/src/features/activity/ActivityPage.test.tsx b/admin/src/features/activity/ActivityPage.test.tsx index 7f75117..6314289 100644 --- a/admin/src/features/activity/ActivityPage.test.tsx +++ b/admin/src/features/activity/ActivityPage.test.tsx @@ -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(); }); diff --git a/admin/src/features/activity/ActivityPage.tsx b/admin/src/features/activity/ActivityPage.tsx index 5080acc..4f19c64 100644 --- a/admin/src/features/activity/ActivityPage.tsx +++ b/admin/src/features/activity/ActivityPage.tsx @@ -8,6 +8,7 @@ * recipient should be looking at. */ +import { useCallback } from "react"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import * as stylex from "@stylexjs/stylex"; import { Tab, TabList, TabPanel, Tabs } from "react-aria-components"; @@ -111,8 +112,6 @@ function panelClass({ isFocusVisible }: { isFocusVisible: boolean }): string { 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. @@ -121,9 +120,19 @@ export default function ActivityPage() { void navigate({ search: (prev) => ({ ...prev, mode }) }); } - function apply(filters: AppliedFilters) { - void navigate({ search: { mode: search.mode, ...filters } }); - } + // A patch, merged into whatever the URL already says: a segment click must not + // spell out the four filters it is not about. `replace` is the debounced text + // commits, so the back button steps between investigations, not keystrokes. + const apply = useCallback( + (patch: Partial, replace = false) => { + void navigate({ search: (prev) => ({ ...prev, ...patch }), replace }); + }, + [navigate], + ); + + const clear = useCallback(() => { + void navigate({ search: (prev) => ({ mode: prev.mode, ...NO_FILTERS }) }); + }, [navigate]); return (
@@ -165,24 +174,17 @@ export default function ActivityPage() { {/* - * 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. + * The toolbar belongs to History alone. It is not remounted on a search + * change: it resyncs its draft from the URL instead, because a remount + * mid-debounce would take the focus out of the input being typed in. */} - apply(NO_FILTERS)} - /> - +

- The stream carries every query the server answers; these filters apply to history only. + The stream carries every query the server answers; the History filters apply to history only.

diff --git a/admin/src/features/activity/HistoryActivity.tsx b/admin/src/features/activity/HistoryActivity.tsx index 7c1f673..a224e6c 100644 --- a/admin/src/features/activity/HistoryActivity.tsx +++ b/admin/src/features/activity/HistoryActivity.tsx @@ -56,7 +56,8 @@ const styles = stylex.create({ lineHeight: "1.25rem", color: colors.textMuted, }, - refetching: { + /** The gap a line standing on its own needs from the block above it. */ + spacedTop: { marginTop: "0.75rem", }, moreButton: { @@ -81,7 +82,9 @@ export default function HistoryActivity({ search }: { search: ActivitySearch }) const pages = base.data?.pages ?? []; const rows: QueryRow[] = pages.flatMap((page) => page.queries); - const coverage = pages[0]?.coverage; + // Only from the settled response. Placeholder pages belong to the previous + // filter, and a watermark is a claim about the window being displayed. + const coverage = base.isPlaceholderData ? undefined : 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 @@ -112,16 +115,27 @@ export default function HistoryActivity({ search }: { search: ActivitySearch }) return ( <> + {/* + * A quiet line, never a skeleton: the rows on screen stay put while a + * filter change is in flight, so a keystroke must not blank the table + * it is narrowing. + */} {base.isFetching && ( -

- Loading… +

+ Updating…

)} - {coverage !== undefined && } {rows.length === 0 ? ( -

- {filterActive ? "No queries match the current filters." : "No queries logged yet."} -

+ <> +

+ {filterActive ? "No queries match the current filters." : "No queries logged yet."} +

+ {coverage !== undefined && ( +
+ +
+ )} + ) : ( <>
@@ -158,6 +172,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch }) Showing {rows.length} {rows.length === 1 ? "query" : "queries"} {hasMore ? "" : " — end of log"}

+ {coverage !== undefined && } {hasMore && (