26 Commits
Author SHA1 Message Date
mokhtar 8cc85b487a build: bump version to 0.0.14
Gates / frontend (push) Successful in 1m47s
Gates / test (push) Successful in 2m32s
Gates / test-aarch64 (push) Successful in 8m30s
Gates / package (push) Successful in 4m37s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 29m58s
Release / guard (push) Successful in 34s
Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m26s
Gates / test-aarch64 (push) Successful in 7m42s
Gates / package (push) Successful in 59s
Gates / container (push) Successful in 14s
Release / gates (push) Successful in 11m28s
Release / publish (push) Successful in 7m4s
2026-08-28 17:57:09 +02:00
mokhtar 0f01c2fbd7 storage: version querylog.db and migrate it in place, never reset a healthy file
Gates / frontend (push) Successful in 2m8s
Gates / test (push) Successful in 2m46s
Gates / test-aarch64 (push) Successful in 8m38s
Gates / package (push) Successful in 4m39s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 31m58s
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 c9701fae85 build: bump version to 0.0.13
Gates / frontend (push) Successful in 1m35s
Gates / test (push) Successful in 2m23s
Gates / test-aarch64 (push) Successful in 8m13s
Gates / package (push) Successful in 4m15s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 23m19s
Release / guard (push) Successful in 34s
Gates / frontend (push) Successful in 1m39s
Gates / test (push) Successful in 2m19s
Gates / test-aarch64 (push) Successful in 7m25s
Gates / package (push) Successful in 48s
Release / publish (push) Successful in 6m36s
Gates / container (push) Successful in 17s
Release / gates (push) Successful in 10m52s
2026-08-27 21:46:08 +02:00
mokhtar a3aa7febb4 upstream: one absolute per-query budget across queueing and failover
Gates / frontend (push) Successful in 1m46s
Gates / test (push) Successful in 2m32s
Gates / package (push) Successful in 4m20s
Gates / test-aarch64 (push) Successful in 8m15s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 30m33s
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 d2e12ae0e2 build: bump version to 0.0.12
Gates / test (push) Successful in 2m20s
Gates / frontend (push) Successful in 1m33s
Gates / test-aarch64 (push) Successful in 8m13s
Gates / package (push) Successful in 4m33s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 27m59s
Release / guard (push) Successful in 1m45s
Gates / frontend (push) Successful in 1m59s
Gates / test (push) Successful in 2m24s
Gates / test-aarch64 (push) Successful in 7m33s
Gates / package (push) Successful in 45s
Gates / container (push) Successful in 14s
Release / gates (push) Successful in 25m53s
Release / publish (push) Successful in 8m32s
2026-08-27 17:49:06 +02:00
mokhtar 6a0630c288 overview: one endpoint, live projections and a response cache (m36)
Gates / frontend (push) Successful in 2m6s
Gates / test (push) Successful in 2m57s
Gates / test-aarch64 (push) Successful in 8m31s
Gates / package (push) Successful in 4m19s
Gates / container (push) Failing after 2s
CI / gates (push) Failing after 26m21s
2026-08-27 17:48:20 +02:00
mokhtar a261dc2aa3 build: bump version to 0.0.11
Gates / test (push) Successful in 2m12s
Gates / test-aarch64 (push) Successful in 7m1s
Gates / package (push) Successful in 42s
Release / gates (push) Successful in 10m8s
Gates / container (push) Successful in 10s
Release / publish (push) Successful in 10m22s
Gates / test-aarch64 (push) Successful in 9m19s
Gates / package (push) Successful in 10m49s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 25m8s
Gates / frontend (push) Successful in 1m42s
Gates / frontend (push) Successful in 3m55s
Gates / test (push) Successful in 4m41s
Release / guard (push) Successful in 1m48s
2026-08-24 18:41:46 +02:00
mokhtar 90b85ef954 changelog: 0.0.11
Gates / test (push) Successful in 2m6s
Gates / test-aarch64 (push) Successful in 7m31s
Gates / frontend (push) Successful in 1m36s
Gates / package (push) Failing after 16m49s
Gates / container (push) Skipped
CI / gates (push) Failing after 35m55s
2026-08-24 18:40:29 +02:00
mokhtar ad26aca198 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 08d756cc87 build: bump version to 0.0.10
Gates / frontend (push) Successful in 2m1s
Gates / test (push) Successful in 3m6s
Gates / test-aarch64 (push) Successful in 9m28s
Gates / package (push) Successful in 9m12s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 22m4s
Release / guard (push) Successful in 1m32s
Gates / frontend (push) Successful in 1m28s
Gates / test (push) Successful in 1m53s
Gates / package (push) Successful in 23s
Gates / container (push) Successful in 17s
Release / gates (push) Successful in 9m33s
Gates / test-aarch64 (push) Successful in 6m58s
Release / publish (push) Successful in 10m46s
2026-08-24 00:05:33 +02:00
mokhtar 44ecc2c4ae changelog: 0.0.10
Gates / frontend (push) Successful in 1m31s
Gates / test (push) Successful in 1m53s
Gates / test-aarch64 (push) Successful in 7m2s
Gates / package (push) Successful in 5m44s
Gates / container (push) Successful in 9s
CI / gates (push) Successful in 48m46s
2026-08-24 00:05:21 +02:00
mokhtar ce143d1d87 db-mode config changes apply live in-process
Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s
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 f7f4c8be09 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 fe71efe335 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 a8fd9fee48 ci: drop the one-insn-per-tb workaround, the qemu panic is an 11.1.0-only regression
Gates / frontend (push) Successful in 1m33s
Gates / test-aarch64 (push) Successful in 7m18s
Gates / container (push) Successful in 18s
CI / gates (push) Successful in 13m41s
Gates / test (push) Successful in 1m47s
Gates / package (push) Successful in 4m12s
Gates / package (push) Successful in 38s
Release / guard (push) Successful in 20s
Gates / frontend (push) Successful in 1m27s
Gates / test (push) Successful in 1m43s
Gates / test-aarch64 (push) Successful in 7m1s
Gates / container (push) Successful in 9s
Release / gates (push) Successful in 9m33s
Release / publish (push) Successful in 4m43s
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 f1a85d3dab logger tests: join every writer future before its database closes, fix the gate-open race
Gates / test (push) Successful in 1m50s
Gates / package (push) Successful in 4m6s
Gates / frontend (push) Successful in 1m49s
Gates / test-aarch64 (push) Failing after 1h13m21s
Gates / container (push) Successful in 10s
CI / gates (push) Failing after 1h23m35s
2026-08-23 12:26:09 +02:00
mokhtar 72dbcbe24f 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 c5875af8c8 admin: live ring capacity is injectable, eviction test no longer timing-bound
Gates / test-aarch64 (push) Failing after 3h1m47s
Gates / package (push) Successful in 4m17s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 4h45m14s
Gates / frontend (push) Successful in 1m21s
Gates / test (push) Successful in 1m42s
2026-08-23 09:04:18 +02:00
mokhtar 409384ee9e build: bump version to 0.0.9
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 6h11m8s
Gates / frontend (push) Successful in 1m26s
Gates / test (push) Successful in 1m45s
Gates / package (push) Successful in 34s
Gates / test-aarch64 (push) Failing after 3h6m37s
2026-08-22 23:32:47 +02:00
mokhtar 9eb78f6171 changelog: 0.0.9 releases today
Gates / frontend (push) Successful in 1m29s
Gates / test (push) Successful in 1m53s
Gates / test-aarch64 (push) Successful in 7m38s
Gates / package (push) Successful in 3m49s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 6h29m21s
2026-08-22 23:32:36 +02:00
mokhtar cc23c97218 milestone 33: contract closure — samples, file-authority enumeration, dead code, bundle ceiling
Gates / frontend (push) Successful in 1m34s
Gates / test (push) Successful in 2m3s
Gates / test-aarch64 (push) Failing after 3h13m33s
Gates / package (push) Successful in 5m20s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 6h30m45s
2026-08-22 23:31:37 +02:00
mokhtar 5da4652e89 querylog: return to the default checkpoint cadence 2026-08-22 23:10:17 +02:00
mokhtar 7e0df5fd94 milestone 32: task-shaped configuration, file mode as a rendering, config status api
Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s
2026-08-22 22:42:50 +02:00
mokhtar 025edbb093 milestone 31: concurrent upstream exchanges, dot session reuse, queue metrics
Gates / frontend (push) Successful in 1m26s
Gates / test (push) Successful in 1m55s
Gates / test-aarch64 (push) Failing after 3h1m8s
Gates / package (push) Successful in 3m55s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 3h21m29s
2026-08-22 19:54:02 +02:00
mokhtar 648d9b4496 milestone 30: overview as a dashboard, explicit health contract, period aggregations
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
2026-08-22 16:45:15 +02:00
mokhtarandClaude Fable 5 17422fac21 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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 10:53:19 +02:00
46 changed files with 874 additions and 2980 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ import { readdirSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const BUDGET_BYTES = 850_000;
const BUDGET_BYTES = 800_000;
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
@@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -313,17 +313,16 @@ 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 {
return screen.getByRole("region", { name: "Related" });
}
/**
* 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 () => {
test("a blocked query's Related offers Pause; an allowed one has nothing to pause about", async () => {
responses["/api/queries/50"] = detail(50, {
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
route: { kind: "blocked", upstream: "" },
@@ -331,12 +330,25 @@ test("Related carries its four links and no control, blocked query or not", asyn
renderDetail(50);
await screen.findByRole("heading", { name: "example.com" });
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
expect(within(related()).getAllByRole("link").map((link) => link.textContent)).toEqual([
"Test this domain against current policy",
"All activity for this domain",
"All activity from this client",
"Diagnostics around this query",
]);
expect(within(related()).queryByRole("button")).toBeNull();
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
cleanup();
responses["/api/queries/51"] = detail(51, { policy: { action: "allow", reason: "no_match", matched: "" } });
renderDetail(51);
await screen.findByRole("heading", { name: "example.com" });
expect(within(related()).queryByRole("button", { name: "Pause" })).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,7 +73,9 @@ export default function ActivityDetailPage() {
domain={domain}
client={client}
ts={time}
origin={origin} />
origin={origin}
blocked={detail.policy.action === "block"}
/>
}
/>
</section>
@@ -1,393 +0,0 @@
/**
* 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();
});
+124 -534
View File
@@ -1,198 +1,73 @@
/**
* The filter toolbar over the Activity history table.
* The filter row over the Activity table.
*
* The applied state is the URL, never this form: what the reader sees is what
* the link they can paste to a housemate will show. So this holds a draft, and
* every control writes through to the URL — the segments and the time presets
* at once, the two text fields after a pause so a five-letter domain is one
* navigation rather than five.
* the link they can paste to a housemate will show. So this holds a draft only,
* and the page remounts it whenever the applied search changes — a back button
* or a pasted URL has to move the form with it, and a form that seeded itself
* once would keep showing the previous investigation's filters.
*
* A time preset writes both bounds as absolute seconds, resolved once at the
* click. Neither half may be left open: a bookmark has to describe the same
* investigation tomorrow, and a window that slid overnight — or one that stayed
* open at the top and swallowed everything logged since — would answer a
* different question under the same link.
*
* A URL can move under this form at any time, and the draft only follows the
* part of it that actually moved. A field is re-seeded when its own URL value
* changed and the new value is not one this toolbar just wrote: the debounce
* means the URL is always a little behind the keyboard, and a landing commit
* must not roll the input back over the letters typed since.
*
* The custom range is the one control that does not live-apply. Two half-typed
* timestamps are a normal intermediate state of typing one of them, and a lower
* bound at or above an upper bound selects nothing at all, so the pair is
* validated and applied together or not at all.
*
* Live mode does not render this at all — the page mounts it inside the History
* panel. The stream is unfiltered, and a row of controls that looked usable
* In live mode the row stays visible and disabled rather than disappearing: the
* filters are retained in the URL and apply again the moment history comes
* back, and hiding them would read as having lost them. The stream itself is
* unfiltered — the server sends every query — so a row that looked usable here
* would promise filtering that is not happening.
*/
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
import { useState, type FormEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
import type { ActivitySearch } from "./search";
/** Long enough that a typed word is one navigation, short enough to feel live. */
const DEBOUNCE_MS = 350;
const RESULTS = [
{ value: "any", label: "Any" },
{ value: "blocked", label: "Blocked" },
{ value: "allowed", label: "Allowed" },
] as const;
const PRESETS = [
{ label: "Any time", seconds: null },
{ label: "Past hour", seconds: 3600 },
{ label: "Past 24 hours", seconds: 86_400 },
{ label: "Past 7 days", seconds: 604_800 },
] as const;
const CUSTOM_ITEM = "Custom…";
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
const HIT_TARGET = 44;
const STATUS_OPTIONS = [
{ value: "any", label: "All" },
{ value: "blocked", label: "Blocked only" },
{ value: "allowed", label: "Allowed only" },
];
const styles = stylex.create({
toolbar: {
/** One column on a phone, two from `sm`, five from `lg`. */
grid: {
marginTop: "1rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
},
/** The domain field is the one that grows; everything else keeps its size. */
searchWrap: {
position: "relative",
flexGrow: 1,
flexShrink: 1,
flexBasis: "14rem",
display: "flex",
},
searchIcon: {
position: "absolute",
insetInlineStart: "0.5rem",
top: "50%",
transform: "translateY(-50%)",
color: colors.textMuted,
pointerEvents: "none",
label: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** Every control in the row is a pointer target before it is anything else. */
field: {
minHeight: HIT_TARGET,
},
searchInput: {
input: {
marginTop: "0.25rem",
width: "100%",
paddingInlineStart: "1.875rem",
// 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 },
},
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: {
buttonRow: {
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",
gridColumn: {
default: null,
"@media (min-width: 640px)": "span 2 / span 2",
"@media (min-width: 1024px)": "span 5 / span 5",
},
customField: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
toolbarButton: {
fontWeight: 500,
},
error: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
@@ -211,22 +86,6 @@ export const NO_FILTERS: AppliedFilters = {
until: undefined,
};
/** The half-open window the API reads: `ts >= since` and `ts < until`. */
interface Bounds {
since: number | undefined;
until: number | undefined;
}
/** The two text filters as the URL spells them, with absent written as empty. */
interface TextPair {
domain: string;
client: string;
}
function sameBounds(a: Bounds, b: Bounds): boolean {
return a.since === b.since && a.until === b.until;
}
function blockedOption(blocked: boolean | undefined): string {
if (blocked === undefined) return "any";
return blocked ? "blocked" : "allowed";
@@ -237,404 +96,135 @@ function optionBlocked(value: string): boolean | undefined {
return value === "allowed" ? false : undefined;
}
/** A filter value the URL can carry: trimmed, and empty means absent. */
function textFilter(value: string): string | undefined {
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
/** The message plus the bound it belongs to, so that input can point at it. */
interface BoundError {
field: "since" | "until";
message: string;
}
/** The 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"
function boundError(label: string, reason: "unparseable" | "nonexistent"): string {
return reason === "unparseable"
? `${label} is not a complete date and time.`
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`,
};
}
/** 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[];
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`;
}
interface Props {
applied: AppliedFilters;
/** Merges a patch into the applied search. `replace` keeps typing out of history. */
onApply: (patch: Partial<AppliedFilters>, replace?: boolean) => void;
isDisabled: boolean;
onApply: (filters: AppliedFilters) => void;
onClear: () => void;
}
export default function ActivityFilters({ applied, onApply, onClear }: Props) {
const urlText: TextPair = { domain: applied.domain ?? "", client: applied.client ?? "" };
const urlBounds: Bounds = { since: applied.since, until: applied.until };
const [domain, setDomain] = useState(urlText.domain);
const [client, setClient] = useState(urlText.client);
const [preset, setPreset] = useState<ChosenPreset | null>(null);
const [showCustom, setShowCustom] = useState(applied.since !== undefined || applied.until !== undefined);
export default function ActivityFilters({ applied, isDisabled, onApply, onClear }: Props) {
const [domain, setDomain] = useState(applied.domain ?? "");
const [client, setClient] = useState(applied.client ?? "");
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
const [error, setError] = useState<BoundError | null>(null);
const [sync, setSync] = useState<Sync>({
url: urlText,
bounds: urlBounds,
pendingText: [],
pendingBounds: [],
});
const [error, setError] = useState<string | null>(null);
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) {
function submit(event: FormEvent) {
event.preventDefault();
if (dirty) commitText(true);
}
function selectPreset(label: string) {
if (label === CUSTOM_ITEM) {
setShowCustom(true);
return;
}
const option = PRESETS.find((candidate) => candidate.label === label);
if (option === undefined) return;
setShowCustom(false);
setError(null);
if (option.seconds === null) {
setPreset(null);
setSince(datetimeField(undefined));
setUntil(datetimeField(undefined));
commitBounds({ since: undefined, until: undefined });
return;
}
// Both bounds, resolved once, here. An open upper bound would keep taking
// in queries logged after the reader stopped looking, so "the past hour"
// would name a different hour every time the link was opened.
const now = Math.floor(Date.now() / 1000);
const bounds: Bounds = { since: now - option.seconds, until: now };
setPreset({ label, ...bounds });
setSince(datetimeField(bounds.since));
setUntil(datetimeField(bounds.until));
commitBounds(bounds);
}
function setRange() {
const sinceValue = resolveDatetimeField(since);
if (!sinceValue.ok) {
setError(boundError("since", sinceValue.reason));
setError(boundError("Since", sinceValue.reason));
return;
}
const untilValue = resolveDatetimeField(until);
if (!untilValue.ok) {
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.",
});
setError(boundError("Until", untilValue.reason));
return;
}
setError(null);
setPreset(null);
commitBounds({ since: sinceValue.value, until: untilValue.value });
onApply({
domain: domain.trim() === "" ? undefined : domain.trim(),
client: client.trim() === "" ? undefined : client.trim(),
blocked: optionBlocked(blocked),
since: sinceValue.value,
until: untilValue.value,
});
}
function clear() {
setDomain("");
setClient("");
setPreset(null);
setShowCustom(false);
setBlocked("any");
setSince(datetimeField(undefined));
setUntil(datetimeField(undefined));
setError(null);
setSync((prev) => ({
...prev,
pendingText: [],
pendingBounds: [...prev.pendingBounds, { since: undefined, until: undefined }],
}));
onClear();
}
const active =
domain.trim() !== "" ||
client.trim() !== "" ||
applied.blocked !== undefined ||
applied.since !== undefined ||
applied.until !== undefined;
// The label the URL earns on its own, overridden only while a preset click is
// still the whole of what the URL says. Bounds nobody here chose read as
// "Custom": that is what a pasted link or an edited range is.
let timeLabel = "Any time";
if (applied.since !== undefined || applied.until !== undefined) {
timeLabel = preset !== null && sameBounds(preset, urlBounds) ? preset.label : "Custom";
}
/** True for the one bound the current message is about; nothing else is marked. */
const invalid = (field: BoundError["field"]): true | undefined =>
error !== null && error.field === field ? true : undefined;
function boundInput(field: BoundError["field"]) {
const state = field === "since" ? since : until;
const set = field === "since" ? setSince : setUntil;
return (
<label {...stylex.props(styles.customField)}>
{field === "since" ? "Since" : "Until"}
<>
<form onSubmit={submit} {...stylex.props(styles.grid)}>
<label {...stylex.props(styles.label)}>
Domain contains
<input
type="text"
value={domain}
disabled={isDisabled}
onChange={(event) => setDomain(event.target.value)}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
/>
</label>
<label {...stylex.props(styles.label)}>
Client (exact)
<input
type="text"
value={client}
disabled={isDisabled}
onChange={(event) => setClient(event.target.value)}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
/>
</label>
<Select
variant="compactField"
label="Result"
value={blocked}
isDisabled={isDisabled}
onChange={setBlocked}
options={STATUS_OPTIONS}
/>
<label {...stylex.props(styles.label)}>
Since
<input
type="datetime-local"
step={1}
value={state.text}
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)}
value={since.text}
disabled={isDisabled}
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
/>
{invalid(field) && (
<span id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
{error?.message}
</span>
)}
</label>
);
}
return (
<form onSubmit={flush}>
<div {...stylex.props(styles.toolbar)}>
<div {...stylex.props(styles.searchWrap)}>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
{...stylex.props(styles.searchIcon)}
>
<circle cx="7" cy="7" r="4.5" />
<path d="M10.5 10.5 14 14" strokeLinecap="round" />
</svg>
<label {...stylex.props(styles.label)}>
Until
<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)}
type="datetime-local"
step={1}
value={until.text}
disabled={isDisabled}
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
/>
</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 ?? ""}
</label>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={isDisabled}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
>
{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>
Apply filters
</button>
<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,
)}
disabled={isDisabled}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
>
Clear
</button>
</div>
{showCustom && (
<div {...stylex.props(styles.customRow)}>
{boundInput("since")}
{boundInput("until")}
<button
type="button"
onClick={setRange}
{...stylex.props(shared.button, styles.hitTarget, shared.focusRing)}
>
Set range
</button>
</div>
)}
</form>
{error !== null && (
<p role="alert" {...stylex.props(styles.error)}>
{error}
</p>
)}
</>
);
}
+22 -210
View File
@@ -12,7 +12,6 @@ import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
import { queryRow } from "@/features/provenance/provenanceFixture";
import { FakeEventSource } from "./fakeEventSource";
function client(id: number, ip: string, name: string, learnedName: string): Client {
return {
@@ -136,22 +135,6 @@ function queryCalls(): string[] {
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
}
/** The toolbar's search field, which is how a domain filter is entered now. */
function domainInput(): HTMLInputElement {
return screen.getByLabelText("Filter domains") as HTMLInputElement;
}
/** Enter in a text field: the debounce's escape hatch, and the fast path here. */
function submitFilters() {
fireEvent.submit(domainInput().closest("form")!);
}
/** The custom range lives behind the Time menu; the two bounds only exist there. */
function openCustomRange() {
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
}
test("renders the first page with the seven columns filled in", async () => {
renderPage();
await screen.findByText("first.example");
@@ -179,7 +162,7 @@ test("renders the first page with the seven columns filled in", async () => {
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
});
test("resolves each row's client to its display name, reading the IP out with it", async () => {
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
@@ -199,22 +182,19 @@ test("resolves each row's client to its display name, reading the IP out with it
// A hand-typed name wins outright; the learned name never surfaces for it.
const named = await screen.findByText("Kitchen Pi");
// 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(named.getAttribute("title")).toBe("192.0.2.10");
expect(screen.queryByText("pi.lan")).toBeNull();
// A learned name reads muted and nothing more here: the "learned" tag would
// repeat on every row of the table, so the Clients page carries it instead.
const learned = screen.getByText("laptop.lan");
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
expect(learned.getAttribute("title")).toBe("192.0.2.11");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
// A known client with neither name, and a client the loaded list has never
// seen, both fall back to the bare address with nothing standing in for it.
expect(screen.getByText("192.0.2.12").textContent).toBe("192.0.2.12");
expect(screen.getByText("192.0.2.99").textContent).toBe("192.0.2.99");
// seen, both fall back to the bare address with no tooltip standing in.
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
});
test("load more appends the next page and stops at the end of the log", async () => {
@@ -236,8 +216,8 @@ test("applying a filter puts it in the url, refetches, and resets the accumulate
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("older.example");
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
await screen.findByText(/Showing 1 query /);
expect(history.location.search).toContain("domain=ads");
@@ -262,8 +242,8 @@ test("a load-more that resolves after a filter change is discarded", async () =>
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
await screen.findByText(/Showing 1 query /);
releaseLoadMore();
@@ -301,8 +281,8 @@ test("load more is disabled while a filter change shows placeholder data, then u
renderPage();
await screen.findByText("first.example");
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
const staleButton = await screen.findByRole("button", { name: "Load more" });
expect(staleButton).toHaveProperty("disabled", true);
@@ -436,7 +416,7 @@ test("a ?domain= link seeds the filter form and fetches that domain on arrival",
renderPage("/activity?domain=ads");
await screen.findByText("ads.example");
expect(domainInput()).toHaveProperty("value", "ads");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
expect(screen.queryByText("first.example")).toBeNull();
});
@@ -461,7 +441,7 @@ test("a rejected search parameter is dropped rather than guessed at", async () =
await screen.findByText("first.example");
// Nothing survived validation, so the request is the unfiltered one.
expect(queryCalls()).toEqual(["/api/queries"]);
expect(domainInput()).toHaveProperty("value", "");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
});
test("the form draft follows the url back and forward, seconds included", async () => {
@@ -474,33 +454,25 @@ test("the form draft follows the url back and forward, seconds included", async
const seeded = 1_700_000_017;
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
await screen.findByLabelText("Filter domains");
expect(domainInput()).toHaveProperty("value", "first");
// A seeded custom range opens its row, so the link's bounds are visible.
const domainInput = await screen.findByLabelText("Domain contains");
expect(domainInput).toHaveProperty("value", "first");
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
expect(sinceInput.value).toContain(":37");
fireEvent.change(domainInput(), { target: { value: "second" } });
submitFilters();
fireEvent.change(domainInput, { target: { value: "second" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
await waitFor(() => expect(history.location.search).toContain("domain=second"));
// The untouched Since bound applied as the exact second it was seeded with.
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);
// A pasted link, then the buttons over it: the draft is derived from the URL,
// so whichever way the browser moves it the field has to move with it.
act(() => history.push(`/activity?mode=history&domain=third&since=${seeded}`));
await waitFor(() => {
expect(domainInput()).toHaveProperty("value", "third");
});
act(() => history.back());
await waitFor(() => {
expect(domainInput()).toHaveProperty("value", "second");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
});
act(() => history.forward());
await waitFor(() => {
expect(domainInput()).toHaveProperty("value", "third");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
});
});
@@ -536,9 +508,8 @@ test("a wall-clock time the daylight-saving jump skips is refused, not silently
const callsBefore = queryCalls().length;
const searchBefore = history.location.search;
openCustomRange();
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
if (inGap) {
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
@@ -573,40 +544,6 @@ test("the policy simulation is reachable from the header, with no rows to click
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 () => {
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
await screen.findByText("ads.example");
@@ -616,130 +553,5 @@ test("Clear empties the url as well as the form", async () => {
expect(history.location.search).not.toContain("domain");
});
expect(history.location.search).not.toContain("blocked");
expect(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();
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
});
+39 -81
View File
@@ -8,10 +8,8 @@
* recipient should be looking at.
*/
import { useCallback } from "react";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
@@ -55,13 +53,6 @@ const styles = stylex.create({
fontWeight: 500,
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: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: { default: colors.textSecondary, ":hover": colors.text },
@@ -85,33 +76,13 @@ const styles = stylex.create({
lineHeight: "1.25rem",
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() {
const search = useSearch({ from: "/shell/activity" });
const navigate = useNavigate({ from: "/activity" });
const live = search.mode === "live";
// The functional form, not a replacement object: the filters are retained
// across a mode switch on purpose, and spelling out a new search here would
// drop every one of them on the way to Live and back.
@@ -120,75 +91,62 @@ export default function ActivityPage() {
void navigate({ search: (prev) => ({ ...prev, mode }) });
}
// A patch, merged into whatever the URL already says: a segment click must not
// spell out the four filters it is not about. `replace` is the debounced text
// commits, so the back button steps between investigations, not keystrokes.
const apply = useCallback(
(patch: Partial<AppliedFilters>, replace = false) => {
void navigate({ search: (prev) => ({ ...prev, ...patch }), replace });
},
[navigate],
);
const clear = useCallback(() => {
void navigate({ search: (prev) => ({ mode: prev.mode, ...NO_FILTERS }) });
}, [navigate]);
function apply(filters: AppliedFilters) {
void navigate({ search: { mode: search.mode, ...filters } });
}
return (
<section>
{/*
* The selected tab is the URL's `mode` and nothing else. RAC would hold
* the selection itself, but a second copy of it would fight the back
* button, so the search parameter stays the only state there is.
*/}
<Tabs
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
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}>
{MODES.map((option) => {
const selected = option.mode === search.mode;
return (
<button
key={option.mode}
id={option.mode}
className={({ isSelected, isFocusVisible }) =>
stylex.props(
type="button"
aria-pressed={selected}
onClick={() => selectMode(option.mode)}
{...stylex.props(
styles.modeButton,
isSelected ? styles.modeSelected : styles.modeIdle,
isFocusVisible && styles.modeFocusVisible,
).className ?? ""
}
selected ? styles.modeSelected : styles.modeIdle,
shared.focusRing,
)}
>
{option.label}
</Tab>
))}
</TabList>
</button>
);
})}
</div>
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
Current policy simulation
</Link>
</div>
{/*
* The toolbar belongs to History alone. It is not remounted on a search
* change: it resyncs its draft from the URL instead, because a remount
* mid-debounce would take the focus out of the input being typed in.
* Remounted whenever the applied search changes, which is what makes
* the back button work: the draft is derived state, and the browser
* moving the URL under it has to move the form with it.
*/}
<TabPanel id="history" className={panelClass}>
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
<HistoryActivity search={search} />
</TabPanel>
<TabPanel id="live" className={panelClass}>
<ActivityFilters
key={`${search.domain ?? ""}|${search.client ?? ""}|${String(search.blocked)}|${String(search.since)}|${String(search.until)}`}
applied={search}
isDisabled={live}
onApply={apply}
onClear={() => apply(NO_FILTERS)}
/>
{live ? (
<>
<p {...stylex.props(styles.liveNote)}>
The stream carries every query the server answers; the History filters apply to history only.
The stream carries every query the server answers; these filters apply to history only.
</p>
<LiveActivity origin={search} />
</TabPanel>
</Tabs>
</>
) : (
<HistoryActivity search={search} />
)}
</section>
);
}
@@ -56,8 +56,7 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textMuted,
},
/** The gap a line standing on its own needs from the block above it. */
spacedTop: {
refetching: {
marginTop: "0.75rem",
},
moreButton: {
@@ -82,9 +81,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
const pages = base.data?.pages ?? [];
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
// 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 coverage = pages[0]?.coverage;
const filterActive = Object.keys(filter).length > 0;
// `base.hasNextPage` reads the query state, which is empty while placeholder
// data stands in for a filter change; derive the cursor from what is on
@@ -115,27 +112,16 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
return (
<>
{/*
* A quiet line, never a skeleton: the rows on screen stay put while a
* filter change is in flight, so a keystroke must not blank the table
* it is narrowing.
*/}
{base.isFetching && (
<p {...stylex.props(styles.note, styles.spacedTop)} role="status">
Updating…
<p {...stylex.props(styles.note, styles.refetching)} role="status">
Loading…
</p>
)}
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
{rows.length === 0 ? (
<>
<p {...stylex.props(styles.empty)}>
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
</p>
{coverage !== undefined && (
<div {...stylex.props(styles.spacedTop)}>
<CoverageNotice coverage={coverage} variant="note" />
</div>
)}
</>
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
@@ -172,7 +158,6 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
{hasMore ? "" : " — end of log"}
</p>
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
{hasMore && (
<button
type="button"
+118 -224
View File
@@ -57,17 +57,7 @@ function json(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/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) {
function stubFetch(handler: (url: string) => Response | Promise<Response> = () => json({})) {
fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
@@ -178,7 +168,7 @@ test("streams rows, flags blocked ones, and freezes the display", async () => {
expect(screen.getByText("later.example")).toBeTruthy();
});
test("resolves each row's client to its display name, reading the IP out with it", async () => {
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
await openLive();
act(() => {
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
@@ -188,14 +178,11 @@ test("resolves each row's client to its display name, reading the IP out with it
});
const named = await screen.findByText("Kitchen Pi");
// 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(named.getAttribute("title")).toBe("192.0.2.10");
expect(screen.queryByText("pi.lan")).toBeNull();
const learned = screen.getByText("laptop.lan");
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
expect(learned.getAttribute("title")).toBe("192.0.2.11");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
@@ -269,40 +256,7 @@ test("a recovered row links to its stored detail; a streamed one opens in place
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
});
/** 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) => {
test("a streamed row opens its own provenance, from the keyboard as well as the pointer", async () => {
await openLive();
act(() =>
sources[0]!.emit(
@@ -319,108 +273,69 @@ test.each([
// only way to lose that is to opt out of it, which nothing here may do.
expect(trigger.tagName).toBe("BUTTON");
expect(trigger.getAttribute("tabindex")).toBeNull();
// The row opens a dialog, so it says so; what it no longer claims is to
// 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();
press(trigger);
const dialog = detailDialog();
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("tabbing forward and backward stays inside the open dialog", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
const dialog = detailDialog();
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
// 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));
const visited = new Set<Element>();
for (const shiftKey of [false, false, false, false, false, false, true, true, true, true]) {
fireEvent.keyDown(document.activeElement!, { key: "Tab", shiftKey });
fireEvent.keyUp(document.activeElement!, { key: "Tab", shiftKey });
expect(dialog.contains(document.activeElement)).toBe(true);
visited.add(document.activeElement!);
}
// Containment that never moved focus would satisfy the check above without
// trapping anything, so the walk has to have actually walked.
expect(visited.size).toBeGreaterThan(1);
});
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();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
const trigger = screen.getByRole("button", { name: "streamed.example" });
act(() => trigger.focus());
expect(document.activeElement).toBe(trigger);
fireEvent.click(trigger);
expect(detailDialog()).toBeTruthy();
dismiss();
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(document.activeElement).toBe(screen.getByRole("button", { name: "streamed.example" }));
});
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();
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" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.getByRole("button", { name: "Freeze" })).toBeTruthy();
expect(screen.queryByRole("heading", { level: 1, name: "streamed.example" })).toBeNull();
});
test("the detail takes focus when a row opens it and hands it back when it closes", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
const trigger = screen.getByRole("button", { name: "streamed.example" });
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.getAttribute("aria-controls")).toBeNull();
// A native button activates on Enter and Space; jsdom does not synthesize
// the click those keys fire, so the click is the activation.
act(() => trigger.focus());
fireEvent.click(trigger);
const panel = screen.getByRole("group", { name: "Streamed query" });
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(trigger.getAttribute("aria-controls")).toBe(panel.id);
// The panel is inserted above the table, behind the trigger in tab order, so
// the only thing that keeps a forward tab inside it is focus moving in.
expect(panel.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(document.activeElement).toBe(panel);
expect(panel.contains(screen.getByRole("button", { name: "Close" }))).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("group", { name: "Streamed query" })).toBeNull();
expect(document.activeElement).toBe(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.getAttribute("aria-controls")).toBeNull();
});
test("opening a second row moves the expanded state and the focus with it", async () => {
await openLive();
act(() => {
sources[0]!.emit("query", frame(1000, "first.example"));
sources[0]!.emit("query", frame(1001, "second.example"));
});
const first = screen.getByRole("button", { name: "first.example" });
const second = screen.getByRole("button", { name: "second.example" });
fireEvent.click(first);
fireEvent.click(second);
const panel = screen.getByRole("group", { name: "Streamed query" });
expect(within(panel).getByRole("heading", { level: 1, name: "second.example" })).toBeTruthy();
expect(document.activeElement).toBe(panel);
expect(first.getAttribute("aria-expanded")).toBe("false");
expect(second.getAttribute("aria-expanded")).toBe("true");
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(document.activeElement).toBe(second);
});
/**
@@ -444,57 +359,23 @@ function renderLiveWithCapacity(capacity: number) {
);
}
/** Push one ringful of filler through a 5-row ring, evicting whatever was there. */
function evictWithFiller() {
test("an open streamed detail survives the row being evicted from the ring buffer", () => {
renderLiveWithCapacity(5);
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(() => {
for (let index = 0; index < 5; index += 1) {
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.queryByRole("button", { name: "evicted.example" })).toBeNull();
});
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);
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
});
test("the route renders the live ring at its production capacity", async () => {
@@ -510,29 +391,34 @@ test("an open streamed detail survives Freeze and Resume", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
const snapshot = detailDialog().textContent;
// Freeze is behind the dialog, so it is reached the way the code reaches it
// rather than by role, which the modal deliberately hides.
const freeze = () => screen.getByText("Freeze") as HTMLButtonElement;
act(() => freeze().click());
expect(detailDialog().textContent).toBe(snapshot);
act(() => (screen.getByText("Resume") as HTMLButtonElement).click());
expect(detailDialog().textContent).toBe(snapshot);
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
});
test("live renders no filter toolbar, not even a disabled one", async () => {
test("the filter row stays visible, keeps its values, and is out of the tab order", async () => {
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
// The stream is unfiltered — the server sends every query — so a row of
// controls here would promise filtering that is not happening. The filters
// are not lost: they are in the URL, and History applies them on the way back.
expect(screen.queryByLabelText("Filter domains")).toBeNull();
expect(screen.queryByLabelText("Client IP (exact match)")).toBeNull();
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
expect(screen.queryByText("Clear")).toBeNull();
expect(screen.getByText(/the History filters apply to history only/)).toBeTruthy();
const domain = screen.getByLabelText("Domain contains") as HTMLInputElement;
expect(domain.value).toBe("ads");
expect(domain.disabled).toBe(true);
expect((screen.getByLabelText("Client (exact)") as HTMLInputElement).disabled).toBe(true);
expect((screen.getByLabelText("Since") as HTMLInputElement).disabled).toBe(true);
expect((screen.getByLabelText("Until") as HTMLInputElement).disabled).toBe(true);
const form = domain.closest("form")!;
const controls = [...form.querySelectorAll("input, button, select, textarea, a[href], [tabindex]")];
expect(controls.length).toBeGreaterThan(0);
for (const control of controls) {
// A disabled form control is skipped by the browser's tab order, and RAC
// pins its own trigger out of it as well. Nothing in the row may
// reintroduce itself with a reachable tabindex.
expect(control.hasAttribute("disabled")).toBe(true);
const tabindex = control.getAttribute("tabindex");
expect(tabindex === null || tabindex === "-1").toBe(true);
}
});
test("live mode asks for no query pages, whatever filters the url retained", async () => {
@@ -545,30 +431,31 @@ 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 () => {
await openLive("/activity?mode=live&domain=ads");
fireEvent.click(screen.getByRole("tab", { name: "History" }));
await screen.findByLabelText("Filter domains");
fireEvent.click(screen.getByRole("button", { name: "History" }));
await screen.findByRole("button", { name: "Apply filters" });
expect(sources).toHaveLength(1);
expect(sources[0]!.closed).toBe(true);
// The filters came along, which is the point of switching rather than
// navigating: the reader keeps the question they were asking.
expect((screen.getByLabelText("Filter domains") as HTMLInputElement).value).toBe("ads");
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads");
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
fireEvent.click(screen.getByRole("tab", { name: "Live" }));
fireEvent.click(screen.getByRole("button", { name: "Live" }));
await screen.findByRole("button", { name: "Freeze" });
expect(sources).toHaveLength(2);
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 {
return screen.getByRole("region", { name: "Related" });
}
/**
* 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 () => {
test("a streamed blocked row carries the same Pause action as the persisted detail", async () => {
await openLive();
act(() =>
sources[0]!.emit(
@@ -581,7 +468,14 @@ test("a streamed blocked row's Related carries links only", async () => {
);
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
expect(within(related()).getAllByRole("link")).toHaveLength(4);
expect(within(related()).queryByRole("button")).toBeNull();
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
});
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();
});
+78 -52
View File
@@ -1,6 +1,6 @@
/**
* Activity in live mode: the SSE stream, its bounded ring buffer, and the modal
* detail a streamed row opens.
* Activity in live mode: the SSE stream, its bounded ring buffer, and the
* in-place detail a streamed row opens.
*
* This subtree is mounted only while the URL says `mode=live`, which is what
* closes the EventSource on the way back to history: the connection is a
@@ -17,7 +17,6 @@ import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { useClientNames } from "@/features/clients/clientNames";
import { summarizeEvent } from "@/features/provenance/querySummary";
import Dialog from "@/ui/Dialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
@@ -89,7 +88,6 @@ const styles = stylex.create({
lineHeight: "1.25rem",
},
dismiss: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
@@ -163,6 +161,29 @@ const styles = stylex.create({
textDecorationLine: "underline",
textDecorationStyle: "dotted",
},
detailPanel: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surface,
padding: "1rem",
},
detailBar: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: "0.75rem",
},
detailLabel: {
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 600,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: colors.textMuted,
},
footnote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
@@ -171,6 +192,10 @@ const styles = stylex.create({
},
});
/** One panel at a time, so the trigger that opened it can name it in `aria-controls`. */
const DETAIL_PANEL_ID = "live-query-detail";
const DETAIL_LABEL_ID = "live-query-detail-label";
const PILL_LABELS: Record<StreamStatus, string> = {
connecting: "Connecting…",
open: "Live",
@@ -200,15 +225,40 @@ function StatusPill({ status }: { status: StreamStatus }) {
*
* 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
* dialog would blank out or swap under their eyes for no reason they could see.
* The snapshot is the whole fact — a streamed frame carries its own provenance
* — so it survives eviction, a merge and a Freeze/Resume, and closes only when
* the reader closes it or leaves live mode. The stream behind it never stops.
* panel would blank out for no reason they could see. The snapshot is the whole
* fact — a streamed frame carries its own provenance — so it survives eviction,
* a merge and a Freeze/Resume, and closes only when the reader closes it or
* leaves live mode.
*/
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
const summary = summarizeEvent(row.event);
const panel = useRef<HTMLDivElement>(null);
// The panel opens above the table, behind the trigger in tab order, so a
// forward tab from the row would walk past it. Focus moves in on open —
// keyed on the row, so choosing a second row moves it again — and the
// closer puts it back on the trigger.
useEffect(() => {
panel.current?.focus();
}, [row.key]);
return (
<Dialog title="Streamed query" size="detail" isOpen onClose={onClose}>
<div
ref={panel}
id={DETAIL_PANEL_ID}
tabIndex={-1}
role="group"
aria-labelledby={DETAIL_LABEL_ID}
{...stylex.props(styles.detailPanel)}
>
<div {...stylex.props(styles.detailBar)}>
<span id={DETAIL_LABEL_ID} {...stylex.props(styles.detailLabel)}>
Streamed query
</span>
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
Close
</button>
</div>
<ProvenanceDetail
provenance={row.event}
persistedId={null}
@@ -218,10 +268,11 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi
client={summary.client_ip}
ts={summary.ts}
origin={origin}
blocked={row.event.policy.action === "block"}
/>
}
/>
</Dialog>
</div>
);
}
@@ -241,36 +292,21 @@ export default function LiveActivity({
const clientNames = useClientNames();
const [selected, setSelected] = useState<StreamedRow | 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) {
trigger.current = from;
restoring.current = true;
setSelected(row);
}
// The row that opened the panel takes focus back, unless the ring has
// already evicted it: a detached button cannot be focused, and the browser
// falls back to the document, which is the best available answer.
function close() {
setSelected(null);
trigger.current?.focus();
trigger.current = null;
}
return (
<>
<div {...stylex.props(styles.toolbar)}>
@@ -326,18 +362,8 @@ export default function LiveActivity({
</div>
)}
{/*
* 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)}
>
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
{live.rows.length === 0 ? (
live.status !== "capped" && (
<p {...stylex.props(styles.empty)}>
@@ -364,7 +390,10 @@ export default function LiveActivity({
row.kind === "streamed" ? (
<button
type="button"
aria-haspopup="dialog"
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,
@@ -393,14 +422,11 @@ export default function LiveActivity({
</table>
</div>
<p {...stylex.props(styles.footnote)}>
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first,
last {capacity} kept).
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
{capacity} kept).
</p>
</>
)}
</div>
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={() => setSelected(null)} />}
</>
);
}
@@ -11,6 +11,7 @@
import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import PauseControl from "@/features/pause/PauseControl";
import { styles as shared } from "@/ui/styles";
import { provenanceRelatedLink } from "./ProvenanceDetail";
import { diagnosticsBounds, relatedBounds } from "./relatedLinks";
@@ -23,9 +24,15 @@ interface Props {
ts: number;
/** The Activity search the reader came from; its bounds win over the defaults. */
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 }: Props) {
export default function RelatedActions({ domain, client, ts, origin, blocked }: Props) {
const bounds = relatedBounds(ts, origin);
const window = diagnosticsBounds(ts);
return (
@@ -54,6 +61,7 @@ export default function RelatedActions({ domain, client, ts, origin }: Props) {
>
Diagnostics around this query
</Link>
{blocked && <PauseControl />}
</>
);
}
@@ -18,10 +18,16 @@ interface Props {
}
const styles = stylex.create({
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1rem",
},
fieldLabel: {
display: "block",
@@ -61,7 +67,8 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
const readOnly = useReadOnlyConfig();
return (
<Dialog title={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
<form
{...stylex.props(styles.form)}
onSubmit={(event) => {
+20 -206
View File
@@ -1,20 +1,6 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { ClientName, type ClientNames } from "./clientNames";
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(" ");
}
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
function clientRow(ip: string): HTMLElement {
const row = screen.getByText(ip).closest("tr");
@@ -87,39 +73,6 @@ test("the address links to the client's detail page", async () => {
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 () => {
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
@@ -187,15 +140,9 @@ test("an unknown group id filters to nothing and offers a way out", async () =>
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 () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
// 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/);
await screen.findAllByLabelText(/Managed by \/etc\/nxdns\/config\.zon/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
@@ -203,34 +150,6 @@ test("file mode drops every edit affordance and keeps the observed delete live (
const observed = clientRow("192.168.1.11");
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
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 () => {
@@ -251,7 +170,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 () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": undefined });
await screen.findAllByText(/^Configuration status unavailable/);
await screen.findAllByLabelText(/Configuration status unavailable/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
@@ -267,6 +186,10 @@ test("a failed config status exposes no configuration mutation, and still delete
// confirmation is already open. `undefined` is the failed status: the fetch stub
// 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([
["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE],
["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE],
@@ -289,13 +212,13 @@ describe.each([
expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull();
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy();
expect(within(dialog).getByText("Locked")).toBeTruthy();
expect(within(dialog).getByLabelText(/^Locked\./)).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
test("locks an open declared-client delete confirmation in place and leaves cancel working", async () => {
test("locks an open declared-client delete confirmation and leaves cancel working", async () => {
const map = { ...BASE };
const { queryClient, fetchMock } = await renderClientsPage(map);
// The Edit affordance appearing is the proof authority resolved to
@@ -304,66 +227,21 @@ describe.each([
const declared = clientRow("192.168.1.10");
fireEvent.click(within(declared).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Confirm delete" })).toBeTruthy();
await setConfigStatus(map, queryClient, status);
// The dialog stays put. Closing it would throw focus at the Delete button
// the same turn disabled, and the reason would survive only as a title
// attribute; here the reason is the dialog's own message.
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
expect(within(dialog).getByText(lockNote)).toBeTruthy();
expect(within(dialog).queryByText(otherNote)).toBeNull();
expect(within(dialog).getByText("Locked")).toBeTruthy();
const confirming = clientRow("192.168.1.10");
expect(within(confirming).queryByRole("button", { name: "Confirm delete" })).toBeNull();
expect(within(confirming).getByText(lockNote)).toBeTruthy();
expect(within(confirming).queryByText(otherNote)).toBeNull();
expect(within(confirming).getByLabelText(/^Locked\./)).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
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);
fireEvent.click(within(confirming).getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(
([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1"),
),
).toHaveLength(1),
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })).toBeTruthy(),
);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
});
test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
@@ -375,11 +253,10 @@ describe.each([
const observed = clientRow("192.168.1.11");
fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
await setConfigStatus(map, queryClient, status);
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Confirm delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(
@@ -394,7 +271,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
// test: nothing configuration owns may be offered on that guess.
await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER });
await screen.findAllByText(/^Checking which configuration source/);
await screen.findAllByLabelText(/Checking which configuration source/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
@@ -403,66 +280,3 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
const observed = clientRow("192.168.1.11");
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();
});
+58 -67
View File
@@ -9,8 +9,7 @@ import ClientEditDialog from "./ClientEditDialog";
import NetworkAssignments from "./NetworkAssignments";
import { ClientDisplayName } from "./clientIdentity";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import ConfigLockIndicator, { lockReason } from "@/features/configuration/ConfigLockIndicator";
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -25,9 +24,6 @@ import { colors } from "@/ui/tokens.stylex";
* 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 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 {
if (authority.state === "resolved") {
return "This client is declared in the configuration file; remove it there and restart.";
@@ -35,16 +31,6 @@ 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.";
}
/**
* 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({
heading: {
fontSize: "1.5rem",
@@ -55,15 +41,6 @@ const styles = stylex.create({
marginTop: "1rem",
color: colors.textMuted,
},
lockNote: {
marginTop: "1rem",
display: "flex",
flexDirection: "column",
gap: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
filterBar: {
marginTop: "1rem",
display: "flex",
@@ -102,11 +79,23 @@ const styles = stylex.create({
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
confirmGroup: {
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: "0.5rem",
},
actionGroup: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
},
note: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
dangerText: {
color: colors.danger,
},
@@ -123,7 +112,7 @@ export default function ClientsPage() {
const queryClient = useQueryClient();
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
const [editing, setEditing] = useState<Client | null>(null);
const [pendingDelete, setPendingDelete] = useState<Client | null>(null);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const readOnly = useReadOnlyConfig();
const authority = useAuthority();
@@ -133,17 +122,6 @@ export default function ClientsPage() {
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);
// 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 (
<section>
<h1 {...stylex.props(styles.heading)}>Clients</h1>
@@ -159,15 +137,6 @@ export default function ClientsPage() {
</Link>
</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 ? (
<p {...stylex.props(styles.empty)}>
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
@@ -209,11 +178,48 @@ export default function ClientsPage() {
<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, styles.right)}>
{confirmingId === client.id ? (
<span {...stylex.props(styles.confirmGroup)}>
{/* Authority is polled, so it can turn while a confirmation
sits open. The confirm path reads it on every render
rather than trusting the state that opened it. */}
<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
type="button"
onClick={() => setConfirmingId(null)}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Cancel
</button>
</span>
) : (
<span {...stylex.props(styles.actionGroup)}>
{/* Naming a client writes configuration, so the affordance is
absent — not disabled — wherever the write cannot land. */}
{readOnly ? (
<ConfigLockIndicator compact />
<ConfigLockIndicator />
) : (
<button
type="button"
@@ -225,10 +231,12 @@ export default function ClientsPage() {
)}
<button
type="button"
onClick={() => setPendingDelete(client)}
onClick={() => setConfirmingId(client.id)}
disabled={readOnly && client.hand_edited}
aria-describedby={
readOnly && client.hand_edited ? DELETE_LOCK_NOTE_ID : undefined
title={
readOnly && client.hand_edited
? declaredDeleteNote(authority)
: undefined
}
{...stylex.props(
shared.smallButton,
@@ -240,6 +248,7 @@ export default function ClientsPage() {
Delete
</button>
</span>
)}
</td>
</tr>
))}
@@ -247,24 +256,6 @@ export default function ClientsPage() {
</table>
</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} />
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
<NetworkAssignments prefixes={prefixes} groups={groups} />
@@ -4,15 +4,7 @@ import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import {
firstProblem,
initPrefixEditor,
isDirty,
prefixEditorReducer,
toInputs,
type PrefixEditorAction,
type PrefixProblem,
} from "./prefixEditor";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import AuthorityGate from "@/features/configuration/AuthorityGate";
import Select from "@/ui/Select";
@@ -24,9 +16,6 @@ interface Props {
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({
section: {
marginTop: "2.5rem",
@@ -76,7 +65,6 @@ const styles = stylex.create({
width: "5rem",
},
removeButton: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
@@ -173,24 +161,12 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
function AssignmentsEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
const [state, apply] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<PrefixProblem | null>(null);
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<string | null>(null);
const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups);
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 problem = firstProblem(state.rows);
setValidation(problem);
@@ -211,8 +187,6 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
<input
type="text"
aria-label={`Range ${index + 1}`}
aria-invalid={invalid(index, "prefix")}
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
placeholder="192.168.1.0/24"
value={row.prefix}
onChange={(event) =>
@@ -233,8 +207,6 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
type="text"
inputMode="numeric"
aria-label={`Priority for range ${index + 1}`}
aria-invalid={invalid(index, "priority")}
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
placeholder="100"
value={row.priority}
onChange={(event) =>
@@ -254,8 +226,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
</ul>
)}
{validation !== null && (
<p id={VALIDATION_ID} role="alert" {...stylex.props(styles.validation)}>
{validation.message}
<p role="alert" {...stylex.props(styles.validation)}>
{validation}
</p>
)}
<InlineError error={mutation.error} />
@@ -278,7 +250,10 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
{dirty && (
<button
type="button"
onClick={() => dispatch({ type: "reset", prefixes })}
onClick={() => {
setValidation(null);
dispatch({ type: "reset", prefixes });
}}
{...stylex.props(shared.button, shared.focusRing)}
>
Discard changes
+3 -13
View File
@@ -16,14 +16,6 @@ import * as stylex from "@stylexjs/stylex";
import { clientsQuery } from "@/lib/queries";
import type { Client } from "@/lib/types";
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">>;
@@ -61,13 +53,11 @@ export function clientLabel(ip: string, names: ClientNames): { text: string; lea
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
const label = clientLabel(ip, names);
if (label === null) return <span {...stylex.props(shared.mono)}>{ip}</span>;
// The name replaces the address, so the address follows it as real text that
// anyone can read and copy. A `title` carried it before, which reaches
// neither a keyboard nor a touch screen.
// The name replaces the address on screen, so the address stays reachable
// as the tooltip rather than disappearing from the row entirely.
return (
<span {...stylex.props(label.learned && shared.learnedName)}>
<span title={ip} {...stylex.props(label.learned && shared.learnedName)}>
{label.text}
<span {...stylex.props(styles.address)}> ({ip})</span>
</span>
);
}
@@ -76,15 +76,11 @@ test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
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: " ", group_id: 1, priority: "" }])).toEqual({
index: 0,
field: "prefix",
message: "Row 1: prefix is required.",
});
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required.");
expect(
firstProblem([
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
]),
).toEqual({ index: 1, field: "priority", message: "Row 2: priority must be a whole number." });
).toBe("Row 2: priority must be a whole number.");
});
+4 -13
View File
@@ -55,20 +55,11 @@ export function isDirty(state: PrefixEditorState): boolean {
});
}
/** Which input the message is about, so the editor can point that input at it. */
export interface PrefixProblem {
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.` };
export function firstProblem(rows: PrefixRow[]): string | null {
for (const [i, row] of rows.entries()) {
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`;
const priority = row.priority.trim();
if (priority !== "" && !/^\d+$/.test(priority))
return { index, field: "priority", message: `Row ${index + 1}: priority must be a whole number.` };
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`;
}
return null;
}
@@ -61,20 +61,17 @@ 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 () => {
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");
await waitFor(() =>
expect(screen.getByText(`Managed by ${CONFIG_PATH}; edit the file and restart nxdns`)).toBeTruthy(),
expect(lock.getAttribute("aria-label")).toBe(
`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 () => {
renderIndicator("failed");
await screen.findByText("Locked");
await waitFor(() => expect(screen.getByText(/^Configuration status unavailable/)).toBeTruthy());
const lock = await screen.findByText("Locked");
await waitFor(() => expect(lock.getAttribute("aria-label")).toContain("Configuration status unavailable"));
});
@@ -1,20 +1,8 @@
import * as stylex from "@stylexjs/stylex";
import { colors } from "@/ui/tokens.stylex";
import { useAuthority, type Authority } from "./authority";
import { useAuthority } from "./authority";
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: {
marginLeft: "0.5rem",
borderWidth: 1,
@@ -38,34 +26,20 @@ const styles = stylex.create({
* The word is real text, not colour or an icon, so a screen reader announces
* the reason the control will not answer.
*/
/**
* 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 }) {
export default function ConfigLockIndicator() {
const authority = useAuthority();
if (authority.state === "resolved" && authority.status.authority === "database") return null;
const reason = lockReason(authority);
const reason =
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 (
<span {...stylex.props(styles.row)}>
<span {...stylex.props(styles.tag)}>Locked</span>
<span {...stylex.props(styles.reason)}>{reason}</span>
<span title={reason} aria-label={`Locked. ${reason}.`} {...stylex.props(styles.tag)}>
Locked
</span>
);
}
@@ -3,8 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
import type { Blocklist } from "@/lib/types";
import { sameSet } from "./sourceSet";
import Checkbox, { CheckboxGroup } from "@/ui/Checkbox";
import { sameSet, toggleSource } from "./sourceSet";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -29,6 +28,13 @@ const styles = stylex.create({
flexDirection: "column",
gap: "0.25rem",
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
buttonRow: {
marginTop: "0.75rem",
display: "flex",
@@ -60,22 +66,21 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
return (
<div {...stylex.props(styles.root)}>
{/* The section's own "Assigned sources" heading is the visible label; a
Label here would put the same words on screen twice. Ids cross the
React Aria boundary as strings, the same convention as Select. */}
<CheckboxGroup
aria-label="Assigned sources"
value={current.map(String)}
onChange={(values) => setSelected(values.map(Number).sort((a, b) => a - b))}
>
<ul {...stylex.props(styles.list)}>
{blocklists.map((blocklist) => (
<li key={blocklist.id}>
<Checkbox value={String(blocklist.id)}>{blocklist.name}</Checkbox>
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={current.includes(blocklist.id)}
onChange={() => setSelected(toggleSource(current, blocklist.id))}
{...stylex.props(shared.focusRing)}
/>
{blocklist.name}
</label>
</li>
))}
</ul>
</CheckboxGroup>
<InlineError error={mutation.error} />
<div {...stylex.props(styles.buttonRow)}>
<button
@@ -21,7 +21,6 @@ import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from
import ConfirmDialog from "@/ui/ConfirmDialog";
import DefinitionList from "@/ui/DefinitionList";
import Select from "@/ui/Select";
import Switch from "@/ui/Switch";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import AuthorityGate from "./AuthorityGate";
@@ -69,6 +68,13 @@ const styles = stylex.create({
alignItems: "center",
gap: "0.75rem",
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
spacer: {
marginLeft: "auto",
},
@@ -349,15 +355,21 @@ function GroupDetailEditable({ group }: { group: Group }) {
)}
<div {...stylex.props(styles.controlRow)}>
<Switch
isSelected={group.safe_search}
isDisabled={update.isPending}
onChange={(safeSearch) =>
update.mutate({ id: group.id, input: { name: group.name, safe_search: safeSearch } })
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={group.safe_search}
disabled={update.isPending}
{...stylex.props(shared.focusRing)}
onChange={(event) =>
update.mutate({
id: group.id,
input: { name: group.name, safe_search: event.target.checked },
})
}
>
/>
Safe search
</Switch>
</label>
<span {...stylex.props(styles.spacer)}>
{!renaming && (
<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 });
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
expect((screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Delete group" }));
const dialog = await screen.findByRole("alertdialog");
@@ -60,7 +60,7 @@ test("toggling safe search resends the whole group row", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
fireEvent.click(screen.getByRole("switch", { name: "Safe search" }));
fireEvent.click(screen.getByRole("checkbox", { name: "Safe search" }));
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
expect(writes("PUT")[0]).toMatchObject({
@@ -89,31 +89,6 @@ 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 () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
@@ -237,33 +212,11 @@ test("the Sources tab lists the catalogue with both skipped columns and their no
expect(
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
).toBeTruthy();
// Switches, not checkboxes: the row applies the moment it moves, and the role
// 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.getByLabelText("Ads enabled") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Trackers enabled") as HTMLInputElement).checked).toBe(false);
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 () => {
let release: ((response: Response) => void) | null = null;
calls = stubApi(DATABASE, {
@@ -14,7 +14,6 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import Switch from "@/ui/Switch";
import AuthorityGate from "./AuthorityGate";
import BlocklistForm from "./BlocklistForm";
import FileModeNote from "./FileModeNote";
@@ -250,11 +249,13 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
</span>
</td>
<td {...stylex.props(shared.td)}>
<Switch
<input
type="checkbox"
aria-label={`${b.name} enabled`}
isSelected={b.enabled}
isDisabled={toggle.isPending}
checked={b.enabled}
disabled={toggle.isPending}
onChange={() => toggleEnabled(b)}
{...stylex.props(shared.focusRing)}
/>
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
@@ -13,9 +13,6 @@ import { styles as config } from "./styles";
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({
form: {
marginTop: "1rem",
@@ -103,7 +100,6 @@ const styles = stylex.create({
gap: "0.75rem",
},
save: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "1rem",
@@ -178,15 +174,12 @@ function FieldRow({
}
if (def.kind === "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 (
<div {...stylex.props(styles.field)}>
<FieldLabel id={id} text={def.key} restart={restart} />
<input
id={id}
type="number"
aria-invalid={Number.isNaN(numeric) || undefined}
value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -292,8 +285,6 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
id="web.password"
type="password"
autoComplete="new-password"
aria-invalid={passwordsMismatch || undefined}
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
value={password}
onChange={(e) => setPassword(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -307,8 +298,6 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
id="web.password_confirm"
type="password"
autoComplete="new-password"
aria-invalid={passwordsMismatch || undefined}
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -321,7 +310,7 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
</p>
)}
{passwordsMismatch && (
<p id={MISMATCH_ID} {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
Passwords do not match.
</p>
)}
@@ -176,52 +176,12 @@ test("enum and boolean fields diff as their own types", async () => {
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 () => {
await openSystem();
const cache = screen.getByRole("group", { name: "Cache" });
const size = within(cache).getByLabelText("size");
fireEvent.change(size, { target: { value: "" } });
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
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 () => {
@@ -7,7 +7,6 @@ import type { Upstream, UpstreamInput } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import Switch from "@/ui/Switch";
import AuthorityGate from "./AuthorityGate";
import FileModeNote from "./FileModeNote";
import QueryPanel from "./QueryPanel";
@@ -166,11 +165,13 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td {...stylex.props(shared.td)}>
<Switch
<input
type="checkbox"
aria-label={`${u.url} enabled`}
isSelected={u.enabled}
isDisabled={toggle.isPending}
checked={u.enabled}
disabled={toggle.isPending}
onChange={() => toggleEnabled(u)}
{...stylex.props(shared.focusRing)}
/>
</td>
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
@@ -24,7 +24,7 @@ afterEach(() => {
function mutationControls(): Element[] {
return [
...contentArea().querySelectorAll(
'input, textarea, select, [role="combobox"], [role="checkbox"], [role="switch"], [contenteditable]',
'input, textarea, select, [role="combobox"], [role="checkbox"], [contenteditable]',
),
];
}
@@ -1,4 +1,18 @@
import { sameSet } from "./sourceSet";
import { sameSet, toggleSource } 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", () => {
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
@@ -1,3 +1,8 @@
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 {
if (a.length !== b.length) return false;
const sortedA = [...a].sort((x, y) => x - y);
+8 -2
View File
@@ -69,6 +69,8 @@ const styles = stylex.create({
interface Series {
key: string;
label: string;
/** Kept beside the label so a renamed client is still identifiable by address. */
address: string | null;
color: string;
buckets: number[];
}
@@ -87,11 +89,15 @@ function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
// 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.
label: clientLabel(client.client, names)?.text ?? client.client,
address: client.client,
color: seriesColor(clientKey(client.client)),
buckets: client.buckets,
}));
if (data.other.every((count) => count === 0)) return named;
return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }];
return [
...named,
{ key: OTHER_KEY, label: "Other", address: null, color: seriesColor(OTHER_KEY), buckets: data.other },
];
}
/**
@@ -213,7 +219,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
)}
<ul {...stylex.props(styles.legend)}>
{series.map((one) => (
<li key={one.key} {...stylex.props(styles.legendItem)}>
<li key={one.key} title={one.address ?? undefined} {...stylex.props(styles.legendItem)}>
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
{one.label}
</li>
+11 -26
View File
@@ -10,7 +10,6 @@
import * as stylex from "@stylexjs/stylex";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { Radio, RadioGroup } from "react-aria-components";
import type { Period } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -39,7 +38,6 @@ const styles = stylex.create({
gap: "0.25rem",
},
period: {
cursor: "pointer",
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
@@ -47,13 +45,6 @@ const styles = stylex.create({
fontSize: "0.875rem",
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. */
periodSelected: {
backgroundColor: {
@@ -76,29 +67,23 @@ const styles = stylex.create({
export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
return (
<RadioGroup
aria-label="Period"
orientation="horizontal"
value={period}
onChange={(next) => onChange(next as Period)}
className={() => stylex.props(styles.periodGroup).className ?? ""}
>
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
{PERIODS.map((option) => (
<Radio
<button
key={option}
value={option}
className={({ isSelected, isFocusVisible }) =>
stylex.props(
type="button"
aria-pressed={option === period}
onClick={() => onChange(option)}
{...stylex.props(
styles.period,
isSelected ? styles.periodSelected : styles.periodIdle,
isFocusVisible && styles.periodFocusVisible,
).className ?? ""
}
option === period ? styles.periodSelected : styles.periodIdle,
shared.focusRing,
)}
>
{option}
</Radio>
</button>
))}
</RadioGroup>
</div>
);
}
@@ -189,7 +189,7 @@ test("a request in flight leaves the heading and the picker usable behind one lo
renderApp();
await screen.findByRole("heading", { name: "Overview", level: 1 });
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
expect(screen.getByRole("button", { name: "1h" })).toBeTruthy();
// One loading state for the whole page, not one per panel.
const loading = await screen.findByText("Loading…");
expect(loading.getAttribute("role")).toBe("status");
@@ -214,16 +214,6 @@ 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));
expect(within(chart).queryByText("192.0.2.30")).toBeNull();
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 () => {
@@ -345,23 +335,14 @@ 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 () => {
renderApp("/overview?period=1h");
await screen.findByText("12");
// One radio group named Period, holding the four periods and exactly one
// 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();
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
});
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");
await screen.findByText("1,000");
expect(screen.getByRole("radio", { name: "24h", checked: true })).toBeTruthy();
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("true");
expect(router.state.location.search).toEqual({});
});
@@ -369,7 +350,7 @@ test("the picker rescopes every panel and writes the period into the url", async
const router = renderApp();
await screen.findByText("1,000");
fireEvent.click(screen.getByRole("radio", { name: "1h" }));
fireEvent.click(screen.getByRole("button", { name: "1h" }));
await screen.findByText("12");
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
@@ -388,7 +369,7 @@ test("a failed request is one error for the whole page, stated once and retryabl
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
// 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("radio", { name: "1h" })).toBeTruthy();
expect(screen.getByRole("button", { name: "1h" })).toBeTruthy();
expect(screen.queryByText("Something went wrong")).toBeNull();
failing = false;
+18 -25
View File
@@ -91,14 +91,11 @@ test("unpaused: duration menu pauses with the picked duration_seconds", async ()
fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
"60 seconds",
"5 minutes",
"30 minutes",
"Indefinitely",
]);
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
expect(screen.getByRole("button", { name: label })).toBeTruthy();
}
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
await screen.findByRole("button", { name: "Resume" });
@@ -108,7 +105,7 @@ test("indefinite pause sends no duration_seconds", async () => {
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("menuitem", { name: "Indefinitely" }));
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
await screen.findByRole("button", { name: "Resume" });
@@ -154,15 +151,11 @@ test("an active resolver states nothing: the button already says Pause", async (
test("escape closes the duration menu", async () => {
renderControl();
fireEvent.click(await findPauseTrigger());
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
// Opening the menu moves focus into it, so Escape is pressed where the reader
// 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");
const trigger = await findPauseTrigger();
fireEvent.click(trigger);
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
fireEvent.keyDown(trigger, { key: "Escape" });
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
});
test("failed pause with 429 shows a ticking retry countdown", async () => {
@@ -175,7 +168,7 @@ test("failed pause with 429 shows a ticking retry countdown", async () => {
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
@@ -194,7 +187,7 @@ test("failed pause with 503 shows the degraded message", async () => {
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("menuitem", { name: "60 seconds" }));
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
@@ -209,12 +202,12 @@ test("a successful pause clears the previous mutation error", async () => {
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await screen.findByRole("alert");
postFailure = null;
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
await screen.findByRole("button", { name: "Resume" });
expect(screen.queryByRole("alert")).toBeNull();
@@ -247,7 +240,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
vi.useFakeTimers({ shouldAdvanceTime: true });
renderControl();
fireEvent.click(await findPauseTrigger());
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
protection = { state: "unavailable", until: null };
await vi.advanceTimersByTimeAsync(11_000);
@@ -260,7 +253,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
// reader opened, and nobody opened this one.
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
vi.useRealTimers();
});
@@ -268,7 +261,7 @@ test("a menu open when someone else pauses does not reopen when that pause ends"
vi.useFakeTimers({ shouldAdvanceTime: true });
renderControl();
fireEvent.click(await findPauseTrigger());
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
// Filtering is paused from somewhere else, and this browser learns it from
// the poll. The Resume rendering has no menu.
@@ -281,7 +274,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" }));
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
vi.useRealTimers();
});
+59 -52
View File
@@ -1,7 +1,8 @@
/**
* The Pause/Resume control, at the foot of the sidebar. A pause stops filtering
* for the whole resolver, so it belongs to the shell rather than to any page,
* and it has no second placement.
* The Pause/Resume control, in the two places a pause is a valid answer to what
* the reader is looking at: the foot of the sidebar, where it belongs to the
* resolver rather than to any page, and beside the detail of a query that was
* blocked.
*
* It reads `Health.protection` rather than `/api/pause` so it cannot contradict
* the Diagnostics health strip, and it renders nothing at all while protection
@@ -19,10 +20,9 @@
* line: the button says Pause, which is the whole message.
*/
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
import { formatClock } from "@/lib/format";
import { pauseMutation } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
@@ -48,8 +48,6 @@ const styles = stylex.create({
":disabled": "oklch(70.5% 0.015 286.067)",
"@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: {
display: "flex",
@@ -63,44 +61,43 @@ const styles = stylex.create({
lineHeight: "1rem",
color: colors.textSecondary,
},
/** `--trigger-width` is RAC's: the menu is as wide as the button that opened it. */
popover: {
width: "var(--trigger-width)",
anchor: {
position: "relative",
},
menu: {
position: "absolute",
left: 0,
top: "100%",
zIndex: 10,
marginTop: "0.25rem",
display: "flex",
width: "9rem",
flexDirection: "column",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
color: colors.text,
paddingBlock: "0.25rem",
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",
borderStyle: "none",
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: "inherit",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textAlign: "left",
fontSize: "0.875rem",
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() {
const queryClient = useQueryClient();
const protection = useProtection();
const mutation = useMutation(pauseMutation(queryClient));
const [menuOpen, setMenuOpen] = useState(false);
const paused = protection.state === "paused";
const { reset } = mutation;
useEffect(() => reset(), [paused, reset]);
@@ -111,6 +108,15 @@ export default function PauseControl() {
// the wrong action, which is the contradiction this control exists to end.
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 (paused) {
@@ -134,40 +140,41 @@ export default function PauseControl() {
}
return (
<div {...stylex.props(styles.row)}>
<MenuTrigger>
<Button
isDisabled={mutation.isPending}
className={() => stylex.props(shared.button, styles.trigger, shared.focusRing).className ?? ""}
<div
{...stylex.props(styles.anchor)}
onKeyDown={(e) => {
if (e.key === "Escape") setMenuOpen(false);
}}
>
<button
type="button"
aria-expanded={menuOpen}
aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Pause
</Button>
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
<Menu {...stylex.props(styles.menu)}>
</button>
{menuOpen && (
<div id="pause-menu" {...stylex.props(styles.menu)}>
{DURATIONS.map(({ label, seconds }) => (
<MenuItem
<button
key={label}
onAction={() =>
type="button"
onClick={() => {
setMenuOpen(false);
mutation.mutate(
seconds === null
? { paused: true }
: { paused: true, duration_seconds: seconds },
)
}
className={({ isFocused }) =>
stylex.props(
styles.menuItem,
shared.insetFocusRing,
isFocused && styles.menuItemFocused,
).className ?? ""
}
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
);
}}
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
>
{label}
</MenuItem>
</button>
))}
</Menu>
</Popover>
</MenuTrigger>
</div>
)}
<InlineError error={mutation.error} />
</div>
);
+2 -18
View File
@@ -17,12 +17,6 @@ const styles = stylex.create({
lineHeight: "1.25rem",
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,
},
});
/**
@@ -33,21 +27,11 @@ const styles = stylex.create({
* watermark and nothing more: the same incompleteness covers a log retention
* has pruned and one that simply has not been running long enough, and the
* response does not say which.
*
* `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,
variant = "banner",
}: {
coverage: Coverage;
variant?: "banner" | "note";
}) {
export default function CoverageNotice({ coverage }: { coverage: Coverage }) {
if (coverage.complete) return null;
return (
<p role="status" {...stylex.props(variant === "note" ? styles.note : styles.notice)}>
<p role="status" {...stylex.props(styles.notice)}>
Query history is available from {formatTime(coverage.available_since)}.
</p>
);
-1
View File
@@ -12,7 +12,6 @@ const styles = stylex.create({
color: colors.danger,
},
retry: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
+2 -69
View File
@@ -214,45 +214,12 @@ test("mount probe reveals the logout button and a failed logout surfaces inline"
renderShell();
// 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" }));
fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
await within(aside).findByText("Rate limited. Try again in 7s.");
await screen.findByText("Rate limited. Try again in 7s.");
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 () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
@@ -290,40 +257,6 @@ test("the mobile drawer carries the same control, not a header one it lost", asy
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 () => {
// 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
+14 -39
View File
@@ -2,7 +2,6 @@ import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { Button, Disclosure, DisclosurePanel } from "react-aria-components";
import { useAuth } from "@/auth/store";
import InlineError from "@/lib/InlineError";
import { healthQuery, versionQuery } from "@/lib/queries";
@@ -111,17 +110,6 @@ const styles = stylex.create({
alignItems: "center",
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: {
minHeight: "100dvh",
height: { default: null, [WIDE]: "100dvh" },
@@ -159,9 +147,8 @@ const styles = stylex.create({
minWidth: { default: null, [WIDE]: 0 },
flexDirection: "column",
},
/** Narrow only: on WIDE the sidebar carries everything this row held. */
header: {
display: { default: "flex", [WIDE]: "none" },
display: "flex",
alignItems: "center",
gap: "0.75rem",
borderBottomWidth: 1,
@@ -297,13 +284,13 @@ function VersionFooter() {
);
}
function LogoutButton({ style }: { style?: stylex.StyleXStyles }) {
function LogoutButton() {
const { authRequired, logout } = useAuth();
const navigate = useNavigate();
const [error, setError] = useState<unknown>(null);
if (authRequired !== true) return null;
return (
<div {...stylex.props(styles.logoutRow, style)}>
<div {...stylex.props(styles.logoutRow)}>
<button
type="button"
onClick={() => {
@@ -331,49 +318,37 @@ export default function AppShell() {
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
<NavLinks />
</nav>
<LogoutButton style={styles.sidebarLogout} />
<SidebarFooter />
</aside>
{/* 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)}
>
<div {...stylex.props(styles.column)}>
<header {...stylex.props(styles.header)}>
<Button
slot="trigger"
className={() =>
stylex.props(shared.button, styles.narrowOnly, shared.focusRing).className ?? ""
}
<button
type="button"
aria-expanded={drawerOpen}
aria-controls="mobile-nav"
onClick={() => setDrawerOpen((open) => !open)}
{...stylex.props(shared.button, styles.narrowOnly, shared.focusRing)}
>
Menu
</Button>
</button>
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
<div {...stylex.props(styles.headerRight)}>
<LogoutButton />
</div>
</header>
<ConfigStatusNotices />
<DisclosurePanel id="mobile-nav" {...stylex.props(styles.drawer)}>
{/* React Aria only hides a collapsed panel; the shell drops it
instead, so a closed drawer keeps no second health poll and
no second Pause control alive behind the header. */}
{drawerOpen && (
<>
<div id="mobile-nav" {...stylex.props(styles.drawer)}>
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
<NavLinks onNavigate={() => setDrawerOpen(false)} />
</nav>
<SidebarFooter />
</>
</div>
)}
</DisclosurePanel>
<main id="main-content" data-scroll-restoration-id="main" {...stylex.props(styles.main)}>
<Outlet />
</main>
</Disclosure>
</div>
</div>
);
}
-92
View File
@@ -1,92 +0,0 @@
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
@@ -1,138 +0,0 @@
/**
* 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>
);
}
+1 -15
View File
@@ -8,7 +8,6 @@
* and a stray outside click is not one. Escape still cancels.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
@@ -20,14 +19,6 @@ interface Props {
/** The full sentence the operator reads before confirming; names the entity. */
message: 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;
onCancel: () => void;
}
@@ -76,7 +67,6 @@ const styles = stylex.create({
marginTop: "1.5rem",
},
dangerButton: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: colors.danger,
@@ -89,7 +79,7 @@ const styles = stylex.create({
},
});
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, lock, onConfirm, onCancel }: Props) {
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, onConfirm, onCancel }: Props) {
return (
<ModalOverlay
isOpen={isOpen}
@@ -108,7 +98,6 @@ export default function ConfirmDialog({ isOpen, title, message, confirmLabel, lo
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
Cancel
</button>
{lock === undefined ? (
<button
type="button"
onClick={onConfirm}
@@ -116,9 +105,6 @@ export default function ConfirmDialog({ isOpen, title, message, confirmLabel, lo
>
{confirmLabel}
</button>
) : (
lock
)}
</div>
</AriaDialog>
</Modal>
+10 -83
View File
@@ -4,25 +4,18 @@
* 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
* 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 * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { Dialog as AriaDialog, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
import { styles as shared } from "./styles";
interface Props {
/** The dialog's heading, and with it the dialog's accessible name. */
title: string;
/** The dialog's accessible name. */
label: string;
isOpen: boolean;
onClose: () => void;
/** `detail` is the wider panel a record needs; `form` fits a column of fields. */
size?: "form" | "detail";
children: ReactNode;
}
@@ -37,74 +30,25 @@ const styles = stylex.create({
padding: "1rem",
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: {
width: "100%",
maxHeight: "85vh",
display: "flex",
flexDirection: "column",
maxWidth: "28rem",
borderRadius: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
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)",
},
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.
*/
/** The panel already draws the boundary; the dialog's own ring would double it. */
body: {
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({ title, isOpen, onClose, size = "form", children }: Props) {
export default function Dialog({ label, isOpen, onClose, children }: Props) {
return (
<ModalOverlay
isOpen={isOpen}
@@ -114,26 +58,9 @@ export default function Dialog({ title, isOpen, onClose, size = "form", children
isDismissable
className={() => stylex.props(styles.overlay).className ?? ""}
>
<Modal
className={() =>
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>
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
{children}
</AriaDialog>
</Modal>
</ModalOverlay>
-91
View File
@@ -1,91 +0,0 @@
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
@@ -1,121 +0,0 @@
/**
* 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,7 +60,6 @@ export const styles = stylex.create({
},
button: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
@@ -71,7 +70,6 @@ export const styles = stylex.create({
lineHeight: "1.25rem",
},
smallButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
@@ -82,7 +80,6 @@ export const styles = stylex.create({
lineHeight: "1.25rem",
},
largeButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
@@ -93,7 +90,6 @@ export const styles = stylex.create({
},
primaryButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: colors.primary,
@@ -106,7 +102,6 @@ export const styles = stylex.create({
opacity: { default: 1, [DISABLED]: 0.5 },
},
largePrimaryButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: colors.primary,
@@ -118,7 +113,6 @@ export const styles = stylex.create({
},
rowButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: "transparent",
@@ -129,7 +123,6 @@ export const styles = stylex.create({
color: colors.primaryOnSurface,
},
linkButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
@@ -139,7 +132,6 @@ export const styles = stylex.create({
color: colors.primaryOnSurface,
},
dangerLinkButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
@@ -150,7 +142,6 @@ export const styles = stylex.create({
opacity: { default: 1, [DISABLED]: 0.5 },
},
retryButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
marginTop: "0.75rem",
borderRadius: "0.25rem",
borderWidth: 1,