Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ae0c974a4
|
||
|
|
2525b01893
|
||
|
|
b774b05456
|
||
|
|
d79dd0bbcb
|
||
|
|
039eeeda96
|
||
|
|
c65d92d8f8
|
||
|
|
207252acee
|
||
|
|
f1de80477a
|
||
|
|
3c674966be
|
||
|
|
59d6be98f8
|
||
|
|
317d5dd4f8
|
||
|
|
79578e1d2a
|
||
|
|
d5613ee718
|
@@ -4,6 +4,21 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang
|
|||||||
|
|
||||||
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
|
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
|
||||||
|
|
||||||
|
## [0.0.15] - 2026-08-31
|
||||||
|
|
||||||
|
The Activity page's history filters become a toolbar you can actually use. Filters apply as you set them, the client field stops asking you to remember IP addresses, and the whole admin picks up one coherent icon set.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **History filters apply live from a toolbar.** The form-and-Apply-button row is gone. Domain text filters as you type (debounced), result and time are one-click controls, and a custom date range applies as one change. Every filter state is a bookmarkable URL.
|
||||||
|
- **The client filter is a picker, not a text field.** It lists the clients the server knows — named, sorted, multi-select — instead of asking for an exact address. Selected clients show as removable chips (first three, then a count), and a query can filter on up to 32 clients at once; the API accepts the same list.
|
||||||
|
- **One icon set.** Interface glyphs (dropdown carets, checkbox ticks, the search magnifier, status marks, back arrows) are now Phosphor icons instead of a mix of text characters and hand-drawn shapes.
|
||||||
|
- **The admin bundle budget rises from 800,000 to 900,000 bytes.** The client picker, the accessible menu and dialog primitives behind it, and the Phosphor icon components are the arrivals that spend it; the built assets sit at about 840,000 bytes.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Accessibility pass over the admin.** Focus-visible rings on every control, labels wired to their inputs, keyboard-reachable menus and dialogs, and visible text where information previously lived only in hover titles.
|
||||||
|
|
||||||
## [0.0.14] - 2026-08-28
|
## [0.0.14] - 2026-08-28
|
||||||
|
|
||||||
Schema changes stop costing you your query history. querylog.db is now version-stamped and migrated in place; the server refuses to start rather than ever reset a healthy file, and the release tooling refuses to ship a schema change that is neither migratable nor explicitly disclosed with recovery steps. Three releases (0.0.6, 0.0.9, 0.0.12) each discarded the log on upgrade; this ends that.
|
Schema changes stop costing you your query history. querylog.db is now version-stamped and migrated in place; the server refuses to start rather than ever reset a healthy file, and the release tooling refuses to ship a schema change that is neither migratable nor explicitly disclosed with recovery steps. Three releases (0.0.6, 0.0.9, 0.0.12) each discarded the log on upgrade; this ends that.
|
||||||
|
|||||||
Generated
+14
@@ -8,6 +8,7 @@
|
|||||||
"name": "nxdns-admin",
|
"name": "nxdns-admin",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@phosphor-icons/react": "2.1.10",
|
||||||
"@stylexjs/stylex": "0.19.0",
|
"@stylexjs/stylex": "0.19.0",
|
||||||
"@tanstack/react-query": "5.101.4",
|
"@tanstack/react-query": "5.101.4",
|
||||||
"@tanstack/react-router": "1.170.18",
|
"@tanstack/react-router": "1.170.18",
|
||||||
@@ -1078,6 +1079,19 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@phosphor-icons/react": {
|
||||||
|
"version": "2.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
|
||||||
|
"integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16.8",
|
||||||
|
"react-dom": ">= 16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-types/shared": {
|
"node_modules/@react-types/shared": {
|
||||||
"version": "3.36.1",
|
"version": "3.36.1",
|
||||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz",
|
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"trailingComma": "all"
|
"trailingComma": "all"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@phosphor-icons/react": "2.1.10",
|
||||||
"@stylexjs/stylex": "0.19.0",
|
"@stylexjs/stylex": "0.19.0",
|
||||||
"@tanstack/react-query": "5.101.4",
|
"@tanstack/react-query": "5.101.4",
|
||||||
"@tanstack/react-router": "1.170.18",
|
"@tanstack/react-router": "1.170.18",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { readdirSync, statSync } from "node:fs";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const BUDGET_BYTES = 800_000;
|
const BUDGET_BYTES = 900_000;
|
||||||
|
|
||||||
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
|
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||||
import { AuthProvider } from "@/auth/store";
|
import { AuthProvider } from "@/auth/store";
|
||||||
@@ -252,7 +252,7 @@ test("the back link restores the investigation the reader came from", async () =
|
|||||||
renderDetail(19, "?mode=history&domain=shop&since=1600000000&blocked=true");
|
renderDetail(19, "?mode=history&domain=shop&since=1600000000&blocked=true");
|
||||||
|
|
||||||
await screen.findByRole("heading", { name: "shop.example" });
|
await screen.findByRole("heading", { name: "shop.example" });
|
||||||
expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
|
expect(hrefSearch(within(screen.getByRole("main")).getByRole("link", { name: "Activity" }))).toEqual({
|
||||||
mode: "history",
|
mode: "history",
|
||||||
domain: "shop",
|
domain: "shop",
|
||||||
since: "1600000000",
|
since: "1600000000",
|
||||||
@@ -307,22 +307,23 @@ test("a row retention has pruned explains the 404 and keeps the way back to the
|
|||||||
|
|
||||||
await screen.findByRole("alert");
|
await screen.findByRole("alert");
|
||||||
expect(screen.getByText(/no such query/)).toBeTruthy();
|
expect(screen.getByText(/no such query/)).toBeTruthy();
|
||||||
expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
|
expect(hrefSearch(within(screen.getByRole("main")).getByRole("link", { name: "Activity" }))).toEqual({
|
||||||
mode: "history",
|
mode: "history",
|
||||||
domain: "gone",
|
domain: "gone",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/** The related-actions region of a query detail. */
|
||||||
* The related-actions region of a query detail. Scoped on purpose: the sidebar
|
|
||||||
* carries a Pause of its own, and this is the one that answers "this query was
|
|
||||||
* blocked and should not have been".
|
|
||||||
*/
|
|
||||||
function related(): HTMLElement {
|
function related(): HTMLElement {
|
||||||
return screen.getByRole("region", { name: "Related" });
|
return screen.getByRole("region", { name: "Related" });
|
||||||
}
|
}
|
||||||
|
|
||||||
test("a blocked query's Related offers Pause; an allowed one has nothing to pause about", async () => {
|
/**
|
||||||
|
* Related is links only. Pause is a resolver-wide control and lives in the
|
||||||
|
* sidebar alone, so a blocked query — the case that used to carry one here —
|
||||||
|
* offers no button of any kind.
|
||||||
|
*/
|
||||||
|
test("Related carries its four links and no control, blocked query or not", async () => {
|
||||||
responses["/api/queries/50"] = detail(50, {
|
responses["/api/queries/50"] = detail(50, {
|
||||||
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
|
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
|
||||||
route: { kind: "blocked", upstream: "" },
|
route: { kind: "blocked", upstream: "" },
|
||||||
@@ -330,25 +331,16 @@ test("a blocked query's Related offers Pause; an allowed one has nothing to paus
|
|||||||
renderDetail(50);
|
renderDetail(50);
|
||||||
|
|
||||||
await screen.findByRole("heading", { name: "example.com" });
|
await screen.findByRole("heading", { name: "example.com" });
|
||||||
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
|
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
|
||||||
|
expect(
|
||||||
cleanup();
|
within(related())
|
||||||
responses["/api/queries/51"] = detail(51, { policy: { action: "allow", reason: "no_match", matched: "" } });
|
.getAllByRole("link")
|
||||||
renderDetail(51);
|
.map((link) => link.textContent),
|
||||||
|
).toEqual([
|
||||||
await screen.findByRole("heading", { name: "example.com" });
|
"Test this domain against current policy",
|
||||||
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
|
"All activity for this domain",
|
||||||
});
|
"All activity from this client",
|
||||||
|
"Diagnostics around this query",
|
||||||
test("the Pause action stays away while protection is unavailable", async () => {
|
]);
|
||||||
responses["/api/health"] = health({ protection: { state: "unavailable", until: null } });
|
expect(within(related()).queryByRole("button")).toBeNull();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ArrowLeft } from "@phosphor-icons/react/dist/icons/ArrowLeft";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, useParams, useSearch } from "@tanstack/react-router";
|
import { Link, useParams, useSearch } from "@tanstack/react-router";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
@@ -12,11 +13,17 @@ import type { ActivitySearch } from "./search";
|
|||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
back: {
|
back: {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.primaryOnSurface,
|
color: colors.primaryOnSurface,
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
},
|
},
|
||||||
|
backIcon: {
|
||||||
|
display: "inline-flex",
|
||||||
|
},
|
||||||
loading: {
|
loading: {
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
@@ -32,7 +39,10 @@ const styles = stylex.create({
|
|||||||
function BackLink({ origin }: { origin: ActivitySearch }) {
|
function BackLink({ origin }: { origin: ActivitySearch }) {
|
||||||
return (
|
return (
|
||||||
<Link to="/activity" search={origin} {...stylex.props(styles.back, shared.focusRing)}>
|
<Link to="/activity" search={origin} {...stylex.props(styles.back, shared.focusRing)}>
|
||||||
← Activity
|
<span aria-hidden="true" {...stylex.props(styles.backIcon)}>
|
||||||
|
<ArrowLeft size={12} />
|
||||||
|
</span>
|
||||||
|
Activity
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,15 +78,7 @@ export default function ActivityDetailPage() {
|
|||||||
<ProvenanceDetail
|
<ProvenanceDetail
|
||||||
provenance={detail}
|
provenance={detail}
|
||||||
persistedId={detail.id}
|
persistedId={detail.id}
|
||||||
relatedActions={
|
relatedActions={<RelatedActions domain={domain} client={client} ts={time} origin={origin} />}
|
||||||
<RelatedActions
|
|
||||||
domain={domain}
|
|
||||||
client={client}
|
|
||||||
ts={time}
|
|
||||||
origin={origin}
|
|
||||||
blocked={detail.policy.action === "block"}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,758 @@
|
|||||||
|
/**
|
||||||
|
* 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, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import { queryKeys } from "@/lib/queries";
|
||||||
|
import type { Client } from "@/lib/types";
|
||||||
|
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
||||||
|
import { unixToDatetimeLocal } from "./datetime";
|
||||||
|
|
||||||
|
vi.stubEnv("TZ", "Europe/Paris");
|
||||||
|
afterAll(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
|
function client(ip: string, name: string, learnedName: string): Client {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
ip,
|
||||||
|
name,
|
||||||
|
learned_name: learnedName,
|
||||||
|
group_id: 1,
|
||||||
|
group: "default",
|
||||||
|
hand_edited: name !== "",
|
||||||
|
first_seen: 1_700_000_000,
|
||||||
|
last_seen: 1_700_000_100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two named clients and one bare address: every case the picker has to show. */
|
||||||
|
const CLIENTS: Client[] = [
|
||||||
|
client("192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||||
|
client("192.0.2.11", "", "laptop.lan"),
|
||||||
|
client("192.0.2.12", "", ""),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The picker reads the clients query the query tables already load. */
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/clients") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ clients: CLIENTS }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A household bigger than the chip row and, at 40, bigger than the 32 cap. */
|
||||||
|
function manyClients(count: number): Client[] {
|
||||||
|
return Array.from({ length: count }, (_, index) => client(`198.51.100.${index + 1}`, `Device ${index + 1}`, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
primeClients = true,
|
||||||
|
clients: Client[] = CLIENTS,
|
||||||
|
) {
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
// Seeded rather than fetched, so the picker has its options on the first
|
||||||
|
// render and a test of the toolbar is not also a test of a request landing.
|
||||||
|
// The one test that is about that arrival opts out and waits for the stub.
|
||||||
|
if (primeClients) queryClient.setQueryData(queryKeys.clients, clients);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Harness />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
/** 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, queryClient };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A press, as react-aria hears one. `usePress` works in pointer events, or in
|
||||||
|
* mouse events where jsdom has no `PointerEvent`; a bare `click` is neither, and
|
||||||
|
* RAC's own controls do not respond to it.
|
||||||
|
*/
|
||||||
|
function press(element: HTMLElement) {
|
||||||
|
fireEvent.mouseDown(element);
|
||||||
|
fireEvent.mouseUp(element);
|
||||||
|
fireEvent.click(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The picker's trigger: the first button of the client group, whatever it reads
|
||||||
|
* as. Its label is the selection, so it cannot be looked up by a fixed name.
|
||||||
|
*/
|
||||||
|
function clientTrigger(): HTMLElement {
|
||||||
|
return within(screen.getByRole("group", { name: "Clients" })).getAllByRole("button")[0] as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The removable chips, in the order they are shown, by what each one reads as. */
|
||||||
|
function clientChips(): string[] {
|
||||||
|
return within(screen.getByRole("group", { name: "Clients" }))
|
||||||
|
.getAllByRole("button")
|
||||||
|
.slice(1)
|
||||||
|
.map((chip) => chip.textContent ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the client menu, if it is not open already.
|
||||||
|
*
|
||||||
|
* The open state is read from the trigger rather than assumed: an open RAC
|
||||||
|
* popover hides the rest of the page from the accessibility tree — the trigger
|
||||||
|
* included — so a blind second click would either fail to find it or shut the
|
||||||
|
* menu it was meant to open.
|
||||||
|
*/
|
||||||
|
function openClientMenu() {
|
||||||
|
if (clientTrigger().getAttribute("aria-expanded") === "true") return;
|
||||||
|
fireEvent.click(clientTrigger());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks one client and shuts the menu again.
|
||||||
|
*
|
||||||
|
* A multiple-selection menu stays open on a pick, which is the point of it — but
|
||||||
|
* an open RAC popover hides the rest of the page from the accessibility tree, so
|
||||||
|
* a test that wants to read the chips has to close it first, exactly as a reader
|
||||||
|
* would before looking at them.
|
||||||
|
*/
|
||||||
|
function pickClient(name: string) {
|
||||||
|
openClientMenu();
|
||||||
|
press(screen.getByRole("menuitemcheckbox", { name }));
|
||||||
|
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape", code: "Escape" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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(domain.parentElement?.querySelector("[aria-hidden='true'] svg")).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" }, { domain: "ads" }]);
|
||||||
|
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 picker offers every client and names the filter it is not yet applying", () => {
|
||||||
|
renderFilters();
|
||||||
|
expect(clientTrigger().textContent).toContain("Clients");
|
||||||
|
expect(clientChips()).toEqual([]);
|
||||||
|
|
||||||
|
openClientMenu();
|
||||||
|
// The name is the way in; the address is still the filter.
|
||||||
|
expect(screen.getAllByRole("menuitemcheckbox").map((item) => item.textContent)).toEqual([
|
||||||
|
"192.0.2.12",
|
||||||
|
"Kitchen Pi — 192.0.2.10",
|
||||||
|
"laptop.lan — 192.0.2.11",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("picking clients commits them to the url at once, as one comma-separated value", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { patches, state } = renderFilters();
|
||||||
|
|
||||||
|
pickClient("Kitchen Pi — 192.0.2.10");
|
||||||
|
// No debounce: a pick is a decision the reader has finished making, and
|
||||||
|
// nothing about it can be half-typed.
|
||||||
|
expect(patches).toEqual([{ client: "192.0.2.10" }]);
|
||||||
|
expect(state.current.client).toBe("192.0.2.10");
|
||||||
|
|
||||||
|
pickClient("laptop.lan — 192.0.2.11");
|
||||||
|
expect(patches).toEqual([{ client: "192.0.2.10" }, { client: "192.0.2.10,192.0.2.11" }]);
|
||||||
|
expect(state.current.client).toBe("192.0.2.10,192.0.2.11");
|
||||||
|
|
||||||
|
// Nothing lands later either: there is no pending commit behind these.
|
||||||
|
act(() => vi.advanceTimersByTime(400));
|
||||||
|
expect(patches).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the trigger names a single client and counts several", () => {
|
||||||
|
const { state } = renderFilters();
|
||||||
|
|
||||||
|
pickClient("Kitchen Pi — 192.0.2.10");
|
||||||
|
expect(clientTrigger().textContent).toContain("Kitchen Pi — 192.0.2.10");
|
||||||
|
|
||||||
|
pickClient("laptop.lan — 192.0.2.11");
|
||||||
|
expect(clientTrigger().textContent).toContain("2 clients");
|
||||||
|
expect(state.current.client).toBe("192.0.2.10,192.0.2.11");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a picked client is a chip, and the chip takes it back off", () => {
|
||||||
|
const { state } = renderFilters();
|
||||||
|
|
||||||
|
pickClient("Kitchen Pi — 192.0.2.10");
|
||||||
|
pickClient("laptop.lan — 192.0.2.11");
|
||||||
|
// A chip is one client beside a row of others, so it wears the short form: the
|
||||||
|
// address is the half that does not fit, and the menu still spells it out.
|
||||||
|
expect(clientChips()).toEqual(["Kitchen Pi", "laptop.lan"]);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove client Kitchen Pi — 192.0.2.10" }));
|
||||||
|
|
||||||
|
expect(state.current.client).toBe("192.0.2.11");
|
||||||
|
expect(clientChips()).toEqual(["laptop.lan"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an address no client claims still shows as a chip and can still be removed", () => {
|
||||||
|
// The log names devices the config has never heard of, and a link filtered on
|
||||||
|
// one has to stay readable and clearable even though the menu cannot offer it.
|
||||||
|
const { state } = renderFilters({ ...NO_FILTERS, client: "203.0.113.9,192.0.2.10" });
|
||||||
|
|
||||||
|
expect(clientChips()).toEqual(["203.0.113.9", "Kitchen Pi"]);
|
||||||
|
expect(clientTrigger().textContent).toContain("2 clients");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove client 203.0.113.9" }));
|
||||||
|
expect(state.current.client).toBe("192.0.2.10");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the menu ticks the clients the url names, not the ones last picked here", () => {
|
||||||
|
const { land } = renderFilters();
|
||||||
|
pickClient("Kitchen Pi — 192.0.2.10");
|
||||||
|
|
||||||
|
// The URL moves to a different client from somewhere else.
|
||||||
|
land({ ...NO_FILTERS, client: "192.0.2.11" });
|
||||||
|
expect(clientChips()).toEqual(["laptop.lan"]);
|
||||||
|
|
||||||
|
openClientMenu();
|
||||||
|
const ticked = screen
|
||||||
|
.getAllByRole("menuitemcheckbox")
|
||||||
|
.filter((item) => item.getAttribute("aria-checked") === "true");
|
||||||
|
expect(ticked.map((item) => item.textContent)).toEqual(["laptop.lan — 192.0.2.11"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rename moves the label and leaves the filter on the address", async () => {
|
||||||
|
const { state, queryClient } = renderFilters();
|
||||||
|
pickClient("Kitchen Pi — 192.0.2.10");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
queryKeys.clients,
|
||||||
|
CLIENTS.map((entry) => (entry.ip === "192.0.2.10" ? { ...entry, name: "Hallway Pi" } : entry)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing here holds a label, so nothing here can commit one: the chip is a
|
||||||
|
// rendering of the address, re-rendered.
|
||||||
|
await waitFor(() => expect(clientChips()).toEqual(["Hallway Pi"]));
|
||||||
|
expect(state.current.client).toBe("192.0.2.10");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Clear empties the picker along with everything else", () => {
|
||||||
|
const { state } = renderFilters();
|
||||||
|
pickClient("Kitchen Pi — 192.0.2.10");
|
||||||
|
expect(clientChips()).toHaveLength(1);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||||
|
|
||||||
|
expect(clientChips()).toEqual([]);
|
||||||
|
expect(state.current).toEqual(NO_FILTERS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with no clients loaded the trigger is disabled rather than opening on nothing", () => {
|
||||||
|
renderFilters({ ...NO_FILTERS, client: "192.0.2.10" }, false, false);
|
||||||
|
|
||||||
|
expect(clientTrigger().hasAttribute("disabled")).toBe(true);
|
||||||
|
// The filter the URL carries is still on screen and still removable, because
|
||||||
|
// none of that waits on the convenience that names it.
|
||||||
|
expect(clientChips()).toEqual(["192.0.2.10"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("past three chips the rest are a count, and the count removes nothing", () => {
|
||||||
|
const clients = manyClients(12);
|
||||||
|
renderFilters({ ...NO_FILTERS, client: clients.map((entry) => entry.ip).join(",") }, false, true, clients);
|
||||||
|
|
||||||
|
// Three, and then how many more. A dozen chips are not more visible than
|
||||||
|
// three: they wrap the toolbar into a block and push the table off the screen.
|
||||||
|
expect(clientChips()).toEqual(["Device 1", "Device 2", "Device 3"]);
|
||||||
|
expect(screen.getByText("+9 more")).toBeTruthy();
|
||||||
|
expect(clientTrigger().textContent).toContain("12 clients");
|
||||||
|
|
||||||
|
// The summary is not a button, so there is no fourth thing to remove and no
|
||||||
|
// pointer target that does nothing.
|
||||||
|
expect(within(screen.getByRole("group", { name: "Clients" })).getAllByRole("button")).toHaveLength(4);
|
||||||
|
expect(screen.queryByRole("button", { name: /Remove client Device 4/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every known client at once commits every one of their addresses", () => {
|
||||||
|
const { patches, state } = renderFilters();
|
||||||
|
|
||||||
|
pickClient("Kitchen Pi — 192.0.2.10");
|
||||||
|
pickClient("laptop.lan — 192.0.2.11");
|
||||||
|
expect(state.current.client).toBe("192.0.2.10,192.0.2.11");
|
||||||
|
|
||||||
|
// The whole household is still written out. What is picked is what is
|
||||||
|
// committed, so the ticks, the chips and the URL all say what the reader just
|
||||||
|
// did — and pruned clients keep history rows, so this is not "no filter".
|
||||||
|
pickClient("192.0.2.12");
|
||||||
|
expect(patches.at(-1)).toEqual({ client: "192.0.2.10,192.0.2.11,192.0.2.12" });
|
||||||
|
expect(state.current.client).toBe("192.0.2.10,192.0.2.11,192.0.2.12");
|
||||||
|
expect(clientChips()).toEqual(["Kitchen Pi", "laptop.lan", "192.0.2.12"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the only client on the network is still a client you can pick", () => {
|
||||||
|
// The household the owner hit: one known client, so picking it is picking all
|
||||||
|
// of them. A picker that answered that click by clearing itself would read as
|
||||||
|
// a control that did nothing at all.
|
||||||
|
const { state } = renderFilters(NO_FILTERS, false, true, [CLIENTS[0] as Client]);
|
||||||
|
|
||||||
|
pickClient("Kitchen Pi — 192.0.2.10");
|
||||||
|
|
||||||
|
expect(state.current.client).toBe("192.0.2.10");
|
||||||
|
expect(clientChips()).toEqual(["Kitchen Pi"]);
|
||||||
|
expect(clientTrigger().textContent).toContain("Kitchen Pi — 192.0.2.10");
|
||||||
|
|
||||||
|
openClientMenu();
|
||||||
|
expect(screen.getByRole("menuitemcheckbox", { name: "Kitchen Pi — 192.0.2.10" }).getAttribute("aria-checked")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the menu stops at the cap the server enforces, and says so", () => {
|
||||||
|
const clients = manyClients(40);
|
||||||
|
const picked = clients.slice(0, 32).map((entry) => entry.ip);
|
||||||
|
const { patches } = renderFilters({ ...NO_FILTERS, client: picked.join(",") }, false, true, clients);
|
||||||
|
|
||||||
|
openClientMenu();
|
||||||
|
expect(screen.getByText("At most 32 clients at a time.")).toBeTruthy();
|
||||||
|
const item = screen.getByRole("menuitemcheckbox", { name: "Device 33 — 198.51.100.33" });
|
||||||
|
expect(item.getAttribute("aria-disabled")).toBe("true");
|
||||||
|
|
||||||
|
// A menu that took a 33rd pick and dropped it would look like it had worked.
|
||||||
|
press(item);
|
||||||
|
expect(patches).toEqual([]);
|
||||||
|
|
||||||
|
// What is already picked can still be unpicked, or the reader would be stuck.
|
||||||
|
const chosen = screen.getByRole("menuitemcheckbox", { name: "Device 1 — 198.51.100.1" });
|
||||||
|
expect(chosen.getAttribute("aria-disabled")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the cap note appears when the cap binds, however few clients are loaded", () => {
|
||||||
|
const clients = manyClients(5);
|
||||||
|
// Two known and thirty unknown: the cap is full while the menu still has three
|
||||||
|
// rows it will not let anyone pick, and a disabled row with nothing to explain
|
||||||
|
// it is the one state this must not reach.
|
||||||
|
const unknown = Array.from({ length: 30 }, (_, index) => `203.0.113.${index + 1}`);
|
||||||
|
renderFilters(
|
||||||
|
{ ...NO_FILTERS, client: [clients[0]!.ip, clients[1]!.ip, ...unknown].join(",") },
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
clients,
|
||||||
|
);
|
||||||
|
|
||||||
|
openClientMenu();
|
||||||
|
expect(screen.getByText("At most 32 clients at a time.")).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("menuitemcheckbox", { name: "Device 3 — 198.51.100.3" }).getAttribute("aria-disabled"),
|
||||||
|
).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with room left the cap note stays out of the menu", () => {
|
||||||
|
renderFilters();
|
||||||
|
openClientMenu();
|
||||||
|
|
||||||
|
// Three clients and nothing picked: a ceiling nobody can reach is not news.
|
||||||
|
expect(screen.queryByText("At most 32 clients at a time.")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an address no client claims takes a slot in the cap like any other", () => {
|
||||||
|
const clients = manyClients(40);
|
||||||
|
// Thirty-one known and one the config has never heard of. The cap is on what
|
||||||
|
// the request may name, not on what this menu happens to be able to show.
|
||||||
|
const picked = [...clients.slice(0, 31).map((entry) => entry.ip), "203.0.113.9"];
|
||||||
|
renderFilters({ ...NO_FILTERS, client: picked.join(",") }, false, true, clients);
|
||||||
|
|
||||||
|
openClientMenu();
|
||||||
|
const item = screen.getByRole("menuitemcheckbox", { name: "Device 33 — 198.51.100.33" });
|
||||||
|
expect(item.getAttribute("aria-disabled")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("select-all is held to the cap, with the addresses already filtered keeping their slots", () => {
|
||||||
|
const clients = manyClients(40);
|
||||||
|
const unknown = ["203.0.113.1", "203.0.113.2", "203.0.113.3", "203.0.113.4", "203.0.113.5"];
|
||||||
|
const { state } = renderFilters({ ...NO_FILTERS, client: unknown.join(",") }, false, true, clients);
|
||||||
|
|
||||||
|
openClientMenu();
|
||||||
|
// Ctrl+A reaches the selection without pressing an item, so the disabled rows
|
||||||
|
// never see it and the cap has to hold here too.
|
||||||
|
fireEvent.keyDown(screen.getByRole("menu"), { key: "a", code: "KeyA", ctrlKey: true });
|
||||||
|
fireEvent.keyUp(screen.getByRole("menu"), { key: "a", code: "KeyA", ctrlKey: true });
|
||||||
|
|
||||||
|
const applied = state.current.client?.split(",") ?? [];
|
||||||
|
expect(applied).toHaveLength(32);
|
||||||
|
// What was already filtered keeps its slots and the new picks take what is
|
||||||
|
// left, rather than the list being cut wherever it happened to run out.
|
||||||
|
expect(applied.slice(0, 5)).toEqual(unknown);
|
||||||
|
// The rest are known clients, each once. Which 27 is the menu's own order and
|
||||||
|
// not something this test should restate.
|
||||||
|
const rest = applied.slice(5);
|
||||||
|
const addresses = new Set(clients.map((entry) => entry.ip));
|
||||||
|
expect(rest.every((ip) => addresses.has(ip))).toBe(true);
|
||||||
|
expect(new Set(rest).size).toBe(27);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removing a chip hands the focus on rather than dropping it", () => {
|
||||||
|
const clients = manyClients(5);
|
||||||
|
const { state } = renderFilters(
|
||||||
|
{
|
||||||
|
...NO_FILTERS,
|
||||||
|
client: clients
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((entry) => entry.ip)
|
||||||
|
.join(","),
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
clients,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The chip that takes the removed one's place, so a reader clearing three
|
||||||
|
// clients from the keyboard does not tab back in from the top each time.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove client Device 2 — 198.51.100.2" }));
|
||||||
|
expect(document.activeElement?.getAttribute("aria-label")).toBe("Remove client Device 3 — 198.51.100.3");
|
||||||
|
|
||||||
|
// Nothing after it, so the one before it.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove client Device 4 — 198.51.100.4" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove client Device 3 — 198.51.100.3" }));
|
||||||
|
expect(document.activeElement?.getAttribute("aria-label")).toBe("Remove client Device 1 — 198.51.100.1");
|
||||||
|
|
||||||
|
// No chips left, so the control the row belongs to.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove client Device 1 — 198.51.100.1" }));
|
||||||
|
expect(document.activeElement).toBe(clientTrigger());
|
||||||
|
expect(state.current.client).toBeUndefined();
|
||||||
|
});
|
||||||
@@ -1,73 +1,196 @@
|
|||||||
/**
|
/**
|
||||||
* The filter row over the Activity table.
|
* The filter toolbar over the Activity history table.
|
||||||
*
|
*
|
||||||
* The applied state is the URL, never this form: what the reader sees is what
|
* The applied state is the URL, never this form: what the reader sees is what
|
||||||
* the link they can paste to a housemate will show. So this holds a draft only,
|
* the link they can paste to a housemate will show. So this holds a draft, and
|
||||||
* and the page remounts it whenever the applied search changes — a back button
|
* every control writes through to the URL — the segments, the client picker and
|
||||||
* or a pasted URL has to move the form with it, and a form that seeded itself
|
* the time presets at once, the domain field after a pause so a five-letter word
|
||||||
* once would keep showing the previous investigation's filters.
|
* is one navigation rather than five.
|
||||||
*
|
*
|
||||||
* In live mode the row stays visible and disabled rather than disappearing: the
|
* A time preset writes both bounds as absolute seconds, resolved once at the
|
||||||
* filters are retained in the URL and apply again the moment history comes
|
* click. Neither half may be left open: a bookmark has to describe the same
|
||||||
* back, and hiding them would read as having lost them. The stream itself is
|
* investigation tomorrow, and a window that slid overnight — or one that stayed
|
||||||
* unfiltered — the server sends every query — so a row that looked usable here
|
* open at the top and swallowed everything logged since — would answer a
|
||||||
|
* different question under the same link.
|
||||||
|
*
|
||||||
|
* A URL can move under this form at any time, and the draft only follows the
|
||||||
|
* part of it that actually moved. A field is re-seeded when its own URL value
|
||||||
|
* changed and the new value is not one this toolbar just wrote: the debounce
|
||||||
|
* means the URL is always a little behind the keyboard, and a landing commit
|
||||||
|
* must not roll the input back over the letters typed since.
|
||||||
|
*
|
||||||
|
* The custom range is the one control that does not live-apply. Two half-typed
|
||||||
|
* timestamps are a normal intermediate state of typing one of them, and a lower
|
||||||
|
* bound at or above an upper bound selects nothing at all, so the pair is
|
||||||
|
* validated and applied together or not at all.
|
||||||
|
*
|
||||||
|
* Live mode does not render this at all — the page mounts it inside the History
|
||||||
|
* panel. The stream is unfiltered, and a row of controls that looked usable
|
||||||
* would promise filtering that is not happening.
|
* would promise filtering that is not happening.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { MagnifyingGlass } from "@phosphor-icons/react/dist/icons/MagnifyingGlass";
|
||||||
|
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import Select from "@/ui/Select";
|
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import ClientFilter, { joinClients, parseClients, useClientOptions } from "./ClientFilter";
|
||||||
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
|
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
|
||||||
import type { ActivitySearch } from "./search";
|
import type { ActivitySearch } from "./search";
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
/** Long enough that a typed word is one navigation, short enough to feel live. */
|
||||||
{ value: "any", label: "All" },
|
const DEBOUNCE_MS = 350;
|
||||||
{ value: "blocked", label: "Blocked only" },
|
|
||||||
{ value: "allowed", label: "Allowed only" },
|
const RESULTS = [
|
||||||
];
|
{ value: "any", label: "Any" },
|
||||||
|
{ value: "blocked", label: "Blocked" },
|
||||||
|
{ value: "allowed", label: "Allowed" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ label: "Any time", seconds: null },
|
||||||
|
{ label: "Past hour", seconds: 3600 },
|
||||||
|
{ label: "Past 24 hours", seconds: 86_400 },
|
||||||
|
{ label: "Past 7 days", seconds: 604_800 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CUSTOM_ITEM = "Custom…";
|
||||||
|
|
||||||
|
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
|
||||||
|
const HIT_TARGET = 44;
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
/** One column on a phone, two from `sm`, five from `lg`. */
|
toolbar: {
|
||||||
grid: {
|
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
display: "grid",
|
display: "flex",
|
||||||
gap: "0.75rem",
|
flexWrap: "wrap",
|
||||||
gridTemplateColumns: {
|
alignItems: "center",
|
||||||
default: "repeat(1, minmax(0, 1fr))",
|
gap: "0.5rem",
|
||||||
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
|
||||||
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
|
|
||||||
},
|
},
|
||||||
|
/** The domain field is the one that grows; everything else keeps its size. */
|
||||||
|
searchWrap: {
|
||||||
|
position: "relative",
|
||||||
|
flexGrow: 1,
|
||||||
|
flexShrink: 1,
|
||||||
|
flexBasis: "14rem",
|
||||||
|
display: "flex",
|
||||||
},
|
},
|
||||||
label: {
|
searchIcon: {
|
||||||
display: "block",
|
display: "inline-flex",
|
||||||
|
position: "absolute",
|
||||||
|
insetInlineStart: "0.5rem",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
color: colors.textMuted,
|
||||||
|
pointerEvents: "none",
|
||||||
|
},
|
||||||
|
/** Every control in the row is a pointer target before it is anything else. */
|
||||||
|
field: {
|
||||||
|
minHeight: HIT_TARGET,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
width: "100%",
|
||||||
|
paddingInlineStart: "1.875rem",
|
||||||
|
},
|
||||||
|
/** A button is text-sized by default; this is the hit area around the text. */
|
||||||
|
hitTarget: {
|
||||||
|
minHeight: HIT_TARGET,
|
||||||
|
minWidth: HIT_TARGET,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
resultGroup: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.25rem",
|
||||||
|
},
|
||||||
|
/** The segment styling of the Overview period picker, item for item. */
|
||||||
|
segment: {
|
||||||
|
cursor: "pointer",
|
||||||
|
borderStyle: "none",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
paddingInline: "0.625rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
minHeight: HIT_TARGET,
|
||||||
|
minWidth: HIT_TARGET,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
|
||||||
|
segmentFocusVisible: {
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineStyle: "solid",
|
||||||
|
outlineColor: colors.focus,
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
|
||||||
|
segmentSelected: {
|
||||||
|
backgroundColor: {
|
||||||
|
default: "oklch(92% 0.004 286.32)",
|
||||||
|
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
||||||
|
},
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
segmentIdle: {
|
||||||
|
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
color: colors.text,
|
||||||
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
outlineStyle: "none",
|
||||||
|
paddingBlock: "0.25rem",
|
||||||
|
},
|
||||||
|
menuItem: {
|
||||||
|
cursor: "pointer",
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
minHeight: HIT_TARGET,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
/** Inset because an item flush against the popover edge clips an outset ring. */
|
||||||
|
menuItemFocused: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
color: colors.primaryText,
|
||||||
|
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Clear keeps its box when there is nothing to clear. It appears the moment a
|
||||||
|
* filter is set, and a control that appeared by widening the row would move
|
||||||
|
* every other control out from under the pointer that was reaching for it.
|
||||||
|
*/
|
||||||
|
clearHidden: {
|
||||||
|
visibility: "hidden",
|
||||||
|
},
|
||||||
|
customRow: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
customField: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.25rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
},
|
},
|
||||||
input: {
|
|
||||||
marginTop: "0.25rem",
|
|
||||||
width: "100%",
|
|
||||||
// A disabled native input keeps its value legible but reads as inert,
|
|
||||||
// matching what RAC does to the Select trigger beside it.
|
|
||||||
cursor: { default: null, ":disabled": "not-allowed" },
|
|
||||||
opacity: { default: null, ":disabled": 0.55 },
|
|
||||||
},
|
|
||||||
buttonRow: {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "flex-end",
|
|
||||||
gap: "0.5rem",
|
|
||||||
gridColumn: {
|
|
||||||
default: null,
|
|
||||||
"@media (min-width: 640px)": "span 2 / span 2",
|
|
||||||
"@media (min-width: 1024px)": "span 5 / span 5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toolbarButton: {
|
|
||||||
fontWeight: 500,
|
|
||||||
},
|
|
||||||
error: {
|
error: {
|
||||||
marginTop: "0.5rem",
|
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.dangerText,
|
color: colors.dangerText,
|
||||||
@@ -86,6 +209,16 @@ export const NO_FILTERS: AppliedFilters = {
|
|||||||
until: undefined,
|
until: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The half-open window the API reads: `ts >= since` and `ts < until`. */
|
||||||
|
interface Bounds {
|
||||||
|
since: number | undefined;
|
||||||
|
until: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameBounds(a: Bounds, b: Bounds): boolean {
|
||||||
|
return a.since === b.since && a.until === b.until;
|
||||||
|
}
|
||||||
|
|
||||||
function blockedOption(blocked: boolean | undefined): string {
|
function blockedOption(blocked: boolean | undefined): string {
|
||||||
if (blocked === undefined) return "any";
|
if (blocked === undefined) return "any";
|
||||||
return blocked ? "blocked" : "allowed";
|
return blocked ? "blocked" : "allowed";
|
||||||
@@ -96,135 +229,374 @@ function optionBlocked(value: string): boolean | undefined {
|
|||||||
return value === "allowed" ? false : undefined;
|
return value === "allowed" ? false : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function boundError(label: string, reason: "unparseable" | "nonexistent"): string {
|
/** A filter value the URL can carry: trimmed, and empty means absent. */
|
||||||
return reason === "unparseable"
|
function textFilter(value: string): string | undefined {
|
||||||
|
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"
|
||||||
? `${label} is not a complete date and time.`
|
? `${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.`;
|
: `${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.
|
||||||
|
*
|
||||||
|
* `domain` and `bounds` are the last values looked at, so a change can be told
|
||||||
|
* apart field by field. `pendingDomain` 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 domain 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.
|
||||||
|
*
|
||||||
|
* Only the domain needs any of this. Every other control commits on the action
|
||||||
|
* itself, so the URL is never behind what the reader is doing with it.
|
||||||
|
*/
|
||||||
|
interface Sync {
|
||||||
|
domain: string;
|
||||||
|
bounds: Bounds;
|
||||||
|
pendingDomain: string[];
|
||||||
|
pendingBounds: Bounds[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applied: AppliedFilters;
|
applied: AppliedFilters;
|
||||||
isDisabled: boolean;
|
/** Merges a patch into the applied search. `replace` keeps typing out of history. */
|
||||||
onApply: (filters: AppliedFilters) => void;
|
onApply: (patch: Partial<AppliedFilters>, replace?: boolean) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityFilters({ applied, isDisabled, onApply, onClear }: Props) {
|
export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
||||||
const [domain, setDomain] = useState(applied.domain ?? "");
|
const urlDomain = applied.domain ?? "";
|
||||||
const [client, setClient] = useState(applied.client ?? "");
|
const urlBounds: Bounds = { since: applied.since, until: applied.until };
|
||||||
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
|
|
||||||
|
const clientOptions = useClientOptions();
|
||||||
|
// The picked clients are read straight off the URL: there is no draft to hold,
|
||||||
|
// because there is no state here the reader can leave half-finished.
|
||||||
|
const clients = parseClients(applied.client);
|
||||||
|
const [domain, setDomain] = useState(urlDomain);
|
||||||
|
const [preset, setPreset] = useState<ChosenPreset | null>(null);
|
||||||
|
const [showCustom, setShowCustom] = useState(applied.since !== undefined || applied.until !== undefined);
|
||||||
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
|
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
|
||||||
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
|
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<BoundError | null>(null);
|
||||||
|
const [sync, setSync] = useState<Sync>({
|
||||||
|
domain: urlDomain,
|
||||||
|
bounds: urlBounds,
|
||||||
|
pendingDomain: [],
|
||||||
|
pendingBounds: [],
|
||||||
|
});
|
||||||
|
|
||||||
function submit(event: FormEvent) {
|
const domainMoved = sync.domain !== urlDomain;
|
||||||
|
const boundsMoved = !sameBounds(sync.bounds, urlBounds);
|
||||||
|
if (domainMoved || boundsMoved) {
|
||||||
|
let pendingDomain = sync.pendingDomain;
|
||||||
|
let pendingBounds = sync.pendingBounds;
|
||||||
|
|
||||||
|
if (domainMoved) {
|
||||||
|
// 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 = pendingDomain.findLastIndex((sent) => sent === urlDomain);
|
||||||
|
if (echo >= 0) {
|
||||||
|
pendingDomain = pendingDomain.slice(echo + 1);
|
||||||
|
} else {
|
||||||
|
pendingDomain = [];
|
||||||
|
setDomain(urlDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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({ domain: urlDomain, bounds: urlBounds, pendingDomain, pendingBounds });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirty = textFilter(domain) !== applied.domain;
|
||||||
|
|
||||||
|
const commitDomain = useCallback(
|
||||||
|
(replace: boolean) => {
|
||||||
|
const next = textFilter(domain);
|
||||||
|
setSync((prev) => ({ ...prev, pendingDomain: [...prev.pendingDomain, next ?? ""] }));
|
||||||
|
onApply({ domain: next }, replace);
|
||||||
|
},
|
||||||
|
[domain, onApply],
|
||||||
|
);
|
||||||
|
|
||||||
|
function commitBounds(bounds: Bounds) {
|
||||||
|
setSync((prev) => ({ ...prev, pendingBounds: [...prev.pendingBounds, bounds] }));
|
||||||
|
onApply(bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return;
|
||||||
|
const id = setTimeout(() => commitDomain(true), DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [dirty, commitDomain]);
|
||||||
|
|
||||||
|
function flush(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (dirty) commitDomain(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPreset(label: string) {
|
||||||
|
if (label === CUSTOM_ITEM) {
|
||||||
|
setShowCustom(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const option = PRESETS.find((candidate) => candidate.label === label);
|
||||||
|
if (option === undefined) return;
|
||||||
|
setShowCustom(false);
|
||||||
|
setError(null);
|
||||||
|
if (option.seconds === null) {
|
||||||
|
setPreset(null);
|
||||||
|
setSince(datetimeField(undefined));
|
||||||
|
setUntil(datetimeField(undefined));
|
||||||
|
commitBounds({ since: undefined, until: undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Both bounds, resolved once, here. An open upper bound would keep taking
|
||||||
|
// in queries logged after the reader stopped looking, so "the past hour"
|
||||||
|
// would name a different hour every time the link was opened.
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const bounds: Bounds = { since: now - option.seconds, until: now };
|
||||||
|
setPreset({ label, ...bounds });
|
||||||
|
setSince(datetimeField(bounds.since));
|
||||||
|
setUntil(datetimeField(bounds.until));
|
||||||
|
commitBounds(bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRange() {
|
||||||
const sinceValue = resolveDatetimeField(since);
|
const sinceValue = resolveDatetimeField(since);
|
||||||
if (!sinceValue.ok) {
|
if (!sinceValue.ok) {
|
||||||
setError(boundError("Since", sinceValue.reason));
|
setError(boundError("since", sinceValue.reason));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const untilValue = resolveDatetimeField(until);
|
const untilValue = resolveDatetimeField(until);
|
||||||
if (!untilValue.ok) {
|
if (!untilValue.ok) {
|
||||||
setError(boundError("Until", untilValue.reason));
|
setError(boundError("until", untilValue.reason));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The window is half-open — `ts >= since` and `ts < until` — so two equal
|
||||||
|
// bounds are as empty as an inverted pair, and neither is worth applying.
|
||||||
|
if (sinceValue.value !== undefined && untilValue.value !== undefined && untilValue.value <= sinceValue.value) {
|
||||||
|
setError({
|
||||||
|
field: "until",
|
||||||
|
message: "Until must be after Since, or the range selects nothing.",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
onApply({
|
setPreset(null);
|
||||||
domain: domain.trim() === "" ? undefined : domain.trim(),
|
commitBounds({ since: sinceValue.value, until: untilValue.value });
|
||||||
client: client.trim() === "" ? undefined : client.trim(),
|
|
||||||
blocked: optionBlocked(blocked),
|
|
||||||
since: sinceValue.value,
|
|
||||||
until: untilValue.value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setDomain("");
|
setDomain("");
|
||||||
setClient("");
|
setPreset(null);
|
||||||
setBlocked("any");
|
setShowCustom(false);
|
||||||
setSince(datetimeField(undefined));
|
setSince(datetimeField(undefined));
|
||||||
setUntil(datetimeField(undefined));
|
setUntil(datetimeField(undefined));
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSync((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pendingDomain: [],
|
||||||
|
pendingBounds: [...prev.pendingBounds, { since: undefined, until: undefined }],
|
||||||
|
}));
|
||||||
onClear();
|
onClear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const active =
|
||||||
|
domain.trim() !== "" ||
|
||||||
|
clients.length > 0 ||
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<label {...stylex.props(styles.customField)}>
|
||||||
<form onSubmit={submit} {...stylex.props(styles.grid)}>
|
{field === "since" ? "Since" : "Until"}
|
||||||
<label {...stylex.props(styles.label)}>
|
|
||||||
Domain contains
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
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)}
|
||||||
|
/>
|
||||||
|
{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)}>
|
||||||
|
<span aria-hidden="true" {...stylex.props(styles.searchIcon)}>
|
||||||
|
<MagnifyingGlass size={14} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
aria-label="Filter domains"
|
||||||
|
placeholder="Filter domains…"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
value={domain}
|
value={domain}
|
||||||
disabled={isDisabled}
|
|
||||||
onChange={(event) => setDomain(event.target.value)}
|
onChange={(event) => setDomain(event.target.value)}
|
||||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
{...stylex.props(shared.smallInput, styles.field, styles.searchInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<label {...stylex.props(styles.label)}>
|
<ClientFilter
|
||||||
Client (exact)
|
options={clientOptions}
|
||||||
<input
|
selected={clients}
|
||||||
type="text"
|
onChange={(next) => onApply({ client: joinClients(next) })}
|
||||||
value={client}
|
|
||||||
disabled={isDisabled}
|
|
||||||
onChange={(event) => setClient(event.target.value)}
|
|
||||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
<RadioGroup
|
||||||
<Select
|
aria-label="Result"
|
||||||
variant="compactField"
|
orientation="horizontal"
|
||||||
label="Result"
|
value={blockedOption(applied.blocked)}
|
||||||
value={blocked}
|
onChange={(next) => onApply({ blocked: optionBlocked(next) })}
|
||||||
isDisabled={isDisabled}
|
className={() => stylex.props(styles.resultGroup).className ?? ""}
|
||||||
onChange={setBlocked}
|
|
||||||
options={STATUS_OPTIONS}
|
|
||||||
/>
|
|
||||||
<label {...stylex.props(styles.label)}>
|
|
||||||
Since
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
step={1}
|
|
||||||
value={since.text}
|
|
||||||
disabled={isDisabled}
|
|
||||||
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
|
|
||||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label {...stylex.props(styles.label)}>
|
|
||||||
Until
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
step={1}
|
|
||||||
value={until.text}
|
|
||||||
disabled={isDisabled}
|
|
||||||
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
|
|
||||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div {...stylex.props(styles.buttonRow)}>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isDisabled}
|
|
||||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
|
||||||
>
|
>
|
||||||
Apply filters
|
{RESULTS.map((option) => (
|
||||||
</button>
|
<Radio
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className={({ isSelected, isFocusVisible }) =>
|
||||||
|
stylex.props(
|
||||||
|
styles.segment,
|
||||||
|
isSelected ? styles.segmentSelected : styles.segmentIdle,
|
||||||
|
isFocusVisible && styles.segmentFocusVisible,
|
||||||
|
).className ?? ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Radio>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
<MenuTrigger>
|
||||||
|
<Button
|
||||||
|
className={() =>
|
||||||
|
stylex.props(shared.button, styles.hitTarget, shared.focusRing).className ?? ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Time: {timeLabel}
|
||||||
|
</Button>
|
||||||
|
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||||
|
<Menu {...stylex.props(styles.menu)}>
|
||||||
|
{[...PRESETS.map((option) => option.label), CUSTOM_ITEM].map((label) => (
|
||||||
|
<MenuItem
|
||||||
|
key={label}
|
||||||
|
onAction={() => selectPreset(label)}
|
||||||
|
className={({ isFocused }) =>
|
||||||
|
stylex.props(
|
||||||
|
styles.menuItem,
|
||||||
|
shared.insetFocusRing,
|
||||||
|
isFocused && styles.menuItemFocused,
|
||||||
|
).className ?? ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</Popover>
|
||||||
|
</MenuTrigger>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clear}
|
onClick={clear}
|
||||||
disabled={isDisabled}
|
tabIndex={active ? undefined : -1}
|
||||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
aria-hidden={active ? undefined : true}
|
||||||
|
{...stylex.props(shared.button, styles.hitTarget, shared.focusRing, !active && styles.clearHidden)}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{showCustom && (
|
||||||
{error !== null && (
|
<div {...stylex.props(styles.customRow)}>
|
||||||
<p role="alert" {...stylex.props(styles.error)}>
|
{boundInput("since")}
|
||||||
{error}
|
{boundInput("until")}
|
||||||
</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={setRange}
|
||||||
|
{...stylex.props(shared.button, styles.hitTarget, shared.focusRing)}
|
||||||
|
>
|
||||||
|
Set range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { createAppRouter } from "@/routes";
|
|||||||
import { health } from "@/lib/healthFixture";
|
import { health } from "@/lib/healthFixture";
|
||||||
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
||||||
import { queryRow } from "@/features/provenance/provenanceFixture";
|
import { queryRow } from "@/features/provenance/provenanceFixture";
|
||||||
|
import { FakeEventSource } from "./fakeEventSource";
|
||||||
|
|
||||||
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||||
return {
|
return {
|
||||||
@@ -135,6 +136,22 @@ function queryCalls(): string[] {
|
|||||||
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
|
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The toolbar's search field, which is how a domain filter is entered now. */
|
||||||
|
function domainInput(): HTMLInputElement {
|
||||||
|
return screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enter in a text field: the debounce's escape hatch, and the fast path here. */
|
||||||
|
function submitFilters() {
|
||||||
|
fireEvent.submit(domainInput().closest("form")!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The custom range lives behind the Time menu; the two bounds only exist there. */
|
||||||
|
function openCustomRange() {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||||
|
}
|
||||||
|
|
||||||
test("renders the first page with the seven columns filled in", async () => {
|
test("renders the first page with the seven columns filled in", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText("first.example");
|
await screen.findByText("first.example");
|
||||||
@@ -162,7 +179,7 @@ test("renders the first page with the seven columns filled in", async () => {
|
|||||||
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
test("resolves each row's client to its display name, reading the IP out with it", async () => {
|
||||||
stubFetch((url) => {
|
stubFetch((url) => {
|
||||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
@@ -182,19 +199,22 @@ test("resolves each row's client to its display name, keeping the IP as the tool
|
|||||||
|
|
||||||
// A hand-typed name wins outright; the learned name never surfaces for it.
|
// A hand-typed name wins outright; the learned name never surfaces for it.
|
||||||
const named = await screen.findByText("Kitchen Pi");
|
const named = await screen.findByText("Kitchen Pi");
|
||||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
// The address reads out with the name it replaced, rather than sitting in a
|
||||||
|
// title only a mouse can reach.
|
||||||
|
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
||||||
|
expect(named.getAttribute("title")).toBeNull();
|
||||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||||
|
|
||||||
// A learned name reads muted and nothing more here: the "learned" tag would
|
// A learned name reads muted and nothing more here: the "learned" tag would
|
||||||
// repeat on every row of the table, so the Clients page carries it instead.
|
// repeat on every row of the table, so the Clients page carries it instead.
|
||||||
const learned = screen.getByText("laptop.lan");
|
const learned = screen.getByText("laptop.lan");
|
||||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
|
||||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||||
|
|
||||||
// A known client with neither name, and a client the loaded list has never
|
// A known client with neither name, and a client the loaded list has never
|
||||||
// seen, both fall back to the bare address with no tooltip standing in.
|
// seen, both fall back to the bare address with nothing standing in for it.
|
||||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
expect(screen.getByText("192.0.2.12").textContent).toBe("192.0.2.12");
|
||||||
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
expect(screen.getByText("192.0.2.99").textContent).toBe("192.0.2.99");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("load more appends the next page and stops at the end of the log", async () => {
|
test("load more appends the next page and stops at the end of the log", async () => {
|
||||||
@@ -216,8 +236,8 @@ test("applying a filter puts it in the url, refetches, and resets the accumulate
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
await screen.findByText("older.example");
|
await screen.findByText("older.example");
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
submitFilters();
|
||||||
|
|
||||||
await screen.findByText(/Showing 1 query /);
|
await screen.findByText(/Showing 1 query /);
|
||||||
expect(history.location.search).toContain("domain=ads");
|
expect(history.location.search).toContain("domain=ads");
|
||||||
@@ -242,8 +262,8 @@ test("a load-more that resolves after a filter change is discarded", async () =>
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
submitFilters();
|
||||||
await screen.findByText(/Showing 1 query /);
|
await screen.findByText(/Showing 1 query /);
|
||||||
|
|
||||||
releaseLoadMore();
|
releaseLoadMore();
|
||||||
@@ -281,8 +301,8 @@ test("load more is disabled while a filter change shows placeholder data, then u
|
|||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText("first.example");
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
submitFilters();
|
||||||
|
|
||||||
const staleButton = await screen.findByRole("button", { name: "Load more" });
|
const staleButton = await screen.findByRole("button", { name: "Load more" });
|
||||||
expect(staleButton).toHaveProperty("disabled", true);
|
expect(staleButton).toHaveProperty("disabled", true);
|
||||||
@@ -416,7 +436,7 @@ test("a ?domain= link seeds the filter form and fetches that domain on arrival",
|
|||||||
renderPage("/activity?domain=ads");
|
renderPage("/activity?domain=ads");
|
||||||
|
|
||||||
await screen.findByText("ads.example");
|
await screen.findByText("ads.example");
|
||||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
|
expect(domainInput()).toHaveProperty("value", "ads");
|
||||||
expect(screen.queryByText("first.example")).toBeNull();
|
expect(screen.queryByText("first.example")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -441,7 +461,7 @@ test("a rejected search parameter is dropped rather than guessed at", async () =
|
|||||||
await screen.findByText("first.example");
|
await screen.findByText("first.example");
|
||||||
// Nothing survived validation, so the request is the unfiltered one.
|
// Nothing survived validation, so the request is the unfiltered one.
|
||||||
expect(queryCalls()).toEqual(["/api/queries"]);
|
expect(queryCalls()).toEqual(["/api/queries"]);
|
||||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
expect(domainInput()).toHaveProperty("value", "");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the form draft follows the url back and forward, seconds included", async () => {
|
test("the form draft follows the url back and forward, seconds included", async () => {
|
||||||
@@ -454,25 +474,33 @@ test("the form draft follows the url back and forward, seconds included", async
|
|||||||
const seeded = 1_700_000_017;
|
const seeded = 1_700_000_017;
|
||||||
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
|
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
|
||||||
|
|
||||||
const domainInput = await screen.findByLabelText("Domain contains");
|
await screen.findByLabelText("Filter domains");
|
||||||
expect(domainInput).toHaveProperty("value", "first");
|
expect(domainInput()).toHaveProperty("value", "first");
|
||||||
|
// A seeded custom range opens its row, so the link's bounds are visible.
|
||||||
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
|
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
|
||||||
expect(sinceInput.value).toContain(":37");
|
expect(sinceInput.value).toContain(":37");
|
||||||
|
|
||||||
fireEvent.change(domainInput, { target: { value: "second" } });
|
fireEvent.change(domainInput(), { target: { value: "second" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
submitFilters();
|
||||||
await waitFor(() => expect(history.location.search).toContain("domain=second"));
|
await waitFor(() => expect(history.location.search).toContain("domain=second"));
|
||||||
// The untouched Since bound applied as the exact second it was seeded with.
|
// The untouched Since bound applied as the exact second it was seeded with.
|
||||||
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);
|
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);
|
||||||
|
|
||||||
|
// A pasted link, then the buttons over it: the draft is derived from the URL,
|
||||||
|
// so whichever way the browser moves it the field has to move with it.
|
||||||
|
act(() => history.push(`/activity?mode=history&domain=third&since=${seeded}`));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(domainInput()).toHaveProperty("value", "third");
|
||||||
|
});
|
||||||
|
|
||||||
act(() => history.back());
|
act(() => history.back());
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
|
expect(domainInput()).toHaveProperty("value", "second");
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => history.forward());
|
act(() => history.forward());
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
|
expect(domainInput()).toHaveProperty("value", "third");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -508,8 +536,9 @@ test("a wall-clock time the daylight-saving jump skips is refused, not silently
|
|||||||
const callsBefore = queryCalls().length;
|
const callsBefore = queryCalls().length;
|
||||||
const searchBefore = history.location.search;
|
const searchBefore = history.location.search;
|
||||||
|
|
||||||
|
openCustomRange();
|
||||||
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
|
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||||
|
|
||||||
if (inGap) {
|
if (inGap) {
|
||||||
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
|
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
|
||||||
@@ -544,6 +573,40 @@ test("the policy simulation is reachable from the header, with no rows to click
|
|||||||
expect(history.location.pathname).toBe("/activity/test");
|
expect(history.location.pathname).toBe("/activity/test");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the mode switch is a tab list whose selection is the url, and it keeps the filters", async () => {
|
||||||
|
// Live opens a stream as soon as its panel mounts, so the switch cannot be
|
||||||
|
// exercised without one; the fake stands in for the browser's EventSource.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"EventSource",
|
||||||
|
class {
|
||||||
|
constructor(url: string) {
|
||||||
|
return new FakeEventSource(url) as unknown as EventSource;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
||||||
|
await screen.findByText("ads.example");
|
||||||
|
|
||||||
|
const tabs = within(screen.getByRole("tablist", { name: "Activity mode" }));
|
||||||
|
expect(tabs.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["History", "Live"]);
|
||||||
|
expect(tabs.getByRole("tab", { name: "History", selected: true })).toBeTruthy();
|
||||||
|
expect(tabs.getByRole("tab", { name: "Live", selected: false })).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(tabs.getByRole("tab", { name: "Live" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(history.location.search).toContain("mode=live"));
|
||||||
|
// The investigation survives the switch: both filters are still in the URL.
|
||||||
|
expect(history.location.search).toContain("domain=ads");
|
||||||
|
expect(history.location.search).toContain("blocked=true");
|
||||||
|
expect(
|
||||||
|
within(screen.getByRole("tablist", { name: "Activity mode" })).getByRole("tab", {
|
||||||
|
name: "Live",
|
||||||
|
selected: true,
|
||||||
|
}),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(await screen.findByText(/the History filters apply to history only/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
test("Clear empties the url as well as the form", async () => {
|
test("Clear empties the url as well as the form", async () => {
|
||||||
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
||||||
await screen.findByText("ads.example");
|
await screen.findByText("ads.example");
|
||||||
@@ -553,5 +616,130 @@ test("Clear empties the url as well as the form", async () => {
|
|||||||
expect(history.location.search).not.toContain("domain");
|
expect(history.location.search).not.toContain("domain");
|
||||||
});
|
});
|
||||||
expect(history.location.search).not.toContain("blocked");
|
expect(history.location.search).not.toContain("blocked");
|
||||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
expect(domainInput()).toHaveProperty("value", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
try {
|
||||||
|
const { history } = renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
fireEvent.change(domainInput(), { target: { value: "a" } });
|
||||||
|
fireEvent.change(domainInput(), { target: { value: "ad" } });
|
||||||
|
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||||
|
// Mid-word the URL has not moved: three keystrokes are one investigation,
|
||||||
|
// not three, and each one would otherwise be a request and a history entry.
|
||||||
|
expect(history.location.search).not.toContain("domain");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
});
|
||||||
|
expect(history.location.search).toContain("domain=ads");
|
||||||
|
// Replaced, not pushed: Back leaves the page, it does not retype the word.
|
||||||
|
expect(history.length).toBe(1);
|
||||||
|
|
||||||
|
fireEvent.change(domainInput(), { target: { value: "first" } });
|
||||||
|
submitFilters();
|
||||||
|
await waitFor(() => expect(history.location.search).toContain("domain=first"));
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the field being typed in keeps the focus when the debounce commits", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
try {
|
||||||
|
const { history } = renderPage();
|
||||||
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
|
const input = domainInput();
|
||||||
|
input.focus();
|
||||||
|
fireEvent.change(input, { target: { value: "ads" } });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(history.location.search).toContain("domain=ads"));
|
||||||
|
|
||||||
|
// The same node, still focused, still holding the caret: a toolbar that
|
||||||
|
// remounted on the URL it just wrote would drop the next keystroke.
|
||||||
|
expect(domainInput()).toBe(input);
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a result segment commits on the click, with no wait and no button", async () => {
|
||||||
|
const { history } = renderPage("/activity?domain=ads");
|
||||||
|
await screen.findByText("ads.example");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(history.location.search).toContain("blocked=true"));
|
||||||
|
expect(history.location.search).toContain("domain=ads");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a time preset writes the second it resolved to, not a rolling window", async () => {
|
||||||
|
const now = 1_700_000_000_000;
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||||
|
stubFetch((url) => {
|
||||||
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||||
|
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||||
|
});
|
||||||
|
const { history } = renderPage();
|
||||||
|
await screen.findByText("No queries logged yet.");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Time: Any time" }));
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
||||||
|
|
||||||
|
// Both bounds are concrete, so the link names a closed hour rather than one
|
||||||
|
// that keeps growing at the top as the log does.
|
||||||
|
const since = now / 1000 - 3600;
|
||||||
|
const until = now / 1000;
|
||||||
|
await waitFor(() => expect(history.location.search).toContain(`since=${since}`));
|
||||||
|
expect(history.location.search).toContain(`until=${until}`);
|
||||||
|
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
|
||||||
|
expect(queryCalls()).toContain(`/api/queries?since=${since}&until=${until}`);
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Live shows no toolbar at all", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"EventSource",
|
||||||
|
class {
|
||||||
|
constructor(url: string) {
|
||||||
|
return new FakeEventSource(url) as unknown as EventSource;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
renderPage("/activity?mode=live&domain=ads");
|
||||||
|
await screen.findByText(/the History filters apply to history only/);
|
||||||
|
|
||||||
|
// The stream is unfiltered, so a control here would promise filtering that is
|
||||||
|
// not happening; the filters are still in the URL, waiting for History.
|
||||||
|
expect(screen.queryByLabelText("Filter domains")).toBeNull();
|
||||||
|
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the coverage watermark reads under the results, never over them", async () => {
|
||||||
|
stubFetch((url) => {
|
||||||
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||||
|
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
|
return json({
|
||||||
|
queries: [row(20, "kept.example")],
|
||||||
|
next_before: null,
|
||||||
|
coverage: { complete: false, available_since: 1_700_000_000 },
|
||||||
|
} satisfies QueriesPage);
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("kept.example");
|
||||||
|
|
||||||
|
const watermark = screen.getByText(/Query history is available from/);
|
||||||
|
const count = screen.getByText(/Showing 1 query/);
|
||||||
|
// After the count in document order, which is what "footer" means here.
|
||||||
|
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
* recipient should be looking at.
|
* recipient should be looking at.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
||||||
@@ -53,6 +55,13 @@ const styles = stylex.create({
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
},
|
},
|
||||||
|
/** A Tab is a `div` with a roving tabindex, so RAC drives the ring, not `:focus-visible`. */
|
||||||
|
modeFocusVisible: {
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineStyle: "solid",
|
||||||
|
outlineColor: colors.focus,
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
modeIdle: {
|
modeIdle: {
|
||||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||||
color: { default: colors.textSecondary, ":hover": colors.text },
|
color: { default: colors.textSecondary, ":hover": colors.text },
|
||||||
@@ -76,13 +85,33 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
|
panel: {
|
||||||
|
outlineStyle: "none",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* A panel with nothing tabbable in it — an empty or loading history — is given
|
||||||
|
* a tabindex by RAC so the reader can still reach its content, so it has to be
|
||||||
|
* able to show that it holds the focus.
|
||||||
|
*/
|
||||||
|
panelFocusVisible: {
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineStyle: "solid",
|
||||||
|
outlineColor: colors.focus,
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
|
||||||
|
tabsRoot: {
|
||||||
|
display: "block",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function panelClass({ isFocusVisible }: { isFocusVisible: boolean }): string {
|
||||||
|
return stylex.props(styles.panel, isFocusVisible && styles.panelFocusVisible).className ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const search = useSearch({ from: "/shell/activity" });
|
const search = useSearch({ from: "/shell/activity" });
|
||||||
const navigate = useNavigate({ from: "/activity" });
|
const navigate = useNavigate({ from: "/activity" });
|
||||||
const live = search.mode === "live";
|
|
||||||
|
|
||||||
// The functional form, not a replacement object: the filters are retained
|
// The functional form, not a replacement object: the filters are retained
|
||||||
// across a mode switch on purpose, and spelling out a new search here would
|
// across a mode switch on purpose, and spelling out a new search here would
|
||||||
// drop every one of them on the way to Live and back.
|
// drop every one of them on the way to Live and back.
|
||||||
@@ -91,62 +120,72 @@ export default function ActivityPage() {
|
|||||||
void navigate({ search: (prev) => ({ ...prev, mode }) });
|
void navigate({ search: (prev) => ({ ...prev, mode }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply(filters: AppliedFilters) {
|
// A patch, merged into whatever the URL already says: a segment click must not
|
||||||
void navigate({ search: { mode: search.mode, ...filters } });
|
// spell out the four filters it is not about. `replace` is the debounced text
|
||||||
}
|
// commits, so the back button steps between investigations, not keystrokes.
|
||||||
|
const apply = useCallback(
|
||||||
|
(patch: Partial<AppliedFilters>, replace = false) => {
|
||||||
|
void navigate({ search: (prev) => ({ ...prev, ...patch }), replace });
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
void navigate({ search: (prev) => ({ mode: prev.mode, ...NO_FILTERS }) });
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
|
{/*
|
||||||
|
* 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)}>
|
<div {...stylex.props(styles.header)}>
|
||||||
<h1 {...stylex.props(styles.heading)}>Activity</h1>
|
<h1 {...stylex.props(styles.heading)}>Activity</h1>
|
||||||
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}>
|
<TabList aria-label="Activity mode" className={() => stylex.props(styles.switch).className ?? ""}>
|
||||||
{MODES.map((option) => {
|
{MODES.map((option) => (
|
||||||
const selected = option.mode === search.mode;
|
<Tab
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.mode}
|
key={option.mode}
|
||||||
type="button"
|
id={option.mode}
|
||||||
aria-pressed={selected}
|
className={({ isSelected, isFocusVisible }) =>
|
||||||
onClick={() => selectMode(option.mode)}
|
stylex.props(
|
||||||
{...stylex.props(
|
|
||||||
styles.modeButton,
|
styles.modeButton,
|
||||||
selected ? styles.modeSelected : styles.modeIdle,
|
isSelected ? styles.modeSelected : styles.modeIdle,
|
||||||
shared.focusRing,
|
isFocusVisible && styles.modeFocusVisible,
|
||||||
)}
|
).className ?? ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</Tab>
|
||||||
);
|
))}
|
||||||
})}
|
</TabList>
|
||||||
</div>
|
|
||||||
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
|
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
|
||||||
Current policy simulation
|
Current policy simulation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
* Remounted whenever the applied search changes, which is what makes
|
* The toolbar belongs to History alone. It is not remounted on a search
|
||||||
* the back button work: the draft is derived state, and the browser
|
* change: it resyncs its draft from the URL instead, because a remount
|
||||||
* moving the URL under it has to move the form with it.
|
* mid-debounce would take the focus out of the input being typed in.
|
||||||
*/}
|
*/}
|
||||||
<ActivityFilters
|
<TabPanel id="history" className={panelClass}>
|
||||||
key={`${search.domain ?? ""}|${search.client ?? ""}|${String(search.blocked)}|${String(search.since)}|${String(search.until)}`}
|
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
|
||||||
applied={search}
|
<HistoryActivity search={search} />
|
||||||
isDisabled={live}
|
</TabPanel>
|
||||||
onApply={apply}
|
<TabPanel id="live" className={panelClass}>
|
||||||
onClear={() => apply(NO_FILTERS)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{live ? (
|
|
||||||
<>
|
|
||||||
<p {...stylex.props(styles.liveNote)}>
|
<p {...stylex.props(styles.liveNote)}>
|
||||||
The stream carries every query the server answers; these filters apply to history only.
|
The stream carries every query the server answers; the History filters apply to history only.
|
||||||
</p>
|
</p>
|
||||||
<LiveActivity origin={search} />
|
<LiveActivity origin={search} />
|
||||||
</>
|
</TabPanel>
|
||||||
) : (
|
</Tabs>
|
||||||
<HistoryActivity search={search} />
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* The client filter: the loaded clients as a list you pick from, and the picked
|
||||||
|
* ones as chips you can take back off.
|
||||||
|
*
|
||||||
|
* There is nothing to type here, deliberately. The filter is exact — the server
|
||||||
|
* matches whole addresses — so a half-typed address is not a narrower filter but
|
||||||
|
* a wrong one, and a field that applied as you typed emptied the table under
|
||||||
|
* every reader who started with a digit. Choosing from a list cannot be
|
||||||
|
* half-done: every state this control can be in is a filter someone meant.
|
||||||
|
*
|
||||||
|
* Several clients at once, because the question is usually about a group — the
|
||||||
|
* two phones, the television and the console — and one address at a time makes
|
||||||
|
* that several passes over the same window.
|
||||||
|
*
|
||||||
|
* The chips carry the whole selection, including addresses no client claims. The
|
||||||
|
* log names devices the config has never heard of, and those are the ones an
|
||||||
|
* operator is most likely hunting; a link filtered on one has to stay readable
|
||||||
|
* and clearable even though the menu below cannot offer it.
|
||||||
|
*
|
||||||
|
* A list that has not loaded, or failed to, leaves the trigger disabled rather
|
||||||
|
* than opening on nothing. Chips from the URL still show, so the filter stays
|
||||||
|
* visible and removable either way.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CaretDown } from "@phosphor-icons/react/dist/icons/CaretDown";
|
||||||
|
import { Check } from "@phosphor-icons/react/dist/icons/Check";
|
||||||
|
import { X } from "@phosphor-icons/react/dist/icons/X";
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
|
||||||
|
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import { MAX_CLIENTS } from "./search";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One client as the picker shows it.
|
||||||
|
*
|
||||||
|
* `label` identifies it outright and `name` is the short form. The menu is a
|
||||||
|
* list of every client at once, where two devices can share a name and only the
|
||||||
|
* address tells them apart; a chip is one client the reader picked a moment ago,
|
||||||
|
* beside a row of others, where the address is the part that does not fit.
|
||||||
|
*/
|
||||||
|
export interface ClientOption {
|
||||||
|
ip: string;
|
||||||
|
label: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
|
||||||
|
const HIT_TARGET = 44;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many chips are shown before the rest become a count.
|
||||||
|
*
|
||||||
|
* The chips exist so an active filter is visible without opening the menu. A
|
||||||
|
* dozen of them are not more visible than three — they wrap the toolbar into a
|
||||||
|
* block of its own and push the table off the screen — so past this the row says
|
||||||
|
* how many more there are and the menu remains where they are managed.
|
||||||
|
*/
|
||||||
|
const MAX_CHIPS = 3;
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
root: {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
|
},
|
||||||
|
caret: {
|
||||||
|
display: "inline-flex",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
trigger: {
|
||||||
|
minHeight: HIT_TARGET,
|
||||||
|
minWidth: HIT_TARGET,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
maxHeight: "16rem",
|
||||||
|
overflowY: "auto",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
color: colors.text,
|
||||||
|
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
outlineStyle: "none",
|
||||||
|
paddingBlock: "0.25rem",
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
cursor: "pointer",
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
minHeight: HIT_TARGET,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
/** Inset because an item flush against a scrolling popover clips an outset ring. */
|
||||||
|
itemFocused: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
color: colors.primaryText,
|
||||||
|
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||||
|
},
|
||||||
|
/** The tick keeps its column when absent, so the labels do not shift on select. */
|
||||||
|
tick: {
|
||||||
|
width: "0.75rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "inline-flex",
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.375rem",
|
||||||
|
minHeight: HIT_TARGET,
|
||||||
|
paddingInline: "0.625rem",
|
||||||
|
borderRadius: "999px",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: { default: colors.surfaceHover, ":hover": colors.surfaceRaised },
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
chipCross: {
|
||||||
|
display: "inline-flex",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
/** Not a button: it removes nothing, and nothing about it is pressable. */
|
||||||
|
chipMore: {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: HIT_TARGET,
|
||||||
|
paddingInline: "0.625rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
capNote: {
|
||||||
|
paddingInline: "0.75rem",
|
||||||
|
paddingBlock: "0.375rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The em dash pairs the two halves without reading as part of either. */
|
||||||
|
function optionLabel(ip: string, name: string | null): string {
|
||||||
|
return name === null ? ip : `${name} — ${ip}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The loaded clients, by the name the query tables already give them.
|
||||||
|
*
|
||||||
|
* This is the same cached query those tables read, so opening the list costs no
|
||||||
|
* request; an empty map — still loading, or failed — yields no options.
|
||||||
|
*/
|
||||||
|
export function useClientOptions(): ClientOption[] {
|
||||||
|
const names = useClientNames();
|
||||||
|
return useMemo(() => {
|
||||||
|
const options = [...names.keys()].map((ip) => {
|
||||||
|
const name = clientLabel(ip, names)?.text ?? null;
|
||||||
|
return { ip, name, label: optionLabel(ip, name) };
|
||||||
|
});
|
||||||
|
return options.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}, [names]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How an address reads in full: its client's line, or the bare address. */
|
||||||
|
export function displayFor(ip: string, options: readonly ClientOption[]): string {
|
||||||
|
return options.find((option) => option.ip === ip)?.label ?? ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How an address reads on a chip: its client's name, or the bare address. */
|
||||||
|
export function chipFor(ip: string, options: readonly ClientOption[]): string {
|
||||||
|
return options.find((option) => option.ip === ip)?.name ?? ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL carries the selection as one comma-separated value; absent means none.
|
||||||
|
*
|
||||||
|
* A plain split, because `validateClients` has already made the value canonical
|
||||||
|
* — trimmed, no blanks, no repeats, within the cap. Dropping anything a second
|
||||||
|
* time here is what would let the chips and the request disagree.
|
||||||
|
*/
|
||||||
|
export function parseClients(value: string | undefined): string[] {
|
||||||
|
if (value === undefined) return [];
|
||||||
|
return value.split(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinClients(ips: readonly string[]): string | undefined {
|
||||||
|
return ips.length === 0 ? undefined : ips.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trigger's label. A single client is named outright, because that is the
|
||||||
|
* one case where the whole filter fits on the button; more than one would not,
|
||||||
|
* and the chips beside it say which ones anyway.
|
||||||
|
*/
|
||||||
|
function triggerLabel(selected: readonly string[], options: readonly ClientOption[]): string {
|
||||||
|
if (selected.length === 0) return "Clients";
|
||||||
|
if (selected.length === 1) return displayFor(selected[0] as string, options);
|
||||||
|
return `${selected.length} clients`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
options: readonly ClientOption[];
|
||||||
|
selected: readonly string[];
|
||||||
|
/** Every change here is a decision already made, so it applies at once. */
|
||||||
|
onChange: (next: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientFilter({ options, selected, onChange }: Props) {
|
||||||
|
const known = new Set(options.map((option) => option.ip));
|
||||||
|
// The menu is offered only the addresses it can account for. An address no
|
||||||
|
// client claims is not in its collection, so handing it over as a selected key
|
||||||
|
// would be handing over a key that resolves to nothing.
|
||||||
|
const chosen = new Set(selected.filter((ip) => known.has(ip)));
|
||||||
|
// Every address the URL carries takes a slot, whether or not a client claims
|
||||||
|
// it: the cap is on what the request may name, not on what this menu can show.
|
||||||
|
const atCap = selected.length >= MAX_CLIENTS;
|
||||||
|
// True only when the cap is actually holding something back. Unknown addresses
|
||||||
|
// spend slots too, so a short list of clients can be closed off while the menu
|
||||||
|
// still has room in it — and a disabled row with nothing to explain it is the
|
||||||
|
// one state this must not reach.
|
||||||
|
const capBinds = atCap && chosen.size < options.length;
|
||||||
|
|
||||||
|
const chips = useRef(new Map<string, HTMLButtonElement>());
|
||||||
|
const trigger = useRef<HTMLButtonElement>(null);
|
||||||
|
/** Where focus goes once the removed chip is gone; null means the trigger. */
|
||||||
|
const focusAfterRemoval = useRef<string | null | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = focusAfterRemoval.current;
|
||||||
|
if (next === undefined) return;
|
||||||
|
focusAfterRemoval.current = undefined;
|
||||||
|
// Removing a chip unmounts the element that had the focus. Left alone the
|
||||||
|
// browser drops focus to the document, and a reader clearing three clients
|
||||||
|
// from the keyboard would have to tab back in from the top each time.
|
||||||
|
(next === null ? trigger.current : (chips.current.get(next) ?? trigger.current))?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function remove(ip: string) {
|
||||||
|
const next = selected.filter((entry) => entry !== ip);
|
||||||
|
const visible = next.slice(0, MAX_CHIPS);
|
||||||
|
const index = selected.indexOf(ip);
|
||||||
|
// The chip that takes this one's place, or the one before it at the end of
|
||||||
|
// the row, or the trigger when the row is empty.
|
||||||
|
focusAfterRemoval.current = visible[index] ?? visible[index - 1] ?? null;
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A selection from the menu, merged back over the addresses the menu could not
|
||||||
|
* see and turned into what the URL should carry.
|
||||||
|
*
|
||||||
|
* What is picked is what is committed, always — even every known client at
|
||||||
|
* once. The tick, the chip and the URL then say what the reader did, and a
|
||||||
|
* picker whose feedback for "you selected everything" is to erase the
|
||||||
|
* selection reads as a control that ignored the click; with one client on the
|
||||||
|
* network that is every click it will ever get. Nor is the full set a no-op:
|
||||||
|
* the history keeps rows for clients the inventory has since pruned, so "all
|
||||||
|
* known clients" and "no filter" are different questions.
|
||||||
|
*
|
||||||
|
* The cap is enforced here as well as on the items, because select-all reaches
|
||||||
|
* this without passing an item at all. What was already filtered keeps its
|
||||||
|
* slots and the new picks take what is left, so a selection too big to send is
|
||||||
|
* cut somewhere the reader can predict rather than wherever the URL ran out.
|
||||||
|
*/
|
||||||
|
function apply(next: Set<string>) {
|
||||||
|
const kept = selected.filter((ip) => !known.has(ip) || next.has(ip));
|
||||||
|
const added = [...next].filter((ip) => !chosen.has(ip));
|
||||||
|
onChange([...kept, ...added].slice(0, MAX_CLIENTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// The picker and the chips it fills are one control between them: the group
|
||||||
|
// says so, and names the chips' "Remove …" buttons as part of it.
|
||||||
|
<div role="group" aria-label="Clients" {...stylex.props(styles.root)}>
|
||||||
|
<MenuTrigger>
|
||||||
|
<Button
|
||||||
|
// Nothing to open, and a trigger that opened on an empty popover would
|
||||||
|
// promise a list that is not there.
|
||||||
|
ref={trigger}
|
||||||
|
isDisabled={options.length === 0}
|
||||||
|
className={() => stylex.props(shared.button, styles.trigger, shared.focusRing).className ?? ""}
|
||||||
|
>
|
||||||
|
{triggerLabel(selected, options)}
|
||||||
|
<span aria-hidden="true" {...stylex.props(styles.caret)}>
|
||||||
|
<CaretDown size={12} />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||||
|
{/* Said where it is doing something, and nowhere else. */}
|
||||||
|
{capBinds && <p {...stylex.props(styles.capNote)}>At most {MAX_CLIENTS} clients at a time.</p>}
|
||||||
|
<Menu
|
||||||
|
aria-label="Clients"
|
||||||
|
selectionMode="multiple"
|
||||||
|
selectedKeys={chosen}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
// Ctrl/Cmd+A hands back the literal "all"; taking every known
|
||||||
|
// client is its only reading, and apply() caps the result.
|
||||||
|
if (keys === "all") apply(new Set(options.map((option) => option.ip)));
|
||||||
|
else apply(new Set([...keys].map(String)));
|
||||||
|
}}
|
||||||
|
autoFocus={false}
|
||||||
|
{...stylex.props(styles.menu)}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<MenuItem
|
||||||
|
key={option.ip}
|
||||||
|
id={option.ip}
|
||||||
|
// At the cap, what is already picked can still be unpicked and
|
||||||
|
// nothing else can be added. A menu that took a 33rd pick and
|
||||||
|
// dropped it would look like it had worked.
|
||||||
|
isDisabled={atCap && !chosen.has(option.ip)}
|
||||||
|
textValue={option.label}
|
||||||
|
className={({ isFocused }) =>
|
||||||
|
stylex.props(styles.item, shared.insetFocusRing, isFocused && styles.itemFocused)
|
||||||
|
.className ?? ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" {...stylex.props(styles.tick)}>
|
||||||
|
{chosen.has(option.ip) && <Check size={12} />}
|
||||||
|
</span>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</Popover>
|
||||||
|
</MenuTrigger>
|
||||||
|
{selected.slice(0, MAX_CHIPS).map((ip) => (
|
||||||
|
<button
|
||||||
|
key={ip}
|
||||||
|
type="button"
|
||||||
|
ref={(node) => {
|
||||||
|
if (node === null) chips.current.delete(ip);
|
||||||
|
else chips.current.set(ip, node);
|
||||||
|
}}
|
||||||
|
// The chip reads as a name and removes an address, so the name alone
|
||||||
|
// would not say what the button does to a reader who cannot see it.
|
||||||
|
aria-label={`Remove client ${displayFor(ip, options)}`}
|
||||||
|
onClick={() => remove(ip)}
|
||||||
|
{...stylex.props(styles.chip, shared.focusRing)}
|
||||||
|
>
|
||||||
|
{chipFor(ip, options)}
|
||||||
|
<span aria-hidden="true" {...stylex.props(styles.chipCross)}>
|
||||||
|
<X size={10} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{selected.length > MAX_CHIPS && (
|
||||||
|
<span {...stylex.props(styles.chipMore)}>+{selected.length - MAX_CHIPS} more</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,7 +56,8 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
refetching: {
|
/** The gap a line standing on its own needs from the block above it. */
|
||||||
|
spacedTop: {
|
||||||
marginTop: "0.75rem",
|
marginTop: "0.75rem",
|
||||||
},
|
},
|
||||||
moreButton: {
|
moreButton: {
|
||||||
@@ -81,7 +82,9 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
|||||||
|
|
||||||
const pages = base.data?.pages ?? [];
|
const pages = base.data?.pages ?? [];
|
||||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||||
const coverage = pages[0]?.coverage;
|
// Only from the settled response. Placeholder pages belong to the previous
|
||||||
|
// filter, and a watermark is a claim about the window being displayed.
|
||||||
|
const coverage = base.isPlaceholderData ? undefined : pages[0]?.coverage;
|
||||||
const filterActive = Object.keys(filter).length > 0;
|
const filterActive = Object.keys(filter).length > 0;
|
||||||
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
||||||
// data stands in for a filter change; derive the cursor from what is on
|
// data stands in for a filter change; derive the cursor from what is on
|
||||||
@@ -112,16 +115,27 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/*
|
||||||
|
* A quiet line, never a skeleton: the rows on screen stay put while a
|
||||||
|
* filter change is in flight, so a keystroke must not blank the table
|
||||||
|
* it is narrowing.
|
||||||
|
*/}
|
||||||
{base.isFetching && (
|
{base.isFetching && (
|
||||||
<p {...stylex.props(styles.note, styles.refetching)} role="status">
|
<p {...stylex.props(styles.note, styles.spacedTop)} role="status">
|
||||||
Loading…
|
Updating…
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
|
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
|
<>
|
||||||
<p {...stylex.props(styles.empty)}>
|
<p {...stylex.props(styles.empty)}>
|
||||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||||
</p>
|
</p>
|
||||||
|
{coverage !== undefined && (
|
||||||
|
<div {...stylex.props(styles.spacedTop)}>
|
||||||
|
<CoverageNotice coverage={coverage} variant="note" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div {...stylex.props(styles.tableWrap)}>
|
<div {...stylex.props(styles.tableWrap)}>
|
||||||
@@ -158,6 +172,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
|||||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||||
{hasMore ? "" : " — end of log"}
|
{hasMore ? "" : " — end of log"}
|
||||||
</p>
|
</p>
|
||||||
|
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -57,7 +57,17 @@ function json(payload: unknown): Response {
|
|||||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function stubFetch(handler: (url: string) => Response | Promise<Response> = () => json({})) {
|
/**
|
||||||
|
* The empty history page. Live mode asks for no query pages, but switching back
|
||||||
|
* to History does, and a page-shaped response is the only honest answer there.
|
||||||
|
*/
|
||||||
|
const EMPTY_PAGE = { queries: [], next_before: null, coverage: { complete: true, available_since: 0 } };
|
||||||
|
|
||||||
|
function defaultHandler(url: string): Response {
|
||||||
|
return json(url === "/api/queries" || url.startsWith("/api/queries?") ? EMPTY_PAGE : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubFetch(handler: (url: string) => Response | Promise<Response> = defaultHandler) {
|
||||||
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
||||||
@@ -168,7 +178,7 @@ test("streams rows, flags blocked ones, and freezes the display", async () => {
|
|||||||
expect(screen.getByText("later.example")).toBeTruthy();
|
expect(screen.getByText("later.example")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
test("resolves each row's client to its display name, reading the IP out with it", async () => {
|
||||||
await openLive();
|
await openLive();
|
||||||
act(() => {
|
act(() => {
|
||||||
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
|
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
|
||||||
@@ -178,11 +188,14 @@ test("resolves each row's client to its display name, keeping the IP as the tool
|
|||||||
});
|
});
|
||||||
|
|
||||||
const named = await screen.findByText("Kitchen Pi");
|
const named = await screen.findByText("Kitchen Pi");
|
||||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
// The address reads out with the name it replaced, rather than sitting in a
|
||||||
|
// title only a mouse can reach.
|
||||||
|
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
||||||
|
expect(named.getAttribute("title")).toBeNull();
|
||||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||||
|
|
||||||
const learned = screen.getByText("laptop.lan");
|
const learned = screen.getByText("laptop.lan");
|
||||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
|
||||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||||
|
|
||||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||||
@@ -256,7 +269,40 @@ test("a recovered row links to its stored detail; a streamed one opens in place
|
|||||||
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a streamed row opens its own provenance, from the keyboard as well as the pointer", async () => {
|
/** The open detail. Named by its heading, so the query proves the name is visible. */
|
||||||
|
function detailDialog(): HTMLElement {
|
||||||
|
return screen.getByRole("dialog", { name: "Streamed query" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** React Aria's ModalOverlay, two levels out from the dialog it wraps. */
|
||||||
|
function backdrop(): HTMLElement {
|
||||||
|
return detailDialog().parentElement!.parentElement!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate a control the way a keyboard does. jsdom runs no default action for
|
||||||
|
* Enter on a button, so the click a browser would then dispatch is issued here;
|
||||||
|
* `detail: 0` is what marks it as keyboard-driven rather than pointer-driven,
|
||||||
|
* and is the flag React Aria itself reads.
|
||||||
|
*/
|
||||||
|
function pressWithKeyboard(control: HTMLElement) {
|
||||||
|
act(() => control.focus());
|
||||||
|
fireEvent.keyDown(control, { key: "Enter" });
|
||||||
|
fireEvent.click(control, { detail: 0 });
|
||||||
|
fireEvent.keyUp(control, { key: "Enter" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Activate a control the way a mouse does, through the full pointer sequence. */
|
||||||
|
function pressWithMouse(control: HTMLElement) {
|
||||||
|
fireEvent.pointerDown(control, { pointerType: "mouse", button: 0 });
|
||||||
|
fireEvent.pointerUp(control, { pointerType: "mouse", button: 0 });
|
||||||
|
fireEvent.click(control, { detail: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["the pointer", pressWithMouse],
|
||||||
|
["the keyboard", pressWithKeyboard],
|
||||||
|
])("a streamed row opens its provenance in a named dialog, from %s", async (_label, press) => {
|
||||||
await openLive();
|
await openLive();
|
||||||
act(() =>
|
act(() =>
|
||||||
sources[0]!.emit(
|
sources[0]!.emit(
|
||||||
@@ -273,69 +319,108 @@ test("a streamed row opens its own provenance, from the keyboard as well as the
|
|||||||
// only way to lose that is to opt out of it, which nothing here may do.
|
// only way to lose that is to opt out of it, which nothing here may do.
|
||||||
expect(trigger.tagName).toBe("BUTTON");
|
expect(trigger.tagName).toBe("BUTTON");
|
||||||
expect(trigger.getAttribute("tabindex")).toBeNull();
|
expect(trigger.getAttribute("tabindex")).toBeNull();
|
||||||
act(() => trigger.focus());
|
// The row opens a dialog, so it says so; what it no longer claims is to
|
||||||
expect(document.activeElement).toBe(trigger);
|
// expand a region that stays in the page.
|
||||||
|
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
|
||||||
|
expect(trigger.getAttribute("aria-expanded")).toBeNull();
|
||||||
|
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||||
|
|
||||||
fireEvent.click(trigger);
|
press(trigger);
|
||||||
const heading = screen.getByRole("heading", { level: 1, name: "streamed.example" });
|
|
||||||
expect(heading).toBeTruthy();
|
|
||||||
const panel = heading.closest("div")!.parentElement!;
|
|
||||||
expect(within(panel).getByText("Blocked locally")).toBeTruthy();
|
|
||||||
expect(panel.textContent).toContain("the query log may not have written it yet");
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
const dialog = detailDialog();
|
||||||
expect(screen.queryByRole("heading", { level: 1, name: "streamed.example" })).toBeNull();
|
expect(within(dialog).getByRole("heading", { level: 1, name: "streamed.example" })).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText("Blocked locally")).toBeTruthy();
|
||||||
|
expect(dialog.textContent).toContain("the query log may not have written it yet");
|
||||||
|
// React Aria may defer the move by a frame, depending on the modality it read
|
||||||
|
// from the activation, so the wait is the assertion rather than a workaround.
|
||||||
|
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
||||||
|
expect(within(dialog).getByRole("button", { name: "Close" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the detail takes focus when a row opens it and hands it back when it closes", async () => {
|
test("tabbing forward and backward stays inside the open dialog", async () => {
|
||||||
await openLive();
|
await openLive();
|
||||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||||
|
|
||||||
const trigger = screen.getByRole("button", { name: "streamed.example" });
|
const dialog = detailDialog();
|
||||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
||||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
// The related links land asynchronously; tabbing before them would walk a
|
||||||
|
// shorter dialog than the reader ever sees.
|
||||||
|
await waitFor(() => expect(within(dialog).getAllByRole("link").length).toBeGreaterThan(1));
|
||||||
|
|
||||||
// A native button activates on Enter and Space; jsdom does not synthesize
|
const visited = new Set<Element>();
|
||||||
// the click those keys fire, so the click is the activation.
|
for (const shiftKey of [false, false, false, false, false, false, true, true, true, true]) {
|
||||||
act(() => trigger.focus());
|
fireEvent.keyDown(document.activeElement!, { key: "Tab", shiftKey });
|
||||||
fireEvent.click(trigger);
|
fireEvent.keyUp(document.activeElement!, { key: "Tab", shiftKey });
|
||||||
|
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
visited.add(document.activeElement!);
|
||||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
}
|
||||||
expect(trigger.getAttribute("aria-controls")).toBe(panel.id);
|
// Containment that never moved focus would satisfy the check above without
|
||||||
// The panel is inserted above the table, behind the trigger in tab order, so
|
// trapping anything, so the walk has to have actually walked.
|
||||||
// the only thing that keeps a forward tab inside it is focus moving in.
|
expect(visited.size).toBeGreaterThan(1);
|
||||||
expect(panel.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
||||||
expect(document.activeElement).toBe(panel);
|
|
||||||
expect(panel.contains(screen.getByRole("button", { name: "Close" }))).toBe(true);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
||||||
expect(screen.queryByRole("group", { name: "Streamed query" })).toBeNull();
|
|
||||||
expect(document.activeElement).toBe(trigger);
|
|
||||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
||||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("opening a second row moves the expanded state and the focus with it", async () => {
|
test.each([
|
||||||
|
["the Close button", () => fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }))],
|
||||||
|
["Escape", () => fireEvent.keyDown(detailDialog(), { key: "Escape" })],
|
||||||
|
[
|
||||||
|
"a click on the backdrop",
|
||||||
|
() => {
|
||||||
|
const overlay = backdrop();
|
||||||
|
fireEvent.pointerDown(overlay, { pointerType: "mouse", button: 0 });
|
||||||
|
fireEvent.pointerUp(overlay, { pointerType: "mouse", button: 0 });
|
||||||
|
fireEvent.click(overlay, { detail: 1 });
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])("%s closes the dialog and returns focus to the row that opened it", async (_label, dismiss) => {
|
||||||
await openLive();
|
await openLive();
|
||||||
act(() => {
|
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||||
sources[0]!.emit("query", frame(1000, "first.example"));
|
const trigger = screen.getByRole("button", { name: "streamed.example" });
|
||||||
sources[0]!.emit("query", frame(1001, "second.example"));
|
act(() => trigger.focus());
|
||||||
});
|
fireEvent.click(trigger);
|
||||||
|
expect(detailDialog()).toBeTruthy();
|
||||||
|
|
||||||
const first = screen.getByRole("button", { name: "first.example" });
|
dismiss();
|
||||||
const second = screen.getByRole("button", { name: "second.example" });
|
|
||||||
fireEvent.click(first);
|
|
||||||
fireEvent.click(second);
|
|
||||||
|
|
||||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
expect(within(panel).getByRole("heading", { level: 1, name: "second.example" })).toBeTruthy();
|
expect(document.activeElement).toBe(screen.getByRole("button", { name: "streamed.example" }));
|
||||||
expect(document.activeElement).toBe(panel);
|
});
|
||||||
expect(first.getAttribute("aria-expanded")).toBe("false");
|
|
||||||
expect(second.getAttribute("aria-expanded")).toBe("true");
|
test("the stream runs on behind the open dialog, and its rows land in the table on close", async () => {
|
||||||
|
await openLive();
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||||
|
|
||||||
|
const snapshot = detailDialog().textContent;
|
||||||
|
act(() => sources[0]!.emit("query", frame(1001, "arrived-while-open.example")));
|
||||||
|
|
||||||
|
// The connection is untouched: no close, no second EventSource.
|
||||||
|
expect(sources).toHaveLength(1);
|
||||||
|
expect(sources[0]!.closed).toBe(false);
|
||||||
|
// And the snapshot is a snapshot: nothing that arrives rewrites it.
|
||||||
|
expect(detailDialog().textContent).toBe(snapshot);
|
||||||
|
|
||||||
|
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
|
expect(screen.getByRole("button", { name: "arrived-while-open.example" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the rows and toolbar behind the dialog are out of reach while it is open", async () => {
|
||||||
|
await openLive();
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||||
|
|
||||||
|
// React Aria hides everything outside the modal from assistive technology
|
||||||
|
// and from the pointer alike, so the row and the toolbar are unreachable by
|
||||||
|
// role: nothing behind the dialog can be operated while it is open.
|
||||||
|
expect(screen.queryByRole("button", { name: "streamed.example" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Freeze" })).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
expect(document.activeElement).toBe(second);
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
|
expect(screen.getByRole("button", { name: "Freeze" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -359,23 +444,57 @@ function renderLiveWithCapacity(capacity: number) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("an open streamed detail survives the row being evicted from the ring buffer", () => {
|
/** Push one ringful of filler through a 5-row ring, evicting whatever was there. */
|
||||||
renderLiveWithCapacity(5);
|
function evictWithFiller() {
|
||||||
act(() => sources[0]!.emit("open"));
|
|
||||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
|
|
||||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
|
||||||
|
|
||||||
// One ringful more: the ring keeps the newest 5, so the selected row is gone
|
|
||||||
// from the table. The detail is a snapshot, not a lookup into the ring.
|
|
||||||
act(() => {
|
act(() => {
|
||||||
for (let index = 0; index < 5; index += 1) {
|
for (let index = 0; index < 5; index += 1) {
|
||||||
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
|
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an open dialog's snapshot survives its row being evicted from the ring buffer", async () => {
|
||||||
|
renderLiveWithCapacity(5);
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
|
||||||
|
const snapshot = detailDialog().textContent;
|
||||||
|
expect(within(detailDialog()).getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||||
|
|
||||||
|
// One ringful more: the ring keeps the newest 5, so the selected row is gone.
|
||||||
|
// The dialog holds the frame itself, not a lookup into the ring, so it neither
|
||||||
|
// blanks out nor closes.
|
||||||
|
evictWithFiller();
|
||||||
|
expect(detailDialog().textContent).toBe(snapshot);
|
||||||
|
|
||||||
|
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
expect(screen.getAllByRole("row")).toHaveLength(6);
|
expect(screen.getAllByRole("row")).toHaveLength(6);
|
||||||
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
|
||||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
});
|
||||||
|
|
||||||
|
test("closing after the source row is evicted anchors focus in the results region", async () => {
|
||||||
|
renderLiveWithCapacity(5);
|
||||||
|
act(() => sources[0]!.emit("open"));
|
||||||
|
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||||
|
const trigger = screen.getByRole("button", { name: "evicted.example" });
|
||||||
|
act(() => trigger.focus());
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
|
||||||
|
evictWithFiller();
|
||||||
|
expect(trigger.isConnected).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
|
|
||||||
|
// Never the body, never whichever row happens to sit where the old one did,
|
||||||
|
// and never the Freeze button: a stable anchor in the region the reader was
|
||||||
|
// reading. React Aria's own deferred restore runs after this and, finding
|
||||||
|
// focus already placed, leaves it alone.
|
||||||
|
const region = screen.getByRole("region", { name: "Live queries" });
|
||||||
|
expect(document.activeElement).toBe(region);
|
||||||
|
await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))));
|
||||||
|
expect(document.activeElement).toBe(region);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the route renders the live ring at its production capacity", async () => {
|
test("the route renders the live ring at its production capacity", async () => {
|
||||||
@@ -391,34 +510,29 @@ test("an open streamed detail survives Freeze and Resume", async () => {
|
|||||||
await openLive();
|
await openLive();
|
||||||
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
|
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
|
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
|
||||||
|
const snapshot = detailDialog().textContent;
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
|
// Freeze is behind the dialog, so it is reached the way the code reaches it
|
||||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
// rather than by role, which the modal deliberately hides.
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
const freeze = () => screen.getByText("Freeze") as HTMLButtonElement;
|
||||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
act(() => freeze().click());
|
||||||
|
expect(detailDialog().textContent).toBe(snapshot);
|
||||||
|
act(() => (screen.getByText("Resume") as HTMLButtonElement).click());
|
||||||
|
expect(detailDialog().textContent).toBe(snapshot);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the filter row stays visible, keeps its values, and is out of the tab order", async () => {
|
test("live renders no filter toolbar, not even a disabled one", async () => {
|
||||||
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
|
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
|
||||||
|
|
||||||
const domain = screen.getByLabelText("Domain contains") as HTMLInputElement;
|
// The stream is unfiltered — the server sends every query — so a row of
|
||||||
expect(domain.value).toBe("ads");
|
// controls here would promise filtering that is not happening. The filters
|
||||||
expect(domain.disabled).toBe(true);
|
// are not lost: they are in the URL, and History applies them on the way back.
|
||||||
expect((screen.getByLabelText("Client (exact)") as HTMLInputElement).disabled).toBe(true);
|
expect(screen.queryByLabelText("Filter domains")).toBeNull();
|
||||||
expect((screen.getByLabelText("Since") as HTMLInputElement).disabled).toBe(true);
|
expect(screen.queryByLabelText("Client IP (exact match)")).toBeNull();
|
||||||
expect((screen.getByLabelText("Until") as HTMLInputElement).disabled).toBe(true);
|
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
|
||||||
const form = domain.closest("form")!;
|
expect(screen.queryByText("Clear")).toBeNull();
|
||||||
const controls = [...form.querySelectorAll("input, button, select, textarea, a[href], [tabindex]")];
|
expect(screen.getByText(/the History filters apply to history only/)).toBeTruthy();
|
||||||
expect(controls.length).toBeGreaterThan(0);
|
|
||||||
for (const control of controls) {
|
|
||||||
// A disabled form control is skipped by the browser's tab order, and RAC
|
|
||||||
// pins its own trigger out of it as well. Nothing in the row may
|
|
||||||
// reintroduce itself with a reachable tabindex.
|
|
||||||
expect(control.hasAttribute("disabled")).toBe(true);
|
|
||||||
const tabindex = control.getAttribute("tabindex");
|
|
||||||
expect(tabindex === null || tabindex === "-1").toBe(true);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("live mode asks for no query pages, whatever filters the url retained", async () => {
|
test("live mode asks for no query pages, whatever filters the url retained", async () => {
|
||||||
@@ -431,31 +545,30 @@ test("live mode asks for no query pages, whatever filters the url retained", asy
|
|||||||
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
|
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
|
||||||
await openLive("/activity?mode=live&domain=ads");
|
await openLive("/activity?mode=live&domain=ads");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "History" }));
|
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||||
await screen.findByRole("button", { name: "Apply filters" });
|
await screen.findByLabelText("Filter domains");
|
||||||
expect(sources).toHaveLength(1);
|
expect(sources).toHaveLength(1);
|
||||||
expect(sources[0]!.closed).toBe(true);
|
expect(sources[0]!.closed).toBe(true);
|
||||||
// The filters came along, which is the point of switching rather than
|
// The filters came along, which is the point of switching rather than
|
||||||
// navigating: the reader keeps the question they were asking.
|
// navigating: the reader keeps the question they were asking.
|
||||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads");
|
expect((screen.getByLabelText("Filter domains") as HTMLInputElement).value).toBe("ads");
|
||||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Live" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Live" }));
|
||||||
await screen.findByRole("button", { name: "Freeze" });
|
await screen.findByRole("button", { name: "Freeze" });
|
||||||
expect(sources).toHaveLength(2);
|
expect(sources).toHaveLength(2);
|
||||||
expect(sources[1]!.closed).toBe(false);
|
expect(sources[1]!.closed).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/** The related-actions region of a query detail. */
|
||||||
* The related-actions region of a query detail. Scoped on purpose: the sidebar
|
|
||||||
* carries a Pause of its own, and this is the one that answers "this query was
|
|
||||||
* blocked and should not have been".
|
|
||||||
*/
|
|
||||||
function related(): HTMLElement {
|
function related(): HTMLElement {
|
||||||
return screen.getByRole("region", { name: "Related" });
|
return screen.getByRole("region", { name: "Related" });
|
||||||
}
|
}
|
||||||
|
|
||||||
test("a streamed blocked row carries the same Pause action as the persisted detail", async () => {
|
/**
|
||||||
|
* The streamed detail carries the same Related as the persisted one: four links
|
||||||
|
* and no control. Pause is resolver-wide and lives in the sidebar alone.
|
||||||
|
*/
|
||||||
|
test("a streamed blocked row's Related carries links only", async () => {
|
||||||
await openLive();
|
await openLive();
|
||||||
act(() =>
|
act(() =>
|
||||||
sources[0]!.emit(
|
sources[0]!.emit(
|
||||||
@@ -468,14 +581,7 @@ test("a streamed blocked row carries the same Pause action as the persisted deta
|
|||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||||
|
|
||||||
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
|
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
|
||||||
});
|
expect(within(related()).getAllByRole("link")).toHaveLength(4);
|
||||||
|
expect(within(related()).queryByRole("button")).toBeNull();
|
||||||
test("a streamed row that was allowed offers nothing to pause", async () => {
|
|
||||||
await openLive();
|
|
||||||
act(() => sources[0]!.emit("query", frame(1001, "allowed.example", { policy: { action: "allow" } })));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "allowed.example" }));
|
|
||||||
|
|
||||||
await screen.findByRole("heading", { level: 1, name: "allowed.example" });
|
|
||||||
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Activity in live mode: the SSE stream, its bounded ring buffer, and the
|
* Activity in live mode: the SSE stream, its bounded ring buffer, and the modal
|
||||||
* in-place detail a streamed row opens.
|
* detail a streamed row opens.
|
||||||
*
|
*
|
||||||
* This subtree is mounted only while the URL says `mode=live`, which is what
|
* This subtree is mounted only while the URL says `mode=live`, which is what
|
||||||
* closes the EventSource on the way back to history: the connection is a
|
* closes the EventSource on the way back to history: the connection is a
|
||||||
@@ -17,6 +17,7 @@ import { Link } from "@tanstack/react-router";
|
|||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { useClientNames } from "@/features/clients/clientNames";
|
import { useClientNames } from "@/features/clients/clientNames";
|
||||||
import { summarizeEvent } from "@/features/provenance/querySummary";
|
import { summarizeEvent } from "@/features/provenance/querySummary";
|
||||||
|
import Dialog from "@/ui/Dialog";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||||
@@ -88,6 +89,7 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
},
|
},
|
||||||
dismiss: {
|
dismiss: {
|
||||||
|
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
@@ -161,29 +163,6 @@ const styles = stylex.create({
|
|||||||
textDecorationLine: "underline",
|
textDecorationLine: "underline",
|
||||||
textDecorationStyle: "dotted",
|
textDecorationStyle: "dotted",
|
||||||
},
|
},
|
||||||
detailPanel: {
|
|
||||||
marginTop: "1rem",
|
|
||||||
borderRadius: "0.25rem",
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: "solid",
|
|
||||||
borderColor: colors.borderStrong,
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
padding: "1rem",
|
|
||||||
},
|
|
||||||
detailBar: {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "baseline",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: "0.75rem",
|
|
||||||
},
|
|
||||||
detailLabel: {
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
lineHeight: "1rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: colors.textMuted,
|
|
||||||
},
|
|
||||||
footnote: {
|
footnote: {
|
||||||
marginTop: "0.75rem",
|
marginTop: "0.75rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
@@ -192,10 +171,6 @@ const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** One panel at a time, so the trigger that opened it can name it in `aria-controls`. */
|
|
||||||
const DETAIL_PANEL_ID = "live-query-detail";
|
|
||||||
const DETAIL_LABEL_ID = "live-query-detail-label";
|
|
||||||
|
|
||||||
const PILL_LABELS: Record<StreamStatus, string> = {
|
const PILL_LABELS: Record<StreamStatus, string> = {
|
||||||
connecting: "Connecting…",
|
connecting: "Connecting…",
|
||||||
open: "Live",
|
open: "Live",
|
||||||
@@ -225,40 +200,15 @@ function StatusPill({ status }: { status: StreamStatus }) {
|
|||||||
*
|
*
|
||||||
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by
|
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by
|
||||||
* key would go stale under the reader while they were still reading it, and the
|
* key would go stale under the reader while they were still reading it, and the
|
||||||
* panel would blank out for no reason they could see. The snapshot is the whole
|
* dialog would blank out or swap under their eyes for no reason they could see.
|
||||||
* fact — a streamed frame carries its own provenance — so it survives eviction,
|
* The snapshot is the whole fact — a streamed frame carries its own provenance
|
||||||
* a merge and a Freeze/Resume, and closes only when the reader closes it or
|
* — so it survives eviction, a merge and a Freeze/Resume, and closes only when
|
||||||
* leaves live mode.
|
* the reader closes it or leaves live mode. The stream behind it never stops.
|
||||||
*/
|
*/
|
||||||
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
|
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
|
||||||
const summary = summarizeEvent(row.event);
|
const summary = summarizeEvent(row.event);
|
||||||
const panel = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// The panel opens above the table, behind the trigger in tab order, so a
|
|
||||||
// forward tab from the row would walk past it. Focus moves in on open —
|
|
||||||
// keyed on the row, so choosing a second row moves it again — and the
|
|
||||||
// closer puts it back on the trigger.
|
|
||||||
useEffect(() => {
|
|
||||||
panel.current?.focus();
|
|
||||||
}, [row.key]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog title="Streamed query" size="detail" isOpen onClose={onClose}>
|
||||||
ref={panel}
|
|
||||||
id={DETAIL_PANEL_ID}
|
|
||||||
tabIndex={-1}
|
|
||||||
role="group"
|
|
||||||
aria-labelledby={DETAIL_LABEL_ID}
|
|
||||||
{...stylex.props(styles.detailPanel)}
|
|
||||||
>
|
|
||||||
<div {...stylex.props(styles.detailBar)}>
|
|
||||||
<span id={DETAIL_LABEL_ID} {...stylex.props(styles.detailLabel)}>
|
|
||||||
Streamed query
|
|
||||||
</span>
|
|
||||||
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<ProvenanceDetail
|
<ProvenanceDetail
|
||||||
provenance={row.event}
|
provenance={row.event}
|
||||||
persistedId={null}
|
persistedId={null}
|
||||||
@@ -268,11 +218,10 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi
|
|||||||
client={summary.client_ip}
|
client={summary.client_ip}
|
||||||
ts={summary.ts}
|
ts={summary.ts}
|
||||||
origin={origin}
|
origin={origin}
|
||||||
blocked={row.event.policy.action === "block"}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,21 +241,36 @@ export default function LiveActivity({
|
|||||||
const clientNames = useClientNames();
|
const clientNames = useClientNames();
|
||||||
const [selected, setSelected] = useState<StreamedRow | null>(null);
|
const [selected, setSelected] = useState<StreamedRow | null>(null);
|
||||||
const trigger = useRef<HTMLButtonElement | null>(null);
|
const trigger = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const results = useRef<HTMLDivElement>(null);
|
||||||
|
const restoring = useRef(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where focus lands when the dialog closes.
|
||||||
|
*
|
||||||
|
* React Aria restores focus itself, but in a `requestAnimationFrame` and
|
||||||
|
* only while focus is still on the body — and its target is the row button,
|
||||||
|
* which the ring may have evicted while the reader was reading. So this runs
|
||||||
|
* in the effect that follows the focus scope's teardown and puts focus on a
|
||||||
|
* connected element first: the row if it is still there, the results region
|
||||||
|
* if it is not. React Aria's deferred pass then finds focus already placed
|
||||||
|
* and does nothing, so the two never fight over it. If a navigation unmounts
|
||||||
|
* this component the effect never runs, which is the right answer — there is
|
||||||
|
* no longer a table to return to.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected !== null || !restoring.current) return;
|
||||||
|
restoring.current = false;
|
||||||
|
const from = trigger.current;
|
||||||
|
trigger.current = null;
|
||||||
|
(from?.isConnected === true ? from : results.current)?.focus();
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
function open(row: StreamedRow, from: HTMLButtonElement) {
|
function open(row: StreamedRow, from: HTMLButtonElement) {
|
||||||
trigger.current = from;
|
trigger.current = from;
|
||||||
|
restoring.current = true;
|
||||||
setSelected(row);
|
setSelected(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The row that opened the panel takes focus back, unless the ring has
|
|
||||||
// already evicted it: a detached button cannot be focused, and the browser
|
|
||||||
// falls back to the document, which is the best available answer.
|
|
||||||
function close() {
|
|
||||||
setSelected(null);
|
|
||||||
trigger.current?.focus();
|
|
||||||
trigger.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div {...stylex.props(styles.toolbar)}>
|
<div {...stylex.props(styles.toolbar)}>
|
||||||
@@ -362,8 +326,18 @@ export default function LiveActivity({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
|
{/*
|
||||||
|
* The region is the anchor focus falls back to when the row that
|
||||||
|
* opened the dialog is gone, so it is rendered unconditionally: an
|
||||||
|
* anchor that disappears with the last row is no anchor at all.
|
||||||
|
*/}
|
||||||
|
<div
|
||||||
|
ref={results}
|
||||||
|
tabIndex={-1}
|
||||||
|
role="region"
|
||||||
|
aria-label="Live queries"
|
||||||
|
{...stylex.props(shared.focusRing)}
|
||||||
|
>
|
||||||
{live.rows.length === 0 ? (
|
{live.rows.length === 0 ? (
|
||||||
live.status !== "capped" && (
|
live.status !== "capped" && (
|
||||||
<p {...stylex.props(styles.empty)}>
|
<p {...stylex.props(styles.empty)}>
|
||||||
@@ -390,10 +364,7 @@ export default function LiveActivity({
|
|||||||
row.kind === "streamed" ? (
|
row.kind === "streamed" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={selected?.key === row.key}
|
aria-haspopup="dialog"
|
||||||
aria-controls={
|
|
||||||
selected?.key === row.key ? DETAIL_PANEL_ID : undefined
|
|
||||||
}
|
|
||||||
onClick={(event) => open(row, event.currentTarget)}
|
onClick={(event) => open(row, event.currentTarget)}
|
||||||
{...stylex.props(
|
{...stylex.props(
|
||||||
styles.domainButton,
|
styles.domainButton,
|
||||||
@@ -422,11 +393,14 @@ export default function LiveActivity({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p {...stylex.props(styles.footnote)}>
|
<p {...stylex.props(styles.footnote)}>
|
||||||
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
|
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first,
|
||||||
{capacity} kept).
|
last {capacity} kept).
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={() => setSelected(null)} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import PauseControl from "@/features/pause/PauseControl";
|
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { provenanceRelatedLink } from "./ProvenanceDetail";
|
import { provenanceRelatedLink } from "./ProvenanceDetail";
|
||||||
import { diagnosticsBounds, relatedBounds } from "./relatedLinks";
|
import { diagnosticsBounds, relatedBounds } from "./relatedLinks";
|
||||||
@@ -24,15 +23,9 @@ interface Props {
|
|||||||
ts: number;
|
ts: number;
|
||||||
/** The Activity search the reader came from; its bounds win over the defaults. */
|
/** The Activity search the reader came from; its bounds win over the defaults. */
|
||||||
origin: Pick<ActivitySearch, "since" | "until">;
|
origin: Pick<ActivitySearch, "since" | "until">;
|
||||||
/**
|
|
||||||
* This query was blocked. Pausing is a valid answer to a block the reader
|
|
||||||
* disagrees with, and to nothing else here — so the control appears for a
|
|
||||||
* block and not beside an allowed query it could not have caused.
|
|
||||||
*/
|
|
||||||
blocked: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RelatedActions({ domain, client, ts, origin, blocked }: Props) {
|
export default function RelatedActions({ domain, client, ts, origin }: Props) {
|
||||||
const bounds = relatedBounds(ts, origin);
|
const bounds = relatedBounds(ts, origin);
|
||||||
const window = diagnosticsBounds(ts);
|
const window = diagnosticsBounds(ts);
|
||||||
return (
|
return (
|
||||||
@@ -61,7 +54,6 @@ export default function RelatedActions({ domain, client, ts, origin, blocked }:
|
|||||||
>
|
>
|
||||||
Diagnostics around this query
|
Diagnostics around this query
|
||||||
</Link>
|
</Link>
|
||||||
{blocked && <PauseControl />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { validateActivitySearch, validateBlocked, validateMode, validateText, validateTimestamp } from "./search";
|
import {
|
||||||
|
validateActivitySearch,
|
||||||
|
validateBlocked,
|
||||||
|
validateMode,
|
||||||
|
validateText,
|
||||||
|
validateTimestamp,
|
||||||
|
MAX_CLIENTS,
|
||||||
|
validateClients,
|
||||||
|
} from "./search";
|
||||||
|
|
||||||
test("mode is the two-value union, defaulting to history", () => {
|
test("mode is the two-value union, defaulting to history", () => {
|
||||||
expect(validateMode("live")).toBe("live");
|
expect(validateMode("live")).toBe("live");
|
||||||
@@ -111,3 +119,23 @@ test("a search of junk applies nothing", () => {
|
|||||||
blocked: undefined,
|
blocked: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the client list is canonicalized once, so the chips and the request agree", () => {
|
||||||
|
expect(validateClients("192.0.2.10,192.0.2.11")).toBe("192.0.2.10,192.0.2.11");
|
||||||
|
|
||||||
|
// Blanks name no client and a repeat asks for the same client twice, so
|
||||||
|
// neither changes which rows come back: dropping them is the same filter
|
||||||
|
// written once, not a different one.
|
||||||
|
expect(validateClients(" 192.0.2.10 , ,192.0.2.11,192.0.2.10,")).toBe("192.0.2.10,192.0.2.11");
|
||||||
|
expect(validateClients(",,")).toBeUndefined();
|
||||||
|
expect(validateClients("")).toBeUndefined();
|
||||||
|
expect(validateClients(null)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a pasted list past the cap is cut to what the api will accept", () => {
|
||||||
|
const addresses = Array.from({ length: MAX_CLIENTS + 8 }, (_, index) => `198.51.100.${index + 1}`);
|
||||||
|
|
||||||
|
// The API refuses a longer list outright, so keeping the extra addresses
|
||||||
|
// would show a filter that cannot be applied at all.
|
||||||
|
expect(validateClients(addresses.join(","))).toBe(addresses.slice(0, MAX_CLIENTS).join(","));
|
||||||
|
});
|
||||||
|
|||||||
@@ -57,6 +57,37 @@ export function validateText(value: unknown): string | undefined {
|
|||||||
return trimmed === "" ? undefined : trimmed;
|
return trimmed === "" ? undefined : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `queries_repo.max_clients`: past this the API answers 400 rather than filter. */
|
||||||
|
export const MAX_CLIENTS = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client filter: a comma-separated list of exact addresses, canonicalized
|
||||||
|
* here and nowhere else.
|
||||||
|
*
|
||||||
|
* This is the one place the value is read, so it is the one place it can be made
|
||||||
|
* to mean exactly one thing. Everything downstream — the chips that show the
|
||||||
|
* filter and the request that applies it — reads what this returns, so the two
|
||||||
|
* cannot disagree about a link somebody pasted.
|
||||||
|
*
|
||||||
|
* The rule: entries are trimmed, blanks are dropped, repeats are dropped, and
|
||||||
|
* the list is cut to the cap. A blank entry names no client and a repeat asks
|
||||||
|
* for the same client twice, so neither changes which rows come back; dropping
|
||||||
|
* them is not a different filter, it is the same filter written once. The cut is
|
||||||
|
* a different filter, and it is the honest one available: the API refuses a
|
||||||
|
* longer list outright, so keeping the extra addresses would show a filter that
|
||||||
|
* cannot be applied at all.
|
||||||
|
*/
|
||||||
|
export function validateClients(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const entry of value.split(",")) {
|
||||||
|
const trimmed = entry.trim();
|
||||||
|
if (trimmed !== "") seen.add(trimmed);
|
||||||
|
if (seen.size === MAX_CLIENTS) break;
|
||||||
|
}
|
||||||
|
return seen.size === 0 ? undefined : [...seen].join(",");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The API filter for a validated search, built field by field.
|
* The API filter for a validated search, built field by field.
|
||||||
*
|
*
|
||||||
@@ -82,7 +113,7 @@ export function validateActivitySearch(search: Record<string, unknown>): Activit
|
|||||||
since: validateTimestamp(search["since"]),
|
since: validateTimestamp(search["since"]),
|
||||||
until: validateTimestamp(search["until"]),
|
until: validateTimestamp(search["until"]),
|
||||||
domain: validateText(search["domain"]),
|
domain: validateText(search["domain"]),
|
||||||
client: validateText(search["client"]),
|
client: validateClients(search["client"]),
|
||||||
blocked: validateBlocked(search["blocked"]),
|
blocked: validateBlocked(search["blocked"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ test("an id the list does not contain renders the missing-client state (D9)", as
|
|||||||
|
|
||||||
await screen.findByRole("heading", { name: "No such client" });
|
await screen.findByRole("heading", { name: "No such client" });
|
||||||
expect(screen.getByText(/no client with id 99/i)).toBeTruthy();
|
expect(screen.getByText(/no client with id 99/i)).toBeTruthy();
|
||||||
expect(screen.getByRole("link", { name: "← All clients" })).toBeTruthy();
|
expect(screen.getByRole("link", { name: "All clients" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("policy links to the group that filters this client", async () => {
|
test("policy links to the group that filters this client", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { ArrowLeft } from "@phosphor-icons/react/dist/icons/ArrowLeft";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, useParams } from "@tanstack/react-router";
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
@@ -17,11 +18,17 @@ const nowInSeconds = () => Math.floor(Date.now() / 1000);
|
|||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
back: {
|
back: {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.primaryOnSurface,
|
color: colors.primaryOnSurface,
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
},
|
},
|
||||||
|
backIcon: {
|
||||||
|
display: "inline-flex",
|
||||||
|
},
|
||||||
heading: {
|
heading: {
|
||||||
marginTop: "0.5rem",
|
marginTop: "0.5rem",
|
||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
@@ -95,7 +102,10 @@ const styles = stylex.create({
|
|||||||
function BackLink() {
|
function BackLink() {
|
||||||
return (
|
return (
|
||||||
<Link to="/clients" search={{}} {...stylex.props(styles.back, shared.focusRing)}>
|
<Link to="/clients" search={{}} {...stylex.props(styles.back, shared.focusRing)}>
|
||||||
← All clients
|
<span aria-hidden="true" {...stylex.props(styles.backIcon)}>
|
||||||
|
<ArrowLeft size={12} />
|
||||||
|
</span>
|
||||||
|
All clients
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,16 +18,10 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
heading: {
|
|
||||||
fontSize: "1.125rem",
|
|
||||||
lineHeight: "1.75rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
form: {
|
form: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "1rem",
|
gap: "1rem",
|
||||||
marginTop: "1rem",
|
|
||||||
},
|
},
|
||||||
fieldLabel: {
|
fieldLabel: {
|
||||||
display: "block",
|
display: "block",
|
||||||
@@ -67,8 +61,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
|||||||
const readOnly = useReadOnlyConfig();
|
const readOnly = useReadOnlyConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
<Dialog title={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
||||||
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
|
|
||||||
<form
|
<form
|
||||||
{...stylex.props(styles.form)}
|
{...stylex.props(styles.form)}
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { ClientName, type ClientNames } from "./clientNames";
|
import { ClientName, type ClientNames } from "./clientNames";
|
||||||
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
|
import { BASE, CLIENTS, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
|
||||||
|
|
||||||
|
/** The text of the elements an input points at with `aria-describedby`. */
|
||||||
|
function describedText(input: HTMLElement): string {
|
||||||
|
const ids = input.getAttribute("aria-describedby");
|
||||||
|
if (ids === null) throw new Error("input has no aria-describedby");
|
||||||
|
return ids
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((id) => {
|
||||||
|
const node = document.getElementById(id);
|
||||||
|
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||||
|
return node.textContent ?? "";
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
function clientRow(ip: string): HTMLElement {
|
function clientRow(ip: string): HTMLElement {
|
||||||
const row = screen.getByText(ip).closest("tr");
|
const row = screen.getByText(ip).closest("tr");
|
||||||
@@ -73,6 +87,39 @@ test("the address links to the client's detail page", async () => {
|
|||||||
expect(link.getAttribute("href")).toBe("/clients/1");
|
expect(link.getAttribute("href")).toBe("/clients/1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("delete asks first, naming the row, and the confirmation carries out the delete", async () => {
|
||||||
|
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
|
||||||
|
|
||||||
|
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||||
|
// The operator has to be able to tell from the dialog alone which row this is.
|
||||||
|
expect(within(dialog).getByText(/kids-tablet\.lan \(192\.168\.1\.11\)/)).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText(/re-materialize on their next DNS query/)).toBeTruthy();
|
||||||
|
// Asking is not deleting.
|
||||||
|
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.filter(([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2")),
|
||||||
|
).toHaveLength(1),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelling the confirmation keeps the client", async () => {
|
||||||
|
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
|
||||||
|
|
||||||
|
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
|
expect(screen.getByText("192.168.1.11")).toBeTruthy();
|
||||||
|
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("shows the DNS-activity empty state when there are no clients", async () => {
|
test("shows the DNS-activity empty state when there are no clients", async () => {
|
||||||
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
|
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
|
||||||
|
|
||||||
@@ -140,9 +187,15 @@ test("an unknown group id filters to nothing and offers a way out", async () =>
|
|||||||
expect(router.state.location.search).toEqual({});
|
expect(router.state.location.search).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Both statuses lock the declared delete, but only file authority proves the
|
||||||
|
// file declares the row; the anchors keep the two sentences apart.
|
||||||
|
const DECLARED_NOTE = /^This client is declared in the configuration file/;
|
||||||
|
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
|
||||||
test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => {
|
test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => {
|
||||||
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
|
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
|
||||||
await screen.findAllByLabelText(/Managed by \/etc\/nxdns\/config\.zon/);
|
// The settled sentence, not the tag: "Locked" is already on screen while
|
||||||
|
// authority is pending, so waiting on it would not wait for this status.
|
||||||
|
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
|
||||||
|
|
||||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||||
|
|
||||||
@@ -150,6 +203,34 @@ test("file mode drops every edit affordance and keeps the observed delete live (
|
|||||||
const observed = clientRow("192.168.1.11");
|
const observed = clientRow("192.168.1.11");
|
||||||
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
|
||||||
|
// Why the locked Delete will not answer, in visible text and exactly once:
|
||||||
|
// per row it would repeat down the whole page, and on the button it was a
|
||||||
|
// title that a keyboard and a touch screen never reached.
|
||||||
|
expect(screen.getAllByText(DECLARED_NOTE, { selector: "p" })).toHaveLength(1);
|
||||||
|
expect(within(declared).queryByText(DECLARED_NOTE, { selector: "p" })).toBeNull();
|
||||||
|
// The description stays on the button itself too, for a reader on that control.
|
||||||
|
const locked = within(declared).getByRole("button", { name: "Delete" });
|
||||||
|
expect(document.getElementById(locked.getAttribute("aria-describedby") ?? "")?.textContent).toMatch(DECLARED_NOTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an all-observed page still says why Edit is gone, with no delete note to carry it", async () => {
|
||||||
|
// Every row observed, so no Delete is locked. The edit lock is still real, and
|
||||||
|
// "Locked" appearing with nothing to explain it is the failure this guards.
|
||||||
|
const observedOnly = { clients: [CLIENTS.clients[1]] };
|
||||||
|
await renderClientsPage({
|
||||||
|
...BASE,
|
||||||
|
"GET /api/clients": observedOnly,
|
||||||
|
"GET /api/config/status": MANAGED_FILE,
|
||||||
|
});
|
||||||
|
// The settled sentence is both the anchor and the assertion: it is the whole
|
||||||
|
// explanation for the missing Edit action.
|
||||||
|
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
|
||||||
|
expect(await screen.findAllByText("Locked")).not.toHaveLength(0);
|
||||||
|
|
||||||
|
// The delete sentence belongs only to a row that has one.
|
||||||
|
expect(screen.queryByText(DECLARED_NOTE)).toBeNull();
|
||||||
|
expect((screen.getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("file mode renders network assignments with no mutation control at all (R2-4)", async () => {
|
test("file mode renders network assignments with no mutation control at all (R2-4)", async () => {
|
||||||
@@ -170,7 +251,7 @@ test("file mode renders network assignments with no mutation control at all (R2-
|
|||||||
|
|
||||||
test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => {
|
test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => {
|
||||||
await renderClientsPage({ ...BASE, "GET /api/config/status": undefined });
|
await renderClientsPage({ ...BASE, "GET /api/config/status": undefined });
|
||||||
await screen.findAllByLabelText(/Configuration status unavailable/);
|
await screen.findAllByText(/^Configuration status unavailable/);
|
||||||
|
|
||||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||||
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
|
||||||
@@ -186,10 +267,6 @@ test("a failed config status exposes no configuration mutation, and still delete
|
|||||||
// confirmation is already open. `undefined` is the failed status: the fetch stub
|
// confirmation is already open. `undefined` is the failed status: the fetch stub
|
||||||
// answers 404 for a key it does not hold.
|
// answers 404 for a key it does not hold.
|
||||||
//
|
//
|
||||||
// Both statuses lock the declared delete, but only file authority proves the
|
|
||||||
// file declares the row; the anchors keep the two sentences apart.
|
|
||||||
const DECLARED_NOTE = /^This client is declared in the configuration file/;
|
|
||||||
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
|
|
||||||
describe.each([
|
describe.each([
|
||||||
["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE],
|
["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE],
|
||||||
["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE],
|
["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE],
|
||||||
@@ -212,13 +289,13 @@ describe.each([
|
|||||||
expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull();
|
expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull();
|
||||||
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
|
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
|
||||||
expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy();
|
expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy();
|
||||||
expect(within(dialog).getByLabelText(/^Locked\./)).toBeTruthy();
|
expect(within(dialog).getByText("Locked")).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
test("locks an open declared-client delete confirmation and leaves cancel working", async () => {
|
test("locks an open declared-client delete confirmation in place and leaves cancel working", async () => {
|
||||||
const map = { ...BASE };
|
const map = { ...BASE };
|
||||||
const { queryClient, fetchMock } = await renderClientsPage(map);
|
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||||
// The Edit affordance appearing is the proof authority resolved to
|
// The Edit affordance appearing is the proof authority resolved to
|
||||||
@@ -227,23 +304,68 @@ describe.each([
|
|||||||
|
|
||||||
const declared = clientRow("192.168.1.10");
|
const declared = clientRow("192.168.1.10");
|
||||||
fireEvent.click(within(declared).getByRole("button", { name: "Delete" }));
|
fireEvent.click(within(declared).getByRole("button", { name: "Delete" }));
|
||||||
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Confirm delete" })).toBeTruthy();
|
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||||
|
|
||||||
await setConfigStatus(map, queryClient, status);
|
await setConfigStatus(map, queryClient, status);
|
||||||
|
|
||||||
const confirming = clientRow("192.168.1.10");
|
// The dialog stays put. Closing it would throw focus at the Delete button
|
||||||
expect(within(confirming).queryByRole("button", { name: "Confirm delete" })).toBeNull();
|
// the same turn disabled, and the reason would survive only as a title
|
||||||
expect(within(confirming).getByText(lockNote)).toBeTruthy();
|
// attribute; here the reason is the dialog's own message.
|
||||||
expect(within(confirming).queryByText(otherNote)).toBeNull();
|
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
|
||||||
expect(within(confirming).getByLabelText(/^Locked\./)).toBeTruthy();
|
expect(within(dialog).getByText(lockNote)).toBeTruthy();
|
||||||
|
expect(within(dialog).queryByText(otherNote)).toBeNull();
|
||||||
|
expect(within(dialog).getByText("Locked")).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(within(confirming).getByRole("button", { name: "Cancel" }));
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })).toBeTruthy(),
|
|
||||||
);
|
|
||||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a cancelled confirmation stays closed when authority comes back", async () => {
|
||||||
|
const map = { ...BASE };
|
||||||
|
const { queryClient } = await renderClientsPage(map);
|
||||||
|
await unlockedEdit(0);
|
||||||
|
|
||||||
|
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
|
|
||||||
|
// A poll that fails and then recovers must not raise a destructive
|
||||||
|
// question the operator already answered.
|
||||||
|
await setConfigStatus(map, queryClient, status);
|
||||||
|
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getAllByRole("button", { name: "Edit" }).length).toBeGreaterThan(0));
|
||||||
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the confirm action comes back when authority does, without a second prompt", async () => {
|
||||||
|
const map = { ...BASE, "DELETE /api/clients/1": {} };
|
||||||
|
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||||
|
await unlockedEdit(0);
|
||||||
|
|
||||||
|
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||||
|
|
||||||
|
await setConfigStatus(map, queryClient, status);
|
||||||
|
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
|
||||||
|
|
||||||
|
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
|
||||||
|
|
||||||
|
// The question was never withdrawn, so the answer returns to the same
|
||||||
|
// dialog rather than asking the operator to start again.
|
||||||
|
const confirm = await within(dialog).findByRole("button", { name: "Delete" });
|
||||||
|
fireEvent.click(confirm);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.filter(
|
||||||
|
([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1"),
|
||||||
|
),
|
||||||
|
).toHaveLength(1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
|
test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
|
||||||
const map = { ...BASE, "DELETE /api/clients/2": {} };
|
const map = { ...BASE, "DELETE /api/clients/2": {} };
|
||||||
const { queryClient, fetchMock } = await renderClientsPage(map);
|
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||||
@@ -253,10 +375,11 @@ describe.each([
|
|||||||
|
|
||||||
const observed = clientRow("192.168.1.11");
|
const observed = clientRow("192.168.1.11");
|
||||||
fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
|
fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||||
|
|
||||||
await setConfigStatus(map, queryClient, status);
|
await setConfigStatus(map, queryClient, status);
|
||||||
|
|
||||||
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Confirm delete" }));
|
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
fetchMock.mock.calls.filter(
|
fetchMock.mock.calls.filter(
|
||||||
@@ -271,7 +394,7 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
|
|||||||
// The status request never settles, so authority stays pending for the whole
|
// The status request never settles, so authority stays pending for the whole
|
||||||
// test: nothing configuration owns may be offered on that guess.
|
// test: nothing configuration owns may be offered on that guess.
|
||||||
await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER });
|
await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER });
|
||||||
await screen.findAllByLabelText(/Checking which configuration source/);
|
await screen.findAllByText(/^Checking which configuration source/);
|
||||||
|
|
||||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||||
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
|
||||||
@@ -280,3 +403,66 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
|
|||||||
const observed = clientRow("192.168.1.11");
|
const observed = clientRow("192.168.1.11");
|
||||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a save-time problem marks the input it is about and describes it", async () => {
|
||||||
|
await renderClientsPage();
|
||||||
|
|
||||||
|
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||||
|
fireEvent.change(range, { target: { value: "" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||||
|
|
||||||
|
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
expect(describedText(range)).toBe("Row 1: prefix is required.");
|
||||||
|
|
||||||
|
// Only the offending input is marked; the priority beside it is untouched.
|
||||||
|
const priority = screen.getByLabelText("Priority for range 1");
|
||||||
|
expect(priority.getAttribute("aria-invalid")).toBeNull();
|
||||||
|
expect(priority.getAttribute("aria-describedby")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Priority for range 1"), { target: { value: "abc" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||||
|
|
||||||
|
expect(range.getAttribute("aria-invalid")).toBeNull();
|
||||||
|
expect(describedText(screen.getByLabelText("Priority for range 1"))).toBe(
|
||||||
|
"Row 1: priority must be a whole number.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removing a row drops the message rather than moving it to another input", async () => {
|
||||||
|
await renderClientsPage();
|
||||||
|
|
||||||
|
// Row 1 is the offending one, so removing it is what would slide the stale
|
||||||
|
// index onto row 2 — an input that validated cleanly.
|
||||||
|
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||||
|
fireEvent.change(range, { target: { value: "" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add range" }));
|
||||||
|
fireEvent.change(screen.getByLabelText("Range 2"), { target: { value: "10.0.0.0/8" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||||
|
|
||||||
|
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
expect(describedText(range)).toBe("Row 1: prefix is required.");
|
||||||
|
|
||||||
|
const section = assignmentsSection();
|
||||||
|
fireEvent.click(within(section).getAllByRole("button", { name: "Remove" })[0] as HTMLButtonElement);
|
||||||
|
|
||||||
|
const survivor = screen.getByLabelText("Range 1") as HTMLInputElement;
|
||||||
|
expect(survivor.value).toBe("10.0.0.0/8");
|
||||||
|
expect(survivor.getAttribute("aria-invalid")).toBeNull();
|
||||||
|
expect(survivor.getAttribute("aria-describedby")).toBeNull();
|
||||||
|
expect(within(section).queryByRole("alert")).toBeNull();
|
||||||
|
expect(screen.queryByLabelText("Range 2")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editing a row clears the message it was about", async () => {
|
||||||
|
await renderClientsPage();
|
||||||
|
|
||||||
|
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||||
|
fireEvent.change(range, { target: { value: "" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||||
|
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
|
||||||
|
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
|
||||||
|
expect(range.getAttribute("aria-invalid")).toBeNull();
|
||||||
|
expect(within(assignmentsSection()).queryByRole("alert")).toBeNull();
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import ClientEditDialog from "./ClientEditDialog";
|
|||||||
import NetworkAssignments from "./NetworkAssignments";
|
import NetworkAssignments from "./NetworkAssignments";
|
||||||
import { ClientDisplayName } from "./clientIdentity";
|
import { ClientDisplayName } from "./clientIdentity";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
|
import ConfigLockIndicator, { lockReason } from "@/features/configuration/ConfigLockIndicator";
|
||||||
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
|
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
@@ -24,6 +25,9 @@ import { colors } from "@/ui/tokens.stylex";
|
|||||||
* alone cannot say which operator surface set the row — so the sentence names
|
* alone cannot say which operator surface set the row — so the sentence names
|
||||||
* the doubt rather than asserting a declaration, the way `provenanceOf` does.
|
* the doubt rather than asserting a declaration, the way `provenanceOf` does.
|
||||||
*/
|
*/
|
||||||
|
/** The one note every locked Delete on this page describes itself with. */
|
||||||
|
const DELETE_LOCK_NOTE_ID = "clients-delete-locked-note";
|
||||||
|
|
||||||
function declaredDeleteNote(authority: Authority): string {
|
function declaredDeleteNote(authority: Authority): string {
|
||||||
if (authority.state === "resolved") {
|
if (authority.state === "resolved") {
|
||||||
return "This client is declared in the configuration file; remove it there and restart.";
|
return "This client is declared in the configuration file; remove it there and restart.";
|
||||||
@@ -31,6 +35,16 @@ function declaredDeleteNote(authority: Authority): string {
|
|||||||
return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked.";
|
return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the confirmation names the row. The address is always there and always
|
||||||
|
* unique, so it carries the sentence; a name the operator recognizes leads when
|
||||||
|
* the row has one.
|
||||||
|
*/
|
||||||
|
function clientLabel(client: Client): string {
|
||||||
|
const name = client.name !== "" ? client.name : client.learned_name;
|
||||||
|
return name === "" ? client.ip : `${name} (${client.ip})`;
|
||||||
|
}
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
heading: {
|
heading: {
|
||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
@@ -41,6 +55,15 @@ const styles = stylex.create({
|
|||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
|
lockNote: {
|
||||||
|
marginTop: "1rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
filterBar: {
|
filterBar: {
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -79,23 +102,11 @@ const styles = stylex.create({
|
|||||||
color: colors.primaryOnSurface,
|
color: colors.primaryOnSurface,
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
},
|
},
|
||||||
confirmGroup: {
|
|
||||||
display: "inline-flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
gap: "0.5rem",
|
|
||||||
},
|
|
||||||
actionGroup: {
|
actionGroup: {
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
},
|
},
|
||||||
note: {
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
lineHeight: "1rem",
|
|
||||||
color: colors.textMuted,
|
|
||||||
},
|
|
||||||
dangerText: {
|
dangerText: {
|
||||||
color: colors.danger,
|
color: colors.danger,
|
||||||
},
|
},
|
||||||
@@ -112,7 +123,7 @@ export default function ClientsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
||||||
const [editing, setEditing] = useState<Client | null>(null);
|
const [editing, setEditing] = useState<Client | null>(null);
|
||||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<Client | null>(null);
|
||||||
const readOnly = useReadOnlyConfig();
|
const readOnly = useReadOnlyConfig();
|
||||||
const authority = useAuthority();
|
const authority = useAuthority();
|
||||||
|
|
||||||
@@ -122,6 +133,17 @@ export default function ClientsPage() {
|
|||||||
const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group);
|
const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group);
|
||||||
const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group);
|
const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group);
|
||||||
|
|
||||||
|
// Authority is polled, so it can turn while the confirmation is open. The
|
||||||
|
// dialog reads it on every render rather than trusting the state that opened
|
||||||
|
// it, and withdraws the answer that would now fail instead of withdrawing the
|
||||||
|
// question: the operator is told why, and only they close the dialog.
|
||||||
|
const deleteLocked = pendingDelete !== null && readOnly && pendingDelete.hand_edited;
|
||||||
|
|
||||||
|
// The reason a locked Delete will not answer, printed once above the table.
|
||||||
|
// Per row it would repeat down the whole page; on the button it was a `title`
|
||||||
|
// that a keyboard and a touch screen never reached.
|
||||||
|
const deletesLocked = readOnly && rows.some((client) => client.hand_edited);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
||||||
@@ -137,6 +159,15 @@ export default function ClientsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* The whole explanation for this page's locks, printed once. Per row it
|
||||||
|
would repeat down the table; on the controls it was a `title` that a
|
||||||
|
keyboard and a touch screen never reached. */}
|
||||||
|
{readOnly && (
|
||||||
|
<div {...stylex.props(styles.lockNote)}>
|
||||||
|
<p>{lockReason(authority)}.</p>
|
||||||
|
{deletesLocked && <p id={DELETE_LOCK_NOTE_ID}>{declaredDeleteNote(authority)}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{clients.length === 0 ? (
|
{clients.length === 0 ? (
|
||||||
<p {...stylex.props(styles.empty)}>
|
<p {...stylex.props(styles.empty)}>
|
||||||
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
||||||
@@ -178,48 +209,11 @@ export default function ClientsPage() {
|
|||||||
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
||||||
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
||||||
<td {...stylex.props(styles.cell, styles.right)}>
|
<td {...stylex.props(styles.cell, styles.right)}>
|
||||||
{confirmingId === client.id ? (
|
|
||||||
<span {...stylex.props(styles.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)}>
|
<span {...stylex.props(styles.actionGroup)}>
|
||||||
{/* Naming a client writes configuration, so the affordance is
|
{/* Naming a client writes configuration, so the affordance is
|
||||||
absent — not disabled — wherever the write cannot land. */}
|
absent — not disabled — wherever the write cannot land. */}
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<ConfigLockIndicator />
|
<ConfigLockIndicator compact />
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -231,12 +225,10 @@ export default function ClientsPage() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmingId(client.id)}
|
onClick={() => setPendingDelete(client)}
|
||||||
disabled={readOnly && client.hand_edited}
|
disabled={readOnly && client.hand_edited}
|
||||||
title={
|
aria-describedby={
|
||||||
readOnly && client.hand_edited
|
readOnly && client.hand_edited ? DELETE_LOCK_NOTE_ID : undefined
|
||||||
? declaredDeleteNote(authority)
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
{...stylex.props(
|
{...stylex.props(
|
||||||
shared.smallButton,
|
shared.smallButton,
|
||||||
@@ -248,7 +240,6 @@ export default function ClientsPage() {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -256,6 +247,24 @@ export default function ClientsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={pendingDelete !== null}
|
||||||
|
title="Delete client"
|
||||||
|
message={
|
||||||
|
pendingDelete === null
|
||||||
|
? ""
|
||||||
|
: deleteLocked
|
||||||
|
? declaredDeleteNote(authority)
|
||||||
|
: `Delete ${clientLabel(pendingDelete)}? Deleted clients re-materialize on their next DNS query.`
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
lock={deleteLocked ? <ConfigLockIndicator /> : undefined}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (pendingDelete !== null) deleteMutation.mutate(pendingDelete.id);
|
||||||
|
setPendingDelete(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setPendingDelete(null)}
|
||||||
|
/>
|
||||||
<InlineError error={deleteMutation.error} />
|
<InlineError error={deleteMutation.error} />
|
||||||
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
||||||
<NetworkAssignments prefixes={prefixes} groups={groups} />
|
<NetworkAssignments prefixes={prefixes} groups={groups} />
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import * as stylex from "@stylexjs/stylex";
|
|||||||
import { clientPrefixesPutMutation } from "@/lib/queries";
|
import { clientPrefixesPutMutation } from "@/lib/queries";
|
||||||
import type { ClientPrefix, Group } from "@/lib/types";
|
import type { ClientPrefix, Group } from "@/lib/types";
|
||||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||||
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
import {
|
||||||
|
firstProblem,
|
||||||
|
initPrefixEditor,
|
||||||
|
isDirty,
|
||||||
|
prefixEditorReducer,
|
||||||
|
toInputs,
|
||||||
|
type PrefixEditorAction,
|
||||||
|
type PrefixProblem,
|
||||||
|
} from "./prefixEditor";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import AuthorityGate from "@/features/configuration/AuthorityGate";
|
import AuthorityGate from "@/features/configuration/AuthorityGate";
|
||||||
import Select from "@/ui/Select";
|
import Select from "@/ui/Select";
|
||||||
@@ -16,6 +24,9 @@ interface Props {
|
|||||||
groups: Group[];
|
groups: Group[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The editor is rendered once per page, so the message can hold a fixed id. */
|
||||||
|
const VALIDATION_ID = "network-assignments-validation";
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
section: {
|
section: {
|
||||||
marginTop: "2.5rem",
|
marginTop: "2.5rem",
|
||||||
@@ -65,6 +76,7 @@ const styles = stylex.create({
|
|||||||
width: "5rem",
|
width: "5rem",
|
||||||
},
|
},
|
||||||
removeButton: {
|
removeButton: {
|
||||||
|
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
@@ -161,12 +173,24 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
|
|||||||
function AssignmentsEditor({ prefixes, groups }: Props) {
|
function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
||||||
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
const [state, apply] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||||
const [validation, setValidation] = useState<string | null>(null);
|
const [validation, setValidation] = useState<PrefixProblem | null>(null);
|
||||||
const dirty = isDirty(state);
|
const dirty = isDirty(state);
|
||||||
const fallbackGroupId = defaultGroupId(groups);
|
const fallbackGroupId = defaultGroupId(groups);
|
||||||
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
|
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
|
||||||
|
|
||||||
|
// A problem names a row by position, and every action here can move, add or
|
||||||
|
// delete a position. The message describes the rows Save read, so it dies
|
||||||
|
// with them rather than drifting onto whatever row inherits the index.
|
||||||
|
const dispatch = (action: PrefixEditorAction) => {
|
||||||
|
setValidation(null);
|
||||||
|
apply(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True for the one input the current message is about; nothing else is marked. */
|
||||||
|
const invalid = (index: number, field: PrefixProblem["field"]): true | undefined =>
|
||||||
|
validation !== null && validation.index === index && validation.field === field ? true : undefined;
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
const problem = firstProblem(state.rows);
|
const problem = firstProblem(state.rows);
|
||||||
setValidation(problem);
|
setValidation(problem);
|
||||||
@@ -187,6 +211,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
aria-label={`Range ${index + 1}`}
|
aria-label={`Range ${index + 1}`}
|
||||||
|
aria-invalid={invalid(index, "prefix")}
|
||||||
|
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
|
||||||
placeholder="192.168.1.0/24"
|
placeholder="192.168.1.0/24"
|
||||||
value={row.prefix}
|
value={row.prefix}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -207,6 +233,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
|||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
aria-label={`Priority for range ${index + 1}`}
|
aria-label={`Priority for range ${index + 1}`}
|
||||||
|
aria-invalid={invalid(index, "priority")}
|
||||||
|
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
|
||||||
placeholder="100"
|
placeholder="100"
|
||||||
value={row.priority}
|
value={row.priority}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -226,8 +254,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{validation !== null && (
|
{validation !== null && (
|
||||||
<p role="alert" {...stylex.props(styles.validation)}>
|
<p id={VALIDATION_ID} role="alert" {...stylex.props(styles.validation)}>
|
||||||
{validation}
|
{validation.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<InlineError error={mutation.error} />
|
<InlineError error={mutation.error} />
|
||||||
@@ -250,10 +278,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
|||||||
{dirty && (
|
{dirty && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => dispatch({ type: "reset", prefixes })}
|
||||||
setValidation(null);
|
|
||||||
dispatch({ type: "reset", prefixes });
|
|
||||||
}}
|
|
||||||
{...stylex.props(shared.button, shared.focusRing)}
|
{...stylex.props(shared.button, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Discard changes
|
Discard changes
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ import * as stylex from "@stylexjs/stylex";
|
|||||||
import { clientsQuery } from "@/lib/queries";
|
import { clientsQuery } from "@/lib/queries";
|
||||||
import type { Client } from "@/lib/types";
|
import type { Client } from "@/lib/types";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
/** Secondary to the name it qualifies, and never the only thing in the cell. */
|
||||||
|
address: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export type ClientNames = ReadonlyMap<string, Pick<Client, "name" | "learned_name">>;
|
export type ClientNames = ReadonlyMap<string, Pick<Client, "name" | "learned_name">>;
|
||||||
|
|
||||||
@@ -53,11 +61,13 @@ export function clientLabel(ip: string, names: ClientNames): { text: string; lea
|
|||||||
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
|
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
|
||||||
const label = clientLabel(ip, names);
|
const label = clientLabel(ip, names);
|
||||||
if (label === null) return <span {...stylex.props(shared.mono)}>{ip}</span>;
|
if (label === null) return <span {...stylex.props(shared.mono)}>{ip}</span>;
|
||||||
// The name replaces the address on screen, so the address stays reachable
|
// The name replaces the address, so the address follows it as real text that
|
||||||
// as the tooltip rather than disappearing from the row entirely.
|
// anyone can read and copy. A `title` carried it before, which reaches
|
||||||
|
// neither a keyboard nor a touch screen.
|
||||||
return (
|
return (
|
||||||
<span title={ip} {...stylex.props(label.learned && shared.learnedName)}>
|
<span {...stylex.props(label.learned && shared.learnedName)}>
|
||||||
{label.text}
|
{label.text}
|
||||||
|
<span {...stylex.props(styles.address)}> ({ip})</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,11 +76,15 @@ test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
|
|||||||
|
|
||||||
test("firstProblem flags empty prefixes and non-integer priorities", () => {
|
test("firstProblem flags empty prefixes and non-integer priorities", () => {
|
||||||
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
|
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
|
||||||
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required.");
|
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toEqual({
|
||||||
|
index: 0,
|
||||||
|
field: "prefix",
|
||||||
|
message: "Row 1: prefix is required.",
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
firstProblem([
|
firstProblem([
|
||||||
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
|
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
|
||||||
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
|
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
|
||||||
]),
|
]),
|
||||||
).toBe("Row 2: priority must be a whole number.");
|
).toEqual({ index: 1, field: "priority", message: "Row 2: priority must be a whole number." });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,11 +55,20 @@ export function isDirty(state: PrefixEditorState): boolean {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function firstProblem(rows: PrefixRow[]): string | null {
|
/** Which input the message is about, so the editor can point that input at it. */
|
||||||
for (const [i, row] of rows.entries()) {
|
export interface PrefixProblem {
|
||||||
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`;
|
index: number;
|
||||||
|
field: "prefix" | "priority";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
|
||||||
|
for (const [index, row] of rows.entries()) {
|
||||||
|
if (row.prefix.trim() === "")
|
||||||
|
return { index, field: "prefix", message: `Row ${index + 1}: prefix is required.` };
|
||||||
const priority = row.priority.trim();
|
const priority = row.priority.trim();
|
||||||
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`;
|
if (priority !== "" && !/^\d+$/.test(priority))
|
||||||
|
return { index, field: "priority", message: `Row ${index + 1}: priority must be a whole number.` };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,17 +61,20 @@ test("the lock is silent when the database owns the configuration", async () =>
|
|||||||
test("under file authority the lock names the file, in words a reader hears", async () => {
|
test("under file authority the lock names the file, in words a reader hears", async () => {
|
||||||
renderIndicator(MANAGED_FILE);
|
renderIndicator(MANAGED_FILE);
|
||||||
|
|
||||||
|
// The reason is visible text beside the tag, not a title and not a label: a
|
||||||
|
// tooltip reaches neither a keyboard nor a touch screen, and screen-reader-only
|
||||||
|
// text is the same failure pointed the other way.
|
||||||
const lock = await screen.findByText("Locked");
|
const lock = await screen.findByText("Locked");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(lock.getAttribute("aria-label")).toBe(
|
expect(screen.getByText(`Managed by ${CONFIG_PATH}; edit the file and restart nxdns`)).toBeTruthy(),
|
||||||
`Locked. Managed by ${CONFIG_PATH}; edit the file and restart nxdns.`,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
expect(lock.getAttribute("title")).toBeNull();
|
||||||
|
expect(lock.getAttribute("aria-label")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an unanswered status still locks, and says that is why", async () => {
|
test("an unanswered status still locks, and says that is why", async () => {
|
||||||
renderIndicator("failed");
|
renderIndicator("failed");
|
||||||
|
|
||||||
const lock = await screen.findByText("Locked");
|
await screen.findByText("Locked");
|
||||||
await waitFor(() => expect(lock.getAttribute("aria-label")).toContain("Configuration status unavailable"));
|
await waitFor(() => expect(screen.getByText(/^Configuration status unavailable/)).toBeTruthy());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { useAuthority } from "./authority";
|
import { useAuthority, type Authority } from "./authority";
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
|
row: {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.375rem",
|
||||||
|
},
|
||||||
|
/** The sentence is secondary to the word, and wraps rather than stretching a row. */
|
||||||
|
reason: {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: "1rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
tag: {
|
tag: {
|
||||||
marginLeft: "0.5rem",
|
marginLeft: "0.5rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -26,20 +38,34 @@ const styles = stylex.create({
|
|||||||
* The word is real text, not colour or an icon, so a screen reader announces
|
* The word is real text, not colour or an icon, so a screen reader announces
|
||||||
* the reason the control will not answer.
|
* the reason the control will not answer.
|
||||||
*/
|
*/
|
||||||
export default function ConfigLockIndicator() {
|
/**
|
||||||
|
* Why a configuration control will not answer. Exported because a page that
|
||||||
|
* shows the compact tag has to print this sentence itself, once, somewhere the
|
||||||
|
* tag can point at.
|
||||||
|
*/
|
||||||
|
export function lockReason(authority: Authority): string {
|
||||||
|
if (authority.state === "pending") return "Checking which configuration source this server obeys";
|
||||||
|
if (authority.state === "failed") return "Configuration status unavailable, so edits are held back";
|
||||||
|
return `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfigLockIndicator({ compact = false }: { compact?: boolean }) {
|
||||||
const authority = useAuthority();
|
const authority = useAuthority();
|
||||||
if (authority.state === "resolved" && authority.status.authority === "database") return null;
|
if (authority.state === "resolved" && authority.status.authority === "database") return null;
|
||||||
|
|
||||||
const reason =
|
const reason = lockReason(authority);
|
||||||
authority.state === "pending"
|
|
||||||
? "Checking which configuration source this server obeys"
|
|
||||||
: authority.state === "failed"
|
|
||||||
? "Configuration status unavailable, so edits are held back"
|
|
||||||
: `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
|
|
||||||
|
|
||||||
|
// A compact caller has no room for the sentence and must print it once
|
||||||
|
// nearby instead: the Clients table would otherwise repeat it down every row.
|
||||||
|
if (compact) return <span {...stylex.props(styles.tag)}>Locked</span>;
|
||||||
|
|
||||||
|
// Everywhere else the reason is visible text rather than a `title` or an
|
||||||
|
// `aria-label`. A tooltip reaches neither a keyboard nor a touch screen, and
|
||||||
|
// screen-reader-only text is the same failure pointed the other way.
|
||||||
return (
|
return (
|
||||||
<span title={reason} aria-label={`Locked. ${reason}.`} {...stylex.props(styles.tag)}>
|
<span {...stylex.props(styles.row)}>
|
||||||
Locked
|
<span {...stylex.props(styles.tag)}>Locked</span>
|
||||||
|
<span {...stylex.props(styles.reason)}>{reason}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
|
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
|
||||||
import type { Blocklist } from "@/lib/types";
|
import type { Blocklist } from "@/lib/types";
|
||||||
import { sameSet, toggleSource } from "./sourceSet";
|
import { sameSet } from "./sourceSet";
|
||||||
|
import Checkbox, { CheckboxGroup } from "@/ui/Checkbox";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
@@ -28,13 +29,6 @@ const styles = stylex.create({
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "0.25rem",
|
gap: "0.25rem",
|
||||||
},
|
},
|
||||||
checkboxLabel: {
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.5rem",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
lineHeight: "1.25rem",
|
|
||||||
},
|
|
||||||
buttonRow: {
|
buttonRow: {
|
||||||
marginTop: "0.75rem",
|
marginTop: "0.75rem",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -66,21 +60,22 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...stylex.props(styles.root)}>
|
<div {...stylex.props(styles.root)}>
|
||||||
|
{/* 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)}>
|
<ul {...stylex.props(styles.list)}>
|
||||||
{blocklists.map((blocklist) => (
|
{blocklists.map((blocklist) => (
|
||||||
<li key={blocklist.id}>
|
<li key={blocklist.id}>
|
||||||
<label {...stylex.props(styles.checkboxLabel)}>
|
<Checkbox value={String(blocklist.id)}>{blocklist.name}</Checkbox>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={current.includes(blocklist.id)}
|
|
||||||
onChange={() => setSelected(toggleSource(current, blocklist.id))}
|
|
||||||
{...stylex.props(shared.focusRing)}
|
|
||||||
/>
|
|
||||||
{blocklist.name}
|
|
||||||
</label>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</CheckboxGroup>
|
||||||
<InlineError error={mutation.error} />
|
<InlineError error={mutation.error} />
|
||||||
<div {...stylex.props(styles.buttonRow)}>
|
<div {...stylex.props(styles.buttonRow)}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from
|
|||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import DefinitionList from "@/ui/DefinitionList";
|
import DefinitionList from "@/ui/DefinitionList";
|
||||||
import Select from "@/ui/Select";
|
import Select from "@/ui/Select";
|
||||||
|
import Switch from "@/ui/Switch";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import AuthorityGate from "./AuthorityGate";
|
import AuthorityGate from "./AuthorityGate";
|
||||||
@@ -68,13 +69,6 @@ const styles = stylex.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
},
|
},
|
||||||
checkboxLabel: {
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.5rem",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
lineHeight: "1.25rem",
|
|
||||||
},
|
|
||||||
spacer: {
|
spacer: {
|
||||||
marginLeft: "auto",
|
marginLeft: "auto",
|
||||||
},
|
},
|
||||||
@@ -355,21 +349,15 @@ function GroupDetailEditable({ group }: { group: Group }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div {...stylex.props(styles.controlRow)}>
|
<div {...stylex.props(styles.controlRow)}>
|
||||||
<label {...stylex.props(styles.checkboxLabel)}>
|
<Switch
|
||||||
<input
|
isSelected={group.safe_search}
|
||||||
type="checkbox"
|
isDisabled={update.isPending}
|
||||||
checked={group.safe_search}
|
onChange={(safeSearch) =>
|
||||||
disabled={update.isPending}
|
update.mutate({ id: group.id, input: { name: group.name, safe_search: safeSearch } })
|
||||||
{...stylex.props(shared.focusRing)}
|
|
||||||
onChange={(event) =>
|
|
||||||
update.mutate({
|
|
||||||
id: group.id,
|
|
||||||
input: { name: group.name, safe_search: event.target.checked },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
Safe search
|
Safe search
|
||||||
</label>
|
</Switch>
|
||||||
<span {...stylex.props(styles.spacer)}>
|
<span {...stylex.props(styles.spacer)}>
|
||||||
{!renaming && (
|
{!renaming && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ test("another group can be renamed and deleted, and carries its safe-search stat
|
|||||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||||
|
|
||||||
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false);
|
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false);
|
||||||
expect((screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
|
expect((screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Delete group" }));
|
fireEvent.click(screen.getByRole("button", { name: "Delete group" }));
|
||||||
const dialog = await screen.findByRole("alertdialog");
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
@@ -60,7 +60,7 @@ test("toggling safe search resends the whole group row", async () => {
|
|||||||
await openProtection(2);
|
await openProtection(2);
|
||||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("checkbox", { name: "Safe search" }));
|
fireEvent.click(screen.getByRole("switch", { name: "Safe search" }));
|
||||||
|
|
||||||
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
|
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
|
||||||
expect(writes("PUT")[0]).toMatchObject({
|
expect(writes("PUT")[0]).toMatchObject({
|
||||||
@@ -89,6 +89,31 @@ test("the source assignment saves the full set via PUT", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the source checkboxes form one named group, and each carries its own state", async () => {
|
||||||
|
await openProtection(2);
|
||||||
|
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||||
|
|
||||||
|
// The section heading names the group; the boxes belong to it rather than
|
||||||
|
// sitting loose beside the group's other checkboxes.
|
||||||
|
const group = await screen.findByRole("group", { name: "Assigned sources" });
|
||||||
|
const ads = within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement;
|
||||||
|
expect(ads.checked).toBe(false);
|
||||||
|
// Safe search is the group's own field, not one of its sources, and it is a
|
||||||
|
// switch rather than a checkbox because it applies the moment it moves.
|
||||||
|
expect(within(group).queryByRole("switch")).toBeNull();
|
||||||
|
expect(screen.getByRole("switch", { name: "Safe search" })).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(ads);
|
||||||
|
await waitFor(() => expect(ads.checked).toBe(true));
|
||||||
|
|
||||||
|
// Discard returns the group to the server's set rather than clearing it.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Discard" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(false),
|
||||||
|
);
|
||||||
|
expect(writes("PUT")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("only the selected group's rules are listed", async () => {
|
test("only the selected group's rules are listed", async () => {
|
||||||
await openProtection(2);
|
await openProtection(2);
|
||||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||||
@@ -212,11 +237,33 @@ test("the Sources tab lists the catalogue with both skipped columns and their no
|
|||||||
expect(
|
expect(
|
||||||
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
|
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect((screen.getByLabelText("Ads enabled") as HTMLInputElement).checked).toBe(true);
|
// Switches, not checkboxes: the row applies the moment it moves, and the role
|
||||||
expect((screen.getByLabelText("Trackers enabled") as HTMLInputElement).checked).toBe(false);
|
// is what tells a screen reader so.
|
||||||
|
expect((screen.getByRole("switch", { name: "Ads enabled" }) as HTMLInputElement).checked).toBe(true);
|
||||||
|
expect((screen.getByRole("switch", { name: "Trackers enabled" }) as HTMLInputElement).checked).toBe(false);
|
||||||
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a source switch resends the whole row immediately, with no Save step", async () => {
|
||||||
|
calls = stubApi(DATABASE);
|
||||||
|
await renderPage("/configuration/protection?tab=sources", "Protection");
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("switch", { name: "Trackers enabled" }));
|
||||||
|
|
||||||
|
// No Save button stands between the switch and the write: one click, one PUT.
|
||||||
|
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
|
||||||
|
expect(writes("PUT")[0]).toMatchObject({
|
||||||
|
url: "/api/blocklists/2",
|
||||||
|
// The whole row goes back, not a patch of the one field that moved.
|
||||||
|
body: {
|
||||||
|
url: "https://example.com/trackers.txt",
|
||||||
|
name: "Trackers",
|
||||||
|
enabled: true,
|
||||||
|
is_suggested: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("Update now says it started, and says nothing once it succeeds", async () => {
|
test("Update now says it started, and says nothing once it succeeds", async () => {
|
||||||
let release: ((response: Response) => void) | null = null;
|
let release: ((response: Response) => void) | null = null;
|
||||||
calls = stubApi(DATABASE, {
|
calls = stubApi(DATABASE, {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
|
|||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import Switch from "@/ui/Switch";
|
||||||
import AuthorityGate from "./AuthorityGate";
|
import AuthorityGate from "./AuthorityGate";
|
||||||
import BlocklistForm from "./BlocklistForm";
|
import BlocklistForm from "./BlocklistForm";
|
||||||
import FileModeNote from "./FileModeNote";
|
import FileModeNote from "./FileModeNote";
|
||||||
@@ -249,13 +250,11 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td)}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<input
|
<Switch
|
||||||
type="checkbox"
|
|
||||||
aria-label={`${b.name} enabled`}
|
aria-label={`${b.name} enabled`}
|
||||||
checked={b.enabled}
|
isSelected={b.enabled}
|
||||||
disabled={toggle.isPending}
|
isDisabled={toggle.isPending}
|
||||||
onChange={() => toggleEnabled(b)}
|
onChange={() => toggleEnabled(b)}
|
||||||
{...stylex.props(shared.focusRing)}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
|
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import { styles as config } from "./styles";
|
|||||||
|
|
||||||
const DARK = "@media (prefers-color-scheme: dark)";
|
const DARK = "@media (prefers-color-scheme: dark)";
|
||||||
|
|
||||||
|
/** The form is rendered once per page, so the message can hold a fixed id. */
|
||||||
|
const MISMATCH_ID = "web.password_mismatch";
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
form: {
|
form: {
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
@@ -100,6 +103,7 @@ const styles = stylex.create({
|
|||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
},
|
},
|
||||||
save: {
|
save: {
|
||||||
|
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
paddingInline: "1rem",
|
paddingInline: "1rem",
|
||||||
@@ -174,12 +178,15 @@ function FieldRow({
|
|||||||
}
|
}
|
||||||
if (def.kind === "number") {
|
if (def.kind === "number") {
|
||||||
const numeric = value as number;
|
const numeric = value as number;
|
||||||
|
// An empty or unparseable number reads back as NaN. The form already refuses
|
||||||
|
// to submit on it; this is what says so to a screen reader.
|
||||||
return (
|
return (
|
||||||
<div {...stylex.props(styles.field)}>
|
<div {...stylex.props(styles.field)}>
|
||||||
<FieldLabel id={id} text={def.key} restart={restart} />
|
<FieldLabel id={id} text={def.key} restart={restart} />
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="number"
|
type="number"
|
||||||
|
aria-invalid={Number.isNaN(numeric) || undefined}
|
||||||
value={Number.isNaN(numeric) ? "" : numeric}
|
value={Number.isNaN(numeric) ? "" : numeric}
|
||||||
onChange={(e) => onChange(e.target.valueAsNumber)}
|
onChange={(e) => onChange(e.target.valueAsNumber)}
|
||||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||||
@@ -285,6 +292,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
|||||||
id="web.password"
|
id="web.password"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
aria-invalid={passwordsMismatch || undefined}
|
||||||
|
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||||
@@ -298,6 +307,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
|||||||
id="web.password_confirm"
|
id="web.password_confirm"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
aria-invalid={passwordsMismatch || undefined}
|
||||||
|
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
|
||||||
value={confirm}
|
value={confirm}
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||||
@@ -310,7 +321,7 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{passwordsMismatch && (
|
{passwordsMismatch && (
|
||||||
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
<p id={MISMATCH_ID} {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
||||||
Passwords do not match.
|
Passwords do not match.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -176,12 +176,52 @@ test("enum and boolean fields diff as their own types", async () => {
|
|||||||
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
|
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The text of the elements an input points at with `aria-describedby`. */
|
||||||
|
function describedText(input: HTMLElement): string {
|
||||||
|
const ids = input.getAttribute("aria-describedby");
|
||||||
|
if (ids === null) throw new Error("input has no aria-describedby");
|
||||||
|
return ids
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((id) => {
|
||||||
|
const node = document.getElementById(id);
|
||||||
|
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||||
|
return node.textContent ?? "";
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
test("clearing a number field disables Save instead of sending NaN", async () => {
|
test("clearing a number field disables Save instead of sending NaN", async () => {
|
||||||
await openSystem();
|
await openSystem();
|
||||||
|
|
||||||
const cache = screen.getByRole("group", { name: "Cache" });
|
const cache = screen.getByRole("group", { name: "Cache" });
|
||||||
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
|
const size = within(cache).getByLabelText("size");
|
||||||
|
fireEvent.change(size, { target: { value: "" } });
|
||||||
expect(saveButton().disabled).toBe(true);
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
// The refusal is on the field itself, not only on the Save button.
|
||||||
|
expect(size.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
|
||||||
|
fireEvent.change(size, { target: { value: "512" } });
|
||||||
|
expect(size.getAttribute("aria-invalid")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the mismatch message is attached to both password inputs", async () => {
|
||||||
|
await openSystem();
|
||||||
|
|
||||||
|
const web = screen.getByRole("group", { name: "Web" });
|
||||||
|
const passwordInput = within(web).getByLabelText("password");
|
||||||
|
const confirmInput = within(web).getByLabelText("confirm password");
|
||||||
|
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
|
||||||
|
for (const input of [passwordInput, confirmInput]) {
|
||||||
|
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
expect(describedText(input)).toBe("Passwords do not match.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
|
||||||
|
for (const input of [passwordInput, confirmInput]) {
|
||||||
|
expect(input.getAttribute("aria-invalid")).toBeNull();
|
||||||
|
expect(input.getAttribute("aria-describedby")).toBeNull();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {
|
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Upstream, UpstreamInput } from "@/lib/types";
|
|||||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
import Switch from "@/ui/Switch";
|
||||||
import AuthorityGate from "./AuthorityGate";
|
import AuthorityGate from "./AuthorityGate";
|
||||||
import FileModeNote from "./FileModeNote";
|
import FileModeNote from "./FileModeNote";
|
||||||
import QueryPanel from "./QueryPanel";
|
import QueryPanel from "./QueryPanel";
|
||||||
@@ -165,13 +166,11 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
|
|||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
|
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
|
||||||
<td {...stylex.props(shared.td)}>
|
<td {...stylex.props(shared.td)}>
|
||||||
<input
|
<Switch
|
||||||
type="checkbox"
|
|
||||||
aria-label={`${u.url} enabled`}
|
aria-label={`${u.url} enabled`}
|
||||||
checked={u.enabled}
|
isSelected={u.enabled}
|
||||||
disabled={toggle.isPending}
|
isDisabled={toggle.isPending}
|
||||||
onChange={() => toggleEnabled(u)}
|
onChange={() => toggleEnabled(u)}
|
||||||
{...stylex.props(shared.focusRing)}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ afterEach(() => {
|
|||||||
function mutationControls(): Element[] {
|
function mutationControls(): Element[] {
|
||||||
return [
|
return [
|
||||||
...contentArea().querySelectorAll(
|
...contentArea().querySelectorAll(
|
||||||
'input, textarea, select, [role="combobox"], [role="checkbox"], [contenteditable]',
|
'input, textarea, select, [role="combobox"], [role="checkbox"], [role="switch"], [contenteditable]',
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,4 @@
|
|||||||
import { sameSet, toggleSource } from "./sourceSet";
|
import { sameSet } from "./sourceSet";
|
||||||
|
|
||||||
test("toggleSource adds a missing id keeping ascending order", () => {
|
|
||||||
expect(toggleSource([1, 3], 2)).toEqual([1, 2, 3]);
|
|
||||||
expect(toggleSource([], 5)).toEqual([5]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("toggleSource removes a present id", () => {
|
|
||||||
expect(toggleSource([1, 2, 3], 2)).toEqual([1, 3]);
|
|
||||||
expect(toggleSource([5], 5)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("toggleSource twice is a no-op set-wise", () => {
|
|
||||||
expect(toggleSource(toggleSource([1, 2], 3), 3)).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sameSet compares regardless of order", () => {
|
test("sameSet compares regardless of order", () => {
|
||||||
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
|
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
export function toggleSource(ids: number[], id: number): number[] {
|
|
||||||
if (ids.includes(id)) return ids.filter((existing) => existing !== id);
|
|
||||||
return [...ids, id].sort((a, b) => a - b);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sameSet(a: number[], b: number[]): boolean {
|
export function sameSet(a: number[], b: number[]): boolean {
|
||||||
if (a.length !== b.length) return false;
|
if (a.length !== b.length) return false;
|
||||||
const sortedA = [...a].sort((x, y) => x - y);
|
const sortedA = [...a].sort((x, y) => x - y);
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ test("every code renders its own title, impact and remediation", async () => {
|
|||||||
test("an event retention has removed shows the server's message, not an empty page", async () => {
|
test("an event retention has removed shows the server's message, not an empty page", async () => {
|
||||||
renderDetail(999);
|
renderDetail(999);
|
||||||
await screen.findByText("no such event");
|
await screen.findByText("no such event");
|
||||||
expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy();
|
expect(screen.getByRole("link", { name: "All diagnostics" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an unavailable store reports the failure instead of loading forever", async () => {
|
test("an unavailable store reports the failure instead of loading forever", async () => {
|
||||||
@@ -219,5 +219,5 @@ test("an unavailable store reports the failure instead of loading forever", asyn
|
|||||||
const alert = await screen.findByRole("alert");
|
const alert = await screen.findByRole("alert");
|
||||||
expect(alert.textContent).toContain("The server is starting or degraded.");
|
expect(alert.textContent).toContain("The server is starting or degraded.");
|
||||||
expect(screen.queryByText("Loading event…")).toBeNull();
|
expect(screen.queryByText("Loading event…")).toBeNull();
|
||||||
expect(screen.getByRole("link", { name: "← All diagnostics" })).toBeTruthy();
|
expect(screen.getByRole("link", { name: "All diagnostics" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { ArrowLeft } from "@phosphor-icons/react/dist/icons/ArrowLeft";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
@@ -13,11 +14,17 @@ import { componentLabel, copyFor } from "./eventCopy";
|
|||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
back: {
|
back: {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.primaryOnSurface,
|
color: colors.primaryOnSurface,
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
},
|
},
|
||||||
|
backIcon: {
|
||||||
|
display: "inline-flex",
|
||||||
|
},
|
||||||
headingRow: {
|
headingRow: {
|
||||||
marginTop: "0.5rem",
|
marginTop: "0.5rem",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -105,6 +112,17 @@ const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function BackLink() {
|
||||||
|
return (
|
||||||
|
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
|
||||||
|
<span aria-hidden="true" {...stylex.props(styles.backIcon)}>
|
||||||
|
<ArrowLeft size={12} />
|
||||||
|
</span>
|
||||||
|
All diagnostics
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function DiagnosticDetailPage() {
|
export default function DiagnosticDetailPage() {
|
||||||
const { id } = useParams({ from: "/shell/diagnostics/$id" });
|
const { id } = useParams({ from: "/shell/diagnostics/$id" });
|
||||||
const eventId = Number(id);
|
const eventId = Number(id);
|
||||||
@@ -132,9 +150,7 @@ export default function DiagnosticDetailPage() {
|
|||||||
if (data === undefined) {
|
if (data === undefined) {
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
|
<BackLink />
|
||||||
← All diagnostics
|
|
||||||
</Link>
|
|
||||||
<InlineError error={error} onRetry={() => void refetch()} />
|
<InlineError error={error} onRetry={() => void refetch()} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -146,9 +162,7 @@ export default function DiagnosticDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
|
<BackLink />
|
||||||
← All diagnostics
|
|
||||||
</Link>
|
|
||||||
<div {...stylex.props(styles.headingRow)}>
|
<div {...stylex.props(styles.headingRow)}>
|
||||||
<h1 {...stylex.props(styles.heading)}>{copy.title}</h1>
|
<h1 {...stylex.props(styles.heading)}>{copy.title}</h1>
|
||||||
<SeverityBadge severity={data.severity} />
|
<SeverityBadge severity={data.severity} />
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
* a claim about the current state, until a poll succeeds again.
|
* a claim about the current state, until a poll succeeds again.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Circle } from "@phosphor-icons/react/dist/icons/Circle";
|
||||||
|
import { Pause } from "@phosphor-icons/react/dist/icons/Pause";
|
||||||
|
import { Warning } from "@phosphor-icons/react/dist/icons/Warning";
|
||||||
|
import { X } from "@phosphor-icons/react/dist/icons/X";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
@@ -95,6 +100,9 @@ const styles = stylex.create({
|
|||||||
danger: {
|
danger: {
|
||||||
color: colors.dangerText,
|
color: colors.dangerText,
|
||||||
},
|
},
|
||||||
|
icon: {
|
||||||
|
display: "inline-flex",
|
||||||
|
},
|
||||||
message: {
|
message: {
|
||||||
marginTop: "0.5rem",
|
marginTop: "0.5rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
@@ -106,7 +114,12 @@ const styles = stylex.create({
|
|||||||
const TONES = { ok: styles.ok, notice: styles.notice, warn: styles.warn, danger: styles.danger } as const;
|
const TONES = { ok: styles.ok, notice: styles.notice, warn: styles.warn, danger: styles.danger } as const;
|
||||||
|
|
||||||
/** Text and icon carry the state; the colour only agrees with them. */
|
/** Text and icon carry the state; the colour only agrees with them. */
|
||||||
const ICONS: Record<FactTone, string> = { ok: "●", notice: "‖", warn: "!", danger: "✕" };
|
const ICONS: Record<FactTone, ReactNode> = {
|
||||||
|
ok: <Circle size={12} weight="fill" />,
|
||||||
|
notice: <Pause size={12} />,
|
||||||
|
warn: <Warning size={12} />,
|
||||||
|
danger: <X size={12} />,
|
||||||
|
};
|
||||||
|
|
||||||
function FactLinkAnchor({ link }: { link: FactLink }) {
|
function FactLinkAnchor({ link }: { link: FactLink }) {
|
||||||
if (link.kind === "filter") {
|
if (link.kind === "filter") {
|
||||||
@@ -151,7 +164,7 @@ function FactLinkAnchor({ link }: { link: FactLink }) {
|
|||||||
function Fact({ fact }: { fact: HealthFact }) {
|
function Fact({ fact }: { fact: HealthFact }) {
|
||||||
return (
|
return (
|
||||||
<li {...stylex.props(styles.fact, fact.tone === "ok" ? styles.quiet : styles.highlighted)}>
|
<li {...stylex.props(styles.fact, fact.tone === "ok" ? styles.quiet : styles.highlighted)}>
|
||||||
<span aria-hidden="true" {...stylex.props(TONES[fact.tone])}>
|
<span aria-hidden="true" {...stylex.props(styles.icon, TONES[fact.tone])}>
|
||||||
{ICONS[fact.tone]}
|
{ICONS[fact.tone]}
|
||||||
</span>
|
</span>
|
||||||
<span {...stylex.props(styles.label)}>{fact.label}</span>
|
<span {...stylex.props(styles.label)}>{fact.label}</span>
|
||||||
|
|||||||
@@ -69,8 +69,6 @@ const styles = stylex.create({
|
|||||||
interface Series {
|
interface Series {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
/** Kept beside the label so a renamed client is still identifiable by address. */
|
|
||||||
address: string | null;
|
|
||||||
color: string;
|
color: string;
|
||||||
buckets: number[];
|
buckets: number[];
|
||||||
}
|
}
|
||||||
@@ -89,15 +87,11 @@ function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
|
|||||||
// the same precedence and the same lookup the query tables use. The colour
|
// the same precedence and the same lookup the query tables use. The colour
|
||||||
// keys on the address regardless, so naming a client never repaints it.
|
// keys on the address regardless, so naming a client never repaints it.
|
||||||
label: clientLabel(client.client, names)?.text ?? client.client,
|
label: clientLabel(client.client, names)?.text ?? client.client,
|
||||||
address: client.client,
|
|
||||||
color: seriesColor(clientKey(client.client)),
|
color: seriesColor(clientKey(client.client)),
|
||||||
buckets: client.buckets,
|
buckets: client.buckets,
|
||||||
}));
|
}));
|
||||||
if (data.other.every((count) => count === 0)) return named;
|
if (data.other.every((count) => count === 0)) return named;
|
||||||
return [
|
return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }];
|
||||||
...named,
|
|
||||||
{ key: OTHER_KEY, label: "Other", address: null, color: seriesColor(OTHER_KEY), buckets: data.other },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,7 +213,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
|||||||
)}
|
)}
|
||||||
<ul {...stylex.props(styles.legend)}>
|
<ul {...stylex.props(styles.legend)}>
|
||||||
{series.map((one) => (
|
{series.map((one) => (
|
||||||
<li key={one.key} title={one.address ?? undefined} {...stylex.props(styles.legendItem)}>
|
<li key={one.key} {...stylex.props(styles.legendItem)}>
|
||||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
|
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
|
||||||
{one.label}
|
{one.label}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
|
import { Radio, RadioGroup } from "react-aria-components";
|
||||||
import type { Period } from "@/lib/types";
|
import type { Period } from "@/lib/types";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
@@ -38,6 +39,7 @@ const styles = stylex.create({
|
|||||||
gap: "0.25rem",
|
gap: "0.25rem",
|
||||||
},
|
},
|
||||||
period: {
|
period: {
|
||||||
|
cursor: "pointer",
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
paddingInline: "0.625rem",
|
paddingInline: "0.625rem",
|
||||||
@@ -45,6 +47,13 @@ const styles = stylex.create({
|
|||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
},
|
},
|
||||||
|
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
|
||||||
|
periodFocusVisible: {
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineStyle: "solid",
|
||||||
|
outlineColor: colors.focus,
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
|
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
|
||||||
periodSelected: {
|
periodSelected: {
|
||||||
backgroundColor: {
|
backgroundColor: {
|
||||||
@@ -67,23 +76,29 @@ const styles = stylex.create({
|
|||||||
|
|
||||||
export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
|
export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
|
||||||
return (
|
return (
|
||||||
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
|
<RadioGroup
|
||||||
|
aria-label="Period"
|
||||||
|
orientation="horizontal"
|
||||||
|
value={period}
|
||||||
|
onChange={(next) => onChange(next as Period)}
|
||||||
|
className={() => stylex.props(styles.periodGroup).className ?? ""}
|
||||||
|
>
|
||||||
{PERIODS.map((option) => (
|
{PERIODS.map((option) => (
|
||||||
<button
|
<Radio
|
||||||
key={option}
|
key={option}
|
||||||
type="button"
|
value={option}
|
||||||
aria-pressed={option === period}
|
className={({ isSelected, isFocusVisible }) =>
|
||||||
onClick={() => onChange(option)}
|
stylex.props(
|
||||||
{...stylex.props(
|
|
||||||
styles.period,
|
styles.period,
|
||||||
option === period ? styles.periodSelected : styles.periodIdle,
|
isSelected ? styles.periodSelected : styles.periodIdle,
|
||||||
shared.focusRing,
|
isFocusVisible && styles.periodFocusVisible,
|
||||||
)}
|
).className ?? ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
</button>
|
</Radio>
|
||||||
))}
|
))}
|
||||||
</div>
|
</RadioGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ test("a request in flight leaves the heading and the picker usable behind one lo
|
|||||||
renderApp();
|
renderApp();
|
||||||
|
|
||||||
await screen.findByRole("heading", { name: "Overview", level: 1 });
|
await screen.findByRole("heading", { name: "Overview", level: 1 });
|
||||||
expect(screen.getByRole("button", { name: "1h" })).toBeTruthy();
|
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
|
||||||
// One loading state for the whole page, not one per panel.
|
// One loading state for the whole page, not one per panel.
|
||||||
const loading = await screen.findByText("Loading…");
|
const loading = await screen.findByText("Loading…");
|
||||||
expect(loading.getAttribute("role")).toBe("status");
|
expect(loading.getAttribute("role")).toBe("status");
|
||||||
@@ -214,6 +214,16 @@ test("a registered client is named in the chart, an unregistered one keeps its a
|
|||||||
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
|
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
|
||||||
expect(within(chart).queryByText("192.0.2.30")).toBeNull();
|
expect(within(chart).queryByText("192.0.2.30")).toBeNull();
|
||||||
expect(within(chart).getAllByText("192.0.2.31")).toHaveLength(2);
|
expect(within(chart).getAllByText("192.0.2.31")).toHaveLength(2);
|
||||||
|
|
||||||
|
// Nor on hover: a pointer-only tooltip would say what the design just chose
|
||||||
|
// not to, and only to a reader holding a mouse.
|
||||||
|
const legendItem = within(chart)
|
||||||
|
.getAllByText("kitchen-pi")
|
||||||
|
.map((node) => node.closest("li"))
|
||||||
|
.find((node) => node !== null);
|
||||||
|
expect(legendItem).toBeTruthy();
|
||||||
|
expect(legendItem?.getAttribute("title")).toBeNull();
|
||||||
|
expect(within(chart).getByRole("list").querySelectorAll("[title]")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a client named only by reverse DNS is named by it too", async () => {
|
test("a client named only by reverse DNS is named by it too", async () => {
|
||||||
@@ -335,14 +345,23 @@ test("an empty window says so in every panel instead of drawing nothing", async
|
|||||||
test("a deep link opens on the period it names", async () => {
|
test("a deep link opens on the period it names", async () => {
|
||||||
renderApp("/overview?period=1h");
|
renderApp("/overview?period=1h");
|
||||||
await screen.findByText("12");
|
await screen.findByText("12");
|
||||||
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
|
// One radio group named Period, holding the four periods and exactly one
|
||||||
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
|
// selection: the segmented picker is a single choice, not four toggles.
|
||||||
|
const picker = within(screen.getByRole("radiogroup", { name: "Period" }));
|
||||||
|
expect(picker.getAllByRole("radio").map((radio) => radio.getAttribute("value"))).toEqual([
|
||||||
|
"1h",
|
||||||
|
"24h",
|
||||||
|
"7d",
|
||||||
|
"30d",
|
||||||
|
]);
|
||||||
|
expect(picker.getByRole("radio", { name: "1h", checked: true })).toBeTruthy();
|
||||||
|
expect(picker.getByRole("radio", { name: "24h", checked: false })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a period the API does not have falls back to the default without carrying it in the url", async () => {
|
test("a period the API does not have falls back to the default without carrying it in the url", async () => {
|
||||||
const router = renderApp("/overview?period=90d");
|
const router = renderApp("/overview?period=90d");
|
||||||
await screen.findByText("1,000");
|
await screen.findByText("1,000");
|
||||||
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("true");
|
expect(screen.getByRole("radio", { name: "24h", checked: true })).toBeTruthy();
|
||||||
expect(router.state.location.search).toEqual({});
|
expect(router.state.location.search).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,7 +369,7 @@ test("the picker rescopes every panel and writes the period into the url", async
|
|||||||
const router = renderApp();
|
const router = renderApp();
|
||||||
await screen.findByText("1,000");
|
await screen.findByText("1,000");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "1h" }));
|
fireEvent.click(screen.getByRole("radio", { name: "1h" }));
|
||||||
|
|
||||||
await screen.findByText("12");
|
await screen.findByText("12");
|
||||||
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
|
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
|
||||||
@@ -369,7 +388,7 @@ test("a failed request is one error for the whole page, stated once and retryabl
|
|||||||
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
|
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
|
||||||
// The heading and the picker survive it, so the reader can rescope or retry.
|
// The heading and the picker survive it, so the reader can rescope or retry.
|
||||||
expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy();
|
||||||
expect(screen.getByRole("button", { name: "1h" })).toBeTruthy();
|
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
|
||||||
expect(screen.queryByText("Something went wrong")).toBeNull();
|
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||||
|
|
||||||
failing = false;
|
failing = false;
|
||||||
|
|||||||
@@ -91,11 +91,14 @@ test("unpaused: duration menu pauses with the picked duration_seconds", async ()
|
|||||||
|
|
||||||
fireEvent.click(trigger);
|
fireEvent.click(trigger);
|
||||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||||
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
|
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
|
||||||
expect(screen.getByRole("button", { name: label })).toBeTruthy();
|
"60 seconds",
|
||||||
}
|
"5 minutes",
|
||||||
|
"30 minutes",
|
||||||
|
"Indefinitely",
|
||||||
|
]);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
|
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
|
||||||
|
|
||||||
await screen.findByRole("button", { name: "Resume" });
|
await screen.findByRole("button", { name: "Resume" });
|
||||||
@@ -105,7 +108,7 @@ test("indefinite pause sends no duration_seconds", async () => {
|
|||||||
renderControl();
|
renderControl();
|
||||||
|
|
||||||
fireEvent.click(await findPauseTrigger());
|
fireEvent.click(await findPauseTrigger());
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
|
fireEvent.click(screen.getByRole("menuitem", { name: "Indefinitely" }));
|
||||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
|
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
|
||||||
|
|
||||||
await screen.findByRole("button", { name: "Resume" });
|
await screen.findByRole("button", { name: "Resume" });
|
||||||
@@ -151,11 +154,15 @@ test("an active resolver states nothing: the button already says Pause", async (
|
|||||||
test("escape closes the duration menu", async () => {
|
test("escape closes the duration menu", async () => {
|
||||||
renderControl();
|
renderControl();
|
||||||
|
|
||||||
const trigger = await findPauseTrigger();
|
fireEvent.click(await findPauseTrigger());
|
||||||
fireEvent.click(trigger);
|
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
||||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
|
||||||
fireEvent.keyDown(trigger, { key: "Escape" });
|
// Opening the menu moves focus into it, so Escape is pressed where the reader
|
||||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
// is, not on the trigger they left.
|
||||||
|
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull());
|
||||||
|
expect(screen.getByRole("button", { name: "Pause" }).getAttribute("aria-expanded")).toBe("false");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("failed pause with 429 shows a ticking retry countdown", async () => {
|
test("failed pause with 429 shows a ticking retry countdown", async () => {
|
||||||
@@ -168,7 +175,7 @@ test("failed pause with 429 shows a ticking retry countdown", async () => {
|
|||||||
renderControl();
|
renderControl();
|
||||||
|
|
||||||
fireEvent.click(await findPauseTrigger());
|
fireEvent.click(await findPauseTrigger());
|
||||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||||
|
|
||||||
const alert = await screen.findByRole("alert");
|
const alert = await screen.findByRole("alert");
|
||||||
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
|
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
|
||||||
@@ -187,7 +194,7 @@ test("failed pause with 503 shows the degraded message", async () => {
|
|||||||
renderControl();
|
renderControl();
|
||||||
|
|
||||||
fireEvent.click(await findPauseTrigger());
|
fireEvent.click(await findPauseTrigger());
|
||||||
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
|
fireEvent.click(screen.getByRole("menuitem", { name: "60 seconds" }));
|
||||||
|
|
||||||
const alert = await screen.findByRole("alert");
|
const alert = await screen.findByRole("alert");
|
||||||
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
|
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
|
||||||
@@ -202,12 +209,12 @@ test("a successful pause clears the previous mutation error", async () => {
|
|||||||
renderControl();
|
renderControl();
|
||||||
|
|
||||||
fireEvent.click(await findPauseTrigger());
|
fireEvent.click(await findPauseTrigger());
|
||||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||||
await screen.findByRole("alert");
|
await screen.findByRole("alert");
|
||||||
|
|
||||||
postFailure = null;
|
postFailure = null;
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
|
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||||
|
|
||||||
await screen.findByRole("button", { name: "Resume" });
|
await screen.findByRole("button", { name: "Resume" });
|
||||||
expect(screen.queryByRole("alert")).toBeNull();
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
@@ -240,7 +247,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
|
|||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
renderControl();
|
renderControl();
|
||||||
fireEvent.click(await findPauseTrigger());
|
fireEvent.click(await findPauseTrigger());
|
||||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
||||||
|
|
||||||
protection = { state: "unavailable", until: null };
|
protection = { state: "unavailable", until: null };
|
||||||
await vi.advanceTimersByTimeAsync(11_000);
|
await vi.advanceTimersByTimeAsync(11_000);
|
||||||
@@ -253,7 +260,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
|
|||||||
// reader opened, and nobody opened this one.
|
// reader opened, and nobody opened this one.
|
||||||
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
||||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,7 +268,7 @@ test("a menu open when someone else pauses does not reopen when that pause ends"
|
|||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
renderControl();
|
renderControl();
|
||||||
fireEvent.click(await findPauseTrigger());
|
fireEvent.click(await findPauseTrigger());
|
||||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
||||||
|
|
||||||
// Filtering is paused from somewhere else, and this browser learns it from
|
// Filtering is paused from somewhere else, and this browser learns it from
|
||||||
// the poll. The Resume rendering has no menu.
|
// the poll. The Resume rendering has no menu.
|
||||||
@@ -274,7 +281,7 @@ test("a menu open when someone else pauses does not reopen when that pause ends"
|
|||||||
|
|
||||||
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
||||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* The Pause/Resume control, in the two places a pause is a valid answer to what
|
* The Pause/Resume control, at the foot of the sidebar. A pause stops filtering
|
||||||
* the reader is looking at: the foot of the sidebar, where it belongs to the
|
* for the whole resolver, so it belongs to the shell rather than to any page,
|
||||||
* resolver rather than to any page, and beside the detail of a query that was
|
* and it has no second placement.
|
||||||
* blocked.
|
|
||||||
*
|
*
|
||||||
* It reads `Health.protection` rather than `/api/pause` so it cannot contradict
|
* It reads `Health.protection` rather than `/api/pause` so it cannot contradict
|
||||||
* the Diagnostics health strip, and it renders nothing at all while protection
|
* the Diagnostics health strip, and it renders nothing at all while protection
|
||||||
@@ -20,9 +19,10 @@
|
|||||||
* line: the button says Pause, which is the whole message.
|
* line: the button says Pause, which is the whole message.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
|
||||||
import { formatClock } from "@/lib/format";
|
import { formatClock } from "@/lib/format";
|
||||||
import { pauseMutation } from "@/lib/queries";
|
import { pauseMutation } from "@/lib/queries";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
@@ -48,6 +48,8 @@ const styles = stylex.create({
|
|||||||
":disabled": "oklch(70.5% 0.015 286.067)",
|
":disabled": "oklch(70.5% 0.015 286.067)",
|
||||||
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
|
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
|
||||||
},
|
},
|
||||||
|
/** The sidebar foot is the only placement, and there Log out sets the width. */
|
||||||
|
width: "100%",
|
||||||
},
|
},
|
||||||
row: {
|
row: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -61,43 +63,44 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1rem",
|
lineHeight: "1rem",
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
},
|
},
|
||||||
anchor: {
|
/** `--trigger-width` is RAC's: the menu is as wide as the button that opened it. */
|
||||||
position: "relative",
|
popover: {
|
||||||
},
|
width: "var(--trigger-width)",
|
||||||
menu: {
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: "100%",
|
|
||||||
zIndex: 10,
|
|
||||||
marginTop: "0.25rem",
|
|
||||||
display: "flex",
|
|
||||||
width: "9rem",
|
|
||||||
flexDirection: "column",
|
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
backgroundColor: colors.surfaceRaised,
|
backgroundColor: colors.surfaceRaised,
|
||||||
paddingBlock: "0.25rem",
|
color: colors.text,
|
||||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||||
},
|
},
|
||||||
|
menu: {
|
||||||
|
outlineStyle: "none",
|
||||||
|
paddingBlock: "0.25rem",
|
||||||
|
},
|
||||||
menuItem: {
|
menuItem: {
|
||||||
borderStyle: "none",
|
cursor: "pointer",
|
||||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
|
||||||
color: "inherit",
|
|
||||||
paddingInline: "0.75rem",
|
paddingInline: "0.75rem",
|
||||||
paddingBlock: "0.375rem",
|
paddingBlock: "0.375rem",
|
||||||
textAlign: "left",
|
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* RAC focuses the item's own node, so the shared ring applies; it is inset
|
||||||
|
* because an item flush against the popover edge clips an outset one, and
|
||||||
|
* recoloured because the focus token is the blue this row just painted.
|
||||||
|
*/
|
||||||
|
menuItemFocused: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
color: colors.primaryText,
|
||||||
|
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function PauseControl() {
|
export default function PauseControl() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const protection = useProtection();
|
const protection = useProtection();
|
||||||
const mutation = useMutation(pauseMutation(queryClient));
|
const mutation = useMutation(pauseMutation(queryClient));
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
const paused = protection.state === "paused";
|
const paused = protection.state === "paused";
|
||||||
const { reset } = mutation;
|
const { reset } = mutation;
|
||||||
useEffect(() => reset(), [paused, reset]);
|
useEffect(() => reset(), [paused, reset]);
|
||||||
@@ -108,15 +111,6 @@ export default function PauseControl() {
|
|||||||
// the wrong action, which is the contradiction this control exists to end.
|
// the wrong action, which is the contradiction this control exists to end.
|
||||||
const actionable = protection.state === "active" || protection.state === "paused";
|
const actionable = protection.state === "active" || protection.state === "paused";
|
||||||
|
|
||||||
// Leaving `active` unmounts the menu but not the state that opened it, and
|
|
||||||
// the menu belongs to the active rendering alone — a pause someone else
|
|
||||||
// started, seen through the poll, takes it away exactly as a withdrawal does.
|
|
||||||
// Closing on the way out rather than on the way back means the trigger can
|
|
||||||
// only ever come back shut, however long it was gone.
|
|
||||||
useEffect(() => {
|
|
||||||
if (protection.state !== "active") setMenuOpen(false);
|
|
||||||
}, [protection.state]);
|
|
||||||
|
|
||||||
if (!actionable) return null;
|
if (!actionable) return null;
|
||||||
|
|
||||||
if (paused) {
|
if (paused) {
|
||||||
@@ -140,41 +134,40 @@ export default function PauseControl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div {...stylex.props(styles.row)}>
|
||||||
{...stylex.props(styles.anchor)}
|
<MenuTrigger>
|
||||||
onKeyDown={(e) => {
|
<Button
|
||||||
if (e.key === "Escape") setMenuOpen(false);
|
isDisabled={mutation.isPending}
|
||||||
}}
|
className={() => stylex.props(shared.button, styles.trigger, shared.focusRing).className ?? ""}
|
||||||
>
|
|
||||||
<button
|
|
||||||
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
|
Pause
|
||||||
</button>
|
</Button>
|
||||||
{menuOpen && (
|
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||||
<div id="pause-menu" {...stylex.props(styles.menu)}>
|
<Menu {...stylex.props(styles.menu)}>
|
||||||
{DURATIONS.map(({ label, seconds }) => (
|
{DURATIONS.map(({ label, seconds }) => (
|
||||||
<button
|
<MenuItem
|
||||||
key={label}
|
key={label}
|
||||||
type="button"
|
onAction={() =>
|
||||||
onClick={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
mutation.mutate(
|
mutation.mutate(
|
||||||
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
seconds === null
|
||||||
);
|
? { paused: true }
|
||||||
}}
|
: { paused: true, duration_seconds: seconds },
|
||||||
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
|
)
|
||||||
|
}
|
||||||
|
className={({ isFocused }) =>
|
||||||
|
stylex.props(
|
||||||
|
styles.menuItem,
|
||||||
|
shared.insetFocusRing,
|
||||||
|
isFocused && styles.menuItemFocused,
|
||||||
|
).className ?? ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Menu>
|
||||||
)}
|
</Popover>
|
||||||
|
</MenuTrigger>
|
||||||
<InlineError error={mutation.error} />
|
<InlineError error={mutation.error} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
},
|
},
|
||||||
|
/** The same sentence with no box, for a results footer that already has one. */
|
||||||
|
note: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,11 +33,21 @@ const styles = stylex.create({
|
|||||||
* watermark and nothing more: the same incompleteness covers a log retention
|
* watermark and nothing more: the same incompleteness covers a log retention
|
||||||
* has pruned and one that simply has not been running long enough, and the
|
* has pruned and one that simply has not been running long enough, and the
|
||||||
* response does not say which.
|
* response does not say which.
|
||||||
|
*
|
||||||
|
* `note` is the same sentence without the panel, for a place that already sits
|
||||||
|
* under the data it qualifies — a results footer states the reach of the count
|
||||||
|
* beside it, where a full-width banner would announce a limit as news.
|
||||||
*/
|
*/
|
||||||
export default function CoverageNotice({ coverage }: { coverage: Coverage }) {
|
export default function CoverageNotice({
|
||||||
|
coverage,
|
||||||
|
variant = "banner",
|
||||||
|
}: {
|
||||||
|
coverage: Coverage;
|
||||||
|
variant?: "banner" | "note";
|
||||||
|
}) {
|
||||||
if (coverage.complete) return null;
|
if (coverage.complete) return null;
|
||||||
return (
|
return (
|
||||||
<p role="status" {...stylex.props(styles.notice)}>
|
<p role="status" {...stylex.props(variant === "note" ? styles.note : styles.notice)}>
|
||||||
Query history is available from {formatTime(coverage.available_since)}.
|
Query history is available from {formatTime(coverage.available_since)}.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const styles = stylex.create({
|
|||||||
color: colors.danger,
|
color: colors.danger,
|
||||||
},
|
},
|
||||||
retry: {
|
retry: {
|
||||||
|
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
|||||||
@@ -214,12 +214,45 @@ test("mount probe reveals the logout button and a failed logout surfaces inline"
|
|||||||
|
|
||||||
renderShell();
|
renderShell();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
|
// Both renderings are mounted; the viewport paints the sidebar one on WIDE
|
||||||
|
// and the header one below it.
|
||||||
|
await waitFor(() => expect(screen.getAllByRole("button", { name: "Log out" })).toHaveLength(2));
|
||||||
|
const aside = document.querySelector("aside") as HTMLElement;
|
||||||
|
fireEvent.click(within(aside).getByRole("button", { name: "Log out" }));
|
||||||
|
|
||||||
await screen.findByText("Rate limited. Try again in 7s.");
|
await within(aside).findByText("Rate limited. Try again in 7s.");
|
||||||
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Log out sits in the sidebar on wide, and only in the header below it", async () => {
|
||||||
|
stubFetch((url) =>
|
||||||
|
url === "/api/auth/login"
|
||||||
|
? new Response(JSON.stringify({ error: "password required" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
renderShell();
|
||||||
|
await screen.findByRole("heading", { name: "Overview" });
|
||||||
|
|
||||||
|
const aside = document.querySelector("aside") as HTMLElement;
|
||||||
|
const header = document.querySelector("header") as HTMLElement;
|
||||||
|
await waitFor(() => expect(within(aside).getByRole("button", { name: "Log out" })).toBeTruthy());
|
||||||
|
expect(within(header).getByRole("button", { name: "Log out" })).toBeTruthy();
|
||||||
|
// The header is the narrow rendering now: the Menu button is its only nav.
|
||||||
|
expect(within(header).getByRole("button", { name: "Menu" })).toBeTruthy();
|
||||||
|
|
||||||
|
// In the sidebar the control precedes the version label rather than crowding it.
|
||||||
|
const version = within(aside).getByText(/^nxdns v/);
|
||||||
|
const logout = within(aside).getByRole("button", { name: "Log out" });
|
||||||
|
expect(logout.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
// The drawer keeps no third copy: the header already carries the narrow one.
|
||||||
|
fireEvent.click(within(header).getByRole("button", { name: "Menu" }));
|
||||||
|
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||||
|
expect(within(drawer).queryByRole("button", { name: "Log out" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("the header carries no protection display at all any more", async () => {
|
test("the header carries no protection display at all any more", async () => {
|
||||||
renderShell();
|
renderShell();
|
||||||
await screen.findByRole("heading", { name: "Overview" });
|
await screen.findByRole("heading", { name: "Overview" });
|
||||||
@@ -257,6 +290,40 @@ test("the mobile drawer carries the same control, not a header one it lost", asy
|
|||||||
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the Menu button is a disclosure trigger, and it names the panel it opens", async () => {
|
||||||
|
renderShell();
|
||||||
|
await screen.findByRole("heading", { name: "Overview" });
|
||||||
|
const menu = screen.getByRole("button", { name: "Menu" });
|
||||||
|
|
||||||
|
// The trigger states the drawer's state, and points at the drawer itself.
|
||||||
|
expect(menu.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(menu.getAttribute("aria-controls")).toBe("mobile-nav");
|
||||||
|
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||||
|
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(menu);
|
||||||
|
expect(menu.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
expect(drawer.getAttribute("hidden")).toBeNull();
|
||||||
|
expect(within(drawer).getByRole("navigation", { name: "Main" })).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(menu);
|
||||||
|
expect(menu.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("following a drawer link closes the drawer behind it", async () => {
|
||||||
|
renderShell();
|
||||||
|
await screen.findByRole("heading", { name: "Overview" });
|
||||||
|
const menu = screen.getByRole("button", { name: "Menu" });
|
||||||
|
fireEvent.click(menu);
|
||||||
|
|
||||||
|
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||||
|
fireEvent.click(within(drawer).getByRole("link", { name: "Clients" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(menu.getAttribute("aria-expanded")).toBe("false"));
|
||||||
|
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("a paused resolver says so in both renderings, not only on Diagnostics", async () => {
|
test("a paused resolver says so in both renderings, not only on Diagnostics", async () => {
|
||||||
// The trace a pause leaves on every page. With the header indicator and the
|
// The trace a pause leaves on every page. With the header indicator and the
|
||||||
// Overview status row both gone, a reader who is not on Diagnostics has only
|
// Overview status row both gone, a reader who is not on Diagnostics has only
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useId, useState } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
|
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { Button, Disclosure, DisclosurePanel } from "react-aria-components";
|
||||||
import { useAuth } from "@/auth/store";
|
import { useAuth } from "@/auth/store";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import { healthQuery, versionQuery } from "@/lib/queries";
|
import { healthQuery, versionQuery } from "@/lib/queries";
|
||||||
@@ -110,6 +111,17 @@ const styles = stylex.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* In a 14rem column the button and its error stack, so the button spans the
|
||||||
|
* sidebar and matches the Pause trigger below it.
|
||||||
|
*/
|
||||||
|
sidebarLogout: {
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "stretch",
|
||||||
|
gap: "0.25rem",
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingTop: "0.75rem",
|
||||||
|
},
|
||||||
shell: {
|
shell: {
|
||||||
minHeight: "100dvh",
|
minHeight: "100dvh",
|
||||||
height: { default: null, [WIDE]: "100dvh" },
|
height: { default: null, [WIDE]: "100dvh" },
|
||||||
@@ -147,8 +159,9 @@ const styles = stylex.create({
|
|||||||
minWidth: { default: null, [WIDE]: 0 },
|
minWidth: { default: null, [WIDE]: 0 },
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
},
|
},
|
||||||
|
/** Narrow only: on WIDE the sidebar carries everything this row held. */
|
||||||
header: {
|
header: {
|
||||||
display: "flex",
|
display: { default: "flex", [WIDE]: "none" },
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.75rem",
|
gap: "0.75rem",
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
@@ -284,13 +297,13 @@ function VersionFooter() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LogoutButton() {
|
function LogoutButton({ style }: { style?: stylex.StyleXStyles }) {
|
||||||
const { authRequired, logout } = useAuth();
|
const { authRequired, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
if (authRequired !== true) return null;
|
if (authRequired !== true) return null;
|
||||||
return (
|
return (
|
||||||
<div {...stylex.props(styles.logoutRow)}>
|
<div {...stylex.props(styles.logoutRow, style)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -318,37 +331,45 @@ export default function AppShell() {
|
|||||||
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
|
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
|
||||||
<NavLinks />
|
<NavLinks />
|
||||||
</nav>
|
</nav>
|
||||||
|
<LogoutButton style={styles.sidebarLogout} />
|
||||||
<SidebarFooter />
|
<SidebarFooter />
|
||||||
</aside>
|
</aside>
|
||||||
<div {...stylex.props(styles.column)}>
|
{/* The column itself is the disclosure: the trigger sits in the header
|
||||||
|
and the panel opens below the notices, so any wrapper around only
|
||||||
|
the two would have to cut across the column's own flex children. */}
|
||||||
|
<Disclosure isExpanded={drawerOpen} onExpandedChange={setDrawerOpen} {...stylex.props(styles.column)}>
|
||||||
<header {...stylex.props(styles.header)}>
|
<header {...stylex.props(styles.header)}>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
slot="trigger"
|
||||||
aria-expanded={drawerOpen}
|
className={() =>
|
||||||
aria-controls="mobile-nav"
|
stylex.props(shared.button, styles.narrowOnly, shared.focusRing).className ?? ""
|
||||||
onClick={() => setDrawerOpen((open) => !open)}
|
}
|
||||||
{...stylex.props(shared.button, styles.narrowOnly, shared.focusRing)}
|
|
||||||
>
|
>
|
||||||
Menu
|
Menu
|
||||||
</button>
|
</Button>
|
||||||
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
|
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
|
||||||
<div {...stylex.props(styles.headerRight)}>
|
<div {...stylex.props(styles.headerRight)}>
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<ConfigStatusNotices />
|
<ConfigStatusNotices />
|
||||||
|
<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 && (
|
{drawerOpen && (
|
||||||
<div id="mobile-nav" {...stylex.props(styles.drawer)}>
|
<>
|
||||||
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
|
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
|
||||||
<NavLinks onNavigate={() => setDrawerOpen(false)} />
|
<NavLinks onNavigate={() => setDrawerOpen(false)} />
|
||||||
</nav>
|
</nav>
|
||||||
<SidebarFooter />
|
<SidebarFooter />
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</DisclosurePanel>
|
||||||
<main id="main-content" data-scroll-restoration-id="main" {...stylex.props(styles.main)}>
|
<main id="main-content" data-scroll-restoration-id="main" {...stylex.props(styles.main)}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</Disclosure>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import Checkbox, { CheckboxGroup } from "./Checkbox";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The drawn box, which is the label's one child that does not hold the hidden
|
||||||
|
* input. StyleX compiles to class names and jsdom loads no stylesheet, so the
|
||||||
|
* class list is the only place the composed ring is observable.
|
||||||
|
*/
|
||||||
|
function indicator(input: HTMLElement): HTMLElement {
|
||||||
|
const label = input.closest("label") as HTMLElement;
|
||||||
|
const spans = [...label.querySelectorAll("span")];
|
||||||
|
const box = spans.find((span) => !span.contains(input));
|
||||||
|
if (box === undefined) throw new Error("the checkbox drew no indicator");
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a standalone checkbox reports its state and reads its label as its name", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<Checkbox isSelected onChange={onChange}>
|
||||||
|
Safe search
|
||||||
|
</Checkbox>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const box = screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement;
|
||||||
|
expect(box.checked).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.click(box);
|
||||||
|
// The caller owns the state, so the box reports the value it would move to
|
||||||
|
// rather than moving there itself.
|
||||||
|
expect(onChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a disabled checkbox keeps its state on screen but takes no input", () => {
|
||||||
|
render(
|
||||||
|
<Checkbox isSelected isDisabled onChange={vi.fn()}>
|
||||||
|
Safe search
|
||||||
|
</Checkbox>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const box = screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement;
|
||||||
|
expect(box.checked).toBe(true);
|
||||||
|
// The disabled input is the whole guard: a browser fires no click on one, and
|
||||||
|
// it is out of the tab order. Clicking it here would prove nothing either way,
|
||||||
|
// because `fireEvent` dispatches straight at the node and skips that check.
|
||||||
|
expect(box.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keyboard focus composes the ring onto the drawn box", () => {
|
||||||
|
render(
|
||||||
|
<Checkbox isSelected={false} onChange={vi.fn()}>
|
||||||
|
Ads
|
||||||
|
</Checkbox>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByRole("checkbox", { name: "Ads" });
|
||||||
|
const box = indicator(input);
|
||||||
|
const idle = box.className.split(" ");
|
||||||
|
expect(input.closest("label")?.getAttribute("data-focus-visible")).toBeNull();
|
||||||
|
|
||||||
|
// React Aria only calls focus visible after the modality is keyboard, which
|
||||||
|
// is why a bare focus() is not enough to raise the ring.
|
||||||
|
act(() => {
|
||||||
|
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(input.closest("label")?.getAttribute("data-focus-visible")).toBe("true");
|
||||||
|
const ringed = box.className.split(" ");
|
||||||
|
// The box gained classes it did not have: the ring is composed onto it, not
|
||||||
|
// merely reported by React Aria on the root.
|
||||||
|
expect(ringed.length).toBeGreaterThan(idle.length);
|
||||||
|
expect(idle.every((name) => ringed.includes(name))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inside a group the group owns the selection, and the group carries the name", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<CheckboxGroup aria-label="Assigned sources" value={["1"]} onChange={onChange}>
|
||||||
|
<Checkbox value="1">Ads</Checkbox>
|
||||||
|
<Checkbox value="2">Trackers</Checkbox>
|
||||||
|
</CheckboxGroup>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const group = screen.getByRole("group", { name: "Assigned sources" });
|
||||||
|
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(true);
|
||||||
|
expect((within(group).getByRole("checkbox", { name: "Trackers" }) as HTMLInputElement).checked).toBe(false);
|
||||||
|
|
||||||
|
// The group reports the whole set, not the box that moved.
|
||||||
|
fireEvent.click(within(group).getByRole("checkbox", { name: "Trackers" }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith(["1", "2"]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Check } from "@phosphor-icons/react/dist/icons/Check";
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Bold: the regular stroke thins to near-invisible at this size. */}
|
||||||
|
{renderProps.isSelected && <Check aria-hidden="true" size={10} weight="bold" />}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaCheckbox>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
* and a stray outside click is not one. Escape still cancels.
|
* and a stray outside click is not one. Escape still cancels.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
||||||
import { colors } from "./tokens.stylex";
|
import { colors } from "./tokens.stylex";
|
||||||
@@ -19,6 +20,14 @@ interface Props {
|
|||||||
/** The full sentence the operator reads before confirming; names the entity. */
|
/** The full sentence the operator reads before confirming; names the entity. */
|
||||||
message: string;
|
message: string;
|
||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
|
/**
|
||||||
|
* Stands in for the confirm action when the write can no longer land, the way
|
||||||
|
* an open edit dialog drops its Save. Closing the dialog instead would throw
|
||||||
|
* focus at a control that is now disabled and say nothing about why, so the
|
||||||
|
* question stays on screen and only the answer that would fail is withdrawn.
|
||||||
|
* Cancel is always live.
|
||||||
|
*/
|
||||||
|
lock?: ReactNode;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
@@ -67,6 +76,7 @@ const styles = stylex.create({
|
|||||||
marginTop: "1.5rem",
|
marginTop: "1.5rem",
|
||||||
},
|
},
|
||||||
dangerButton: {
|
dangerButton: {
|
||||||
|
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
backgroundColor: colors.danger,
|
backgroundColor: colors.danger,
|
||||||
@@ -79,7 +89,7 @@ const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, onConfirm, onCancel }: Props) {
|
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, lock, onConfirm, onCancel }: Props) {
|
||||||
return (
|
return (
|
||||||
<ModalOverlay
|
<ModalOverlay
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
@@ -98,6 +108,7 @@ export default function ConfirmDialog({ isOpen, title, message, confirmLabel, on
|
|||||||
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
|
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
{lock === undefined ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
@@ -105,6 +116,9 @@ export default function ConfirmDialog({ isOpen, title, message, confirmLabel, on
|
|||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
lock
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AriaDialog>
|
</AriaDialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
+83
-10
@@ -4,18 +4,25 @@
|
|||||||
* React Aria owns the focus trap, the Escape handler and the `aria-modal`
|
* React Aria owns the focus trap, the Escape handler and the `aria-modal`
|
||||||
* wiring that the hand-rolled overlay only approximated. State is controlled by
|
* wiring that the hand-rolled overlay only approximated. State is controlled by
|
||||||
* the caller because the trigger is a table row button, not a `DialogTrigger`.
|
* the caller because the trigger is a table row button, not a `DialogTrigger`.
|
||||||
|
*
|
||||||
|
* The title is both the visible heading and the accessible name: `Heading
|
||||||
|
* slot="title"` is what React Aria points `aria-labelledby` at, so a caller
|
||||||
|
* cannot name the dialog one thing and show another.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { Dialog as AriaDialog, Modal, ModalOverlay } from "react-aria-components";
|
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
||||||
import { colors } from "./tokens.stylex";
|
import { colors } from "./tokens.stylex";
|
||||||
|
import { styles as shared } from "./styles";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** The dialog's accessible name. */
|
/** The dialog's heading, and with it the dialog's accessible name. */
|
||||||
label: string;
|
title: string;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** `detail` is the wider panel a record needs; `form` fits a column of fields. */
|
||||||
|
size?: "form" | "detail";
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,25 +37,74 @@ const styles = stylex.create({
|
|||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* A column so the header stays put and the body scrolls under it. The height
|
||||||
|
* cap is what keeps a long record inside the viewport instead of running off
|
||||||
|
* the bottom of a short one.
|
||||||
|
*/
|
||||||
panel: {
|
panel: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: "28rem",
|
maxHeight: "85vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
backgroundColor: colors.surfaceRaised,
|
backgroundColor: colors.surfaceRaised,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
padding: "1.5rem",
|
|
||||||
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||||
},
|
},
|
||||||
/** The panel already draws the boundary; the dialog's own ring would double it. */
|
panelForm: {
|
||||||
|
maxWidth: "28rem",
|
||||||
|
},
|
||||||
|
panelDetail: {
|
||||||
|
maxWidth: "52rem",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The panel already draws the boundary; the dialog's own ring would double
|
||||||
|
* it. `minHeight: 0` is what lets the body shrink far enough to scroll.
|
||||||
|
*/
|
||||||
body: {
|
body: {
|
||||||
outlineStyle: "none",
|
outlineStyle: "none",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "1rem",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
paddingInline: "1.5rem",
|
||||||
|
paddingBlock: "0.75rem",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
/** 44px on both axes: the pointer-target floor, which the word alone misses. */
|
||||||
|
close: {
|
||||||
|
minWidth: 44,
|
||||||
|
minHeight: 44,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
paddingInline: "1.5rem",
|
||||||
|
paddingBlock: "1.5rem",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Dialog({ label, isOpen, onClose, children }: Props) {
|
export default function Dialog({ title, isOpen, onClose, size = "form", children }: Props) {
|
||||||
return (
|
return (
|
||||||
<ModalOverlay
|
<ModalOverlay
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
@@ -58,9 +114,26 @@ export default function Dialog({ label, isOpen, onClose, children }: Props) {
|
|||||||
isDismissable
|
isDismissable
|
||||||
className={() => stylex.props(styles.overlay).className ?? ""}
|
className={() => stylex.props(styles.overlay).className ?? ""}
|
||||||
>
|
>
|
||||||
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
|
<Modal
|
||||||
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
|
className={() =>
|
||||||
{children}
|
stylex.props(styles.panel, size === "detail" ? styles.panelDetail : styles.panelForm).className ??
|
||||||
|
""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AriaDialog {...stylex.props(styles.body)}>
|
||||||
|
<div {...stylex.props(styles.header)}>
|
||||||
|
<Heading slot="title" level={2} {...stylex.props(styles.title)}>
|
||||||
|
{title}
|
||||||
|
</Heading>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
{...stylex.props(shared.button, styles.close, shared.focusRing)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div {...stylex.props(styles.content)}>{children}</div>
|
||||||
</AriaDialog>
|
</AriaDialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
</ModalOverlay>
|
</ModalOverlay>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* models an id converts on both edges.
|
* models an id converts on both edges.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { CaretDown } from "@phosphor-icons/react/dist/icons/CaretDown";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -83,6 +84,7 @@ const styles = stylex.create({
|
|||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
},
|
},
|
||||||
chevron: {
|
chevron: {
|
||||||
|
display: "inline-flex",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
@@ -155,7 +157,7 @@ export default function Select({
|
|||||||
<Button className={() => stylex.props(base, block, styles.trigger, shared.focusRing).className ?? ""}>
|
<Button className={() => stylex.props(base, block, styles.trigger, shared.focusRing).className ?? ""}>
|
||||||
<SelectValue className={() => stylex.props(styles.value).className ?? ""} />
|
<SelectValue className={() => stylex.props(styles.value).className ?? ""} />
|
||||||
<span aria-hidden="true" {...stylex.props(styles.chevron)}>
|
<span aria-hidden="true" {...stylex.props(styles.chevron)}>
|
||||||
▾
|
<CaretDown size={12} />
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
{description !== undefined && (
|
{description !== undefined && (
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import Switch from "./Switch";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The drawn track, which is the label's one span that does not hold the hidden
|
||||||
|
* input. StyleX compiles to class names and jsdom loads no stylesheet, so the
|
||||||
|
* class list is the only place the composed state is observable.
|
||||||
|
*/
|
||||||
|
function track(input: HTMLElement): HTMLElement {
|
||||||
|
const label = input.closest("label") as HTMLElement;
|
||||||
|
const spans = [...label.querySelectorAll("span")];
|
||||||
|
const drawn = spans.find((span) => !span.contains(input));
|
||||||
|
if (drawn === undefined) throw new Error("the switch drew no track");
|
||||||
|
return drawn;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a switch reports the switch role, not a checkbox one", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<Switch isSelected onChange={onChange}>
|
||||||
|
Safe search
|
||||||
|
</Switch>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The role is the whole point: it tells a screen reader the setting moves now
|
||||||
|
// rather than on some later Save.
|
||||||
|
const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
|
||||||
|
expect(control.checked).toBe(true);
|
||||||
|
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(control);
|
||||||
|
// The caller owns the state, so the switch reports the value it would move to.
|
||||||
|
expect(onChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bare switch takes its name from aria-label", () => {
|
||||||
|
render(<Switch aria-label="udp://1.1.1.1:53 enabled" isSelected={false} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const control = screen.getByRole("switch", { name: "udp://1.1.1.1:53 enabled" }) as HTMLInputElement;
|
||||||
|
expect(control.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a disabled switch keeps its state on screen but takes no input", () => {
|
||||||
|
render(
|
||||||
|
<Switch isSelected isDisabled onChange={vi.fn()}>
|
||||||
|
Safe search
|
||||||
|
</Switch>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
|
||||||
|
expect(control.checked).toBe(true);
|
||||||
|
// The disabled input is the whole guard: a browser fires no click on one, and
|
||||||
|
// it is out of the tab order. Clicking it here would prove nothing either way,
|
||||||
|
// because `fireEvent` dispatches straight at the node and skips that check.
|
||||||
|
expect(control.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the track is drawn from the selected state, not left to the browser", () => {
|
||||||
|
const { rerender } = render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const control = screen.getByRole("switch");
|
||||||
|
const off = track(control).className.split(" ");
|
||||||
|
|
||||||
|
rerender(<Switch aria-label="Safe search" isSelected onChange={vi.fn()} />);
|
||||||
|
const on = track(control).className.split(" ");
|
||||||
|
|
||||||
|
// The two states compose to different class sets, so the thumb and the fill
|
||||||
|
// actually move rather than the input alone changing.
|
||||||
|
expect(on).not.toEqual(off);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keyboard focus composes the ring onto the drawn track", () => {
|
||||||
|
render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const control = screen.getByRole("switch");
|
||||||
|
const drawn = track(control);
|
||||||
|
const idle = drawn.className.split(" ");
|
||||||
|
expect(control.closest("label")?.getAttribute("data-focus-visible")).toBeNull();
|
||||||
|
|
||||||
|
// React Aria only calls focus visible after the modality is keyboard, which
|
||||||
|
// is why a bare focus() is not enough to raise the ring.
|
||||||
|
act(() => {
|
||||||
|
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||||
|
control.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(control.closest("label")?.getAttribute("data-focus-visible")).toBe("true");
|
||||||
|
const ringed = drawn.className.split(" ");
|
||||||
|
expect(ringed.length).toBeGreaterThan(idle.length);
|
||||||
|
expect(idle.every((name) => ringed.includes(name))).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* The on/off switch, wrapping React Aria's.
|
||||||
|
*
|
||||||
|
* A switch, not a checkbox: every call site flips a setting that takes effect
|
||||||
|
* the moment it moves, with no Save between. A checkbox states what a form will
|
||||||
|
* submit, which is a promise these controls do not make.
|
||||||
|
*
|
||||||
|
* React Aria hides the real input and leaves the track to the call site, so the
|
||||||
|
* track and thumb below are what the reader sees. The caller always holds the
|
||||||
|
* state — none of these controls owns the value it shows, because the server's
|
||||||
|
* answer is the value.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { Switch as AriaSwitch } from "react-aria-components";
|
||||||
|
import { colors } from "./tokens.stylex";
|
||||||
|
|
||||||
|
interface Common {
|
||||||
|
isSelected: boolean;
|
||||||
|
onChange: (isSelected: boolean) => void;
|
||||||
|
/** Visible but inert; React Aria also drops it from the tab order. */
|
||||||
|
isDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** With visible words beside the track, which name it. */
|
||||||
|
interface Labelled extends Common {
|
||||||
|
children: ReactNode;
|
||||||
|
"aria-label"?: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bare, in a table cell whose column heading cannot name a single row. */
|
||||||
|
interface Named extends Common {
|
||||||
|
children?: never;
|
||||||
|
"aria-label": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A switch has to be named, and exactly one of the two ways: visible words that
|
||||||
|
* `aria-label` would then override and hide from the reader who can see them,
|
||||||
|
* or no words and a label only the screen reader gets.
|
||||||
|
*/
|
||||||
|
type Props = Labelled | Named;
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
/**
|
||||||
|
* The whole label is the hit area, so it carries the 44px pointer-target
|
||||||
|
* floor on both axes, the same floor `Checkbox` and the dialog's Close
|
||||||
|
* button already set. The track itself is far under it.
|
||||||
|
*/
|
||||||
|
label: {
|
||||||
|
minWidth: 44,
|
||||||
|
minHeight: 44,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
/** Bare switches sit in a table cell, where the row sets the rhythm. */
|
||||||
|
track: {
|
||||||
|
flexShrink: 0,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
width: 28,
|
||||||
|
height: 16,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 2,
|
||||||
|
backgroundColor: colors.borderStrong,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The thumb moves by the box's own alignment rather than by a transform, so
|
||||||
|
* there is no transition to withhold from a reader who asked for less motion.
|
||||||
|
*/
|
||||||
|
trackSelected: {
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
/** As on `Checkbox`: the ring follows the state React Aria reports, because
|
||||||
|
* `:focus-visible` lands on the hidden input rather than on this track. */
|
||||||
|
trackFocused: {
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineStyle: "solid",
|
||||||
|
outlineColor: colors.focus,
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
/** The track dims, not the words: the label still has to be readable. */
|
||||||
|
trackDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
thumb: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: colors.surfaceRaised,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Switch({ children, ...state }: Props) {
|
||||||
|
return (
|
||||||
|
<AriaSwitch {...state} className={() => stylex.props(styles.label).className ?? ""}>
|
||||||
|
{(renderProps) => (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
{...stylex.props(
|
||||||
|
styles.track,
|
||||||
|
renderProps.isSelected && styles.trackSelected,
|
||||||
|
renderProps.isFocusVisible && styles.trackFocused,
|
||||||
|
renderProps.isDisabled && styles.trackDisabled,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span {...stylex.props(styles.thumb)} />
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AriaSwitch>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ export const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
button: {
|
button: {
|
||||||
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
@@ -70,6 +71,7 @@ export const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
},
|
},
|
||||||
smallButton: {
|
smallButton: {
|
||||||
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
@@ -80,6 +82,7 @@ export const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
},
|
},
|
||||||
largeButton: {
|
largeButton: {
|
||||||
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
@@ -90,6 +93,7 @@ export const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
primaryButton: {
|
primaryButton: {
|
||||||
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
@@ -102,6 +106,7 @@ export const styles = stylex.create({
|
|||||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||||
},
|
},
|
||||||
largePrimaryButton: {
|
largePrimaryButton: {
|
||||||
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
@@ -113,6 +118,7 @@ export const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
rowButton: {
|
rowButton: {
|
||||||
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
@@ -123,6 +129,7 @@ export const styles = stylex.create({
|
|||||||
color: colors.primaryOnSurface,
|
color: colors.primaryOnSurface,
|
||||||
},
|
},
|
||||||
linkButton: {
|
linkButton: {
|
||||||
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
@@ -132,6 +139,7 @@ export const styles = stylex.create({
|
|||||||
color: colors.primaryOnSurface,
|
color: colors.primaryOnSurface,
|
||||||
},
|
},
|
||||||
dangerLinkButton: {
|
dangerLinkButton: {
|
||||||
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
@@ -142,6 +150,7 @@ export const styles = stylex.create({
|
|||||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||||
},
|
},
|
||||||
retryButton: {
|
retryButton: {
|
||||||
|
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||||
marginTop: "0.75rem",
|
marginTop: "0.75rem",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
.{
|
.{
|
||||||
.name = .nxdns,
|
.name = .nxdns,
|
||||||
.version = "0.0.14",
|
.version = "0.0.15",
|
||||||
.minimum_zig_version = "0.16.0",
|
.minimum_zig_version = "0.16.0",
|
||||||
.paths = .{""},
|
.paths = .{""},
|
||||||
.fingerprint = 0x3307b311dded1d91,
|
.fingerprint = 0x3307b311dded1d91,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8A
|
|||||||
@internationalized/date 3.12.3 Apache-2.0
|
@internationalized/date 3.12.3 Apache-2.0
|
||||||
@internationalized/number 3.6.7 Apache-2.0
|
@internationalized/number 3.6.7 Apache-2.0
|
||||||
@internationalized/string 3.2.10 Apache-2.0
|
@internationalized/string 3.2.10 Apache-2.0
|
||||||
|
@phosphor-icons/react 2.1.10 MIT
|
||||||
@react-types/shared 3.36.1 Apache-2.0
|
@react-types/shared 3.36.1 Apache-2.0
|
||||||
@stylexjs/stylex 0.19.0 MIT
|
@stylexjs/stylex 0.19.0 MIT
|
||||||
@swc/helpers 0.5.23 Apache-2.0
|
@swc/helpers 0.5.23 Apache-2.0
|
||||||
@@ -129,6 +130,7 @@ alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695
|
|||||||
|
|
||||||
[npm packages bundled into admin/dist]
|
[npm packages bundled into admin/dist]
|
||||||
@internationalized/string
|
@internationalized/string
|
||||||
|
@phosphor-icons/react
|
||||||
@stylexjs/stylex
|
@stylexjs/stylex
|
||||||
@tanstack/history
|
@tanstack/history
|
||||||
@tanstack/query-core
|
@tanstack/query-core
|
||||||
|
|||||||
@@ -150,6 +150,12 @@
|
|||||||
.note = "The dialog, alert dialog, tab and select behaviour of the admin UI, bundled into the JavaScript embedded in the binary. The first Apache-2.0 npm dependency this project has taken, and the first non-MIT one: Mokhtar Mial accepted Apache-2.0 inbound for nxdns on 2026-08-12, which is the decision that let these four ship. Apache-2.0 Section 4 attribution is satisfied by carrying the licence text in THIRD-PARTY-NOTICES, which the file below does; none of the four ships a NOTICE file, so 4(d) adds nothing. All four carry a byte-identical LICENSE. In the tarballs and in the image.",
|
.note = "The dialog, alert dialog, tab and select behaviour of the admin UI, bundled into the JavaScript embedded in the binary. The first Apache-2.0 npm dependency this project has taken, and the first non-MIT one: Mokhtar Mial accepted Apache-2.0 inbound for nxdns on 2026-08-12, which is the decision that let these four ship. Apache-2.0 Section 4 attribution is satisfied by carrying the licence text in THIRD-PARTY-NOTICES, which the file below does; none of the four ships a NOTICE file, so 4(d) adds nothing. All four carry a byte-identical LICENSE. In the tarballs and in the image.",
|
||||||
.file = "react-aria-apache-2.0.txt",
|
.file = "react-aria-apache-2.0.txt",
|
||||||
},
|
},
|
||||||
|
.{
|
||||||
|
.component = "Phosphor Icons",
|
||||||
|
.version = "@phosphor-icons/react 2.1.10",
|
||||||
|
.note = "The icon set of the admin UI — carets, ticks, crosses, the search magnifier, status marks and back arrows — bundled into the JavaScript embedded in the binary. Tree-shaken: only the imported icon components ship. The npm tarball carries the LICENSE this text is copied from.",
|
||||||
|
.file = "phosphor-mit.txt",
|
||||||
|
},
|
||||||
.{
|
.{
|
||||||
.component = "clsx",
|
.component = "clsx",
|
||||||
.version = "clsx 2.1.1",
|
.version = "clsx 2.1.1",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pub const texts: []const Text = &.{
|
|||||||
.{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-mit.txt") },
|
.{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-mit.txt") },
|
||||||
.{ .name = "tanstack-store-mit.txt", .body = @embedFile("tanstack-store-mit.txt") },
|
.{ .name = "tanstack-store-mit.txt", .body = @embedFile("tanstack-store-mit.txt") },
|
||||||
.{ .name = "react-aria-apache-2.0.txt", .body = @embedFile("react-aria-apache-2.0.txt") },
|
.{ .name = "react-aria-apache-2.0.txt", .body = @embedFile("react-aria-apache-2.0.txt") },
|
||||||
|
.{ .name = "phosphor-mit.txt", .body = @embedFile("phosphor-mit.txt") },
|
||||||
.{ .name = "clsx-mit.txt", .body = @embedFile("clsx-mit.txt") },
|
.{ .name = "clsx-mit.txt", .body = @embedFile("clsx-mit.txt") },
|
||||||
.{ .name = "stylex-mit.txt", .body = @embedFile("stylex-mit.txt") },
|
.{ .name = "stylex-mit.txt", .body = @embedFile("stylex-mit.txt") },
|
||||||
.{ .name = "styleq-mit.txt", .body = @embedFile("styleq-mit.txt") },
|
.{ .name = "styleq-mit.txt", .body = @embedFile("styleq-mit.txt") },
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2020 Phosphor Icons
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -674,7 +674,10 @@ pub const QueryFilter = struct {
|
|||||||
/// Matched case-insensitively for ASCII, which is what SQLite's `LIKE`
|
/// Matched case-insensitively for ASCII, which is what SQLite's `LIKE`
|
||||||
/// does and what a domain search wants.
|
/// does and what a domain search wants.
|
||||||
domain_substring: ?[]const u8 = null,
|
domain_substring: ?[]const u8 = null,
|
||||||
client: ?[]const u8 = null,
|
/// Exact client addresses, matched any-of. Empty means no client filter;
|
||||||
|
/// one address is the common case and reads the same as the old single
|
||||||
|
/// filter did. Never more than `max_clients`.
|
||||||
|
clients: []const []const u8 = &.{},
|
||||||
blocked: ?bool = null,
|
blocked: ?bool = null,
|
||||||
since: ?i64 = null,
|
since: ?i64 = null,
|
||||||
until: ?i64 = null,
|
until: ?i64 = null,
|
||||||
@@ -684,6 +687,12 @@ pub const QueryFilter = struct {
|
|||||||
/// that forgets cannot ask this connection for the whole table.
|
/// that forgets cannot ask this connection for the whole table.
|
||||||
pub const max_limit: u32 = 1000;
|
pub const max_limit: u32 = 1000;
|
||||||
|
|
||||||
|
/// How many addresses one client filter may name. The statement is assembled
|
||||||
|
/// into a fixed buffer, so this is a hard bound rather than a preference: a
|
||||||
|
/// household picking more than this from a list is not a case worth widening
|
||||||
|
/// the buffer for.
|
||||||
|
pub const max_clients: usize = 32;
|
||||||
|
|
||||||
const select_head =
|
const select_head =
|
||||||
\\SELECT q.id, q.timestamp, d.domain, q.client_ip, q.qtype, q.blocked,
|
\\SELECT q.id, q.timestamp, d.domain, q.client_ip, q.qtype, q.blocked,
|
||||||
\\ q.response_time_us, q.cache_hit, q.upstream, q.qclass, q.rcode,
|
\\ q.response_time_us, q.cache_hit, q.upstream, q.qclass, q.rcode,
|
||||||
@@ -697,7 +706,10 @@ const like_escape = '\\';
|
|||||||
|
|
||||||
const where_before = " q.id < ?";
|
const where_before = " q.id < ?";
|
||||||
const where_domain = " d.domain LIKE ? ESCAPE '\\'";
|
const where_domain = " d.domain LIKE ? ESCAPE '\\'";
|
||||||
const where_client = " q.client_ip = ?";
|
const where_client_head = " q.client_ip IN (";
|
||||||
|
const where_client_tail = ")";
|
||||||
|
/// `?` per address with a comma between, at the widest the cap allows.
|
||||||
|
const where_client_max = where_client_head.len + 2 * max_clients + where_client_tail.len;
|
||||||
const where_blocked = " q.blocked = ?";
|
const where_blocked = " q.blocked = ?";
|
||||||
const where_since = " q.timestamp >= ?";
|
const where_since = " q.timestamp >= ?";
|
||||||
const where_until = " q.timestamp < ?";
|
const where_until = " q.timestamp < ?";
|
||||||
@@ -716,7 +728,7 @@ const Sql = struct {
|
|||||||
/// `where_keyword` is longer than `and_keyword` and is used at most once,
|
/// `where_keyword` is longer than `and_keyword` and is used at most once,
|
||||||
/// so counting six of it bounds every reachable combination.
|
/// so counting six of it bounds every reachable combination.
|
||||||
const capacity = select_head.len + 6 * where_keyword.len + select_tail.len +
|
const capacity = select_head.len + 6 * where_keyword.len + select_tail.len +
|
||||||
where_before.len + where_domain.len + where_client.len +
|
where_before.len + where_domain.len + where_client_max +
|
||||||
where_blocked.len + where_since.len + where_until.len;
|
where_blocked.len + where_since.len + where_until.len;
|
||||||
|
|
||||||
buf: [capacity]u8 = undefined,
|
buf: [capacity]u8 = undefined,
|
||||||
@@ -734,6 +746,19 @@ const Sql = struct {
|
|||||||
self.put(fragment);
|
self.put(fragment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One placeholder per address. The addresses themselves are bound, like
|
||||||
|
/// every other value; only their count reaches this buffer.
|
||||||
|
fn clientPredicate(self: *Sql, count: usize) void {
|
||||||
|
self.put(if (self.has_where) and_keyword else where_keyword);
|
||||||
|
self.has_where = true;
|
||||||
|
self.put(where_client_head);
|
||||||
|
for (0..count) |i| {
|
||||||
|
if (i > 0) self.put(",");
|
||||||
|
self.put("?");
|
||||||
|
}
|
||||||
|
self.put(where_client_tail);
|
||||||
|
}
|
||||||
|
|
||||||
fn text(self: *const Sql) []const u8 {
|
fn text(self: *const Sql) []const u8 {
|
||||||
return self.buf[0..self.len];
|
return self.buf[0..self.len];
|
||||||
}
|
}
|
||||||
@@ -743,11 +768,16 @@ const Sql = struct {
|
|||||||
/// `arena`, including the list's own storage, so the caller frees the whole
|
/// `arena`, including the list's own storage, so the caller frees the whole
|
||||||
/// result by resetting the arena — there is nothing to unwind on failure.
|
/// result by resetting the arena — there is nothing to unwind on failure.
|
||||||
pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db.Error!std.ArrayList(QueryRow) {
|
pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db.Error!std.ArrayList(QueryRow) {
|
||||||
|
// The statement buffer is sized for the cap, so a longer list would be a
|
||||||
|
// buffer overrun rather than a slow query. The handler rejects it first;
|
||||||
|
// this is the wall behind that, for a caller that skips the handler.
|
||||||
|
if (filter.clients.len > max_clients) return error.Misuse;
|
||||||
|
|
||||||
var sql: Sql = .{};
|
var sql: Sql = .{};
|
||||||
sql.put(select_head);
|
sql.put(select_head);
|
||||||
if (filter.before != null) sql.predicate(where_before);
|
if (filter.before != null) sql.predicate(where_before);
|
||||||
if (filter.domain_substring != null) sql.predicate(where_domain);
|
if (filter.domain_substring != null) sql.predicate(where_domain);
|
||||||
if (filter.client != null) sql.predicate(where_client);
|
if (filter.clients.len > 0) sql.clientPredicate(filter.clients.len);
|
||||||
if (filter.blocked != null) sql.predicate(where_blocked);
|
if (filter.blocked != null) sql.predicate(where_blocked);
|
||||||
if (filter.since != null) sql.predicate(where_since);
|
if (filter.since != null) sql.predicate(where_since);
|
||||||
if (filter.until != null) sql.predicate(where_until);
|
if (filter.until != null) sql.predicate(where_until);
|
||||||
@@ -765,9 +795,9 @@ pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db
|
|||||||
idx += 1;
|
idx += 1;
|
||||||
try stmt.bindText(idx, try likePattern(arena, v));
|
try stmt.bindText(idx, try likePattern(arena, v));
|
||||||
}
|
}
|
||||||
if (filter.client) |v| {
|
for (filter.clients) |client| {
|
||||||
idx += 1;
|
idx += 1;
|
||||||
try stmt.bindText(idx, v);
|
try stmt.bindText(idx, client);
|
||||||
}
|
}
|
||||||
if (filter.blocked) |v| {
|
if (filter.blocked) |v| {
|
||||||
idx += 1;
|
idx += 1;
|
||||||
@@ -2031,11 +2061,11 @@ test "each filter narrows the result on its own" {
|
|||||||
const by_domain = try selectQueries(&database, arena, .{ .domain_substring = "example.com" });
|
const by_domain = try selectQueries(&database, arena, .{ .domain_substring = "example.com" });
|
||||||
try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(by_domain.items, &buf));
|
try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(by_domain.items, &buf));
|
||||||
|
|
||||||
const by_client = try selectQueries(&database, arena, .{ .client = "192.0.2.20" });
|
const by_client = try selectQueries(&database, arena, .{ .clients = &.{"192.0.2.20"} });
|
||||||
try testing.expectEqualSlices(i64, &.{2}, ids(by_client.items, &buf));
|
try testing.expectEqualSlices(i64, &.{2}, ids(by_client.items, &buf));
|
||||||
|
|
||||||
// An exact match, not a prefix: the seeded clients share the first octets.
|
// An exact match, not a prefix: the seeded clients share the first octets.
|
||||||
const no_client = try selectQueries(&database, arena, .{ .client = "192.0.2" });
|
const no_client = try selectQueries(&database, arena, .{ .clients = &.{"192.0.2"} });
|
||||||
try testing.expectEqual(@as(usize, 0), no_client.items.len);
|
try testing.expectEqual(@as(usize, 0), no_client.items.len);
|
||||||
|
|
||||||
const only_blocked = try selectQueries(&database, arena, .{ .blocked = true });
|
const only_blocked = try selectQueries(&database, arena, .{ .blocked = true });
|
||||||
@@ -2049,7 +2079,7 @@ test "each filter narrows the result on its own" {
|
|||||||
.limit = 10,
|
.limit = 10,
|
||||||
.before = 3,
|
.before = 3,
|
||||||
.domain_substring = "ads",
|
.domain_substring = "ads",
|
||||||
.client = "192.0.2.20",
|
.clients = &.{"192.0.2.20"},
|
||||||
.blocked = true,
|
.blocked = true,
|
||||||
.since = 200,
|
.since = 200,
|
||||||
.until = 300,
|
.until = 300,
|
||||||
@@ -2125,18 +2155,84 @@ test "the built SQL never carries a filter value and fits its buffer" {
|
|||||||
sql.put(select_head);
|
sql.put(select_head);
|
||||||
sql.predicate(where_before);
|
sql.predicate(where_before);
|
||||||
sql.predicate(where_domain);
|
sql.predicate(where_domain);
|
||||||
sql.predicate(where_client);
|
sql.clientPredicate(max_clients);
|
||||||
sql.predicate(where_blocked);
|
sql.predicate(where_blocked);
|
||||||
sql.predicate(where_since);
|
sql.predicate(where_since);
|
||||||
sql.predicate(where_until);
|
sql.predicate(where_until);
|
||||||
sql.put(select_tail);
|
sql.put(select_tail);
|
||||||
|
|
||||||
// Every predicate present is the longest reachable statement.
|
// Every predicate present, with the client list at its cap, is the longest
|
||||||
|
// reachable statement — which is what the buffer is sized against.
|
||||||
try testing.expect(sql.len <= Sql.capacity);
|
try testing.expect(sql.len <= Sql.capacity);
|
||||||
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, sql.text(), " WHERE"));
|
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, sql.text(), " WHERE"));
|
||||||
try testing.expectEqual(@as(usize, 5), std.mem.count(u8, sql.text(), " AND"));
|
try testing.expectEqual(@as(usize, 5), std.mem.count(u8, sql.text(), " AND"));
|
||||||
// Six filters plus the LIMIT, each a bare parameter.
|
// Five scalar filters plus the LIMIT, plus one per address, each a bare
|
||||||
try testing.expectEqual(@as(usize, 7), std.mem.count(u8, sql.text(), "?"));
|
// parameter: no value is ever spelled into the statement.
|
||||||
|
try testing.expectEqual(@as(usize, 6 + max_clients), std.mem.count(u8, sql.text(), "?"));
|
||||||
|
// The select list has commas of its own, so the client list is measured as
|
||||||
|
// the difference against the same statement without it.
|
||||||
|
var without: Sql = .{};
|
||||||
|
without.put(select_head);
|
||||||
|
without.predicate(where_before);
|
||||||
|
without.predicate(where_domain);
|
||||||
|
without.predicate(where_blocked);
|
||||||
|
without.predicate(where_since);
|
||||||
|
without.predicate(where_until);
|
||||||
|
without.put(select_tail);
|
||||||
|
try testing.expectEqual(
|
||||||
|
@as(usize, max_clients - 1),
|
||||||
|
std.mem.count(u8, sql.text(), ",") - std.mem.count(u8, without.text(), ","),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "one client reads as an exact match and several read as any-of" {
|
||||||
|
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const arena = arena_state.allocator();
|
||||||
|
|
||||||
|
var database = try openLog();
|
||||||
|
defer database.close();
|
||||||
|
|
||||||
|
var second = plainRow(200, "b.example");
|
||||||
|
second.client_ip = "192.0.2.11";
|
||||||
|
var third = plainRow(300, "c.example");
|
||||||
|
third.client_ip = "192.0.2.12";
|
||||||
|
try seed(&database, &.{ plainRow(100, "a.example"), second, third });
|
||||||
|
|
||||||
|
var buf: [8]i64 = undefined;
|
||||||
|
|
||||||
|
const one = try selectQueries(&database, arena, .{ .clients = &.{"192.0.2.11"} });
|
||||||
|
try testing.expectEqualSlices(i64, &.{2}, ids(one.items, &buf));
|
||||||
|
|
||||||
|
// Newest-first, so the higher id leads however the addresses are ordered.
|
||||||
|
const two = try selectQueries(&database, arena, .{ .clients = &.{ "192.0.2.12", "192.0.2.10" } });
|
||||||
|
try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(two.items, &buf));
|
||||||
|
|
||||||
|
// An address nothing was logged from narrows to nothing rather than being
|
||||||
|
// ignored, which is the difference between a filter and a suggestion.
|
||||||
|
const absent = try selectQueries(&database, arena, .{ .clients = &.{"198.51.100.1"} });
|
||||||
|
try testing.expectEqual(@as(usize, 0), absent.items.len);
|
||||||
|
|
||||||
|
// No addresses at all is no client filter.
|
||||||
|
const none = try selectQueries(&database, arena, .{});
|
||||||
|
try testing.expectEqual(@as(usize, 3), none.items.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "a client list past the cap is refused rather than truncated" {
|
||||||
|
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const arena = arena_state.allocator();
|
||||||
|
|
||||||
|
var database = try openLog();
|
||||||
|
defer database.close();
|
||||||
|
|
||||||
|
var too_many: [max_clients + 1][]const u8 = undefined;
|
||||||
|
for (&too_many) |*slot| slot.* = "192.0.2.10";
|
||||||
|
|
||||||
|
// Dropping the overflow would narrow the filter silently, which answers a
|
||||||
|
// question the caller did not ask; the statement buffer could not hold it
|
||||||
|
// either way.
|
||||||
|
try testing.expectError(error.Misuse, selectQueries(&database, arena, .{ .clients = &too_many }));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "likePattern wraps the needle and neutralises every metacharacter" {
|
test "likePattern wraps the needle and neutralises every metacharacter" {
|
||||||
|
|||||||
@@ -32,12 +32,34 @@ pub const max_limit: u32 = queries_repo.max_limit;
|
|||||||
pub const max_domain_len = logger.max_domain_len;
|
pub const max_domain_len = logger.max_domain_len;
|
||||||
pub const max_client_len = logger.max_client_len;
|
pub const max_client_len = logger.max_client_len;
|
||||||
|
|
||||||
|
/// How many addresses one `client` parameter may name, the repository's cap.
|
||||||
|
pub const max_clients = queries_repo.max_clients;
|
||||||
|
|
||||||
|
/// The longest `client` value that can decode to a full list: every address at
|
||||||
|
/// its width, separated by commas.
|
||||||
|
pub const max_client_list_len = max_clients * max_client_len + (max_clients - 1);
|
||||||
|
|
||||||
|
/// The buffer that value needs.
|
||||||
|
///
|
||||||
|
/// `queryValue` measures the value as it arrives and only then decodes it in
|
||||||
|
/// place, so the buffer is sized for the percent-encoded form rather than the
|
||||||
|
/// decoded one. A browser encodes the separating commas, and every colon of an
|
||||||
|
/// IPv6 address with them, so a legal near-cap selection of IPv6 clients would
|
||||||
|
/// be refused by a buffer sized for what it decodes to. Three bytes per byte is
|
||||||
|
/// the worst `%XX` can do.
|
||||||
|
const max_client_value_len = 3 * max_client_list_len;
|
||||||
|
|
||||||
/// Where the two string filters are copied to. The parsed filter borrows them,
|
/// Where the two string filters are copied to. The parsed filter borrows them,
|
||||||
/// so it must not outlive the buffers — in the handler both live in the same
|
/// so it must not outlive the buffers — in the handler both live in the same
|
||||||
/// stack frame.
|
/// stack frame.
|
||||||
|
///
|
||||||
|
/// `client` holds the raw comma-separated value and `clients` indexes into it,
|
||||||
|
/// so the addresses are never copied a second time: the filter's slices point
|
||||||
|
/// into the same bytes the query string was decoded into.
|
||||||
pub const Buffers = struct {
|
pub const Buffers = struct {
|
||||||
domain: [max_domain_len]u8 = undefined,
|
domain: [max_domain_len]u8 = undefined,
|
||||||
client: [max_client_len]u8 = undefined,
|
client: [max_client_value_len]u8 = undefined,
|
||||||
|
clients: [max_clients][]const u8 = undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Page = struct {
|
pub const Page = struct {
|
||||||
@@ -82,8 +104,22 @@ pub fn parseFilter(query: []const u8, buffers: *Buffers) FilterError!queries_rep
|
|||||||
if (domain.len != 0) filter.domain_substring = domain;
|
if (domain.len != 0) filter.domain_substring = domain;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (http_util.queryValue(query, "client", &buffers.client) catch return error.BadClient) |client| {
|
// A comma-separated list of exact addresses, matched any-of. An empty entry
|
||||||
if (client.len != 0) filter.client = client;
|
// is a malformed list rather than a filter to drop: `?client=a,,b` is a
|
||||||
|
// client bug, and answering it as `a,b` would hide the bug behind an answer
|
||||||
|
// to a question nobody asked.
|
||||||
|
if (http_util.queryValue(query, "client", &buffers.client) catch return error.BadClient) |raw| {
|
||||||
|
if (raw.len != 0) {
|
||||||
|
var count: usize = 0;
|
||||||
|
var it = std.mem.splitScalar(u8, raw, ',');
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
if (entry.len == 0 or entry.len > max_client_len) return error.BadClient;
|
||||||
|
if (count == max_clients) return error.BadClient;
|
||||||
|
buffers.clients[count] = entry;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
filter.clients = buffers.clients[0..count];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filter.blocked = http_util.queryBool(query, "blocked") catch return error.BadBlocked;
|
filter.blocked = http_util.queryBool(query, "blocked") catch return error.BadBlocked;
|
||||||
@@ -98,7 +134,7 @@ pub fn message(err: FilterError) []const u8 {
|
|||||||
error.BadLimit => "limit must be between 1 and 1000",
|
error.BadLimit => "limit must be between 1 and 1000",
|
||||||
error.BadBefore => "before must be a positive row id",
|
error.BadBefore => "before must be a positive row id",
|
||||||
error.BadDomain => "domain is not a valid filter",
|
error.BadDomain => "domain is not a valid filter",
|
||||||
error.BadClient => "client is not a valid filter",
|
error.BadClient => "client must be up to 32 comma-separated client addresses",
|
||||||
error.BadBlocked => "blocked must be true or false",
|
error.BadBlocked => "blocked must be true or false",
|
||||||
error.BadSince => "since must be a unix timestamp in seconds",
|
error.BadSince => "since must be a unix timestamp in seconds",
|
||||||
error.BadUntil => "until must be a unix timestamp in seconds",
|
error.BadUntil => "until must be a unix timestamp in seconds",
|
||||||
@@ -228,7 +264,8 @@ test "every filter reaches the repository untouched" {
|
|||||||
try testing.expectEqual(@as(u32, 250), filter.limit);
|
try testing.expectEqual(@as(u32, 250), filter.limit);
|
||||||
try testing.expectEqual(@as(?i64, 900), filter.before);
|
try testing.expectEqual(@as(?i64, 900), filter.before);
|
||||||
try testing.expectEqualStrings("ads.example", filter.domain_substring.?);
|
try testing.expectEqualStrings("ads.example", filter.domain_substring.?);
|
||||||
try testing.expectEqualStrings("192.0.2.10", filter.client.?);
|
try testing.expectEqual(@as(usize, 1), filter.clients.len);
|
||||||
|
try testing.expectEqualStrings("192.0.2.10", filter.clients[0]);
|
||||||
try testing.expectEqual(@as(?bool, true), filter.blocked);
|
try testing.expectEqual(@as(?bool, true), filter.blocked);
|
||||||
try testing.expectEqual(@as(?i64, 100), filter.since);
|
try testing.expectEqual(@as(?i64, 100), filter.since);
|
||||||
try testing.expectEqual(@as(?i64, 200), filter.until);
|
try testing.expectEqual(@as(?i64, 200), filter.until);
|
||||||
@@ -238,7 +275,48 @@ test "an empty string filter is no filter at all" {
|
|||||||
var buffers: Buffers = .{};
|
var buffers: Buffers = .{};
|
||||||
const filter = try parseFilter("domain=&client=", &buffers);
|
const filter = try parseFilter("domain=&client=", &buffers);
|
||||||
try testing.expectEqual(@as(?[]const u8, null), filter.domain_substring);
|
try testing.expectEqual(@as(?[]const u8, null), filter.domain_substring);
|
||||||
try testing.expectEqual(@as(?[]const u8, null), filter.client);
|
try testing.expectEqual(@as(usize, 0), filter.clients.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "a client list is several exact addresses and each entry must be well formed" {
|
||||||
|
var buffers: Buffers = .{};
|
||||||
|
const several = try parseFilter("client=192.0.2.10,192.0.2.11,192.0.2.12", &buffers);
|
||||||
|
try testing.expectEqual(@as(usize, 3), several.clients.len);
|
||||||
|
try testing.expectEqualStrings("192.0.2.10", several.clients[0]);
|
||||||
|
try testing.expectEqualStrings("192.0.2.11", several.clients[1]);
|
||||||
|
try testing.expectEqualStrings("192.0.2.12", several.clients[2]);
|
||||||
|
|
||||||
|
// An empty entry would silently widen the filter, so it is malformed input.
|
||||||
|
try testing.expectError(error.BadClient, parseFilter("client=192.0.2.10,", &buffers));
|
||||||
|
try testing.expectError(error.BadClient, parseFilter("client=,192.0.2.10", &buffers));
|
||||||
|
try testing.expectError(error.BadClient, parseFilter("client=192.0.2.10,,192.0.2.11", &buffers));
|
||||||
|
|
||||||
|
var many: std.ArrayList(u8) = .empty;
|
||||||
|
defer many.deinit(testing.allocator);
|
||||||
|
try many.appendSlice(testing.allocator, "client=192.0.2.1");
|
||||||
|
for (0..max_clients) |_| try many.appendSlice(testing.allocator, ",192.0.2.1");
|
||||||
|
try testing.expectError(error.BadClient, parseFilter(many.items, &buffers));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "a full list of percent-encoded IPv6 clients fits" {
|
||||||
|
// A browser encodes the separators and every colon it puts between them, so
|
||||||
|
// the value on the wire is several times the length of what it decodes to.
|
||||||
|
// Sizing the buffer for the decoded form refuses a selection that is legal.
|
||||||
|
const address = "2001:0db8:0000:0000:0000:0000:0000:0001";
|
||||||
|
const encoded = "2001%3A0db8%3A0000%3A0000%3A0000%3A0000%3A0000%3A0001";
|
||||||
|
|
||||||
|
var query: std.ArrayList(u8) = .empty;
|
||||||
|
defer query.deinit(testing.allocator);
|
||||||
|
try query.appendSlice(testing.allocator, "client=");
|
||||||
|
for (0..max_clients) |index| {
|
||||||
|
if (index != 0) try query.appendSlice(testing.allocator, "%2C");
|
||||||
|
try query.appendSlice(testing.allocator, encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffers: Buffers = .{};
|
||||||
|
const filter = try parseFilter(query.items, &buffers);
|
||||||
|
try testing.expectEqual(@as(usize, max_clients), filter.clients.len);
|
||||||
|
for (filter.clients) |entry| try testing.expectEqualStrings(address, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "each malformed parameter names itself in a 400" {
|
test "each malformed parameter names itself in a 400" {
|
||||||
|
|||||||
@@ -200,8 +200,10 @@ paths:
|
|||||||
schema: { type: string, maxLength: 253 }
|
schema: { type: string, maxLength: 253 }
|
||||||
- name: client
|
- name: client
|
||||||
in: query
|
in: query
|
||||||
description: Exact client address.
|
description: >-
|
||||||
schema: { type: string, maxLength: 64 }
|
Up to 32 comma-separated exact client addresses, matched any-of.
|
||||||
|
Each entry is at most 45 characters; an empty entry is a 400.
|
||||||
|
schema: { type: string, maxLength: 1471 }
|
||||||
- name: blocked
|
- name: blocked
|
||||||
in: query
|
in: query
|
||||||
schema: { type: boolean }
|
schema: { type: boolean }
|
||||||
|
|||||||
Reference in New Issue
Block a user