35 Commits
Author SHA1 Message Date
mokhtar 039eeeda96 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.
2026-08-29 14:56:40 +02:00
mokhtar c65d92d8f8 admin: title-only information becomes visible text
the client address follows its name as visible muted text in the query tables, the config lock indicator prints its reason beside the tag except in table rows where a page-level note explains the lock instead, and the locked delete buttons describe themselves through that one visible note. the chart legend tooltip is deleted because a named client is deliberately not addressed in the chart, and the dead series address field went with it. titles that merely repeat visible copyable text stay.
2026-08-29 13:03:30 +02:00
mokhtar 207252acee admin: enable toggles become rac switches
the upstream, blocklist source, and safe search enable controls mutate the row the moment they move, so they now carry the switch role via a shared drawn rac switch with a 44px hit area, a focus-visible ring, and naming modes made exclusive by a discriminated union. safe search supersedes its one-commit-old checkbox form, and the file-authority guard now names the switch role instead of relying on the bare input selector.
2026-08-29 12:40:01 +02:00
mokhtar f1de80477a admin: group sources and safe search become rac checkboxes
the assigned sources list is a rac checkboxgroup and safe search uses the same drawn checkbox, extracted to a shared ui component with grouped and standalone modes enforced by a discriminated union. the label carries a 44px pointer-target floor on both axes, the focus ring is driven from rac's focus-visible state and guarded by a test, and toggleSource is gone because the group hands back the whole set.
2026-08-29 12:33:00 +02:00
mokhtar 3c674966be admin: the mobile drawer becomes a rac disclosure, asset budget raised to 850,000 bytes
the drawer is inline flow content, so disclosure is the honest semantic: rac now owns aria-expanded, aria-controls, and the panel hidden state, while the open guard still unmounts the drawer contents so a closed drawer keeps no second pause control or health poll alive. the disclosure modules cost 3,669 bytes and the assets gate had 1,998 of headroom, so the budget moves from 800,000 to 850,000.
2026-08-29 12:20:07 +02:00
mokhtar 59d6be98f8 admin: associate inline field errors with their inputs
network assignment rows, activity filters, and the settings password pair now mark the offending input with aria-invalid and point it at the error text with aria-describedby. the prefix validator returns the row and field it is about, and any row mutation clears a message that named a position. settings numeric fields carry aria-invalid on an unparseable value.
2026-08-29 12:08:19 +02:00
mokhtar 317d5dd4f8 admin: live query detail becomes a modal, live/history and period pickers become tabs and radios, client delete confirms in a dialog
the streamed-query detail panel is now a rac modal dialog with focus containment and restore. the live/history switch is rac tabs driven by the url, the overview period picker is a rac radio group, and the clients delete flow uses the shared confirm dialog; an authority turn keeps the dialog open and withdraws only the destructive action.
2026-08-29 11:55:24 +02:00
mokhtar 79578e1d2a admin: reclaim the desktop header, move pause and log out to the sidebar
the header row survives only on narrow screens; on desktop its lone occupant, log out, joins pause in the sidebar footer, both full width. pause leaves the query detail page's related actions, where a global control had no business, and its hand-rolled duration dropdown becomes a react-aria menu with real keyboard navigation, dismissal and positioning.
2026-08-29 11:23:17 +02:00
mokhtar d5613ee718 admin: pointer cursor on every button
the shared button variants set no cursor, so only components with local one-off styles showed pointer. all nine variants and the six buttons styled outside them now carry pointer, with not-allowed when disabled.
2026-08-29 01:29:11 +02:00
mokhtar 09932b2d84 build: bump version to 0.0.14
Release / guard (push) Successful in 34s
Gates / frontend (push) Successful in 1m47s
Gates / test (push) Successful in 2m43s
Gates / test-aarch64 (push) Successful in 7m29s
Gates / package (push) Successful in 58s
Gates / container (push) Successful in 14s
Release / gates (push) Successful in 11m27s
Release / publish (push) Successful in 1m30s
2026-08-28 17:57:09 +02:00
mokhtar 272655f60c storage: version querylog.db and migrate it in place, never reset a healthy file
querylog.db carries a schema version; migrations run at startup as one transaction after a vacuumed 0600 backup, and every failure refuses startup (exit 2, no systemd restart loop) instead of starting empty. corruption is the only automatic recreate left. the cut gate now requires a fixture-proven migration or an explicit versioned break with restore instructions, and locks shipped migration files and fixtures byte-for-byte.
2026-08-28 17:56:19 +02:00
mokhtar dd5a9f6ab8 build: bump version to 0.0.13
Release / guard (push) Successful in 34s
Gates / frontend (push) Successful in 1m39s
Gates / test (push) Successful in 2m27s
Gates / test-aarch64 (push) Successful in 8m9s
Gates / package (push) Successful in 4m24s
Gates / container (push) Successful in 16s
Release / gates (push) Successful in 15m18s
Release / publish (push) Failing after 4m55s
2026-08-27 21:46:08 +02:00
mokhtar 72cdbbd113 upstream: one absolute per-query budget across queueing and failover
waiting for a slot now spends the query budget; truncated attempts that
expire fault the budget, not the upstream, and are never attributed.
admission sweeps in priority order before blocking. forward zones spend
read_timeout_ms once across udp, truncation and tcp. adds
nxdns_upstream_budget_exhausted_total and a 64-upstream validation limit.
2026-08-27 21:10:43 +02:00
mokhtar 6170571d23 build: bump version to 0.0.12
Release / guard (push) Successful in 35s
Gates / frontend (push) Successful in 1m40s
Gates / test (push) Successful in 2m23s
Gates / test-aarch64 (push) Successful in 7m12s
Gates / package (push) Successful in 4m18s
Gates / container (push) Successful in 16s
Release / gates (push) Successful in 14m12s
Release / publish (push) Failing after 4m57s
2026-08-27 17:49:06 +02:00
mokhtar c8470724ce overview: one endpoint, live projections and a response cache (m36) 2026-08-27 17:48:20 +02:00
mokhtar 7cdb74ec17 build: bump version to 0.0.11
Release / guard (push) Successful in 35s
Gates / frontend (push) Successful in 1m51s
Gates / test (push) Successful in 3m20s
Gates / test-aarch64 (push) Successful in 7m30s
Gates / package (push) Successful in 5m46s
Gates / container (push) Successful in 15s
Release / gates (push) Successful in 16m54s
Release / publish (push) Failing after 4m48s
2026-08-24 18:41:46 +02:00
mokhtar 4367003788 changelog: 0.0.11 2026-08-24 18:40:29 +02:00
mokhtar 728b8d64e1 admin: draw the overview charts with visx
the hand-written scale, tick, stacking and arc math is replaced by visx 4.0.0
primitives; rendering, colours and themes stay the app's own. all four charts
share one hover treatment: the client chart gains the tooltip and dimming the
query timeline had, the donuts gain both, an open tooltip follows a data
refresh instead of going stale, and it retires when the window rolls. the
timeline's third series is named allowed instead of other, and the client
chart's other aggregate disappears from a window where it counted nothing.
licenses gain the isc text for the bundled d3 modules.
2026-08-24 18:40:17 +02:00
mokhtar 091962a996 build: bump version to 0.0.10
Release / guard (push) Successful in 38s
Gates / frontend (push) Successful in 1m40s
Gates / test (push) Successful in 2m29s
Gates / test-aarch64 (push) Successful in 7m53s
Gates / package (push) Successful in 4m17s
Gates / container (push) Successful in 15s
Release / gates (push) Successful in 14m58s
Release / publish (push) Failing after 6m21s
2026-08-24 00:05:33 +02:00
mokhtar 4fcbd0ac12 changelog: 0.0.10 2026-08-24 00:05:21 +02:00
mokhtar d961b152a3 db-mode config changes apply live in-process
settings and upstream writes now follow a prepare, commit, publish, retire
contract: candidates are built and validated before the database transaction,
published as infallible pointer swaps, and old generations retire after their
readers drain. per-query policy values snapshot once per query; upstream pool,
cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and
the query-log queue each gained one named live operation. restart_required
shrinks from every scalar key to the bind keys and web.enabled; the admin ui
drops its restart notices for everything else. file mode is unchanged.
2026-08-24 00:04:28 +02:00
mokhtar 17e6e93ce1 admin: only the content region scrolls on wide screens
the shell grid grew past the viewport and scrolled the document,
carrying the sidebar with it. the shell is now viewport-height at the
wide breakpoint with main as the sole scroll container; the nav list
scrolls inside the pinned rail; router scroll restoration targets the
inner scroller so navigation resets it and back/forward restores it.
2026-08-23 15:17:51 +02:00
mokhtar 8a44d901d9 cut: schema gate — refuse to release an undisclosed querylog schema change
the gate recomputes the previous release tag's ddl fingerprint from the
remote peeled object and compares it against the tree's; a change must
be disclosed by 'resets your query history' in the version's changelog
section. the 0.0.9 reset shipped with an announcement claiming no
schema change; this makes the impact mechanical instead of remembered.
2026-08-23 15:07:27 +02:00
mokhtar 03149b5d65 ci: drop the one-insn-per-tb workaround, the qemu panic is an 11.1.0-only regression
Release / guard (push) Successful in 36s
Gates / frontend (push) Successful in 2m3s
Gates / test (push) Successful in 2m18s
Gates / test-aarch64 (push) Successful in 7m41s
Gates / package (push) Successful in 4m20s
Gates / container (push) Successful in 10s
Release / gates (push) Successful in 19m21s
Release / publish (push) Failing after 4m49s
the bisect proved the runner image's qemu 8.2 was never affected; the
hangs this masked were the logger deadlock fixed in f1a85d3. the 10x
slowdown it cost also broke the full-batch flush timing test in run 592.
2026-08-23 13:55:22 +02:00
mokhtar a972c8db4d logger tests: join every writer future before its database closes, fix the gate-open race 2026-08-23 12:26:09 +02:00
mokhtar 78d688a40f ci: run the aarch64 suite with one qemu insn per tb, tcg optimization falsely trips the ubsan pointer check 2026-08-23 12:26:05 +02:00
mokhtar fe5bd967ca admin: live ring capacity is injectable, eviction test no longer timing-bound 2026-08-23 09:04:18 +02:00
mokhtar 1f215ac623 build: bump version to 0.0.9 2026-08-22 23:32:47 +02:00
mokhtar 2f86cd72fd changelog: 0.0.9 releases today 2026-08-22 23:32:36 +02:00
mokhtar 51cda8b8cb milestone 33: contract closure — samples, file-authority enumeration, dead code, bundle ceiling 2026-08-22 23:31:37 +02:00
mokhtar e5f23d5466 querylog: return to the default checkpoint cadence 2026-08-22 23:10:17 +02:00
mokhtar 24521ab9a9 milestone 32: task-shaped configuration, file mode as a rendering, config status api 2026-08-22 22:42:50 +02:00
mokhtar c99a37d170 milestone 31: concurrent upstream exchanges, dot session reuse, queue metrics 2026-08-22 19:54:02 +02:00
mokhtar 623667e475 milestone 30: overview as a dashboard, explicit health contract, period aggregations 2026-08-22 16:45:15 +02:00
mokhtar 0e83477d80 agents: move opus-coder to the global scope
The routing rule is global, so the agent it names lives next to it now
(~/.claude/agents/opus-coder.md), at medium effort and without the
milestone-spec wording that only fit this repository.
2026-08-22 10:53:19 +02:00
46 changed files with 2980 additions and 874 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ import { readdirSync, statSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const BUDGET_BYTES = 800_000; const BUDGET_BYTES = 850_000;
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets"); const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
@@ -1,4 +1,4 @@
import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; import { render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store"; import { AuthProvider } from "@/auth/store";
@@ -313,16 +313,17 @@ test("a row retention has pruned explains the 404 and keeps the way back to the
}); });
}); });
/** /** The related-actions region of a query detail. */
* The related-actions region of a query detail. Scoped on purpose: the sidebar
* carries a Pause of its own, and this is the one that answers "this query was
* blocked and should not have been".
*/
function related(): HTMLElement { function related(): HTMLElement {
return screen.getByRole("region", { name: "Related" }); return screen.getByRole("region", { name: "Related" });
} }
test("a blocked query's Related offers Pause; an allowed one has nothing to pause about", async () => { /**
* Related is links only. Pause is a resolver-wide control and lives in the
* sidebar alone, so a blocked query — the case that used to carry one here —
* offers no button of any kind.
*/
test("Related carries its four links and no control, blocked query or not", async () => {
responses["/api/queries/50"] = detail(50, { responses["/api/queries/50"] = detail(50, {
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" }, policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
route: { kind: "blocked", upstream: "" }, route: { kind: "blocked", upstream: "" },
@@ -330,25 +331,12 @@ test("a blocked query's Related offers Pause; an allowed one has nothing to paus
renderDetail(50); renderDetail(50);
await screen.findByRole("heading", { name: "example.com" }); await screen.findByRole("heading", { name: "example.com" });
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy()); await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
expect(within(related()).getAllByRole("link").map((link) => link.textContent)).toEqual([
cleanup(); "Test this domain against current policy",
responses["/api/queries/51"] = detail(51, { policy: { action: "allow", reason: "no_match", matched: "" } }); "All activity for this domain",
renderDetail(51); "All activity from this client",
"Diagnostics around this query",
await screen.findByRole("heading", { name: "example.com" }); ]);
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull(); expect(within(related()).queryByRole("button")).toBeNull();
});
test("the Pause action stays away while protection is unavailable", async () => {
responses["/api/health"] = health({ protection: { state: "unavailable", until: null } });
responses["/api/queries/52"] = detail(52, {
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
route: { kind: "blocked", upstream: "" },
});
renderDetail(52);
await screen.findByRole("heading", { name: "example.com" });
await waitFor(() => expect(screen.getByText("Diagnostics around this query")).toBeTruthy());
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
}); });
@@ -73,9 +73,7 @@ export default function ActivityDetailPage() {
domain={domain} domain={domain}
client={client} client={client}
ts={time} ts={time}
origin={origin} origin={origin} />
blocked={detail.policy.action === "block"}
/>
} }
/> />
</section> </section>
@@ -0,0 +1,393 @@
/**
* 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
* `datetime-local` value sanitization algorithm, so text that is merely
* incomplete never reaches the component at all — the input hands it back as
* the empty string, which is a bound the operator cleared.
*/
import { useCallback, useState } from "react";
import { afterAll, expect, test, vi } from "vitest";
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());
/** 02:30 does not exist on this date in Paris; the clock jumps 02:00 to 03:00. */
const GAP_WALL_TIME = "2026-03-29T02:30:00";
/** The text of the elements an input points at with `aria-describedby`. */
function describedText(input: HTMLElement): string {
const ids = input.getAttribute("aria-describedby");
if (ids === null) throw new Error("input has no aria-describedby");
return ids
.split(/\s+/)
.map((id) => {
const node = document.getElementById(id);
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
return node.textContent ?? "";
})
.join(" ");
}
/**
* 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<Partial<AppliedFilters>> = [];
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<AppliedFilters>) => {
patches.push(patch);
if (!deferred) setApplied((prev) => ({ ...prev, ...patch }));
}, []);
const onClear = useCallback(() => {
patches.push({});
if (!deferred) setApplied(NO_FILTERS);
}, []);
return <ActivityFilters applied={applied} onApply={onApply} onClear={onClear} />;
}
render(<Harness />);
/** 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 { 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: "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(patches).toEqual([]);
expect(until.getAttribute("aria-invalid")).toBeNull();
expect(until.getAttribute("aria-describedby")).toBeNull();
});
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" }));
expect(until.getAttribute("aria-invalid")).toBe("true");
expect(describedText(until)).toContain("selects nothing");
expect(since.getAttribute("aria-invalid")).toBeNull();
expect(patches).toEqual([]);
// 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(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();
});
+550 -140
View File
@@ -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 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, * the link they can paste to a housemate will show. So this holds a draft, and
* and the page remounts it whenever the applied search changes — a back button * every control writes through to the URL — the segments and the time presets
* or a pasted URL has to move the form with it, and a form that seeded itself * at once, the two text fields after a pause so a five-letter domain is one
* once would keep showing the previous investigation's filters. * navigation rather than five.
* *
* In live mode the row stays visible and disabled rather than disappearing: the * A time preset writes both bounds as absolute seconds, resolved once at the
* filters are retained in the URL and apply again the moment history comes * click. Neither half may be left open: a bookmark has to describe the same
* back, and hiding them would read as having lost them. The stream itself is * investigation tomorrow, and a window that slid overnight — or one that stayed
* unfiltered — the server sends every query — so a row that looked usable here * 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. * 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 * 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 { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime"; import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
import type { ActivitySearch } from "./search"; import type { ActivitySearch } from "./search";
const STATUS_OPTIONS = [ /** Long enough that a typed word is one navigation, short enough to feel live. */
{ value: "any", label: "All" }, const DEBOUNCE_MS = 350;
{ value: "blocked", label: "Blocked only" },
{ value: "allowed", label: "Allowed only" }, 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({ const styles = stylex.create({
/** One column on a phone, two from `sm`, five from `lg`. */ toolbar: {
grid: {
marginTop: "1rem", marginTop: "1rem",
display: "grid", display: "flex",
gap: "0.75rem", flexWrap: "wrap",
gridTemplateColumns: { alignItems: "center",
default: "repeat(1, minmax(0, 1fr))", gap: "0.5rem",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
},
}, },
label: { /** The domain field is the one that grows; everything else keeps its size. */
display: "block", 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", fontSize: "0.875rem",
lineHeight: "1.25rem", 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: { error: {
marginTop: "0.5rem",
fontSize: "0.875rem", fontSize: "0.875rem",
lineHeight: "1.25rem", lineHeight: "1.25rem",
color: colors.dangerText, color: colors.dangerText,
@@ -86,6 +211,22 @@ export const NO_FILTERS: AppliedFilters = {
until: undefined, 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 { function blockedOption(blocked: boolean | undefined): string {
if (blocked === undefined) return "any"; if (blocked === undefined) return "any";
return blocked ? "blocked" : "allowed"; return blocked ? "blocked" : "allowed";
@@ -96,135 +237,404 @@ function optionBlocked(value: string): boolean | undefined {
return value === "allowed" ? false : undefined; return value === "allowed" ? false : undefined;
} }
function boundError(label: string, reason: "unparseable" | "nonexistent"): string { /** A filter value the URL can carry: trimmed, and empty means absent. */
return reason === "unparseable" function textFilter(value: string): string | undefined {
? `${label} is not a complete date and time.` const trimmed = value.trim();
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`; 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 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 {
const label = field === "since" ? "Since" : "Until";
return {
field,
message:
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.`,
};
}
/** 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 { interface Props {
applied: AppliedFilters; applied: AppliedFilters;
isDisabled: boolean; /** Merges a patch into the applied search. `replace` keeps typing out of history. */
onApply: (filters: AppliedFilters) => void; onApply: (patch: Partial<AppliedFilters>, replace?: boolean) => void;
onClear: () => void; onClear: () => void;
} }
export default function ActivityFilters({ applied, isDisabled, onApply, onClear }: Props) { export default function ActivityFilters({ applied, onApply, onClear }: Props) {
const [domain, setDomain] = useState(applied.domain ?? ""); const urlText: TextPair = { domain: applied.domain ?? "", client: applied.client ?? "" };
const [client, setClient] = useState(applied.client ?? ""); const urlBounds: Bounds = { since: applied.since, until: applied.until };
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
const [domain, setDomain] = useState(urlText.domain);
const [client, setClient] = useState(urlText.client);
const [preset, setPreset] = useState<ChosenPreset | null>(null);
const [showCustom, setShowCustom] = useState(applied.since !== undefined || applied.until !== undefined);
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since)); const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until)); const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<BoundError | null>(null);
const [sync, setSync] = useState<Sync>({
url: urlText,
bounds: urlBounds,
pendingText: [],
pendingBounds: [],
});
function submit(event: FormEvent) { 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;
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(); 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); const sinceValue = resolveDatetimeField(since);
if (!sinceValue.ok) { if (!sinceValue.ok) {
setError(boundError("Since", sinceValue.reason)); setError(boundError("since", sinceValue.reason));
return; return;
} }
const untilValue = resolveDatetimeField(until); const untilValue = resolveDatetimeField(until);
if (!untilValue.ok) { if (!untilValue.ok) {
setError(boundError("Until", untilValue.reason)); 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; return;
} }
setError(null); setError(null);
onApply({ setPreset(null);
domain: domain.trim() === "" ? undefined : domain.trim(), commitBounds({ since: sinceValue.value, until: untilValue.value });
client: client.trim() === "" ? undefined : client.trim(),
blocked: optionBlocked(blocked),
since: sinceValue.value,
until: untilValue.value,
});
} }
function clear() { function clear() {
setDomain(""); setDomain("");
setClient(""); setClient("");
setBlocked("any"); setPreset(null);
setShowCustom(false);
setSince(datetimeField(undefined)); setSince(datetimeField(undefined));
setUntil(datetimeField(undefined)); setUntil(datetimeField(undefined));
setError(null); setError(null);
setSync((prev) => ({
...prev,
pendingText: [],
pendingBounds: [...prev.pendingBounds, { since: undefined, until: undefined }],
}));
onClear(); onClear();
} }
return ( const active =
<> domain.trim() !== "" ||
<form onSubmit={submit} {...stylex.props(styles.grid)}> client.trim() !== "" ||
<label {...stylex.props(styles.label)}> applied.blocked !== undefined ||
Domain contains applied.since !== undefined ||
<input applied.until !== undefined;
type="text"
value={domain} // The label the URL earns on its own, overridden only while a preset click is
disabled={isDisabled} // still the whole of what the URL says. Bounds nobody here chose read as
onChange={(event) => setDomain(event.target.value)} // "Custom": that is what a pasted link or an edited range is.
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)} let timeLabel = "Any time";
/> if (applied.since !== undefined || applied.until !== undefined) {
</label> timeLabel = preset !== null && sameBounds(preset, urlBounds) ? preset.label : "Custom";
<label {...stylex.props(styles.label)}> }
Client (exact)
<input /** True for the one bound the current message is about; nothing else is marked. */
type="text" const invalid = (field: BoundError["field"]): true | undefined =>
value={client} error !== null && error.field === field ? true : undefined;
disabled={isDisabled}
onChange={(event) => setClient(event.target.value)} function boundInput(field: BoundError["field"]) {
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)} const state = field === "since" ? since : until;
/> const set = field === "since" ? setSince : setUntil;
</label> return (
<Select <label {...stylex.props(styles.customField)}>
variant="compactField" {field === "since" ? "Since" : "Until"}
label="Result" <input
value={blocked} type="datetime-local"
isDisabled={isDisabled} step={1}
onChange={setBlocked} value={state.text}
options={STATUS_OPTIONS} aria-invalid={invalid(field)}
aria-describedby={invalid(field) && ERROR_ID}
onChange={(event) => set(editDatetimeField(state, event.target.value))}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
// 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)}
/> />
<label {...stylex.props(styles.label)}> {invalid(field) && (
Since <span id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
<input {error?.message}
type="datetime-local" </span>
step={1} )}
value={since.text} </label>
disabled={isDisabled} );
onChange={(event) => setSince(editDatetimeField(since, event.target.value))} }
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
/> return (
</label> <form onSubmit={flush}>
<label {...stylex.props(styles.label)}> <div {...stylex.props(styles.toolbar)}>
Until <div {...stylex.props(styles.searchWrap)}>
<input <svg
type="datetime-local" aria-hidden="true"
step={1} viewBox="0 0 16 16"
value={until.text} width="14"
disabled={isDisabled} height="14"
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))} fill="none"
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)} stroke="currentColor"
/> strokeWidth="1.5"
</label> {...stylex.props(styles.searchIcon)}
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={isDisabled}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
> >
Apply filters <circle cx="7" cy="7" r="4.5" />
</button> <path d="M10.5 10.5 14 14" strokeLinecap="round" />
</svg>
<input
type="search"
aria-label="Filter domains"
placeholder="Filter domains…"
spellCheck={false}
autoComplete="off"
value={domain}
onChange={(event) => setDomain(event.target.value)}
{...stylex.props(shared.smallInput, styles.field, styles.searchInput, shared.focusRing)}
/>
</div>
{/*
* 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.
*/}
<input
type="text"
aria-label="Client IP (exact match)"
placeholder="Client IP…"
spellCheck={false}
autoComplete="off"
value={client}
onChange={(event) => setClient(event.target.value)}
{...stylex.props(shared.smallInput, styles.field, styles.clientInput, shared.focusRing)}
/>
<RadioGroup
aria-label="Result"
orientation="horizontal"
value={blockedOption(applied.blocked)}
onChange={(next) => onApply({ blocked: optionBlocked(next) })}
className={() => stylex.props(styles.resultGroup).className ?? ""}
>
{RESULTS.map((option) => (
<Radio
key={option.value}
value={option.value}
className={({ isSelected, isFocusVisible }) =>
stylex.props(
styles.segment,
isSelected ? styles.segmentSelected : styles.segmentIdle,
isFocusVisible && styles.segmentFocusVisible,
).className ?? ""
}
>
{option.label}
</Radio>
))}
</RadioGroup>
<MenuTrigger>
<Button
className={() =>
stylex.props(shared.button, styles.hitTarget, shared.focusRing).className ?? ""
}
>
Time: {timeLabel}
</Button>
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
<Menu {...stylex.props(styles.menu)}>
{[...PRESETS.map((option) => option.label), CUSTOM_ITEM].map((label) => (
<MenuItem
key={label}
onAction={() => selectPreset(label)}
className={({ isFocused }) =>
stylex.props(
styles.menuItem,
shared.insetFocusRing,
isFocused && styles.menuItemFocused,
).className ?? ""
}
>
{label}
</MenuItem>
))}
</Menu>
</Popover>
</MenuTrigger>
<button
type="button"
onClick={clear}
tabIndex={active ? undefined : -1}
aria-hidden={active ? undefined : true}
{...stylex.props(
shared.button,
styles.hitTarget,
shared.focusRing,
!active && styles.clearHidden,
)}
>
Clear
</button>
</div>
{showCustom && (
<div {...stylex.props(styles.customRow)}>
{boundInput("since")}
{boundInput("until")}
<button <button
type="button" type="button"
onClick={clear} onClick={setRange}
disabled={isDisabled} {...stylex.props(shared.button, styles.hitTarget, shared.focusRing)}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
> >
Clear Set range
</button> </button>
</div> </div>
</form>
{error !== null && (
<p role="alert" {...stylex.props(styles.error)}>
{error}
</p>
)} )}
</> </form>
); );
} }
+210 -22
View File
@@ -12,6 +12,7 @@ import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture"; import { health } from "@/lib/healthFixture";
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types"; import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
import { queryRow } from "@/features/provenance/provenanceFixture"; import { queryRow } from "@/features/provenance/provenanceFixture";
import { FakeEventSource } from "./fakeEventSource";
function client(id: number, ip: string, name: string, learnedName: string): Client { function client(id: number, ip: string, name: string, learnedName: string): Client {
return { return {
@@ -135,6 +136,22 @@ function queryCalls(): string[] {
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?")); .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 () => { test("renders the first page with the seven columns filled in", async () => {
renderPage(); renderPage();
await screen.findByText("first.example"); await screen.findByText("first.example");
@@ -162,7 +179,7 @@ test("renders the first page with the seven columns filled in", async () => {
expect(screen.getByText(/Showing 2 queries/)).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 () => { test("resolves each row's client to its display name, reading the IP out with it", async () => {
stubFetch((url) => { stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS }); if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
@@ -182,19 +199,22 @@ test("resolves each row's client to its display name, keeping the IP as the tool
// A hand-typed name wins outright; the learned name never surfaces for it. // A hand-typed name wins outright; the learned name never surfaces for it.
const named = await screen.findByText("Kitchen Pi"); const named = await screen.findByText("Kitchen Pi");
expect(named.getAttribute("title")).toBe("192.0.2.10"); // The address reads out with the name it replaced, rather than sitting in a
// title only a mouse can reach.
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
expect(named.getAttribute("title")).toBeNull();
expect(screen.queryByText("pi.lan")).toBeNull(); expect(screen.queryByText("pi.lan")).toBeNull();
// A learned name reads muted and nothing more here: the "learned" tag would // 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. // repeat on every row of the table, so the Clients page carries it instead.
const learned = screen.getByText("laptop.lan"); const learned = screen.getByText("laptop.lan");
expect(learned.getAttribute("title")).toBe("192.0.2.11"); expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull(); expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
// A known client with neither name, and a client the loaded list has never // 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. // seen, both fall back to the bare address with nothing standing in for it.
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull(); expect(screen.getByText("192.0.2.12").textContent).toBe("192.0.2.12");
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull(); expect(screen.getByText("192.0.2.99").textContent).toBe("192.0.2.99");
}); });
test("load more appends the next page and stops at the end of the log", async () => { test("load more appends the next page and stops at the end of the log", async () => {
@@ -216,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" })); fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("older.example"); await screen.findByText("older.example");
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } }); fireEvent.change(domainInput(), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); submitFilters();
await screen.findByText(/Showing 1 query /); await screen.findByText(/Showing 1 query /);
expect(history.location.search).toContain("domain=ads"); expect(history.location.search).toContain("domain=ads");
@@ -242,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.click(screen.getByRole("button", { name: "Load more" }));
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } }); fireEvent.change(domainInput(), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); submitFilters();
await screen.findByText(/Showing 1 query /); await screen.findByText(/Showing 1 query /);
releaseLoadMore(); releaseLoadMore();
@@ -281,8 +301,8 @@ test("load more is disabled while a filter change shows placeholder data, then u
renderPage(); renderPage();
await screen.findByText("first.example"); await screen.findByText("first.example");
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } }); fireEvent.change(domainInput(), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); submitFilters();
const staleButton = await screen.findByRole("button", { name: "Load more" }); const staleButton = await screen.findByRole("button", { name: "Load more" });
expect(staleButton).toHaveProperty("disabled", true); expect(staleButton).toHaveProperty("disabled", true);
@@ -416,7 +436,7 @@ test("a ?domain= link seeds the filter form and fetches that domain on arrival",
renderPage("/activity?domain=ads"); renderPage("/activity?domain=ads");
await screen.findByText("ads.example"); await screen.findByText("ads.example");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads"); expect(domainInput()).toHaveProperty("value", "ads");
expect(screen.queryByText("first.example")).toBeNull(); expect(screen.queryByText("first.example")).toBeNull();
}); });
@@ -441,7 +461,7 @@ test("a rejected search parameter is dropped rather than guessed at", async () =
await screen.findByText("first.example"); await screen.findByText("first.example");
// Nothing survived validation, so the request is the unfiltered one. // Nothing survived validation, so the request is the unfiltered one.
expect(queryCalls()).toEqual(["/api/queries"]); 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 () => { test("the form draft follows the url back and forward, seconds included", async () => {
@@ -454,25 +474,33 @@ test("the form draft follows the url back and forward, seconds included", async
const seeded = 1_700_000_017; const seeded = 1_700_000_017;
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`); const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
const domainInput = await screen.findByLabelText("Domain contains"); await screen.findByLabelText("Filter domains");
expect(domainInput).toHaveProperty("value", "first"); 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; const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
expect(sinceInput.value).toContain(":37"); expect(sinceInput.value).toContain(":37");
fireEvent.change(domainInput, { target: { value: "second" } }); fireEvent.change(domainInput(), { target: { value: "second" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" })); submitFilters();
await waitFor(() => expect(history.location.search).toContain("domain=second")); await waitFor(() => expect(history.location.search).toContain("domain=second"));
// The untouched Since bound applied as the exact second it was seeded with. // The untouched Since bound applied as the exact second it was seeded with.
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`); 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()); act(() => history.back());
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first"); expect(domainInput()).toHaveProperty("value", "second");
}); });
act(() => history.forward()); act(() => history.forward());
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second"); expect(domainInput()).toHaveProperty("value", "third");
}); });
}); });
@@ -508,8 +536,9 @@ test("a wall-clock time the daylight-saving jump skips is refused, not silently
const callsBefore = queryCalls().length; const callsBefore = queryCalls().length;
const searchBefore = history.location.search; const searchBefore = history.location.search;
openCustomRange();
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } }); 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) { if (inGap) {
expect(screen.getByRole("alert").textContent).toContain("daylight saving"); expect(screen.getByRole("alert").textContent).toContain("daylight saving");
@@ -544,6 +573,40 @@ test("the policy simulation is reachable from the header, with no rows to click
expect(history.location.pathname).toBe("/activity/test"); expect(history.location.pathname).toBe("/activity/test");
}); });
test("the mode switch is a tab list whose selection is the url, and it keeps the filters", async () => {
// Live opens a stream as soon as its panel mounts, so the switch cannot be
// exercised without one; the fake stands in for the browser's EventSource.
vi.stubGlobal(
"EventSource",
class {
constructor(url: string) {
return new FakeEventSource(url) as unknown as EventSource;
}
},
);
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
await screen.findByText("ads.example");
const tabs = within(screen.getByRole("tablist", { name: "Activity mode" }));
expect(tabs.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["History", "Live"]);
expect(tabs.getByRole("tab", { name: "History", selected: true })).toBeTruthy();
expect(tabs.getByRole("tab", { name: "Live", selected: false })).toBeTruthy();
fireEvent.click(tabs.getByRole("tab", { name: "Live" }));
await waitFor(() => expect(history.location.search).toContain("mode=live"));
// The investigation survives the switch: both filters are still in the URL.
expect(history.location.search).toContain("domain=ads");
expect(history.location.search).toContain("blocked=true");
expect(
within(screen.getByRole("tablist", { name: "Activity mode" })).getByRole("tab", {
name: "Live",
selected: true,
}),
).toBeTruthy();
expect(await screen.findByText(/the History filters apply to history only/)).toBeTruthy();
});
test("Clear empties the url as well as the form", async () => { test("Clear empties the url as well as the form", async () => {
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true"); const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
await screen.findByText("ads.example"); await screen.findByText("ads.example");
@@ -553,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("domain");
}); });
expect(history.location.search).not.toContain("blocked"); 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();
}); });
+89 -47
View File
@@ -8,8 +8,10 @@
* recipient should be looking at. * recipient should be looking at.
*/ */
import { useCallback } from "react";
import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters"; import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
@@ -53,6 +55,13 @@ const styles = stylex.create({
fontWeight: 500, fontWeight: 500,
cursor: "pointer", cursor: "pointer",
}, },
/** A Tab is a `div` with a roving tabindex, so RAC drives the ring, not `:focus-visible`. */
modeFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
modeIdle: { modeIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover }, backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: { default: colors.textSecondary, ":hover": colors.text }, color: { default: colors.textSecondary, ":hover": colors.text },
@@ -76,13 +85,33 @@ const styles = stylex.create({
lineHeight: "1.25rem", lineHeight: "1.25rem",
color: colors.textMuted, color: colors.textMuted,
}, },
panel: {
outlineStyle: "none",
},
/**
* A panel with nothing tabbable in it — an empty or loading history — is given
* a tabindex by RAC so the reader can still reach its content, so it has to be
* able to show that it holds the focus.
*/
panelFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
tabsRoot: {
display: "block",
},
}); });
function panelClass({ isFocusVisible }: { isFocusVisible: boolean }): string {
return stylex.props(styles.panel, isFocusVisible && styles.panelFocusVisible).className ?? "";
}
export default function ActivityPage() { export default function ActivityPage() {
const search = useSearch({ from: "/shell/activity" }); const search = useSearch({ from: "/shell/activity" });
const navigate = useNavigate({ from: "/activity" }); const navigate = useNavigate({ from: "/activity" });
const live = search.mode === "live";
// The functional form, not a replacement object: the filters are retained // 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 // 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. // drop every one of them on the way to Live and back.
@@ -91,62 +120,75 @@ export default function ActivityPage() {
void navigate({ search: (prev) => ({ ...prev, mode }) }); void navigate({ search: (prev) => ({ ...prev, mode }) });
} }
function apply(filters: AppliedFilters) { // A patch, merged into whatever the URL already says: a segment click must not
void navigate({ search: { mode: search.mode, ...filters } }); // 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<AppliedFilters>, replace = false) => {
void navigate({ search: (prev) => ({ ...prev, ...patch }), replace });
},
[navigate],
);
const clear = useCallback(() => {
void navigate({ search: (prev) => ({ mode: prev.mode, ...NO_FILTERS }) });
}, [navigate]);
return ( return (
<section> <section>
<div {...stylex.props(styles.header)}> {/*
<h1 {...stylex.props(styles.heading)}>Activity</h1> * The selected tab is the URL's `mode` and nothing else. RAC would hold
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}> * the selection itself, but a second copy of it would fight the back
{MODES.map((option) => { * button, so the search parameter stays the only state there is.
const selected = option.mode === search.mode; */}
return ( <Tabs
<button selectedKey={search.mode}
onSelectionChange={(key) => selectMode(key as ActivityMode)}
className={() => stylex.props(styles.tabsRoot).className ?? ""}
>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Activity</h1>
<TabList
aria-label="Activity mode"
className={() => stylex.props(styles.switch).className ?? ""}
>
{MODES.map((option) => (
<Tab
key={option.mode} key={option.mode}
type="button" id={option.mode}
aria-pressed={selected} className={({ isSelected, isFocusVisible }) =>
onClick={() => selectMode(option.mode)} stylex.props(
{...stylex.props( styles.modeButton,
styles.modeButton, isSelected ? styles.modeSelected : styles.modeIdle,
selected ? styles.modeSelected : styles.modeIdle, isFocusVisible && styles.modeFocusVisible,
shared.focusRing, ).className ?? ""
)} }
> >
{option.label} {option.label}
</button> </Tab>
); ))}
})} </TabList>
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
Current policy simulation
</Link>
</div> </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 toolbar belongs to History alone. It is not remounted on a search
* the back button work: the draft is derived state, and the browser * change: it resyncs its draft from the URL instead, because a remount
* moving the URL under it has to move the form with it. * mid-debounce would take the focus out of the input being typed in.
*/} */}
<ActivityFilters <TabPanel id="history" className={panelClass}>
key={`${search.domain ?? ""}|${search.client ?? ""}|${String(search.blocked)}|${String(search.since)}|${String(search.until)}`} <ActivityFilters applied={search} onApply={apply} onClear={clear} />
applied={search} <HistoryActivity search={search} />
isDisabled={live} </TabPanel>
onApply={apply} <TabPanel id="live" className={panelClass}>
onClear={() => apply(NO_FILTERS)}
/>
{live ? (
<>
<p {...stylex.props(styles.liveNote)}> <p {...stylex.props(styles.liveNote)}>
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.
</p> </p>
<LiveActivity origin={search} /> <LiveActivity origin={search} />
</> </TabPanel>
) : ( </Tabs>
<HistoryActivity search={search} />
)}
</section> </section>
); );
} }
@@ -56,7 +56,8 @@ const styles = stylex.create({
lineHeight: "1.25rem", lineHeight: "1.25rem",
color: colors.textMuted, color: colors.textMuted,
}, },
refetching: { /** The gap a line standing on its own needs from the block above it. */
spacedTop: {
marginTop: "0.75rem", marginTop: "0.75rem",
}, },
moreButton: { moreButton: {
@@ -81,7 +82,9 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
const pages = base.data?.pages ?? []; const pages = base.data?.pages ?? [];
const rows: QueryRow[] = pages.flatMap((page) => page.queries); 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; const filterActive = Object.keys(filter).length > 0;
// `base.hasNextPage` reads the query state, which is empty while placeholder // `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 // 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 ( 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 && ( {base.isFetching && (
<p {...stylex.props(styles.note, styles.refetching)} role="status"> <p {...stylex.props(styles.note, styles.spacedTop)} role="status">
Loading… Updating…
</p> </p>
)} )}
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
{rows.length === 0 ? ( {rows.length === 0 ? (
<p {...stylex.props(styles.empty)}> <>
{filterActive ? "No queries match the current filters." : "No queries logged yet."} <p {...stylex.props(styles.empty)}>
</p> {filterActive ? "No queries match the current filters." : "No queries logged yet."}
</p>
{coverage !== undefined && (
<div {...stylex.props(styles.spacedTop)}>
<CoverageNotice coverage={coverage} variant="note" />
</div>
)}
</>
) : ( ) : (
<> <>
<div {...stylex.props(styles.tableWrap)}> <div {...stylex.props(styles.tableWrap)}>
@@ -158,6 +172,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
Showing {rows.length} {rows.length === 1 ? "query" : "queries"} Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
{hasMore ? "" : " — end of log"} {hasMore ? "" : " — end of log"}
</p> </p>
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
{hasMore && ( {hasMore && (
<button <button
type="button" type="button"
+213 -107
View File
@@ -57,7 +57,17 @@ function json(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } }); return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
} }
function stubFetch(handler: (url: string) => Response | Promise<Response> = () => json({})) { /**
* The empty history page. Live mode asks for no query pages, but switching back
* to History does, and a page-shaped response is the only honest answer there.
*/
const EMPTY_PAGE = { queries: [], next_before: null, coverage: { complete: true, available_since: 0 } };
function defaultHandler(url: string): Response {
return json(url === "/api/queries" || url.startsWith("/api/queries?") ? EMPTY_PAGE : {});
}
function stubFetch(handler: (url: string) => Response | Promise<Response> = defaultHandler) {
fetchMock = vi.fn((input: RequestInfo | URL) => { fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input); const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION)); if (url === "/api/version") return Promise.resolve(json(VERSION));
@@ -168,7 +178,7 @@ test("streams rows, flags blocked ones, and freezes the display", async () => {
expect(screen.getByText("later.example")).toBeTruthy(); expect(screen.getByText("later.example")).toBeTruthy();
}); });
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => { test("resolves each row's client to its display name, reading the IP out with it", async () => {
await openLive(); await openLive();
act(() => { act(() => {
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } })); sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
@@ -178,11 +188,14 @@ test("resolves each row's client to its display name, keeping the IP as the tool
}); });
const named = await screen.findByText("Kitchen Pi"); const named = await screen.findByText("Kitchen Pi");
expect(named.getAttribute("title")).toBe("192.0.2.10"); // The address reads out with the name it replaced, rather than sitting in a
// title only a mouse can reach.
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
expect(named.getAttribute("title")).toBeNull();
expect(screen.queryByText("pi.lan")).toBeNull(); expect(screen.queryByText("pi.lan")).toBeNull();
const learned = screen.getByText("laptop.lan"); const learned = screen.getByText("laptop.lan");
expect(learned.getAttribute("title")).toBe("192.0.2.11"); expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull(); expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull(); expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
@@ -256,7 +269,40 @@ test("a recovered row links to its stored detail; a streamed one opens in place
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy(); 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 () => { /** The open detail. Named by its heading, so the query proves the name is visible. */
function detailDialog(): HTMLElement {
return screen.getByRole("dialog", { name: "Streamed query" });
}
/** React Aria's ModalOverlay, two levels out from the dialog it wraps. */
function backdrop(): HTMLElement {
return detailDialog().parentElement!.parentElement!;
}
/**
* Activate a control the way a keyboard does. jsdom runs no default action for
* Enter on a button, so the click a browser would then dispatch is issued here;
* `detail: 0` is what marks it as keyboard-driven rather than pointer-driven,
* and is the flag React Aria itself reads.
*/
function pressWithKeyboard(control: HTMLElement) {
act(() => control.focus());
fireEvent.keyDown(control, { key: "Enter" });
fireEvent.click(control, { detail: 0 });
fireEvent.keyUp(control, { key: "Enter" });
}
/** Activate a control the way a mouse does, through the full pointer sequence. */
function pressWithMouse(control: HTMLElement) {
fireEvent.pointerDown(control, { pointerType: "mouse", button: 0 });
fireEvent.pointerUp(control, { pointerType: "mouse", button: 0 });
fireEvent.click(control, { detail: 1 });
}
test.each([
["the pointer", pressWithMouse],
["the keyboard", pressWithKeyboard],
])("a streamed row opens its provenance in a named dialog, from %s", async (_label, press) => {
await openLive(); await openLive();
act(() => act(() =>
sources[0]!.emit( sources[0]!.emit(
@@ -273,69 +319,108 @@ test("a streamed row opens its own provenance, from the keyboard as well as the
// only way to lose that is to opt out of it, which nothing here may do. // only way to lose that is to opt out of it, which nothing here may do.
expect(trigger.tagName).toBe("BUTTON"); expect(trigger.tagName).toBe("BUTTON");
expect(trigger.getAttribute("tabindex")).toBeNull(); expect(trigger.getAttribute("tabindex")).toBeNull();
act(() => trigger.focus()); // The row opens a dialog, so it says so; what it no longer claims is to
expect(document.activeElement).toBe(trigger); // expand a region that stays in the page.
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
expect(trigger.getAttribute("aria-expanded")).toBeNull();
expect(trigger.getAttribute("aria-controls")).toBeNull();
fireEvent.click(trigger); press(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" })); const dialog = detailDialog();
expect(screen.queryByRole("heading", { level: 1, name: "streamed.example" })).toBeNull(); expect(within(dialog).getByRole("heading", { level: 1, name: "streamed.example" })).toBeTruthy();
expect(within(dialog).getByText("Blocked locally")).toBeTruthy();
expect(dialog.textContent).toContain("the query log may not have written it yet");
// React Aria may defer the move by a frame, depending on the modality it read
// from the activation, so the wait is the assertion rather than a workaround.
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
expect(within(dialog).getByRole("button", { name: "Close" })).toBeTruthy();
}); });
test("the detail takes focus when a row opens it and hands it back when it closes", async () => { test("tabbing forward and backward stays inside the open dialog", async () => {
await openLive(); await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example"))); act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
const trigger = screen.getByRole("button", { name: "streamed.example" }); const dialog = detailDialog();
expect(trigger.getAttribute("aria-expanded")).toBe("false"); await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
expect(trigger.getAttribute("aria-controls")).toBeNull(); // The related links land asynchronously; tabbing before them would walk a
// shorter dialog than the reader ever sees.
await waitFor(() => expect(within(dialog).getAllByRole("link").length).toBeGreaterThan(1));
// A native button activates on Enter and Space; jsdom does not synthesize const visited = new Set<Element>();
// the click those keys fire, so the click is the activation. for (const shiftKey of [false, false, false, false, false, false, true, true, true, true]) {
act(() => trigger.focus()); fireEvent.keyDown(document.activeElement!, { key: "Tab", shiftKey });
fireEvent.click(trigger); fireEvent.keyUp(document.activeElement!, { key: "Tab", shiftKey });
expect(dialog.contains(document.activeElement)).toBe(true);
const panel = screen.getByRole("group", { name: "Streamed query" }); visited.add(document.activeElement!);
expect(trigger.getAttribute("aria-expanded")).toBe("true"); }
expect(trigger.getAttribute("aria-controls")).toBe(panel.id); // Containment that never moved focus would satisfy the check above without
// The panel is inserted above the table, behind the trigger in tab order, so // trapping anything, so the walk has to have actually walked.
// the only thing that keeps a forward tab inside it is focus moving in. expect(visited.size).toBeGreaterThan(1);
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 () => { test.each([
["the Close button", () => fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }))],
["Escape", () => fireEvent.keyDown(detailDialog(), { key: "Escape" })],
[
"a click on the backdrop",
() => {
const overlay = backdrop();
fireEvent.pointerDown(overlay, { pointerType: "mouse", button: 0 });
fireEvent.pointerUp(overlay, { pointerType: "mouse", button: 0 });
fireEvent.click(overlay, { detail: 1 });
},
],
])("%s closes the dialog and returns focus to the row that opened it", async (_label, dismiss) => {
await openLive(); await openLive();
act(() => { act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
sources[0]!.emit("query", frame(1000, "first.example")); const trigger = screen.getByRole("button", { name: "streamed.example" });
sources[0]!.emit("query", frame(1001, "second.example")); act(() => trigger.focus());
}); fireEvent.click(trigger);
expect(detailDialog()).toBeTruthy();
const first = screen.getByRole("button", { name: "first.example" }); dismiss();
const second = screen.getByRole("button", { name: "second.example" });
fireEvent.click(first);
fireEvent.click(second);
const panel = screen.getByRole("group", { name: "Streamed query" }); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(within(panel).getByRole("heading", { level: 1, name: "second.example" })).toBeTruthy(); expect(document.activeElement).toBe(screen.getByRole("button", { name: "streamed.example" }));
expect(document.activeElement).toBe(panel); });
expect(first.getAttribute("aria-expanded")).toBe("false");
expect(second.getAttribute("aria-expanded")).toBe("true"); test("the stream runs on behind the open dialog, and its rows land in the table on close", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
const snapshot = detailDialog().textContent;
act(() => sources[0]!.emit("query", frame(1001, "arrived-while-open.example")));
// The connection is untouched: no close, no second EventSource.
expect(sources).toHaveLength(1);
expect(sources[0]!.closed).toBe(false);
// And the snapshot is a snapshot: nothing that arrives rewrites it.
expect(detailDialog().textContent).toBe(snapshot);
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.getByRole("button", { name: "arrived-while-open.example" })).toBeTruthy();
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
});
test("the rows and toolbar behind the dialog are out of reach while it is open", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
// React Aria hides everything outside the modal from assistive technology
// and from the pointer alike, so the row and the toolbar are unreachable by
// role: nothing behind the dialog can be operated while it is open.
expect(screen.queryByRole("button", { name: "streamed.example" })).toBeNull();
expect(screen.queryByRole("button", { name: "Freeze" })).toBeNull();
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Close" })); fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(document.activeElement).toBe(second); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.getByRole("button", { name: "Freeze" })).toBeTruthy();
}); });
/** /**
@@ -359,23 +444,57 @@ function renderLiveWithCapacity(capacity: number) {
); );
} }
test("an open streamed detail survives the row being evicted from the ring buffer", () => { /** Push one ringful of filler through a 5-row ring, evicting whatever was there. */
renderLiveWithCapacity(5); function evictWithFiller() {
act(() => sources[0]!.emit("open"));
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();
// One ringful more: the ring keeps the newest 5, so the selected row is gone
// from the table. The detail is a snapshot, not a lookup into the ring.
act(() => { act(() => {
for (let index = 0; index < 5; index += 1) { for (let index = 0; index < 5; index += 1) {
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`)); sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
} }
}); });
}
test("an open dialog's snapshot survives its row being evicted from the ring buffer", async () => {
renderLiveWithCapacity(5);
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
const snapshot = detailDialog().textContent;
expect(within(detailDialog()).getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
// One ringful more: the ring keeps the newest 5, so the selected row is gone.
// The dialog holds the frame itself, not a lookup into the ring, so it neither
// blanks out nor closes.
evictWithFiller();
expect(detailDialog().textContent).toBe(snapshot);
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.getAllByRole("row")).toHaveLength(6); expect(screen.getAllByRole("row")).toHaveLength(6);
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull(); expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy(); });
test("closing after the source row is evicted anchors focus in the results region", async () => {
renderLiveWithCapacity(5);
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
const trigger = screen.getByRole("button", { name: "evicted.example" });
act(() => trigger.focus());
fireEvent.click(trigger);
evictWithFiller();
expect(trigger.isConnected).toBe(false);
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
// Never the body, never whichever row happens to sit where the old one did,
// and never the Freeze button: a stable anchor in the region the reader was
// reading. React Aria's own deferred restore runs after this and, finding
// focus already placed, leaves it alone.
const region = screen.getByRole("region", { name: "Live queries" });
expect(document.activeElement).toBe(region);
await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))));
expect(document.activeElement).toBe(region);
}); });
test("the route renders the live ring at its production capacity", async () => { test("the route renders the live ring at its production capacity", async () => {
@@ -391,34 +510,29 @@ test("an open streamed detail survives Freeze and Resume", async () => {
await openLive(); await openLive();
act(() => sources[0]!.emit("query", frame(1000, "held.example"))); act(() => sources[0]!.emit("query", frame(1000, "held.example")));
fireEvent.click(screen.getByRole("button", { name: "held.example" })); fireEvent.click(screen.getByRole("button", { name: "held.example" }));
const snapshot = detailDialog().textContent;
fireEvent.click(screen.getByRole("button", { name: "Freeze" })); // Freeze is behind the dialog, so it is reached the way the code reaches it
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy(); // rather than by role, which the modal deliberately hides.
fireEvent.click(screen.getByRole("button", { name: "Resume" })); const freeze = () => screen.getByText("Freeze") as HTMLButtonElement;
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy(); act(() => freeze().click());
expect(detailDialog().textContent).toBe(snapshot);
act(() => (screen.getByText("Resume") as HTMLButtonElement).click());
expect(detailDialog().textContent).toBe(snapshot);
}); });
test("the filter row stays visible, keeps its values, and is out of the tab order", async () => { test("live renders no filter toolbar, not even a disabled one", async () => {
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true"); await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
const domain = screen.getByLabelText("Domain contains") as HTMLInputElement; // The stream is unfiltered — the server sends every query — so a row of
expect(domain.value).toBe("ads"); // controls here would promise filtering that is not happening. The filters
expect(domain.disabled).toBe(true); // are not lost: they are in the URL, and History applies them on the way back.
expect((screen.getByLabelText("Client (exact)") as HTMLInputElement).disabled).toBe(true); expect(screen.queryByLabelText("Filter domains")).toBeNull();
expect((screen.getByLabelText("Since") as HTMLInputElement).disabled).toBe(true); expect(screen.queryByLabelText("Client IP (exact match)")).toBeNull();
expect((screen.getByLabelText("Until") as HTMLInputElement).disabled).toBe(true); expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
const form = domain.closest("form")!; expect(screen.queryByText("Clear")).toBeNull();
const controls = [...form.querySelectorAll("input, button, select, textarea, a[href], [tabindex]")]; expect(screen.getByText(/the History filters apply to history only/)).toBeTruthy();
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 () => { test("live mode asks for no query pages, whatever filters the url retained", async () => {
@@ -431,31 +545,30 @@ test("live mode asks for no query pages, whatever filters the url retained", asy
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => { test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
await openLive("/activity?mode=live&domain=ads"); await openLive("/activity?mode=live&domain=ads");
fireEvent.click(screen.getByRole("button", { name: "History" })); fireEvent.click(screen.getByRole("tab", { name: "History" }));
await screen.findByRole("button", { name: "Apply filters" }); await screen.findByLabelText("Filter domains");
expect(sources).toHaveLength(1); expect(sources).toHaveLength(1);
expect(sources[0]!.closed).toBe(true); expect(sources[0]!.closed).toBe(true);
// The filters came along, which is the point of switching rather than // The filters came along, which is the point of switching rather than
// navigating: the reader keeps the question they were asking. // navigating: the reader keeps the question they were asking.
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads"); expect((screen.getByLabelText("Filter domains") as HTMLInputElement).value).toBe("ads");
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Live" })); fireEvent.click(screen.getByRole("tab", { name: "Live" }));
await screen.findByRole("button", { name: "Freeze" }); await screen.findByRole("button", { name: "Freeze" });
expect(sources).toHaveLength(2); expect(sources).toHaveLength(2);
expect(sources[1]!.closed).toBe(false); expect(sources[1]!.closed).toBe(false);
}); });
/** /** The related-actions region of a query detail. */
* The related-actions region of a query detail. Scoped on purpose: the sidebar
* carries a Pause of its own, and this is the one that answers "this query was
* blocked and should not have been".
*/
function related(): HTMLElement { function related(): HTMLElement {
return screen.getByRole("region", { name: "Related" }); return screen.getByRole("region", { name: "Related" });
} }
test("a streamed blocked row carries the same Pause action as the persisted detail", async () => { /**
* The streamed detail carries the same Related as the persisted one: four links
* and no control. Pause is resolver-wide and lives in the sidebar alone.
*/
test("a streamed blocked row's Related carries links only", async () => {
await openLive(); await openLive();
act(() => act(() =>
sources[0]!.emit( sources[0]!.emit(
@@ -468,14 +581,7 @@ test("a streamed blocked row carries the same Pause action as the persisted deta
); );
fireEvent.click(screen.getByRole("button", { name: "streamed.example" })); fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy()); await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
}); expect(within(related()).getAllByRole("link")).toHaveLength(4);
expect(within(related()).queryByRole("button")).toBeNull();
test("a streamed row that was allowed offers nothing to pause", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1001, "allowed.example", { policy: { action: "allow" } })));
fireEvent.click(screen.getByRole("button", { name: "allowed.example" }));
await screen.findByRole("heading", { level: 1, name: "allowed.example" });
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
}); });
+108 -134
View File
@@ -1,6 +1,6 @@
/** /**
* Activity in live mode: the SSE stream, its bounded ring buffer, and the * Activity in live mode: the SSE stream, its bounded ring buffer, and the modal
* in-place detail a streamed row opens. * detail a streamed row opens.
* *
* This subtree is mounted only while the URL says `mode=live`, which is what * 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 * closes the EventSource on the way back to history: the connection is a
@@ -17,6 +17,7 @@ import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { useClientNames } from "@/features/clients/clientNames"; import { useClientNames } from "@/features/clients/clientNames";
import { summarizeEvent } from "@/features/provenance/querySummary"; import { summarizeEvent } from "@/features/provenance/querySummary";
import Dialog from "@/ui/Dialog";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells"; import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
@@ -88,6 +89,7 @@ const styles = stylex.create({
lineHeight: "1.25rem", lineHeight: "1.25rem",
}, },
dismiss: { dismiss: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderStyle: "none", borderStyle: "none",
backgroundColor: "transparent", backgroundColor: "transparent",
padding: 0, padding: 0,
@@ -161,29 +163,6 @@ const styles = stylex.create({
textDecorationLine: "underline", textDecorationLine: "underline",
textDecorationStyle: "dotted", 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: { footnote: {
marginTop: "0.75rem", marginTop: "0.75rem",
fontSize: "0.875rem", fontSize: "0.875rem",
@@ -192,10 +171,6 @@ 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> = { const PILL_LABELS: Record<StreamStatus, string> = {
connecting: "Connecting…", connecting: "Connecting…",
open: "Live", open: "Live",
@@ -225,40 +200,15 @@ function StatusPill({ status }: { status: StreamStatus }) {
* *
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by * 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 * 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 * dialog would blank out or swap under their eyes for no reason they could see.
* fact — a streamed frame carries its own provenance — so it survives eviction, * The snapshot is the whole fact — a streamed frame carries its own provenance
* a merge and a Freeze/Resume, and closes only when the reader closes it or * — so it survives eviction, a merge and a Freeze/Resume, and closes only when
* leaves live mode. * the reader closes it or leaves live mode. The stream behind it never stops.
*/ */
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) { function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
const summary = summarizeEvent(row.event); 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 ( return (
<div <Dialog title="Streamed query" size="detail" isOpen onClose={onClose}>
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 <ProvenanceDetail
provenance={row.event} provenance={row.event}
persistedId={null} persistedId={null}
@@ -268,11 +218,10 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi
client={summary.client_ip} client={summary.client_ip}
ts={summary.ts} ts={summary.ts}
origin={origin} origin={origin}
blocked={row.event.policy.action === "block"}
/> />
} }
/> />
</div> </Dialog>
); );
} }
@@ -292,21 +241,36 @@ export default function LiveActivity({
const clientNames = useClientNames(); const clientNames = useClientNames();
const [selected, setSelected] = useState<StreamedRow | null>(null); const [selected, setSelected] = useState<StreamedRow | null>(null);
const trigger = useRef<HTMLButtonElement | null>(null); const trigger = useRef<HTMLButtonElement | null>(null);
const results = useRef<HTMLDivElement>(null);
const restoring = useRef(false);
/**
* Where focus lands when the dialog closes.
*
* React Aria restores focus itself, but in a `requestAnimationFrame` and
* only while focus is still on the body — and its target is the row button,
* which the ring may have evicted while the reader was reading. So this runs
* in the effect that follows the focus scope's teardown and puts focus on a
* connected element first: the row if it is still there, the results region
* if it is not. React Aria's deferred pass then finds focus already placed
* and does nothing, so the two never fight over it. If a navigation unmounts
* this component the effect never runs, which is the right answer — there is
* no longer a table to return to.
*/
useEffect(() => {
if (selected !== null || !restoring.current) return;
restoring.current = false;
const from = trigger.current;
trigger.current = null;
(from?.isConnected === true ? from : results.current)?.focus();
}, [selected]);
function open(row: StreamedRow, from: HTMLButtonElement) { function open(row: StreamedRow, from: HTMLButtonElement) {
trigger.current = from; trigger.current = from;
restoring.current = true;
setSelected(row); 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 ( return (
<> <>
<div {...stylex.props(styles.toolbar)}> <div {...stylex.props(styles.toolbar)}>
@@ -362,71 +326,81 @@ export default function LiveActivity({
</div> </div>
)} )}
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />} {/*
* The region is the anchor focus falls back to when the row that
* opened the dialog is gone, so it is rendered unconditionally: an
* anchor that disappears with the last row is no anchor at all.
*/}
<div
ref={results}
tabIndex={-1}
role="region"
aria-label="Live queries"
{...stylex.props(shared.focusRing)}
>
{live.rows.length === 0 ? (
live.status !== "capped" && (
<p {...stylex.props(styles.empty)}>
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
</p>
)
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
{live.rows.map((row) => {
const summary = summaryOf(row);
return (
<tr
key={row.key}
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
>
<ActivityCells
row={summary}
clientNames={clientNames}
renderDomain={(_id, children) =>
row.kind === "streamed" ? (
<button
type="button"
aria-haspopup="dialog"
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>
);
})}
</tbody>
</table>
</div>
<p {...stylex.props(styles.footnote)}>
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first,
last {capacity} kept).
</p>
</>
)}
</div>
{live.rows.length === 0 ? ( {selected !== null && <LiveDetail row={selected} origin={origin} onClose={() => setSelected(null)} />}
live.status !== "capped" && (
<p {...stylex.props(styles.empty)}>
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
</p>
)
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
{live.rows.map((row) => {
const summary = summaryOf(row);
return (
<tr
key={row.key}
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
>
<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>
);
})}
</tbody>
</table>
</div>
<p {...stylex.props(styles.footnote)}>
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
{capacity} kept).
</p>
</>
)}
</> </>
); );
} }
@@ -11,7 +11,6 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import PauseControl from "@/features/pause/PauseControl";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { provenanceRelatedLink } from "./ProvenanceDetail"; import { provenanceRelatedLink } from "./ProvenanceDetail";
import { diagnosticsBounds, relatedBounds } from "./relatedLinks"; import { diagnosticsBounds, relatedBounds } from "./relatedLinks";
@@ -24,15 +23,9 @@ interface Props {
ts: number; ts: number;
/** The Activity search the reader came from; its bounds win over the defaults. */ /** The Activity search the reader came from; its bounds win over the defaults. */
origin: Pick<ActivitySearch, "since" | "until">; origin: Pick<ActivitySearch, "since" | "until">;
/**
* This query was blocked. Pausing is a valid answer to a block the reader
* disagrees with, and to nothing else here — so the control appears for a
* block and not beside an allowed query it could not have caused.
*/
blocked: boolean;
} }
export default function RelatedActions({ domain, client, ts, origin, blocked }: Props) { export default function RelatedActions({ domain, client, ts, origin }: Props) {
const bounds = relatedBounds(ts, origin); const bounds = relatedBounds(ts, origin);
const window = diagnosticsBounds(ts); const window = diagnosticsBounds(ts);
return ( return (
@@ -61,7 +54,6 @@ export default function RelatedActions({ domain, client, ts, origin, blocked }:
> >
Diagnostics around this query Diagnostics around this query
</Link> </Link>
{blocked && <PauseControl />}
</> </>
); );
} }
@@ -18,16 +18,10 @@ interface Props {
} }
const styles = stylex.create({ const styles = stylex.create({
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
form: { form: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: "1rem", gap: "1rem",
marginTop: "1rem",
}, },
fieldLabel: { fieldLabel: {
display: "block", display: "block",
@@ -67,8 +61,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
const readOnly = useReadOnlyConfig(); const readOnly = useReadOnlyConfig();
return ( return (
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}> <Dialog title={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
<form <form
{...stylex.props(styles.form)} {...stylex.props(styles.form)}
onSubmit={(event) => { onSubmit={(event) => {
+207 -21
View File
@@ -1,6 +1,20 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { ClientName, type ClientNames } from "./clientNames"; import { ClientName, type ClientNames } from "./clientNames";
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures"; import { BASE, CLIENTS, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
/** The text of the elements an input points at with `aria-describedby`. */
function describedText(input: HTMLElement): string {
const ids = input.getAttribute("aria-describedby");
if (ids === null) throw new Error("input has no aria-describedby");
return ids
.split(/\s+/)
.map((id) => {
const node = document.getElementById(id);
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
return node.textContent ?? "";
})
.join(" ");
}
function clientRow(ip: string): HTMLElement { function clientRow(ip: string): HTMLElement {
const row = screen.getByText(ip).closest("tr"); const row = screen.getByText(ip).closest("tr");
@@ -73,6 +87,39 @@ test("the address links to the client's detail page", async () => {
expect(link.getAttribute("href")).toBe("/clients/1"); expect(link.getAttribute("href")).toBe("/clients/1");
}); });
test("delete asks first, naming the row, and the confirmation carries out the delete", async () => {
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
// The operator has to be able to tell from the dialog alone which row this is.
expect(within(dialog).getByText(/kids-tablet\.lan \(192\.168\.1\.11\)/)).toBeTruthy();
expect(within(dialog).getByText(/re-materialize on their next DNS query/)).toBeTruthy();
// Asking is not deleting.
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2")),
).toHaveLength(1),
);
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
});
test("cancelling the confirmation keeps the client", async () => {
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(screen.getByText("192.168.1.11")).toBeTruthy();
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
});
test("shows the DNS-activity empty state when there are no clients", async () => { test("shows the DNS-activity empty state when there are no clients", async () => {
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } }); await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
@@ -140,9 +187,15 @@ test("an unknown group id filters to nothing and offers a way out", async () =>
expect(router.state.location.search).toEqual({}); expect(router.state.location.search).toEqual({});
}); });
// Both statuses lock the declared delete, but only file authority proves the
// file declares the row; the anchors keep the two sentences apart.
const DECLARED_NOTE = /^This client is declared in the configuration file/;
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => { test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE }); await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
await screen.findAllByLabelText(/Managed by \/etc\/nxdns\/config\.zon/); // The settled sentence, not the tag: "Locked" is already on screen while
// authority is pending, so waiting on it would not wait for this status.
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]); expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
@@ -150,6 +203,34 @@ test("file mode drops every edit affordance and keeps the observed delete live (
const observed = clientRow("192.168.1.11"); const observed = clientRow("192.168.1.11");
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true); expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false); expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
// Why the locked Delete will not answer, in visible text and exactly once:
// per row it would repeat down the whole page, and on the button it was a
// title that a keyboard and a touch screen never reached.
expect(screen.getAllByText(DECLARED_NOTE, { selector: "p" })).toHaveLength(1);
expect(within(declared).queryByText(DECLARED_NOTE, { selector: "p" })).toBeNull();
// The description stays on the button itself too, for a reader on that control.
const locked = within(declared).getByRole("button", { name: "Delete" });
expect(document.getElementById(locked.getAttribute("aria-describedby") ?? "")?.textContent).toMatch(DECLARED_NOTE);
});
test("an all-observed page still says why Edit is gone, with no delete note to carry it", async () => {
// Every row observed, so no Delete is locked. The edit lock is still real, and
// "Locked" appearing with nothing to explain it is the failure this guards.
const observedOnly = { clients: [CLIENTS.clients[1]] };
await renderClientsPage({
...BASE,
"GET /api/clients": observedOnly,
"GET /api/config/status": MANAGED_FILE,
});
// The settled sentence is both the anchor and the assertion: it is the whole
// explanation for the missing Edit action.
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
expect(await screen.findAllByText("Locked")).not.toHaveLength(0);
// The delete sentence belongs only to a row that has one.
expect(screen.queryByText(DECLARED_NOTE)).toBeNull();
expect((screen.getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
}); });
test("file mode renders network assignments with no mutation control at all (R2-4)", async () => { test("file mode renders network assignments with no mutation control at all (R2-4)", async () => {
@@ -170,7 +251,7 @@ test("file mode renders network assignments with no mutation control at all (R2-
test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => { test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": undefined }); await renderClientsPage({ ...BASE, "GET /api/config/status": undefined });
await screen.findAllByLabelText(/Configuration status unavailable/); await screen.findAllByText(/^Configuration status unavailable/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]); expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull(); expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
@@ -186,10 +267,6 @@ test("a failed config status exposes no configuration mutation, and still delete
// confirmation is already open. `undefined` is the failed status: the fetch stub // confirmation is already open. `undefined` is the failed status: the fetch stub
// answers 404 for a key it does not hold. // answers 404 for a key it does not hold.
// //
// Both statuses lock the declared delete, but only file authority proves the
// file declares the row; the anchors keep the two sentences apart.
const DECLARED_NOTE = /^This client is declared in the configuration file/;
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
describe.each([ describe.each([
["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE], ["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE],
["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE], ["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE],
@@ -212,13 +289,13 @@ describe.each([
expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull(); expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull();
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop"); expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy(); expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy();
expect(within(dialog).getByLabelText(/^Locked\./)).toBeTruthy(); expect(within(dialog).getByText("Locked")).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
}); });
test("locks an open declared-client delete confirmation and leaves cancel working", async () => { test("locks an open declared-client delete confirmation in place and leaves cancel working", async () => {
const map = { ...BASE }; const map = { ...BASE };
const { queryClient, fetchMock } = await renderClientsPage(map); const { queryClient, fetchMock } = await renderClientsPage(map);
// The Edit affordance appearing is the proof authority resolved to // The Edit affordance appearing is the proof authority resolved to
@@ -227,23 +304,68 @@ describe.each([
const declared = clientRow("192.168.1.10"); const declared = clientRow("192.168.1.10");
fireEvent.click(within(declared).getByRole("button", { name: "Delete" })); fireEvent.click(within(declared).getByRole("button", { name: "Delete" }));
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Confirm delete" })).toBeTruthy(); const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
await setConfigStatus(map, queryClient, status); await setConfigStatus(map, queryClient, status);
const confirming = clientRow("192.168.1.10"); // The dialog stays put. Closing it would throw focus at the Delete button
expect(within(confirming).queryByRole("button", { name: "Confirm delete" })).toBeNull(); // the same turn disabled, and the reason would survive only as a title
expect(within(confirming).getByText(lockNote)).toBeTruthy(); // attribute; here the reason is the dialog's own message.
expect(within(confirming).queryByText(otherNote)).toBeNull(); await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
expect(within(confirming).getByLabelText(/^Locked\./)).toBeTruthy(); expect(within(dialog).getByText(lockNote)).toBeTruthy();
expect(within(dialog).queryByText(otherNote)).toBeNull();
expect(within(dialog).getByText("Locked")).toBeTruthy();
fireEvent.click(within(confirming).getByRole("button", { name: "Cancel" })); fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })).toBeTruthy(),
);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]); expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
}); });
test("a cancelled confirmation stays closed when authority comes back", async () => {
const map = { ...BASE };
const { queryClient } = await renderClientsPage(map);
await unlockedEdit(0);
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
// A poll that fails and then recovers must not raise a destructive
// question the operator already answered.
await setConfigStatus(map, queryClient, status);
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
await waitFor(() => expect(screen.getAllByRole("button", { name: "Edit" }).length).toBeGreaterThan(0));
expect(screen.queryByRole("alertdialog")).toBeNull();
});
test("the confirm action comes back when authority does, without a second prompt", async () => {
const map = { ...BASE, "DELETE /api/clients/1": {} };
const { queryClient, fetchMock } = await renderClientsPage(map);
await unlockedEdit(0);
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
await setConfigStatus(map, queryClient, status);
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
// The question was never withdrawn, so the answer returns to the same
// dialog rather than asking the operator to start again.
const confirm = await within(dialog).findByRole("button", { name: "Delete" });
fireEvent.click(confirm);
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(
([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1"),
),
).toHaveLength(1),
);
});
test("keeps an open observed-client delete confirmation live (R3-4)", async () => { test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
const map = { ...BASE, "DELETE /api/clients/2": {} }; const map = { ...BASE, "DELETE /api/clients/2": {} };
const { queryClient, fetchMock } = await renderClientsPage(map); const { queryClient, fetchMock } = await renderClientsPage(map);
@@ -253,10 +375,11 @@ describe.each([
const observed = clientRow("192.168.1.11"); const observed = clientRow("192.168.1.11");
fireEvent.click(within(observed).getByRole("button", { name: "Delete" })); fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
await setConfigStatus(map, queryClient, status); await setConfigStatus(map, queryClient, status);
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Confirm delete" })); fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => await waitFor(() =>
expect( expect(
fetchMock.mock.calls.filter( fetchMock.mock.calls.filter(
@@ -271,7 +394,7 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
// The status request never settles, so authority stays pending for the whole // The status request never settles, so authority stays pending for the whole
// test: nothing configuration owns may be offered on that guess. // test: nothing configuration owns may be offered on that guess.
await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER }); await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER });
await screen.findAllByLabelText(/Checking which configuration source/); await screen.findAllByText(/^Checking which configuration source/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]); expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull(); expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
@@ -280,3 +403,66 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
const observed = clientRow("192.168.1.11"); const observed = clientRow("192.168.1.11");
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false); expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
}); });
test("a save-time problem marks the input it is about and describes it", async () => {
await renderClientsPage();
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
fireEvent.change(range, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
expect(range.getAttribute("aria-invalid")).toBe("true");
expect(describedText(range)).toBe("Row 1: prefix is required.");
// Only the offending input is marked; the priority beside it is untouched.
const priority = screen.getByLabelText("Priority for range 1");
expect(priority.getAttribute("aria-invalid")).toBeNull();
expect(priority.getAttribute("aria-describedby")).toBeNull();
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
fireEvent.change(screen.getByLabelText("Priority for range 1"), { target: { value: "abc" } });
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
expect(range.getAttribute("aria-invalid")).toBeNull();
expect(describedText(screen.getByLabelText("Priority for range 1"))).toBe(
"Row 1: priority must be a whole number.",
);
});
test("removing a row drops the message rather than moving it to another input", async () => {
await renderClientsPage();
// Row 1 is the offending one, so removing it is what would slide the stale
// index onto row 2 — an input that validated cleanly.
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
fireEvent.change(range, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "Add range" }));
fireEvent.change(screen.getByLabelText("Range 2"), { target: { value: "10.0.0.0/8" } });
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
expect(range.getAttribute("aria-invalid")).toBe("true");
expect(describedText(range)).toBe("Row 1: prefix is required.");
const section = assignmentsSection();
fireEvent.click(within(section).getAllByRole("button", { name: "Remove" })[0] as HTMLButtonElement);
const survivor = screen.getByLabelText("Range 1") as HTMLInputElement;
expect(survivor.value).toBe("10.0.0.0/8");
expect(survivor.getAttribute("aria-invalid")).toBeNull();
expect(survivor.getAttribute("aria-describedby")).toBeNull();
expect(within(section).queryByRole("alert")).toBeNull();
expect(screen.queryByLabelText("Range 2")).toBeNull();
});
test("editing a row clears the message it was about", async () => {
await renderClientsPage();
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
fireEvent.change(range, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
expect(range.getAttribute("aria-invalid")).toBe("true");
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
expect(range.getAttribute("aria-invalid")).toBeNull();
expect(within(assignmentsSection()).queryByRole("alert")).toBeNull();
});
+88 -79
View File
@@ -9,7 +9,8 @@ import ClientEditDialog from "./ClientEditDialog";
import NetworkAssignments from "./NetworkAssignments"; import NetworkAssignments from "./NetworkAssignments";
import { ClientDisplayName } from "./clientIdentity"; import { ClientDisplayName } from "./clientIdentity";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator"; import ConfirmDialog from "@/ui/ConfirmDialog";
import ConfigLockIndicator, { lockReason } from "@/features/configuration/ConfigLockIndicator";
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority"; import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
@@ -24,6 +25,9 @@ import { colors } from "@/ui/tokens.stylex";
* alone cannot say which operator surface set the row — so the sentence names * alone cannot say which operator surface set the row — so the sentence names
* the doubt rather than asserting a declaration, the way `provenanceOf` does. * the doubt rather than asserting a declaration, the way `provenanceOf` does.
*/ */
/** The one note every locked Delete on this page describes itself with. */
const DELETE_LOCK_NOTE_ID = "clients-delete-locked-note";
function declaredDeleteNote(authority: Authority): string { function declaredDeleteNote(authority: Authority): string {
if (authority.state === "resolved") { if (authority.state === "resolved") {
return "This client is declared in the configuration file; remove it there and restart."; return "This client is declared in the configuration file; remove it there and restart.";
@@ -31,6 +35,16 @@ function declaredDeleteNote(authority: Authority): string {
return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked."; return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked.";
} }
/**
* How the confirmation names the row. The address is always there and always
* unique, so it carries the sentence; a name the operator recognizes leads when
* the row has one.
*/
function clientLabel(client: Client): string {
const name = client.name !== "" ? client.name : client.learned_name;
return name === "" ? client.ip : `${name} (${client.ip})`;
}
const styles = stylex.create({ const styles = stylex.create({
heading: { heading: {
fontSize: "1.5rem", fontSize: "1.5rem",
@@ -41,6 +55,15 @@ const styles = stylex.create({
marginTop: "1rem", marginTop: "1rem",
color: colors.textMuted, color: colors.textMuted,
}, },
lockNote: {
marginTop: "1rem",
display: "flex",
flexDirection: "column",
gap: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
filterBar: { filterBar: {
marginTop: "1rem", marginTop: "1rem",
display: "flex", display: "flex",
@@ -79,23 +102,11 @@ const styles = stylex.create({
color: colors.primaryOnSurface, color: colors.primaryOnSurface,
textDecorationLine: "none", textDecorationLine: "none",
}, },
confirmGroup: {
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: "0.5rem",
},
actionGroup: { actionGroup: {
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: "0.5rem", gap: "0.5rem",
}, },
note: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
dangerText: { dangerText: {
color: colors.danger, color: colors.danger,
}, },
@@ -112,7 +123,7 @@ export default function ClientsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const deleteMutation = useMutation(clientDeleteMutation(queryClient)); const deleteMutation = useMutation(clientDeleteMutation(queryClient));
const [editing, setEditing] = useState<Client | null>(null); const [editing, setEditing] = useState<Client | null>(null);
const [confirmingId, setConfirmingId] = useState<number | null>(null); const [pendingDelete, setPendingDelete] = useState<Client | null>(null);
const readOnly = useReadOnlyConfig(); const readOnly = useReadOnlyConfig();
const authority = useAuthority(); const authority = useAuthority();
@@ -122,6 +133,17 @@ export default function ClientsPage() {
const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group); const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group);
const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group); const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group);
// Authority is polled, so it can turn while the confirmation is open. The
// dialog reads it on every render rather than trusting the state that opened
// it, and withdraws the answer that would now fail instead of withdrawing the
// question: the operator is told why, and only they close the dialog.
const deleteLocked = pendingDelete !== null && readOnly && pendingDelete.hand_edited;
// The reason a locked Delete will not answer, printed once above the table.
// Per row it would repeat down the whole page; on the button it was a `title`
// that a keyboard and a touch screen never reached.
const deletesLocked = readOnly && rows.some((client) => client.hand_edited);
return ( return (
<section> <section>
<h1 {...stylex.props(styles.heading)}>Clients</h1> <h1 {...stylex.props(styles.heading)}>Clients</h1>
@@ -137,6 +159,15 @@ export default function ClientsPage() {
</Link> </Link>
</div> </div>
)} )}
{/* The whole explanation for this page's locks, printed once. Per row it
would repeat down the table; on the controls it was a `title` that a
keyboard and a touch screen never reached. */}
{readOnly && (
<div {...stylex.props(styles.lockNote)}>
<p>{lockReason(authority)}.</p>
{deletesLocked && <p id={DELETE_LOCK_NOTE_ID}>{declaredDeleteNote(authority)}</p>}
</div>
)}
{clients.length === 0 ? ( {clients.length === 0 ? (
<p {...stylex.props(styles.empty)}> <p {...stylex.props(styles.empty)}>
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
@@ -178,77 +209,37 @@ export default function ClientsPage() {
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td> <td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td> <td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
<td {...stylex.props(styles.cell, styles.right)}> <td {...stylex.props(styles.cell, styles.right)}>
{confirmingId === client.id ? ( <span {...stylex.props(styles.actionGroup)}>
<span {...stylex.props(styles.confirmGroup)}> {/* Naming a client writes configuration, so the affordance is
{/* Authority is polled, so it can turn while a confirmation absent — not disabled — wherever the write cannot land. */}
sits open. The confirm path reads it on every render {readOnly ? (
rather than trusting the state that opened it. */} <ConfigLockIndicator compact />
<span {...stylex.props(styles.note)}> ) : (
{readOnly && client.hand_edited
? declaredDeleteNote(authority)
: "Deleted clients re-materialize on their next DNS query."}
</span>
{readOnly && client.hand_edited ? (
<ConfigLockIndicator />
) : (
<button
type="button"
onClick={() => {
setConfirmingId(null);
deleteMutation.mutate(client.id);
}}
{...stylex.props(
shared.smallButton,
styles.dangerText,
shared.focusRing,
)}
>
Confirm delete
</button>
)}
<button <button
type="button" type="button"
onClick={() => setConfirmingId(null)} onClick={() => setEditing(client)}
{...stylex.props(shared.smallButton, shared.focusRing)} {...stylex.props(shared.smallButton, shared.focusRing)}
> >
Cancel Edit
</button> </button>
</span> )}
) : ( <button
<span {...stylex.props(styles.actionGroup)}> type="button"
{/* Naming a client writes configuration, so the affordance is onClick={() => setPendingDelete(client)}
absent — not disabled — wherever the write cannot land. */} disabled={readOnly && client.hand_edited}
{readOnly ? ( aria-describedby={
<ConfigLockIndicator /> readOnly && client.hand_edited ? DELETE_LOCK_NOTE_ID : undefined
) : ( }
<button {...stylex.props(
type="button" shared.smallButton,
onClick={() => setEditing(client)} styles.dangerText,
{...stylex.props(shared.smallButton, shared.focusRing)} styles.dimWhenDisabled,
> shared.focusRing,
Edit
</button>
)} )}
<button >
type="button" Delete
onClick={() => setConfirmingId(client.id)} </button>
disabled={readOnly && client.hand_edited} </span>
title={
readOnly && client.hand_edited
? declaredDeleteNote(authority)
: undefined
}
{...stylex.props(
shared.smallButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
</span>
)}
</td> </td>
</tr> </tr>
))} ))}
@@ -256,6 +247,24 @@ export default function ClientsPage() {
</table> </table>
</div> </div>
)} )}
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete client"
message={
pendingDelete === null
? ""
: deleteLocked
? declaredDeleteNote(authority)
: `Delete ${clientLabel(pendingDelete)}? Deleted clients re-materialize on their next DNS query.`
}
confirmLabel="Delete"
lock={deleteLocked ? <ConfigLockIndicator /> : undefined}
onConfirm={() => {
if (pendingDelete !== null) deleteMutation.mutate(pendingDelete.id);
setPendingDelete(null);
}}
onCancel={() => setPendingDelete(null)}
/>
<InlineError error={deleteMutation.error} /> <InlineError error={deleteMutation.error} />
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />} {editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
<NetworkAssignments prefixes={prefixes} groups={groups} /> <NetworkAssignments prefixes={prefixes} groups={groups} />
@@ -4,7 +4,15 @@ import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries"; import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types"; import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup"; import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor"; import {
firstProblem,
initPrefixEditor,
isDirty,
prefixEditorReducer,
toInputs,
type PrefixEditorAction,
type PrefixProblem,
} from "./prefixEditor";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import AuthorityGate from "@/features/configuration/AuthorityGate"; import AuthorityGate from "@/features/configuration/AuthorityGate";
import Select from "@/ui/Select"; import Select from "@/ui/Select";
@@ -16,6 +24,9 @@ interface Props {
groups: Group[]; groups: Group[];
} }
/** The editor is rendered once per page, so the message can hold a fixed id. */
const VALIDATION_ID = "network-assignments-validation";
const styles = stylex.create({ const styles = stylex.create({
section: { section: {
marginTop: "2.5rem", marginTop: "2.5rem",
@@ -65,6 +76,7 @@ const styles = stylex.create({
width: "5rem", width: "5rem",
}, },
removeButton: { removeButton: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderRadius: "0.25rem", borderRadius: "0.25rem",
borderWidth: 1, borderWidth: 1,
borderStyle: "solid", borderStyle: "solid",
@@ -161,12 +173,24 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
function AssignmentsEditor({ prefixes, groups }: Props) { function AssignmentsEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient)); const mutation = useMutation(clientPrefixesPutMutation(queryClient));
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor); const [state, apply] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<string | null>(null); const [validation, setValidation] = useState<PrefixProblem | null>(null);
const dirty = isDirty(state); const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups); const fallbackGroupId = defaultGroupId(groups);
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name })); const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
// A problem names a row by position, and every action here can move, add or
// delete a position. The message describes the rows Save read, so it dies
// with them rather than drifting onto whatever row inherits the index.
const dispatch = (action: PrefixEditorAction) => {
setValidation(null);
apply(action);
};
/** True for the one input the current message is about; nothing else is marked. */
const invalid = (index: number, field: PrefixProblem["field"]): true | undefined =>
validation !== null && validation.index === index && validation.field === field ? true : undefined;
const save = () => { const save = () => {
const problem = firstProblem(state.rows); const problem = firstProblem(state.rows);
setValidation(problem); setValidation(problem);
@@ -187,6 +211,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
<input <input
type="text" type="text"
aria-label={`Range ${index + 1}`} aria-label={`Range ${index + 1}`}
aria-invalid={invalid(index, "prefix")}
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
placeholder="192.168.1.0/24" placeholder="192.168.1.0/24"
value={row.prefix} value={row.prefix}
onChange={(event) => onChange={(event) =>
@@ -207,6 +233,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
type="text" type="text"
inputMode="numeric" inputMode="numeric"
aria-label={`Priority for range ${index + 1}`} aria-label={`Priority for range ${index + 1}`}
aria-invalid={invalid(index, "priority")}
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
placeholder="100" placeholder="100"
value={row.priority} value={row.priority}
onChange={(event) => onChange={(event) =>
@@ -226,8 +254,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
</ul> </ul>
)} )}
{validation !== null && ( {validation !== null && (
<p role="alert" {...stylex.props(styles.validation)}> <p id={VALIDATION_ID} role="alert" {...stylex.props(styles.validation)}>
{validation} {validation.message}
</p> </p>
)} )}
<InlineError error={mutation.error} /> <InlineError error={mutation.error} />
@@ -250,10 +278,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
{dirty && ( {dirty && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => dispatch({ type: "reset", prefixes })}
setValidation(null);
dispatch({ type: "reset", prefixes });
}}
{...stylex.props(shared.button, shared.focusRing)} {...stylex.props(shared.button, shared.focusRing)}
> >
Discard changes Discard changes
+13 -3
View File
@@ -16,6 +16,14 @@ import * as stylex from "@stylexjs/stylex";
import { clientsQuery } from "@/lib/queries"; import { clientsQuery } from "@/lib/queries";
import type { Client } from "@/lib/types"; import type { Client } from "@/lib/types";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
/** Secondary to the name it qualifies, and never the only thing in the cell. */
address: {
color: colors.textMuted,
},
});
export type ClientNames = ReadonlyMap<string, Pick<Client, "name" | "learned_name">>; export type ClientNames = ReadonlyMap<string, Pick<Client, "name" | "learned_name">>;
@@ -53,11 +61,13 @@ export function clientLabel(ip: string, names: ClientNames): { text: string; lea
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) { export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
const label = clientLabel(ip, names); const label = clientLabel(ip, names);
if (label === null) return <span {...stylex.props(shared.mono)}>{ip}</span>; if (label === null) return <span {...stylex.props(shared.mono)}>{ip}</span>;
// The name replaces the address on screen, so the address stays reachable // The name replaces the address, so the address follows it as real text that
// as the tooltip rather than disappearing from the row entirely. // anyone can read and copy. A `title` carried it before, which reaches
// neither a keyboard nor a touch screen.
return ( return (
<span title={ip} {...stylex.props(label.learned && shared.learnedName)}> <span {...stylex.props(label.learned && shared.learnedName)}>
{label.text} {label.text}
<span {...stylex.props(styles.address)}> ({ip})</span>
</span> </span>
); );
} }
@@ -76,11 +76,15 @@ test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
test("firstProblem flags empty prefixes and non-integer priorities", () => { test("firstProblem flags empty prefixes and non-integer priorities", () => {
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull(); expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required."); expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toEqual({
index: 0,
field: "prefix",
message: "Row 1: prefix is required.",
});
expect( expect(
firstProblem([ firstProblem([
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" }, { prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" }, { prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
]), ]),
).toBe("Row 2: priority must be a whole number."); ).toEqual({ index: 1, field: "priority", message: "Row 2: priority must be a whole number." });
}); });
+13 -4
View File
@@ -55,11 +55,20 @@ export function isDirty(state: PrefixEditorState): boolean {
}); });
} }
export function firstProblem(rows: PrefixRow[]): string | null { /** Which input the message is about, so the editor can point that input at it. */
for (const [i, row] of rows.entries()) { export interface PrefixProblem {
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`; index: number;
field: "prefix" | "priority";
message: string;
}
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
for (const [index, row] of rows.entries()) {
if (row.prefix.trim() === "")
return { index, field: "prefix", message: `Row ${index + 1}: prefix is required.` };
const priority = row.priority.trim(); const priority = row.priority.trim();
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`; if (priority !== "" && !/^\d+$/.test(priority))
return { index, field: "priority", message: `Row ${index + 1}: priority must be a whole number.` };
} }
return null; return null;
} }
@@ -61,17 +61,20 @@ test("the lock is silent when the database owns the configuration", async () =>
test("under file authority the lock names the file, in words a reader hears", async () => { test("under file authority the lock names the file, in words a reader hears", async () => {
renderIndicator(MANAGED_FILE); renderIndicator(MANAGED_FILE);
// The reason is visible text beside the tag, not a title and not a label: a
// tooltip reaches neither a keyboard nor a touch screen, and screen-reader-only
// text is the same failure pointed the other way.
const lock = await screen.findByText("Locked"); const lock = await screen.findByText("Locked");
await waitFor(() => await waitFor(() =>
expect(lock.getAttribute("aria-label")).toBe( expect(screen.getByText(`Managed by ${CONFIG_PATH}; edit the file and restart nxdns`)).toBeTruthy(),
`Locked. Managed by ${CONFIG_PATH}; edit the file and restart nxdns.`,
),
); );
expect(lock.getAttribute("title")).toBeNull();
expect(lock.getAttribute("aria-label")).toBeNull();
}); });
test("an unanswered status still locks, and says that is why", async () => { test("an unanswered status still locks, and says that is why", async () => {
renderIndicator("failed"); renderIndicator("failed");
const lock = await screen.findByText("Locked"); await screen.findByText("Locked");
await waitFor(() => expect(lock.getAttribute("aria-label")).toContain("Configuration status unavailable")); await waitFor(() => expect(screen.getByText(/^Configuration status unavailable/)).toBeTruthy());
}); });
@@ -1,8 +1,20 @@
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
import { useAuthority } from "./authority"; import { useAuthority, type Authority } from "./authority";
const styles = stylex.create({ const styles = stylex.create({
row: {
display: "inline-flex",
alignItems: "baseline",
flexWrap: "wrap",
gap: "0.375rem",
},
/** The sentence is secondary to the word, and wraps rather than stretching a row. */
reason: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
tag: { tag: {
marginLeft: "0.5rem", marginLeft: "0.5rem",
borderWidth: 1, borderWidth: 1,
@@ -26,20 +38,34 @@ const styles = stylex.create({
* The word is real text, not colour or an icon, so a screen reader announces * The word is real text, not colour or an icon, so a screen reader announces
* the reason the control will not answer. * the reason the control will not answer.
*/ */
export default function ConfigLockIndicator() { /**
* Why a configuration control will not answer. Exported because a page that
* shows the compact tag has to print this sentence itself, once, somewhere the
* tag can point at.
*/
export function lockReason(authority: Authority): string {
if (authority.state === "pending") return "Checking which configuration source this server obeys";
if (authority.state === "failed") return "Configuration status unavailable, so edits are held back";
return `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
}
export default function ConfigLockIndicator({ compact = false }: { compact?: boolean }) {
const authority = useAuthority(); const authority = useAuthority();
if (authority.state === "resolved" && authority.status.authority === "database") return null; if (authority.state === "resolved" && authority.status.authority === "database") return null;
const reason = const reason = lockReason(authority);
authority.state === "pending"
? "Checking which configuration source this server obeys"
: authority.state === "failed"
? "Configuration status unavailable, so edits are held back"
: `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
// A compact caller has no room for the sentence and must print it once
// nearby instead: the Clients table would otherwise repeat it down every row.
if (compact) return <span {...stylex.props(styles.tag)}>Locked</span>;
// Everywhere else the reason is visible text rather than a `title` or an
// `aria-label`. A tooltip reaches neither a keyboard nor a touch screen, and
// screen-reader-only text is the same failure pointed the other way.
return ( return (
<span title={reason} aria-label={`Locked. ${reason}.`} {...stylex.props(styles.tag)}> <span {...stylex.props(styles.row)}>
Locked <span {...stylex.props(styles.tag)}>Locked</span>
<span {...stylex.props(styles.reason)}>{reason}</span>
</span> </span>
); );
} }
@@ -3,7 +3,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries"; import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
import type { Blocklist } from "@/lib/types"; import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet"; import { sameSet } from "./sourceSet";
import Checkbox, { CheckboxGroup } from "@/ui/Checkbox";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
@@ -28,13 +29,6 @@ const styles = stylex.create({
flexDirection: "column", flexDirection: "column",
gap: "0.25rem", gap: "0.25rem",
}, },
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
buttonRow: { buttonRow: {
marginTop: "0.75rem", marginTop: "0.75rem",
display: "flex", display: "flex",
@@ -66,21 +60,22 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
return ( return (
<div {...stylex.props(styles.root)}> <div {...stylex.props(styles.root)}>
<ul {...stylex.props(styles.list)}> {/* The section's own "Assigned sources" heading is the visible label; a
{blocklists.map((blocklist) => ( Label here would put the same words on screen twice. Ids cross the
<li key={blocklist.id}> React Aria boundary as strings, the same convention as Select. */}
<label {...stylex.props(styles.checkboxLabel)}> <CheckboxGroup
<input aria-label="Assigned sources"
type="checkbox" value={current.map(String)}
checked={current.includes(blocklist.id)} onChange={(values) => setSelected(values.map(Number).sort((a, b) => a - b))}
onChange={() => setSelected(toggleSource(current, blocklist.id))} >
{...stylex.props(shared.focusRing)} <ul {...stylex.props(styles.list)}>
/> {blocklists.map((blocklist) => (
{blocklist.name} <li key={blocklist.id}>
</label> <Checkbox value={String(blocklist.id)}>{blocklist.name}</Checkbox>
</li> </li>
))} ))}
</ul> </ul>
</CheckboxGroup>
<InlineError error={mutation.error} /> <InlineError error={mutation.error} />
<div {...stylex.props(styles.buttonRow)}> <div {...stylex.props(styles.buttonRow)}>
<button <button
@@ -21,6 +21,7 @@ import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from
import ConfirmDialog from "@/ui/ConfirmDialog"; import ConfirmDialog from "@/ui/ConfirmDialog";
import DefinitionList from "@/ui/DefinitionList"; import DefinitionList from "@/ui/DefinitionList";
import Select from "@/ui/Select"; import Select from "@/ui/Select";
import Switch from "@/ui/Switch";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
import AuthorityGate from "./AuthorityGate"; import AuthorityGate from "./AuthorityGate";
@@ -68,13 +69,6 @@ const styles = stylex.create({
alignItems: "center", alignItems: "center",
gap: "0.75rem", gap: "0.75rem",
}, },
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
spacer: { spacer: {
marginLeft: "auto", marginLeft: "auto",
}, },
@@ -355,21 +349,15 @@ function GroupDetailEditable({ group }: { group: Group }) {
)} )}
<div {...stylex.props(styles.controlRow)}> <div {...stylex.props(styles.controlRow)}>
<label {...stylex.props(styles.checkboxLabel)}> <Switch
<input isSelected={group.safe_search}
type="checkbox" isDisabled={update.isPending}
checked={group.safe_search} onChange={(safeSearch) =>
disabled={update.isPending} update.mutate({ id: group.id, input: { name: group.name, safe_search: safeSearch } })
{...stylex.props(shared.focusRing)} }
onChange={(event) => >
update.mutate({
id: group.id,
input: { name: group.name, safe_search: event.target.checked },
})
}
/>
Safe search Safe search
</label> </Switch>
<span {...stylex.props(styles.spacer)}> <span {...stylex.props(styles.spacer)}>
{!renaming && ( {!renaming && (
<button <button
@@ -46,7 +46,7 @@ test("another group can be renamed and deleted, and carries its safe-search stat
await screen.findByRole("heading", { name: "kids", level: 2 }); await screen.findByRole("heading", { name: "kids", level: 2 });
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false); expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true); expect((screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Delete group" })); fireEvent.click(screen.getByRole("button", { name: "Delete group" }));
const dialog = await screen.findByRole("alertdialog"); const dialog = await screen.findByRole("alertdialog");
@@ -60,7 +60,7 @@ test("toggling safe search resends the whole group row", async () => {
await openProtection(2); await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 }); await screen.findByRole("heading", { name: "kids", level: 2 });
fireEvent.click(screen.getByRole("checkbox", { name: "Safe search" })); fireEvent.click(screen.getByRole("switch", { name: "Safe search" }));
await waitFor(() => expect(writes("PUT")).toHaveLength(1)); await waitFor(() => expect(writes("PUT")).toHaveLength(1));
expect(writes("PUT")[0]).toMatchObject({ expect(writes("PUT")[0]).toMatchObject({
@@ -89,6 +89,31 @@ test("the source assignment saves the full set via PUT", async () => {
}); });
}); });
test("the source checkboxes form one named group, and each carries its own state", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
// The section heading names the group; the boxes belong to it rather than
// sitting loose beside the group's other checkboxes.
const group = await screen.findByRole("group", { name: "Assigned sources" });
const ads = within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement;
expect(ads.checked).toBe(false);
// Safe search is the group's own field, not one of its sources, and it is a
// switch rather than a checkbox because it applies the moment it moves.
expect(within(group).queryByRole("switch")).toBeNull();
expect(screen.getByRole("switch", { name: "Safe search" })).toBeTruthy();
fireEvent.click(ads);
await waitFor(() => expect(ads.checked).toBe(true));
// Discard returns the group to the server's set rather than clearing it.
fireEvent.click(screen.getByRole("button", { name: "Discard" }));
await waitFor(() =>
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(false),
);
expect(writes("PUT")).toEqual([]);
});
test("only the selected group's rules are listed", async () => { test("only the selected group's rules are listed", async () => {
await openProtection(2); await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 }); await screen.findByRole("heading", { name: "kids", level: 2 });
@@ -212,11 +237,33 @@ test("the Sources tab lists the catalogue with both skipped columns and their no
expect( expect(
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/), screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
).toBeTruthy(); ).toBeTruthy();
expect((screen.getByLabelText("Ads enabled") as HTMLInputElement).checked).toBe(true); // Switches, not checkboxes: the row applies the moment it moves, and the role
expect((screen.getByLabelText("Trackers enabled") as HTMLInputElement).checked).toBe(false); // is what tells a screen reader so.
expect((screen.getByRole("switch", { name: "Ads enabled" }) as HTMLInputElement).checked).toBe(true);
expect((screen.getByRole("switch", { name: "Trackers enabled" }) as HTMLInputElement).checked).toBe(false);
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy(); expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
}); });
test("a source switch resends the whole row immediately, with no Save step", async () => {
calls = stubApi(DATABASE);
await renderPage("/configuration/protection?tab=sources", "Protection");
fireEvent.click(await screen.findByRole("switch", { name: "Trackers enabled" }));
// No Save button stands between the switch and the write: one click, one PUT.
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
expect(writes("PUT")[0]).toMatchObject({
url: "/api/blocklists/2",
// The whole row goes back, not a patch of the one field that moved.
body: {
url: "https://example.com/trackers.txt",
name: "Trackers",
enabled: true,
is_suggested: true,
},
});
});
test("Update now says it started, and says nothing once it succeeds", async () => { test("Update now says it started, and says nothing once it succeeds", async () => {
let release: ((response: Response) => void) | null = null; let release: ((response: Response) => void) | null = null;
calls = stubApi(DATABASE, { calls = stubApi(DATABASE, {
@@ -14,6 +14,7 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog"; import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
import Switch from "@/ui/Switch";
import AuthorityGate from "./AuthorityGate"; import AuthorityGate from "./AuthorityGate";
import BlocklistForm from "./BlocklistForm"; import BlocklistForm from "./BlocklistForm";
import FileModeNote from "./FileModeNote"; import FileModeNote from "./FileModeNote";
@@ -249,13 +250,11 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
</span> </span>
</td> </td>
<td {...stylex.props(shared.td)}> <td {...stylex.props(shared.td)}>
<input <Switch
type="checkbox"
aria-label={`${b.name} enabled`} aria-label={`${b.name} enabled`}
checked={b.enabled} isSelected={b.enabled}
disabled={toggle.isPending} isDisabled={toggle.isPending}
onChange={() => toggleEnabled(b)} onChange={() => toggleEnabled(b)}
{...stylex.props(shared.focusRing)}
/> />
</td> </td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
@@ -13,6 +13,9 @@ import { styles as config } from "./styles";
const DARK = "@media (prefers-color-scheme: dark)"; const DARK = "@media (prefers-color-scheme: dark)";
/** The form is rendered once per page, so the message can hold a fixed id. */
const MISMATCH_ID = "web.password_mismatch";
const styles = stylex.create({ const styles = stylex.create({
form: { form: {
marginTop: "1rem", marginTop: "1rem",
@@ -100,6 +103,7 @@ const styles = stylex.create({
gap: "0.75rem", gap: "0.75rem",
}, },
save: { save: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderStyle: "none", borderStyle: "none",
borderRadius: "0.25rem", borderRadius: "0.25rem",
paddingInline: "1rem", paddingInline: "1rem",
@@ -174,12 +178,15 @@ function FieldRow({
} }
if (def.kind === "number") { if (def.kind === "number") {
const numeric = value as number; const numeric = value as number;
// An empty or unparseable number reads back as NaN. The form already refuses
// to submit on it; this is what says so to a screen reader.
return ( return (
<div {...stylex.props(styles.field)}> <div {...stylex.props(styles.field)}>
<FieldLabel id={id} text={def.key} restart={restart} /> <FieldLabel id={id} text={def.key} restart={restart} />
<input <input
id={id} id={id}
type="number" type="number"
aria-invalid={Number.isNaN(numeric) || undefined}
value={Number.isNaN(numeric) ? "" : numeric} value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)} onChange={(e) => onChange(e.target.valueAsNumber)}
{...stylex.props(styles.fieldInput, shared.focusRing)} {...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -285,6 +292,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
id="web.password" id="web.password"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
aria-invalid={passwordsMismatch || undefined}
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)} {...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -298,6 +307,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
id="web.password_confirm" id="web.password_confirm"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
aria-invalid={passwordsMismatch || undefined}
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)} {...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -310,7 +321,7 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
</p> </p>
)} )}
{passwordsMismatch && ( {passwordsMismatch && (
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}> <p id={MISMATCH_ID} {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
Passwords do not match. Passwords do not match.
</p> </p>
)} )}
@@ -176,12 +176,52 @@ test("enum and boolean fields diff as their own types", async () => {
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } }); expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
}); });
/** The text of the elements an input points at with `aria-describedby`. */
function describedText(input: HTMLElement): string {
const ids = input.getAttribute("aria-describedby");
if (ids === null) throw new Error("input has no aria-describedby");
return ids
.split(/\s+/)
.map((id) => {
const node = document.getElementById(id);
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
return node.textContent ?? "";
})
.join(" ");
}
test("clearing a number field disables Save instead of sending NaN", async () => { test("clearing a number field disables Save instead of sending NaN", async () => {
await openSystem(); await openSystem();
const cache = screen.getByRole("group", { name: "Cache" }); const cache = screen.getByRole("group", { name: "Cache" });
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } }); const size = within(cache).getByLabelText("size");
fireEvent.change(size, { target: { value: "" } });
expect(saveButton().disabled).toBe(true); expect(saveButton().disabled).toBe(true);
// The refusal is on the field itself, not only on the Save button.
expect(size.getAttribute("aria-invalid")).toBe("true");
fireEvent.change(size, { target: { value: "512" } });
expect(size.getAttribute("aria-invalid")).toBeNull();
});
test("the mismatch message is attached to both password inputs", async () => {
await openSystem();
const web = screen.getByRole("group", { name: "Web" });
const passwordInput = within(web).getByLabelText("password");
const confirmInput = within(web).getByLabelText("confirm password");
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
for (const input of [passwordInput, confirmInput]) {
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(describedText(input)).toBe("Passwords do not match.");
}
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
for (const input of [passwordInput, confirmInput]) {
expect(input.getAttribute("aria-invalid")).toBeNull();
expect(input.getAttribute("aria-describedby")).toBeNull();
}
}); });
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => { test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {
@@ -7,6 +7,7 @@ import type { Upstream, UpstreamInput } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog"; import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
import Switch from "@/ui/Switch";
import AuthorityGate from "./AuthorityGate"; import AuthorityGate from "./AuthorityGate";
import FileModeNote from "./FileModeNote"; import FileModeNote from "./FileModeNote";
import QueryPanel from "./QueryPanel"; import QueryPanel from "./QueryPanel";
@@ -165,13 +166,11 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
</td> </td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td> <td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td {...stylex.props(shared.td)}> <td {...stylex.props(shared.td)}>
<input <Switch
type="checkbox"
aria-label={`${u.url} enabled`} aria-label={`${u.url} enabled`}
checked={u.enabled} isSelected={u.enabled}
disabled={toggle.isPending} isDisabled={toggle.isPending}
onChange={() => toggleEnabled(u)} onChange={() => toggleEnabled(u)}
{...stylex.props(shared.focusRing)}
/> />
</td> </td>
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td> <td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
@@ -24,7 +24,7 @@ afterEach(() => {
function mutationControls(): Element[] { function mutationControls(): Element[] {
return [ return [
...contentArea().querySelectorAll( ...contentArea().querySelectorAll(
'input, textarea, select, [role="combobox"], [role="checkbox"], [contenteditable]', 'input, textarea, select, [role="combobox"], [role="checkbox"], [role="switch"], [contenteditable]',
), ),
]; ];
} }
@@ -1,18 +1,4 @@
import { sameSet, toggleSource } from "./sourceSet"; import { sameSet } from "./sourceSet";
test("toggleSource adds a missing id keeping ascending order", () => {
expect(toggleSource([1, 3], 2)).toEqual([1, 2, 3]);
expect(toggleSource([], 5)).toEqual([5]);
});
test("toggleSource removes a present id", () => {
expect(toggleSource([1, 2, 3], 2)).toEqual([1, 3]);
expect(toggleSource([5], 5)).toEqual([]);
});
test("toggleSource twice is a no-op set-wise", () => {
expect(toggleSource(toggleSource([1, 2], 3), 3)).toEqual([1, 2]);
});
test("sameSet compares regardless of order", () => { test("sameSet compares regardless of order", () => {
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true); expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
@@ -1,8 +1,3 @@
export function toggleSource(ids: number[], id: number): number[] {
if (ids.includes(id)) return ids.filter((existing) => existing !== id);
return [...ids, id].sort((a, b) => a - b);
}
export function sameSet(a: number[], b: number[]): boolean { export function sameSet(a: number[], b: number[]): boolean {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
const sortedA = [...a].sort((x, y) => x - y); const sortedA = [...a].sort((x, y) => x - y);
+2 -8
View File
@@ -69,8 +69,6 @@ const styles = stylex.create({
interface Series { interface Series {
key: string; key: string;
label: string; label: string;
/** Kept beside the label so a renamed client is still identifiable by address. */
address: string | null;
color: string; color: string;
buckets: number[]; buckets: number[];
} }
@@ -89,15 +87,11 @@ function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
// the same precedence and the same lookup the query tables use. The colour // the same precedence and the same lookup the query tables use. The colour
// keys on the address regardless, so naming a client never repaints it. // keys on the address regardless, so naming a client never repaints it.
label: clientLabel(client.client, names)?.text ?? client.client, label: clientLabel(client.client, names)?.text ?? client.client,
address: client.client,
color: seriesColor(clientKey(client.client)), color: seriesColor(clientKey(client.client)),
buckets: client.buckets, buckets: client.buckets,
})); }));
if (data.other.every((count) => count === 0)) return named; if (data.other.every((count) => count === 0)) return named;
return [ return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }];
...named,
{ key: OTHER_KEY, label: "Other", address: null, color: seriesColor(OTHER_KEY), buckets: data.other },
];
} }
/** /**
@@ -219,7 +213,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
)} )}
<ul {...stylex.props(styles.legend)}> <ul {...stylex.props(styles.legend)}>
{series.map((one) => ( {series.map((one) => (
<li key={one.key} title={one.address ?? undefined} {...stylex.props(styles.legendItem)}> <li key={one.key} {...stylex.props(styles.legendItem)}>
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} /> <span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
{one.label} {one.label}
</li> </li>
+27 -12
View File
@@ -10,6 +10,7 @@
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { useNavigate, useSearch } from "@tanstack/react-router"; import { useNavigate, useSearch } from "@tanstack/react-router";
import { Radio, RadioGroup } from "react-aria-components";
import type { Period } from "@/lib/types"; import type { Period } from "@/lib/types";
import { styles as shared } from "@/ui/styles"; import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex"; import { colors } from "@/ui/tokens.stylex";
@@ -38,6 +39,7 @@ const styles = stylex.create({
gap: "0.25rem", gap: "0.25rem",
}, },
period: { period: {
cursor: "pointer",
borderStyle: "none", borderStyle: "none",
borderRadius: "0.25rem", borderRadius: "0.25rem",
paddingInline: "0.625rem", paddingInline: "0.625rem",
@@ -45,6 +47,13 @@ const styles = stylex.create({
fontSize: "0.875rem", fontSize: "0.875rem",
lineHeight: "1.25rem", lineHeight: "1.25rem",
}, },
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
periodFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */ /** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
periodSelected: { periodSelected: {
backgroundColor: { backgroundColor: {
@@ -67,23 +76,29 @@ const styles = stylex.create({
export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) { export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
return ( return (
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}> <RadioGroup
aria-label="Period"
orientation="horizontal"
value={period}
onChange={(next) => onChange(next as Period)}
className={() => stylex.props(styles.periodGroup).className ?? ""}
>
{PERIODS.map((option) => ( {PERIODS.map((option) => (
<button <Radio
key={option} key={option}
type="button" value={option}
aria-pressed={option === period} className={({ isSelected, isFocusVisible }) =>
onClick={() => onChange(option)} stylex.props(
{...stylex.props( styles.period,
styles.period, isSelected ? styles.periodSelected : styles.periodIdle,
option === period ? styles.periodSelected : styles.periodIdle, isFocusVisible && styles.periodFocusVisible,
shared.focusRing, ).className ?? ""
)} }
> >
{option} {option}
</button> </Radio>
))} ))}
</div> </RadioGroup>
); );
} }
@@ -189,7 +189,7 @@ test("a request in flight leaves the heading and the picker usable behind one lo
renderApp(); renderApp();
await screen.findByRole("heading", { name: "Overview", level: 1 }); await screen.findByRole("heading", { name: "Overview", level: 1 });
expect(screen.getByRole("button", { name: "1h" })).toBeTruthy(); expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
// One loading state for the whole page, not one per panel. // One loading state for the whole page, not one per panel.
const loading = await screen.findByText("Loading…"); const loading = await screen.findByText("Loading…");
expect(loading.getAttribute("role")).toBe("status"); expect(loading.getAttribute("role")).toBe("status");
@@ -214,6 +214,16 @@ test("a registered client is named in the chart, an unregistered one keeps its a
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2)); await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
expect(within(chart).queryByText("192.0.2.30")).toBeNull(); expect(within(chart).queryByText("192.0.2.30")).toBeNull();
expect(within(chart).getAllByText("192.0.2.31")).toHaveLength(2); expect(within(chart).getAllByText("192.0.2.31")).toHaveLength(2);
// Nor on hover: a pointer-only tooltip would say what the design just chose
// not to, and only to a reader holding a mouse.
const legendItem = within(chart)
.getAllByText("kitchen-pi")
.map((node) => node.closest("li"))
.find((node) => node !== null);
expect(legendItem).toBeTruthy();
expect(legendItem?.getAttribute("title")).toBeNull();
expect(within(chart).getByRole("list").querySelectorAll("[title]")).toHaveLength(0);
}); });
test("a client named only by reverse DNS is named by it too", async () => { test("a client named only by reverse DNS is named by it too", async () => {
@@ -335,14 +345,23 @@ test("an empty window says so in every panel instead of drawing nothing", async
test("a deep link opens on the period it names", async () => { test("a deep link opens on the period it names", async () => {
renderApp("/overview?period=1h"); renderApp("/overview?period=1h");
await screen.findByText("12"); await screen.findByText("12");
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true"); // One radio group named Period, holding the four periods and exactly one
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false"); // selection: the segmented picker is a single choice, not four toggles.
const picker = within(screen.getByRole("radiogroup", { name: "Period" }));
expect(picker.getAllByRole("radio").map((radio) => radio.getAttribute("value"))).toEqual([
"1h",
"24h",
"7d",
"30d",
]);
expect(picker.getByRole("radio", { name: "1h", checked: true })).toBeTruthy();
expect(picker.getByRole("radio", { name: "24h", checked: false })).toBeTruthy();
}); });
test("a period the API does not have falls back to the default without carrying it in the url", async () => { test("a period the API does not have falls back to the default without carrying it in the url", async () => {
const router = renderApp("/overview?period=90d"); const router = renderApp("/overview?period=90d");
await screen.findByText("1,000"); await screen.findByText("1,000");
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("true"); expect(screen.getByRole("radio", { name: "24h", checked: true })).toBeTruthy();
expect(router.state.location.search).toEqual({}); expect(router.state.location.search).toEqual({});
}); });
@@ -350,7 +369,7 @@ test("the picker rescopes every panel and writes the period into the url", async
const router = renderApp(); const router = renderApp();
await screen.findByText("1,000"); await screen.findByText("1,000");
fireEvent.click(screen.getByRole("button", { name: "1h" })); fireEvent.click(screen.getByRole("radio", { name: "1h" }));
await screen.findByText("12"); await screen.findByText("12");
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" })); await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
@@ -369,7 +388,7 @@ test("a failed request is one error for the whole page, stated once and retryabl
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1); expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
// The heading and the picker survive it, so the reader can rescope or retry. // The heading and the picker survive it, so the reader can rescope or retry.
expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy(); expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy();
expect(screen.getByRole("button", { name: "1h" })).toBeTruthy(); expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
expect(screen.queryByText("Something went wrong")).toBeNull(); expect(screen.queryByText("Something went wrong")).toBeNull();
failing = false; failing = false;
+25 -18
View File
@@ -91,11 +91,14 @@ test("unpaused: duration menu pauses with the picked duration_seconds", async ()
fireEvent.click(trigger); fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true"); expect(trigger.getAttribute("aria-expanded")).toBe("true");
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) { expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
expect(screen.getByRole("button", { name: label })).toBeTruthy(); "60 seconds",
} "5 minutes",
"30 minutes",
"Indefinitely",
]);
fireEvent.click(screen.getByRole("button", { name: "5 minutes" })); fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }])); await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
await screen.findByRole("button", { name: "Resume" }); await screen.findByRole("button", { name: "Resume" });
@@ -105,7 +108,7 @@ test("indefinite pause sends no duration_seconds", async () => {
renderControl(); renderControl();
fireEvent.click(await findPauseTrigger()); fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" })); fireEvent.click(screen.getByRole("menuitem", { name: "Indefinitely" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true }])); await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
await screen.findByRole("button", { name: "Resume" }); await screen.findByRole("button", { name: "Resume" });
@@ -151,11 +154,15 @@ test("an active resolver states nothing: the button already says Pause", async (
test("escape closes the duration menu", async () => { test("escape closes the duration menu", async () => {
renderControl(); renderControl();
const trigger = await findPauseTrigger(); fireEvent.click(await findPauseTrigger());
fireEvent.click(trigger); expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
fireEvent.keyDown(trigger, { key: "Escape" }); // Opening the menu moves focus into it, so Escape is pressed where the reader
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull(); // is, not on the trigger they left.
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull());
expect(screen.getByRole("button", { name: "Pause" }).getAttribute("aria-expanded")).toBe("false");
}); });
test("failed pause with 429 shows a ticking retry countdown", async () => { test("failed pause with 429 shows a ticking retry countdown", async () => {
@@ -168,7 +175,7 @@ test("failed pause with 429 shows a ticking retry countdown", async () => {
renderControl(); renderControl();
fireEvent.click(await findPauseTrigger()); fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "5 minutes" })); fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 30s."); expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
@@ -187,7 +194,7 @@ test("failed pause with 503 shows the degraded message", async () => {
renderControl(); renderControl();
fireEvent.click(await findPauseTrigger()); fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "60 seconds" })); fireEvent.click(screen.getByRole("menuitem", { name: "60 seconds" }));
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly."); expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
@@ -202,12 +209,12 @@ test("a successful pause clears the previous mutation error", async () => {
renderControl(); renderControl();
fireEvent.click(await findPauseTrigger()); fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "5 minutes" })); fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
await screen.findByRole("alert"); await screen.findByRole("alert");
postFailure = null; postFailure = null;
fireEvent.click(screen.getByRole("button", { name: "Pause" })); fireEvent.click(screen.getByRole("button", { name: "Pause" }));
fireEvent.click(screen.getByRole("button", { name: "5 minutes" })); fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
await screen.findByRole("button", { name: "Resume" }); await screen.findByRole("button", { name: "Resume" });
expect(screen.queryByRole("alert")).toBeNull(); expect(screen.queryByRole("alert")).toBeNull();
@@ -240,7 +247,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
vi.useFakeTimers({ shouldAdvanceTime: true }); vi.useFakeTimers({ shouldAdvanceTime: true });
renderControl(); renderControl();
fireEvent.click(await findPauseTrigger()); fireEvent.click(await findPauseTrigger());
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy(); expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
protection = { state: "unavailable", until: null }; protection = { state: "unavailable", until: null };
await vi.advanceTimersByTimeAsync(11_000); await vi.advanceTimersByTimeAsync(11_000);
@@ -253,7 +260,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
// reader opened, and nobody opened this one. // reader opened, and nobody opened this one.
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" })); const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
expect(trigger.getAttribute("aria-expanded")).toBe("false"); expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull(); expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
vi.useRealTimers(); vi.useRealTimers();
}); });
@@ -261,7 +268,7 @@ test("a menu open when someone else pauses does not reopen when that pause ends"
vi.useFakeTimers({ shouldAdvanceTime: true }); vi.useFakeTimers({ shouldAdvanceTime: true });
renderControl(); renderControl();
fireEvent.click(await findPauseTrigger()); fireEvent.click(await findPauseTrigger());
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy(); expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
// Filtering is paused from somewhere else, and this browser learns it from // Filtering is paused from somewhere else, and this browser learns it from
// the poll. The Resume rendering has no menu. // the poll. The Resume rendering has no menu.
@@ -274,7 +281,7 @@ test("a menu open when someone else pauses does not reopen when that pause ends"
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" })); const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
expect(trigger.getAttribute("aria-expanded")).toBe("false"); expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull(); expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
vi.useRealTimers(); vi.useRealTimers();
}); });
+60 -67
View File
@@ -1,8 +1,7 @@
/** /**
* The Pause/Resume control, in the two places a pause is a valid answer to what * The Pause/Resume control, at the foot of the sidebar. A pause stops filtering
* the reader is looking at: the foot of the sidebar, where it belongs to the * for the whole resolver, so it belongs to the shell rather than to any page,
* resolver rather than to any page, and beside the detail of a query that was * and it has no second placement.
* blocked.
* *
* It reads `Health.protection` rather than `/api/pause` so it cannot contradict * It reads `Health.protection` rather than `/api/pause` so it cannot contradict
* the Diagnostics health strip, and it renders nothing at all while protection * the Diagnostics health strip, and it renders nothing at all while protection
@@ -20,9 +19,10 @@
* line: the button says Pause, which is the whole message. * line: the button says Pause, which is the whole message.
*/ */
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
import { formatClock } from "@/lib/format"; import { formatClock } from "@/lib/format";
import { pauseMutation } from "@/lib/queries"; import { pauseMutation } from "@/lib/queries";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
@@ -48,6 +48,8 @@ const styles = stylex.create({
":disabled": "oklch(70.5% 0.015 286.067)", ":disabled": "oklch(70.5% 0.015 286.067)",
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" }, "@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
}, },
/** The sidebar foot is the only placement, and there Log out sets the width. */
width: "100%",
}, },
row: { row: {
display: "flex", display: "flex",
@@ -61,43 +63,44 @@ const styles = stylex.create({
lineHeight: "1rem", lineHeight: "1rem",
color: colors.textSecondary, color: colors.textSecondary,
}, },
anchor: { /** `--trigger-width` is RAC's: the menu is as wide as the button that opened it. */
position: "relative", popover: {
}, width: "var(--trigger-width)",
menu: {
position: "absolute",
left: 0,
top: "100%",
zIndex: 10,
marginTop: "0.25rem",
display: "flex",
width: "9rem",
flexDirection: "column",
borderRadius: "0.25rem", borderRadius: "0.25rem",
borderWidth: 1, borderWidth: 1,
borderStyle: "solid", borderStyle: "solid",
borderColor: colors.border, borderColor: colors.border,
backgroundColor: colors.surfaceRaised, backgroundColor: colors.surfaceRaised,
paddingBlock: "0.25rem", color: colors.text,
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", 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: { menuItem: {
borderStyle: "none", cursor: "pointer",
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: "inherit",
paddingInline: "0.75rem", paddingInline: "0.75rem",
paddingBlock: "0.375rem", paddingBlock: "0.375rem",
textAlign: "left",
fontSize: "0.875rem", fontSize: "0.875rem",
lineHeight: "1.25rem", lineHeight: "1.25rem",
}, },
/**
* RAC focuses the item's own node, so the shared ring applies; it is inset
* because an item flush against the popover edge clips an outset one, and
* recoloured because the focus token is the blue this row just painted.
*/
menuItemFocused: {
backgroundColor: colors.primary,
color: colors.primaryText,
outlineColor: { default: null, ":focus-visible": colors.primaryText },
},
}); });
export default function PauseControl() { export default function PauseControl() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const protection = useProtection(); const protection = useProtection();
const mutation = useMutation(pauseMutation(queryClient)); const mutation = useMutation(pauseMutation(queryClient));
const [menuOpen, setMenuOpen] = useState(false);
const paused = protection.state === "paused"; const paused = protection.state === "paused";
const { reset } = mutation; const { reset } = mutation;
useEffect(() => reset(), [paused, reset]); useEffect(() => reset(), [paused, reset]);
@@ -108,15 +111,6 @@ export default function PauseControl() {
// the wrong action, which is the contradiction this control exists to end. // the wrong action, which is the contradiction this control exists to end.
const actionable = protection.state === "active" || protection.state === "paused"; const actionable = protection.state === "active" || protection.state === "paused";
// Leaving `active` unmounts the menu but not the state that opened it, and
// the menu belongs to the active rendering alone — a pause someone else
// started, seen through the poll, takes it away exactly as a withdrawal does.
// Closing on the way out rather than on the way back means the trigger can
// only ever come back shut, however long it was gone.
useEffect(() => {
if (protection.state !== "active") setMenuOpen(false);
}, [protection.state]);
if (!actionable) return null; if (!actionable) return null;
if (paused) { if (paused) {
@@ -140,41 +134,40 @@ export default function PauseControl() {
} }
return ( return (
<div <div {...stylex.props(styles.row)}>
{...stylex.props(styles.anchor)} <MenuTrigger>
onKeyDown={(e) => { <Button
if (e.key === "Escape") setMenuOpen(false); isDisabled={mutation.isPending}
}} className={() => stylex.props(shared.button, styles.trigger, shared.focusRing).className ?? ""}
> >
<button Pause
type="button" </Button>
aria-expanded={menuOpen} <Popover className={() => stylex.props(styles.popover).className ?? ""}>
aria-controls="pause-menu" <Menu {...stylex.props(styles.menu)}>
onClick={() => setMenuOpen((open) => !open)} {DURATIONS.map(({ label, seconds }) => (
disabled={mutation.isPending} <MenuItem
{...stylex.props(shared.button, styles.trigger, shared.focusRing)} key={label}
> onAction={() =>
Pause mutation.mutate(
</button> seconds === null
{menuOpen && ( ? { paused: true }
<div id="pause-menu" {...stylex.props(styles.menu)}> : { paused: true, duration_seconds: seconds },
{DURATIONS.map(({ label, seconds }) => ( )
<button }
key={label} className={({ isFocused }) =>
type="button" stylex.props(
onClick={() => { styles.menuItem,
setMenuOpen(false); shared.insetFocusRing,
mutation.mutate( isFocused && styles.menuItemFocused,
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds }, ).className ?? ""
); }
}} >
{...stylex.props(styles.menuItem, shared.insetFocusRing)} {label}
> </MenuItem>
{label} ))}
</button> </Menu>
))} </Popover>
</div> </MenuTrigger>
)}
<InlineError error={mutation.error} /> <InlineError error={mutation.error} />
</div> </div>
); );
+18 -2
View File
@@ -17,6 +17,12 @@ const styles = stylex.create({
lineHeight: "1.25rem", lineHeight: "1.25rem",
color: colors.textSecondary, color: colors.textSecondary,
}, },
/** The same sentence with no box, for a results footer that already has one. */
note: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
}); });
/** /**
@@ -27,11 +33,21 @@ const styles = stylex.create({
* watermark and nothing more: the same incompleteness covers a log retention * watermark and nothing more: the same incompleteness covers a log retention
* has pruned and one that simply has not been running long enough, and the * has pruned and one that simply has not been running long enough, and the
* response does not say which. * response does not say which.
*
* `note` is the same sentence without the panel, for a place that already sits
* under the data it qualifies — a results footer states the reach of the count
* beside it, where a full-width banner would announce a limit as news.
*/ */
export default function CoverageNotice({ coverage }: { coverage: Coverage }) { export default function CoverageNotice({
coverage,
variant = "banner",
}: {
coverage: Coverage;
variant?: "banner" | "note";
}) {
if (coverage.complete) return null; if (coverage.complete) return null;
return ( return (
<p role="status" {...stylex.props(styles.notice)}> <p role="status" {...stylex.props(variant === "note" ? styles.note : styles.notice)}>
Query history is available from {formatTime(coverage.available_since)}. Query history is available from {formatTime(coverage.available_since)}.
</p> </p>
); );
+1
View File
@@ -12,6 +12,7 @@ const styles = stylex.create({
color: colors.danger, color: colors.danger,
}, },
retry: { retry: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderStyle: "none", borderStyle: "none",
backgroundColor: "transparent", backgroundColor: "transparent",
padding: 0, padding: 0,
+69 -2
View File
@@ -214,12 +214,45 @@ test("mount probe reveals the logout button and a failed logout surfaces inline"
renderShell(); renderShell();
fireEvent.click(await screen.findByRole("button", { name: "Log out" })); // Both renderings are mounted; the viewport paints the sidebar one on WIDE
// and the header one below it.
await waitFor(() => expect(screen.getAllByRole("button", { name: "Log out" })).toHaveLength(2));
const aside = document.querySelector("aside") as HTMLElement;
fireEvent.click(within(aside).getByRole("button", { name: "Log out" }));
await screen.findByText("Rate limited. Try again in 7s."); await within(aside).findByText("Rate limited. Try again in 7s.");
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy(); expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
}); });
test("Log out sits in the sidebar on wide, and only in the header below it", async () => {
stubFetch((url) =>
url === "/api/auth/login"
? new Response(JSON.stringify({ error: "password required" }), {
status: 401,
headers: { "content-type": "application/json" },
})
: null,
);
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
const header = document.querySelector("header") as HTMLElement;
await waitFor(() => expect(within(aside).getByRole("button", { name: "Log out" })).toBeTruthy());
expect(within(header).getByRole("button", { name: "Log out" })).toBeTruthy();
// The header is the narrow rendering now: the Menu button is its only nav.
expect(within(header).getByRole("button", { name: "Menu" })).toBeTruthy();
// In the sidebar the control precedes the version label rather than crowding it.
const version = within(aside).getByText(/^nxdns v/);
const logout = within(aside).getByRole("button", { name: "Log out" });
expect(logout.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// The drawer keeps no third copy: the header already carries the narrow one.
fireEvent.click(within(header).getByRole("button", { name: "Menu" }));
const drawer = document.getElementById("mobile-nav") as HTMLElement;
expect(within(drawer).queryByRole("button", { name: "Log out" })).toBeNull();
});
test("the header carries no protection display at all any more", async () => { test("the header carries no protection display at all any more", async () => {
renderShell(); renderShell();
await screen.findByRole("heading", { name: "Overview" }); await screen.findByRole("heading", { name: "Overview" });
@@ -257,6 +290,40 @@ test("the mobile drawer carries the same control, not a header one it lost", asy
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}); });
test("the Menu button is a disclosure trigger, and it names the panel it opens", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const menu = screen.getByRole("button", { name: "Menu" });
// The trigger states the drawer's state, and points at the drawer itself.
expect(menu.getAttribute("aria-expanded")).toBe("false");
expect(menu.getAttribute("aria-controls")).toBe("mobile-nav");
const drawer = document.getElementById("mobile-nav") as HTMLElement;
expect(drawer.getAttribute("hidden")).not.toBeNull();
fireEvent.click(menu);
expect(menu.getAttribute("aria-expanded")).toBe("true");
expect(drawer.getAttribute("hidden")).toBeNull();
expect(within(drawer).getByRole("navigation", { name: "Main" })).toBeTruthy();
fireEvent.click(menu);
expect(menu.getAttribute("aria-expanded")).toBe("false");
expect(drawer.getAttribute("hidden")).not.toBeNull();
});
test("following a drawer link closes the drawer behind it", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const menu = screen.getByRole("button", { name: "Menu" });
fireEvent.click(menu);
const drawer = document.getElementById("mobile-nav") as HTMLElement;
fireEvent.click(within(drawer).getByRole("link", { name: "Clients" }));
await waitFor(() => expect(menu.getAttribute("aria-expanded")).toBe("false"));
expect(drawer.getAttribute("hidden")).not.toBeNull();
});
test("a paused resolver says so in both renderings, not only on Diagnostics", async () => { test("a paused resolver says so in both renderings, not only on Diagnostics", async () => {
// The trace a pause leaves on every page. With the header indicator and the // The trace a pause leaves on every page. With the header indicator and the
// Overview status row both gone, a reader who is not on Diagnostics has only // Overview status row both gone, a reader who is not on Diagnostics has only
+45 -20
View File
@@ -2,6 +2,7 @@ import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link, Outlet, useNavigate } from "@tanstack/react-router"; import { Link, Outlet, useNavigate } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { Button, Disclosure, DisclosurePanel } from "react-aria-components";
import { useAuth } from "@/auth/store"; import { useAuth } from "@/auth/store";
import InlineError from "@/lib/InlineError"; import InlineError from "@/lib/InlineError";
import { healthQuery, versionQuery } from "@/lib/queries"; import { healthQuery, versionQuery } from "@/lib/queries";
@@ -110,6 +111,17 @@ const styles = stylex.create({
alignItems: "center", alignItems: "center",
gap: "0.5rem", gap: "0.5rem",
}, },
/**
* In a 14rem column the button and its error stack, so the button spans the
* sidebar and matches the Pause trigger below it.
*/
sidebarLogout: {
flexDirection: "column",
alignItems: "stretch",
gap: "0.25rem",
paddingInline: "1rem",
paddingTop: "0.75rem",
},
shell: { shell: {
minHeight: "100dvh", minHeight: "100dvh",
height: { default: null, [WIDE]: "100dvh" }, height: { default: null, [WIDE]: "100dvh" },
@@ -147,8 +159,9 @@ const styles = stylex.create({
minWidth: { default: null, [WIDE]: 0 }, minWidth: { default: null, [WIDE]: 0 },
flexDirection: "column", flexDirection: "column",
}, },
/** Narrow only: on WIDE the sidebar carries everything this row held. */
header: { header: {
display: "flex", display: { default: "flex", [WIDE]: "none" },
alignItems: "center", alignItems: "center",
gap: "0.75rem", gap: "0.75rem",
borderBottomWidth: 1, borderBottomWidth: 1,
@@ -284,13 +297,13 @@ function VersionFooter() {
); );
} }
function LogoutButton() { function LogoutButton({ style }: { style?: stylex.StyleXStyles }) {
const { authRequired, logout } = useAuth(); const { authRequired, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
if (authRequired !== true) return null; if (authRequired !== true) return null;
return ( return (
<div {...stylex.props(styles.logoutRow)}> <div {...stylex.props(styles.logoutRow, style)}>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -318,37 +331,49 @@ export default function AppShell() {
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}> <nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
<NavLinks /> <NavLinks />
</nav> </nav>
<LogoutButton style={styles.sidebarLogout} />
<SidebarFooter /> <SidebarFooter />
</aside> </aside>
<div {...stylex.props(styles.column)}> {/* The column itself is the disclosure: the trigger sits in the header
and the panel opens below the notices, so any wrapper around only
the two would have to cut across the column's own flex children. */}
<Disclosure
isExpanded={drawerOpen}
onExpandedChange={setDrawerOpen}
{...stylex.props(styles.column)}
>
<header {...stylex.props(styles.header)}> <header {...stylex.props(styles.header)}>
<button <Button
type="button" slot="trigger"
aria-expanded={drawerOpen} className={() =>
aria-controls="mobile-nav" stylex.props(shared.button, styles.narrowOnly, shared.focusRing).className ?? ""
onClick={() => setDrawerOpen((open) => !open)} }
{...stylex.props(shared.button, styles.narrowOnly, shared.focusRing)}
> >
Menu Menu
</button> </Button>
<span {...stylex.props(styles.narrowBrand)}>nxdns</span> <span {...stylex.props(styles.narrowBrand)}>nxdns</span>
<div {...stylex.props(styles.headerRight)}> <div {...stylex.props(styles.headerRight)}>
<LogoutButton /> <LogoutButton />
</div> </div>
</header> </header>
<ConfigStatusNotices /> <ConfigStatusNotices />
{drawerOpen && ( <DisclosurePanel id="mobile-nav" {...stylex.props(styles.drawer)}>
<div id="mobile-nav" {...stylex.props(styles.drawer)}> {/* React Aria only hides a collapsed panel; the shell drops it
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}> instead, so a closed drawer keeps no second health poll and
<NavLinks onNavigate={() => setDrawerOpen(false)} /> no second Pause control alive behind the header. */}
</nav> {drawerOpen && (
<SidebarFooter /> <>
</div> <nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
)} <NavLinks onNavigate={() => setDrawerOpen(false)} />
</nav>
<SidebarFooter />
</>
)}
</DisclosurePanel>
<main id="main-content" data-scroll-restoration-id="main" {...stylex.props(styles.main)}> <main id="main-content" data-scroll-restoration-id="main" {...stylex.props(styles.main)}>
<Outlet /> <Outlet />
</main> </main>
</div> </Disclosure>
</div> </div>
); );
} }
+92
View File
@@ -0,0 +1,92 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import Checkbox, { CheckboxGroup } from "./Checkbox";
/**
* The drawn box, which is the label's one child that does not hold the hidden
* input. StyleX compiles to class names and jsdom loads no stylesheet, so the
* class list is the only place the composed ring is observable.
*/
function indicator(input: HTMLElement): HTMLElement {
const label = input.closest("label") as HTMLElement;
const spans = [...label.querySelectorAll("span")];
const box = spans.find((span) => !span.contains(input));
if (box === undefined) throw new Error("the checkbox drew no indicator");
return box;
}
test("a standalone checkbox reports its state and reads its label as its name", () => {
const onChange = vi.fn();
render(
<Checkbox isSelected onChange={onChange}>
Safe search
</Checkbox>,
);
const box = screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement;
expect(box.checked).toBe(true);
fireEvent.click(box);
// The caller owns the state, so the box reports the value it would move to
// rather than moving there itself.
expect(onChange).toHaveBeenCalledWith(false);
});
test("a disabled checkbox keeps its state on screen but takes no input", () => {
render(
<Checkbox isSelected isDisabled onChange={vi.fn()}>
Safe search
</Checkbox>,
);
const box = screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement;
expect(box.checked).toBe(true);
// The disabled input is the whole guard: a browser fires no click on one, and
// it is out of the tab order. Clicking it here would prove nothing either way,
// because `fireEvent` dispatches straight at the node and skips that check.
expect(box.disabled).toBe(true);
});
test("keyboard focus composes the ring onto the drawn box", () => {
render(
<Checkbox isSelected={false} onChange={vi.fn()}>
Ads
</Checkbox>,
);
const input = screen.getByRole("checkbox", { name: "Ads" });
const box = indicator(input);
const idle = box.className.split(" ");
expect(input.closest("label")?.getAttribute("data-focus-visible")).toBeNull();
// React Aria only calls focus visible after the modality is keyboard, which
// is why a bare focus() is not enough to raise the ring.
act(() => {
fireEvent.keyDown(document.body, { key: "Tab" });
input.focus();
});
expect(input.closest("label")?.getAttribute("data-focus-visible")).toBe("true");
const ringed = box.className.split(" ");
// The box gained classes it did not have: the ring is composed onto it, not
// merely reported by React Aria on the root.
expect(ringed.length).toBeGreaterThan(idle.length);
expect(idle.every((name) => ringed.includes(name))).toBe(true);
});
test("inside a group the group owns the selection, and the group carries the name", () => {
const onChange = vi.fn();
render(
<CheckboxGroup aria-label="Assigned sources" value={["1"]} onChange={onChange}>
<Checkbox value="1">Ads</Checkbox>
<Checkbox value="2">Trackers</Checkbox>
</CheckboxGroup>,
);
const group = screen.getByRole("group", { name: "Assigned sources" });
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(true);
expect((within(group).getByRole("checkbox", { name: "Trackers" }) as HTMLInputElement).checked).toBe(false);
// The group reports the whole set, not the box that moved.
fireEvent.click(within(group).getByRole("checkbox", { name: "Trackers" }));
expect(onChange).toHaveBeenCalledWith(["1", "2"]);
});
+138
View File
@@ -0,0 +1,138 @@
/**
* The checkbox, wrapping React Aria's.
*
* React Aria hides the real input and leaves the mark to the call site, so the
* box below is what the reader sees. It is drawn to the native control's size
* so a row that held a native checkbox keeps its height and its baseline.
*
* One component covers both uses: pass `value` for a box inside a
* `CheckboxGroup`, which owns the selection, or `isSelected`/`onChange` for a
* standalone box that owns its own.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Checkbox as AriaCheckbox } from "react-aria-components";
import { colors } from "./tokens.stylex";
/**
* Re-exported rather than wrapped: the group adds no styling of its own, and
* routing it through here keeps React Aria's checkbox parts to one import site,
* the same rule `Select` follows.
*/
export { CheckboxGroup } from "react-aria-components";
interface Common {
/** The visible label, which is also the accessible name. */
children: ReactNode;
/** Visible but inert; React Aria also drops it from the tab order. */
isDisabled?: boolean;
}
/** Inside a `CheckboxGroup`, which holds the selection for every box in it. */
interface Grouped extends Common {
value: string;
isSelected?: never;
onChange?: never;
}
/** On its own, where the caller holds the state and is told to move it. */
interface Standalone extends Common {
value?: never;
isSelected: boolean;
onChange: (isSelected: boolean) => void;
}
/**
* The two modes are exclusive: a grouped box that also carried `isSelected`
* would have two sources of truth, and a standalone one without `onChange`
* could never move. The union is what makes both unrepresentable.
*/
type Props = Grouped | Standalone;
const styles = stylex.create({
/**
* The whole label is the hit area, so it carries the 44px pointer-target
* floor the dialog's Close button already sets, on both axes. The text is
* 20px tall, and a one-word source name is narrower than 44px again.
*/
label: {
minHeight: 44,
minWidth: 44,
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
box: {
flexShrink: 0,
boxSizing: "border-box",
width: "0.875rem",
height: "0.875rem",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.1875rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
},
boxSelected: {
borderColor: colors.primary,
backgroundColor: colors.primary,
color: colors.primaryText,
},
/**
* The shared ring keys off `:focus-visible`, which lands on the hidden input
* rather than on this box, so the ring follows the state React Aria reports.
*/
boxFocused: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** The box dims, not the words: the label still has to be readable. */
boxDisabled: {
opacity: 0.5,
},
});
/** `currentColor` so the mark follows the selected box's foreground token. */
function CheckMark() {
return (
<svg aria-hidden="true" viewBox="0 0 12 12" width="10" height="10" fill="none">
<path
d="M2.5 6.5 5 9l4.5-5.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function Checkbox({ children, ...state }: Props) {
return (
<AriaCheckbox {...state} className={() => stylex.props(styles.label).className ?? ""}>
{(renderProps) => (
<>
<span
{...stylex.props(
styles.box,
renderProps.isSelected && styles.boxSelected,
renderProps.isFocusVisible && styles.boxFocused,
renderProps.isDisabled && styles.boxDisabled,
)}
>
{renderProps.isSelected && <CheckMark />}
</span>
{children}
</>
)}
</AriaCheckbox>
);
}
+22 -8
View File
@@ -8,6 +8,7 @@
* and a stray outside click is not one. Escape still cancels. * and a stray outside click is not one. Escape still cancels.
*/ */
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components"; import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex"; import { colors } from "./tokens.stylex";
@@ -19,6 +20,14 @@ interface Props {
/** The full sentence the operator reads before confirming; names the entity. */ /** The full sentence the operator reads before confirming; names the entity. */
message: string; message: string;
confirmLabel: string; confirmLabel: string;
/**
* Stands in for the confirm action when the write can no longer land, the way
* an open edit dialog drops its Save. Closing the dialog instead would throw
* focus at a control that is now disabled and say nothing about why, so the
* question stays on screen and only the answer that would fail is withdrawn.
* Cancel is always live.
*/
lock?: ReactNode;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -67,6 +76,7 @@ const styles = stylex.create({
marginTop: "1.5rem", marginTop: "1.5rem",
}, },
dangerButton: { dangerButton: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderRadius: "0.25rem", borderRadius: "0.25rem",
borderStyle: "none", borderStyle: "none",
backgroundColor: colors.danger, backgroundColor: colors.danger,
@@ -79,7 +89,7 @@ const styles = stylex.create({
}, },
}); });
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, onConfirm, onCancel }: Props) { export default function ConfirmDialog({ isOpen, title, message, confirmLabel, lock, onConfirm, onCancel }: Props) {
return ( return (
<ModalOverlay <ModalOverlay
isOpen={isOpen} isOpen={isOpen}
@@ -98,13 +108,17 @@ export default function ConfirmDialog({ isOpen, title, message, confirmLabel, on
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}> <button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
Cancel Cancel
</button> </button>
<button {lock === undefined ? (
type="button" <button
onClick={onConfirm} type="button"
{...stylex.props(styles.dangerButton, shared.focusRing)} onClick={onConfirm}
> {...stylex.props(styles.dangerButton, shared.focusRing)}
{confirmLabel} >
</button> {confirmLabel}
</button>
) : (
lock
)}
</div> </div>
</AriaDialog> </AriaDialog>
</Modal> </Modal>
+83 -10
View File
@@ -4,18 +4,25 @@
* React Aria owns the focus trap, the Escape handler and the `aria-modal` * React Aria owns the focus trap, the Escape handler and the `aria-modal`
* wiring that the hand-rolled overlay only approximated. State is controlled by * wiring that the hand-rolled overlay only approximated. State is controlled by
* the caller because the trigger is a table row button, not a `DialogTrigger`. * the caller because the trigger is a table row button, not a `DialogTrigger`.
*
* The title is both the visible heading and the accessible name: `Heading
* slot="title"` is what React Aria points `aria-labelledby` at, so a caller
* cannot name the dialog one thing and show another.
*/ */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex"; import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Modal, ModalOverlay } from "react-aria-components"; import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex"; import { colors } from "./tokens.stylex";
import { styles as shared } from "./styles";
interface Props { interface Props {
/** The dialog's accessible name. */ /** The dialog's heading, and with it the dialog's accessible name. */
label: string; title: string;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
/** `detail` is the wider panel a record needs; `form` fits a column of fields. */
size?: "form" | "detail";
children: ReactNode; children: ReactNode;
} }
@@ -30,25 +37,74 @@ const styles = stylex.create({
padding: "1rem", padding: "1rem",
backgroundColor: "rgba(0, 0, 0, 0.4)", backgroundColor: "rgba(0, 0, 0, 0.4)",
}, },
/**
* A column so the header stays put and the body scrolls under it. The height
* cap is what keeps a long record inside the viewport instead of running off
* the bottom of a short one.
*/
panel: { panel: {
width: "100%", width: "100%",
maxWidth: "28rem", maxHeight: "85vh",
display: "flex",
flexDirection: "column",
borderRadius: "0.5rem", borderRadius: "0.5rem",
borderWidth: 1, borderWidth: 1,
borderStyle: "solid", borderStyle: "solid",
borderColor: colors.border, borderColor: colors.border,
backgroundColor: colors.surfaceRaised, backgroundColor: colors.surfaceRaised,
color: colors.text, color: colors.text,
padding: "1.5rem",
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
}, },
/** The panel already draws the boundary; the dialog's own ring would double it. */ panelForm: {
maxWidth: "28rem",
},
panelDetail: {
maxWidth: "52rem",
},
/**
* The panel already draws the boundary; the dialog's own ring would double
* it. `minHeight: 0` is what lets the body shrink far enough to scroll.
*/
body: { body: {
outlineStyle: "none", outlineStyle: "none",
display: "flex",
flexDirection: "column",
minHeight: 0,
},
header: {
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
paddingInline: "1.5rem",
paddingBlock: "0.75rem",
},
title: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
/** 44px on both axes: the pointer-target floor, which the word alone misses. */
close: {
minWidth: 44,
minHeight: 44,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
},
content: {
minHeight: 0,
overflowY: "auto",
paddingInline: "1.5rem",
paddingBlock: "1.5rem",
}, },
}); });
export default function Dialog({ label, isOpen, onClose, children }: Props) { export default function Dialog({ title, isOpen, onClose, size = "form", children }: Props) {
return ( return (
<ModalOverlay <ModalOverlay
isOpen={isOpen} isOpen={isOpen}
@@ -58,9 +114,26 @@ export default function Dialog({ label, isOpen, onClose, children }: Props) {
isDismissable isDismissable
className={() => stylex.props(styles.overlay).className ?? ""} className={() => stylex.props(styles.overlay).className ?? ""}
> >
<Modal className={() => stylex.props(styles.panel).className ?? ""}> <Modal
<AriaDialog aria-label={label} {...stylex.props(styles.body)}> className={() =>
{children} stylex.props(styles.panel, size === "detail" ? styles.panelDetail : styles.panelForm).className ??
""
}
>
<AriaDialog {...stylex.props(styles.body)}>
<div {...stylex.props(styles.header)}>
<Heading slot="title" level={2} {...stylex.props(styles.title)}>
{title}
</Heading>
<button
type="button"
onClick={onClose}
{...stylex.props(shared.button, styles.close, shared.focusRing)}
>
Close
</button>
</div>
<div {...stylex.props(styles.content)}>{children}</div>
</AriaDialog> </AriaDialog>
</Modal> </Modal>
</ModalOverlay> </ModalOverlay>
+91
View File
@@ -0,0 +1,91 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import Switch from "./Switch";
/**
* The drawn track, which is the label's one span that does not hold the hidden
* input. StyleX compiles to class names and jsdom loads no stylesheet, so the
* class list is the only place the composed state is observable.
*/
function track(input: HTMLElement): HTMLElement {
const label = input.closest("label") as HTMLElement;
const spans = [...label.querySelectorAll("span")];
const drawn = spans.find((span) => !span.contains(input));
if (drawn === undefined) throw new Error("the switch drew no track");
return drawn;
}
test("a switch reports the switch role, not a checkbox one", () => {
const onChange = vi.fn();
render(
<Switch isSelected onChange={onChange}>
Safe search
</Switch>,
);
// The role is the whole point: it tells a screen reader the setting moves now
// rather than on some later Save.
const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
expect(control.checked).toBe(true);
expect(screen.queryByRole("checkbox")).toBeNull();
fireEvent.click(control);
// The caller owns the state, so the switch reports the value it would move to.
expect(onChange).toHaveBeenCalledWith(false);
});
test("a bare switch takes its name from aria-label", () => {
render(<Switch aria-label="udp://1.1.1.1:53 enabled" isSelected={false} onChange={vi.fn()} />);
const control = screen.getByRole("switch", { name: "udp://1.1.1.1:53 enabled" }) as HTMLInputElement;
expect(control.checked).toBe(false);
});
test("a disabled switch keeps its state on screen but takes no input", () => {
render(
<Switch isSelected isDisabled onChange={vi.fn()}>
Safe search
</Switch>,
);
const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
expect(control.checked).toBe(true);
// The disabled input is the whole guard: a browser fires no click on one, and
// it is out of the tab order. Clicking it here would prove nothing either way,
// because `fireEvent` dispatches straight at the node and skips that check.
expect(control.disabled).toBe(true);
});
test("the track is drawn from the selected state, not left to the browser", () => {
const { rerender } = render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);
const control = screen.getByRole("switch");
const off = track(control).className.split(" ");
rerender(<Switch aria-label="Safe search" isSelected onChange={vi.fn()} />);
const on = track(control).className.split(" ");
// The two states compose to different class sets, so the thumb and the fill
// actually move rather than the input alone changing.
expect(on).not.toEqual(off);
});
test("keyboard focus composes the ring onto the drawn track", () => {
render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);
const control = screen.getByRole("switch");
const drawn = track(control);
const idle = drawn.className.split(" ");
expect(control.closest("label")?.getAttribute("data-focus-visible")).toBeNull();
// React Aria only calls focus visible after the modality is keyboard, which
// is why a bare focus() is not enough to raise the ring.
act(() => {
fireEvent.keyDown(document.body, { key: "Tab" });
control.focus();
});
expect(control.closest("label")?.getAttribute("data-focus-visible")).toBe("true");
const ringed = drawn.className.split(" ");
expect(ringed.length).toBeGreaterThan(idle.length);
expect(idle.every((name) => ringed.includes(name))).toBe(true);
});
+121
View File
@@ -0,0 +1,121 @@
/**
* The on/off switch, wrapping React Aria's.
*
* A switch, not a checkbox: every call site flips a setting that takes effect
* the moment it moves, with no Save between. A checkbox states what a form will
* submit, which is a promise these controls do not make.
*
* React Aria hides the real input and leaves the track to the call site, so the
* track and thumb below are what the reader sees. The caller always holds the
* state — none of these controls owns the value it shows, because the server's
* answer is the value.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Switch as AriaSwitch } from "react-aria-components";
import { colors } from "./tokens.stylex";
interface Common {
isSelected: boolean;
onChange: (isSelected: boolean) => void;
/** Visible but inert; React Aria also drops it from the tab order. */
isDisabled?: boolean;
}
/** With visible words beside the track, which name it. */
interface Labelled extends Common {
children: ReactNode;
"aria-label"?: never;
}
/** Bare, in a table cell whose column heading cannot name a single row. */
interface Named extends Common {
children?: never;
"aria-label": string;
}
/**
* A switch has to be named, and exactly one of the two ways: visible words that
* `aria-label` would then override and hide from the reader who can see them,
* or no words and a label only the screen reader gets.
*/
type Props = Labelled | Named;
const styles = stylex.create({
/**
* The whole label is the hit area, so it carries the 44px pointer-target
* floor on both axes, the same floor `Checkbox` and the dialog's Close
* button already set. The track itself is far under it.
*/
label: {
minWidth: 44,
minHeight: 44,
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** Bare switches sit in a table cell, where the row sets the rhythm. */
track: {
flexShrink: 0,
boxSizing: "border-box",
width: 28,
height: 16,
display: "inline-flex",
alignItems: "center",
justifyContent: "flex-start",
borderRadius: 8,
padding: 2,
backgroundColor: colors.borderStrong,
},
/**
* The thumb moves by the box's own alignment rather than by a transform, so
* there is no transition to withhold from a reader who asked for less motion.
*/
trackSelected: {
justifyContent: "flex-end",
backgroundColor: colors.primary,
},
/** As on `Checkbox`: the ring follows the state React Aria reports, because
* `:focus-visible` lands on the hidden input rather than on this track. */
trackFocused: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** The track dims, not the words: the label still has to be readable. */
trackDisabled: {
opacity: 0.5,
},
thumb: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: colors.surfaceRaised,
},
});
export default function Switch({ children, ...state }: Props) {
return (
<AriaSwitch {...state} className={() => stylex.props(styles.label).className ?? ""}>
{(renderProps) => (
<>
<span
{...stylex.props(
styles.track,
renderProps.isSelected && styles.trackSelected,
renderProps.isFocusVisible && styles.trackFocused,
renderProps.isDisabled && styles.trackDisabled,
)}
>
<span {...stylex.props(styles.thumb)} />
</span>
{children}
</>
)}
</AriaSwitch>
);
}
+9
View File
@@ -60,6 +60,7 @@ export const styles = stylex.create({
}, },
button: { button: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem", borderRadius: "0.25rem",
borderWidth: 1, borderWidth: 1,
borderStyle: "solid", borderStyle: "solid",
@@ -70,6 +71,7 @@ export const styles = stylex.create({
lineHeight: "1.25rem", lineHeight: "1.25rem",
}, },
smallButton: { smallButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem", borderRadius: "0.25rem",
borderWidth: 1, borderWidth: 1,
borderStyle: "solid", borderStyle: "solid",
@@ -80,6 +82,7 @@ export const styles = stylex.create({
lineHeight: "1.25rem", lineHeight: "1.25rem",
}, },
largeButton: { largeButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem", borderRadius: "0.25rem",
borderWidth: 1, borderWidth: 1,
borderStyle: "solid", borderStyle: "solid",
@@ -90,6 +93,7 @@ export const styles = stylex.create({
}, },
primaryButton: { primaryButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem", borderRadius: "0.25rem",
borderStyle: "none", borderStyle: "none",
backgroundColor: colors.primary, backgroundColor: colors.primary,
@@ -102,6 +106,7 @@ export const styles = stylex.create({
opacity: { default: 1, [DISABLED]: 0.5 }, opacity: { default: 1, [DISABLED]: 0.5 },
}, },
largePrimaryButton: { largePrimaryButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem", borderRadius: "0.25rem",
borderStyle: "none", borderStyle: "none",
backgroundColor: colors.primary, backgroundColor: colors.primary,
@@ -113,6 +118,7 @@ export const styles = stylex.create({
}, },
rowButton: { rowButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem", borderRadius: "0.25rem",
borderStyle: "none", borderStyle: "none",
backgroundColor: "transparent", backgroundColor: "transparent",
@@ -123,6 +129,7 @@ export const styles = stylex.create({
color: colors.primaryOnSurface, color: colors.primaryOnSurface,
}, },
linkButton: { linkButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderStyle: "none", borderStyle: "none",
backgroundColor: "transparent", backgroundColor: "transparent",
padding: 0, padding: 0,
@@ -132,6 +139,7 @@ export const styles = stylex.create({
color: colors.primaryOnSurface, color: colors.primaryOnSurface,
}, },
dangerLinkButton: { dangerLinkButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderStyle: "none", borderStyle: "none",
backgroundColor: "transparent", backgroundColor: "transparent",
padding: 0, padding: 0,
@@ -142,6 +150,7 @@ export const styles = stylex.create({
opacity: { default: 1, [DISABLED]: 0.5 }, opacity: { default: 1, [DISABLED]: 0.5 },
}, },
retryButton: { retryButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
marginTop: "0.75rem", marginTop: "0.75rem",
borderRadius: "0.25rem", borderRadius: "0.25rem",
borderWidth: 1, borderWidth: 1,