Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ae0c974a4
|
||
|
|
2525b01893
|
||
|
|
b774b05456
|
@@ -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 = 850_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");
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +307,7 @@ 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",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,63 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { afterAll, expect, test, vi } from "vitest";
|
import { afterAll, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
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 ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
||||||
import { unixToDatetimeLocal } from "./datetime";
|
import { unixToDatetimeLocal } from "./datetime";
|
||||||
|
|
||||||
vi.stubEnv("TZ", "Europe/Paris");
|
vi.stubEnv("TZ", "Europe/Paris");
|
||||||
afterAll(() => vi.unstubAllEnvs());
|
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. */
|
/** 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";
|
const GAP_WALL_TIME = "2026-03-29T02:30:00";
|
||||||
|
|
||||||
@@ -42,7 +91,12 @@ function describedText(input: HTMLElement): string {
|
|||||||
* land them in an order the network and the router can genuinely produce —
|
* 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.
|
* an early debounce arriving after a later one, over a draft that has moved on.
|
||||||
*/
|
*/
|
||||||
function renderFilters(initial: AppliedFilters = NO_FILTERS, deferred = false) {
|
function renderFilters(
|
||||||
|
initial: AppliedFilters = NO_FILTERS,
|
||||||
|
deferred = false,
|
||||||
|
primeClients = true,
|
||||||
|
clients: Client[] = CLIENTS,
|
||||||
|
) {
|
||||||
const patches: Array<Partial<AppliedFilters>> = [];
|
const patches: Array<Partial<AppliedFilters>> = [];
|
||||||
const state: { current: AppliedFilters } = { current: initial };
|
const state: { current: AppliedFilters } = { current: initial };
|
||||||
const setter: { current: ((next: AppliedFilters) => void) | null } = { current: null };
|
const setter: { current: ((next: AppliedFilters) => void) | null } = { current: null };
|
||||||
@@ -62,12 +116,75 @@ function renderFilters(initial: AppliedFilters = NO_FILTERS, deferred = false) {
|
|||||||
return <ActivityFilters applied={applied} onApply={onApply} onClear={onClear} />;
|
return <ActivityFilters applied={applied} onApply={onApply} onClear={onClear} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(<Harness />);
|
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. */
|
/** The URL moving under the form: a commit landing, or the back button. */
|
||||||
function land(next: AppliedFilters) {
|
function land(next: AppliedFilters) {
|
||||||
act(() => setter.current!(next));
|
act(() => setter.current!(next));
|
||||||
}
|
}
|
||||||
return { patches, state, land };
|
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() {
|
function openTimeMenu() {
|
||||||
@@ -251,7 +368,7 @@ test("the domain field is search-shaped, unspellchecked, and labelled without a
|
|||||||
expect(domain.getAttribute("autocomplete")).toBe("off");
|
expect(domain.getAttribute("autocomplete")).toBe("off");
|
||||||
expect(domain.getAttribute("placeholder")).toBe("Filter domains…");
|
expect(domain.getAttribute("placeholder")).toBe("Filter domains…");
|
||||||
// The magnifier is decoration over the field, never a second thing to read.
|
// The magnifier is decoration over the field, never a second thing to read.
|
||||||
expect(document.querySelector("svg[aria-hidden='true']")).toBeTruthy();
|
expect(domain.parentElement?.querySelector("[aria-hidden='true'] svg")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a bound change does not reset the domain draft that is still being typed", () => {
|
test("a bound change does not reset the domain draft that is still being typed", () => {
|
||||||
@@ -284,10 +401,7 @@ test("a debounced commit landing late does not roll the field back over newer ty
|
|||||||
act(() => vi.advanceTimersByTime(400));
|
act(() => vi.advanceTimersByTime(400));
|
||||||
fireEvent.change(domain, { target: { value: "ads" } });
|
fireEvent.change(domain, { target: { value: "ads" } });
|
||||||
act(() => vi.advanceTimersByTime(400));
|
act(() => vi.advanceTimersByTime(400));
|
||||||
expect(patches).toEqual([
|
expect(patches).toEqual([{ domain: "ad" }, { domain: "ads" }]);
|
||||||
{ domain: "ad", client: undefined },
|
|
||||||
{ domain: "ads", client: undefined },
|
|
||||||
]);
|
|
||||||
fireEvent.change(domain, { target: { value: "adsx" } });
|
fireEvent.change(domain, { target: { value: "adsx" } });
|
||||||
|
|
||||||
// The older one lands first. It is this toolbar's own echo, two keystrokes
|
// The older one lands first. It is this toolbar's own echo, two keystrokes
|
||||||
@@ -352,40 +466,293 @@ test("a window moved from outside clears an error left over from the old one", (
|
|||||||
expect(screen.queryByRole("alert")).toBeNull();
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the client field names the exact match it performs", () => {
|
test("the picker offers every client and names the filter it is not yet applying", () => {
|
||||||
renderFilters();
|
renderFilters();
|
||||||
const client = screen.getByLabelText("Client IP (exact match)") as HTMLInputElement;
|
expect(clientTrigger().textContent).toContain("Clients");
|
||||||
expect(client.getAttribute("placeholder")).toBe("Client IP…");
|
expect(clientChips()).toEqual([]);
|
||||||
});
|
|
||||||
|
|
||||||
test("an earlier range commit landing late does not unseat the pick that is current", () => {
|
openClientMenu();
|
||||||
const first = 1_800_000_000_000;
|
// The name is the way in; the address is still the filter.
|
||||||
vi.spyOn(Date, "now").mockReturnValue(first);
|
expect(screen.getAllByRole("menuitemcheckbox").map((item) => item.textContent)).toEqual([
|
||||||
const { patches, land } = renderFilters(NO_FILTERS, true);
|
"192.0.2.12",
|
||||||
|
"Kitchen Pi — 192.0.2.10",
|
||||||
openTimeMenu();
|
"laptop.lan — 192.0.2.11",
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
|
||||||
|
|
||||||
// A second pick before the first has come back through the URL.
|
|
||||||
const second = first + 60_000;
|
|
||||||
vi.spyOn(Date, "now").mockReturnValue(second);
|
|
||||||
openTimeMenu();
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past 24 hours" }));
|
|
||||||
|
|
||||||
expect(patches).toEqual([
|
|
||||||
{ since: first / 1000 - 3600, until: first / 1000 },
|
|
||||||
{ since: second / 1000 - 86_400, until: second / 1000 },
|
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
// The older window lands first. It is this toolbar's own echo, so it must not
|
test("picking clients commits them to the url at once, as one comma-separated value", () => {
|
||||||
// read as a range someone else chose: doing that would clear the preset and
|
vi.useFakeTimers();
|
||||||
// throw the custom row open over a window the reader has already moved past.
|
try {
|
||||||
land({ ...NO_FILTERS, since: first / 1000 - 3600, until: first / 1000 });
|
const { patches, state } = renderFilters();
|
||||||
expect(screen.queryByLabelText("Since")).toBeNull();
|
|
||||||
|
|
||||||
land({ ...NO_FILTERS, since: second / 1000 - 86_400, until: second / 1000 });
|
pickClient("Kitchen Pi — 192.0.2.10");
|
||||||
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
|
// No debounce: a pick is a decision the reader has finished making, and
|
||||||
expect(screen.queryByLabelText("Since")).toBeNull();
|
// nothing about it can be half-typed.
|
||||||
|
expect(patches).toEqual([{ client: "192.0.2.10" }]);
|
||||||
|
expect(state.current.client).toBe("192.0.2.10");
|
||||||
|
|
||||||
vi.restoreAllMocks();
|
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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
*
|
*
|
||||||
* 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, and
|
* the link they can paste to a housemate will show. So this holds a draft, and
|
||||||
* every control writes through to the URL — the segments and the time presets
|
* every control writes through to the URL — the segments, the client picker and
|
||||||
* at once, the two text fields after a pause so a five-letter domain is one
|
* the time presets at once, the domain field after a pause so a five-letter word
|
||||||
* navigation rather than five.
|
* is one navigation rather than five.
|
||||||
*
|
*
|
||||||
* A time preset writes both bounds as absolute seconds, resolved once at the
|
* A time preset writes both bounds as absolute seconds, resolved once at the
|
||||||
* click. Neither half may be left open: a bookmark has to describe the same
|
* click. Neither half may be left open: a bookmark has to describe the same
|
||||||
@@ -29,11 +29,13 @@
|
|||||||
* would promise filtering that is not happening.
|
* would promise filtering that is not happening.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { MagnifyingGlass } from "@phosphor-icons/react/dist/icons/MagnifyingGlass";
|
||||||
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
|
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
|
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";
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ const styles = stylex.create({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
},
|
},
|
||||||
searchIcon: {
|
searchIcon: {
|
||||||
|
display: "inline-flex",
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
insetInlineStart: "0.5rem",
|
insetInlineStart: "0.5rem",
|
||||||
top: "50%",
|
top: "50%",
|
||||||
@@ -90,11 +93,6 @@ const styles = stylex.create({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
paddingInlineStart: "1.875rem",
|
paddingInlineStart: "1.875rem",
|
||||||
},
|
},
|
||||||
clientInput: {
|
|
||||||
flexGrow: 0,
|
|
||||||
flexShrink: 1,
|
|
||||||
flexBasis: "10rem",
|
|
||||||
},
|
|
||||||
/** A button is text-sized by default; this is the hit area around the text. */
|
/** A button is text-sized by default; this is the hit area around the text. */
|
||||||
hitTarget: {
|
hitTarget: {
|
||||||
minHeight: HIT_TARGET,
|
minHeight: HIT_TARGET,
|
||||||
@@ -217,12 +215,6 @@ interface Bounds {
|
|||||||
until: number | undefined;
|
until: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The two text filters as the URL spells them, with absent written as empty. */
|
|
||||||
interface TextPair {
|
|
||||||
domain: string;
|
|
||||||
client: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameBounds(a: Bounds, b: Bounds): boolean {
|
function sameBounds(a: Bounds, b: Bounds): boolean {
|
||||||
return a.since === b.since && a.until === b.until;
|
return a.since === b.since && a.until === b.until;
|
||||||
}
|
}
|
||||||
@@ -272,19 +264,22 @@ interface ChosenPreset extends Bounds {
|
|||||||
* What this toolbar knows about the URL, and what it is still waiting to see
|
* What this toolbar knows about the URL, and what it is still waiting to see
|
||||||
* come back from it.
|
* come back from it.
|
||||||
*
|
*
|
||||||
* `url` and `bounds` are the last values looked at, so a change can be told
|
* `domain` and `bounds` are the last values looked at, so a change can be told
|
||||||
* apart field by field. `pendingText` and `pendingBounds` are every commit made
|
* apart field by field. `pendingDomain` and `pendingBounds` are every commit
|
||||||
* here that the URL has not echoed yet. Both are queues rather than single
|
* made here that the URL has not echoed yet. Both are queues rather than single
|
||||||
* slots: two keystrokes either side of the debounce put two text commits in
|
* 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
|
* 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
|
* 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
|
* else's edit — that is what would clear the preset out from under the pick
|
||||||
* that is actually current.
|
* 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 {
|
interface Sync {
|
||||||
url: TextPair;
|
domain: string;
|
||||||
bounds: Bounds;
|
bounds: Bounds;
|
||||||
pendingText: TextPair[];
|
pendingDomain: string[];
|
||||||
pendingBounds: Bounds[];
|
pendingBounds: Bounds[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,41 +291,41 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
||||||
const urlText: TextPair = { domain: applied.domain ?? "", client: applied.client ?? "" };
|
const urlDomain = applied.domain ?? "";
|
||||||
const urlBounds: Bounds = { since: applied.since, until: applied.until };
|
const urlBounds: Bounds = { since: applied.since, until: applied.until };
|
||||||
|
|
||||||
const [domain, setDomain] = useState(urlText.domain);
|
const clientOptions = useClientOptions();
|
||||||
const [client, setClient] = useState(urlText.client);
|
// 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 [preset, setPreset] = useState<ChosenPreset | null>(null);
|
||||||
const [showCustom, setShowCustom] = useState(applied.since !== undefined || applied.until !== undefined);
|
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<BoundError | null>(null);
|
const [error, setError] = useState<BoundError | null>(null);
|
||||||
const [sync, setSync] = useState<Sync>({
|
const [sync, setSync] = useState<Sync>({
|
||||||
url: urlText,
|
domain: urlDomain,
|
||||||
bounds: urlBounds,
|
bounds: urlBounds,
|
||||||
pendingText: [],
|
pendingDomain: [],
|
||||||
pendingBounds: [],
|
pendingBounds: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const textMoved = sync.url.domain !== urlText.domain || sync.url.client !== urlText.client;
|
const domainMoved = sync.domain !== urlDomain;
|
||||||
const boundsMoved = !sameBounds(sync.bounds, urlBounds);
|
const boundsMoved = !sameBounds(sync.bounds, urlBounds);
|
||||||
if (textMoved || boundsMoved) {
|
if (domainMoved || boundsMoved) {
|
||||||
let pendingText = sync.pendingText;
|
let pendingDomain = sync.pendingDomain;
|
||||||
let pendingBounds = sync.pendingBounds;
|
let pendingBounds = sync.pendingBounds;
|
||||||
|
|
||||||
if (textMoved) {
|
if (domainMoved) {
|
||||||
// The newest commit the URL matches, and everything before it, has now
|
// The newest commit the URL matches, and everything before it, has now
|
||||||
// been accounted for; an older one landing later is not new news.
|
// been accounted for; an older one landing later is not new news.
|
||||||
const echo = pendingText.findLastIndex(
|
const echo = pendingDomain.findLastIndex((sent) => sent === urlDomain);
|
||||||
(sent) => sent.domain === urlText.domain && sent.client === urlText.client,
|
|
||||||
);
|
|
||||||
if (echo >= 0) {
|
if (echo >= 0) {
|
||||||
pendingText = pendingText.slice(echo + 1);
|
pendingDomain = pendingDomain.slice(echo + 1);
|
||||||
} else {
|
} else {
|
||||||
pendingText = [];
|
pendingDomain = [];
|
||||||
if (sync.url.domain !== urlText.domain) setDomain(urlText.domain);
|
setDomain(urlDomain);
|
||||||
if (sync.url.client !== urlText.client) setClient(urlText.client);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,21 +347,18 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSync({ url: urlText, bounds: urlBounds, pendingText, pendingBounds });
|
setSync({ domain: urlDomain, bounds: urlBounds, pendingDomain, pendingBounds });
|
||||||
}
|
}
|
||||||
|
|
||||||
const dirty = textFilter(domain) !== applied.domain || textFilter(client) !== applied.client;
|
const dirty = textFilter(domain) !== applied.domain;
|
||||||
|
|
||||||
const commitText = useCallback(
|
const commitDomain = useCallback(
|
||||||
(replace: boolean) => {
|
(replace: boolean) => {
|
||||||
const next = { domain: textFilter(domain), client: textFilter(client) };
|
const next = textFilter(domain);
|
||||||
setSync((prev) => ({
|
setSync((prev) => ({ ...prev, pendingDomain: [...prev.pendingDomain, next ?? ""] }));
|
||||||
...prev,
|
onApply({ domain: next }, replace);
|
||||||
pendingText: [...prev.pendingText, { domain: next.domain ?? "", client: next.client ?? "" }],
|
|
||||||
}));
|
|
||||||
onApply(next, replace);
|
|
||||||
},
|
},
|
||||||
[domain, client, onApply],
|
[domain, onApply],
|
||||||
);
|
);
|
||||||
|
|
||||||
function commitBounds(bounds: Bounds) {
|
function commitBounds(bounds: Bounds) {
|
||||||
@@ -376,13 +368,13 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dirty) return;
|
if (!dirty) return;
|
||||||
const id = setTimeout(() => commitText(true), DEBOUNCE_MS);
|
const id = setTimeout(() => commitDomain(true), DEBOUNCE_MS);
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [dirty, commitText]);
|
}, [dirty, commitDomain]);
|
||||||
|
|
||||||
function flush(event: FormEvent) {
|
function flush(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (dirty) commitText(true);
|
if (dirty) commitDomain(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPreset(label: string) {
|
function selectPreset(label: string) {
|
||||||
@@ -439,7 +431,6 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
|||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setDomain("");
|
setDomain("");
|
||||||
setClient("");
|
|
||||||
setPreset(null);
|
setPreset(null);
|
||||||
setShowCustom(false);
|
setShowCustom(false);
|
||||||
setSince(datetimeField(undefined));
|
setSince(datetimeField(undefined));
|
||||||
@@ -447,7 +438,7 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setSync((prev) => ({
|
setSync((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
pendingText: [],
|
pendingDomain: [],
|
||||||
pendingBounds: [...prev.pendingBounds, { since: undefined, until: undefined }],
|
pendingBounds: [...prev.pendingBounds, { since: undefined, until: undefined }],
|
||||||
}));
|
}));
|
||||||
onClear();
|
onClear();
|
||||||
@@ -455,7 +446,7 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
|||||||
|
|
||||||
const active =
|
const active =
|
||||||
domain.trim() !== "" ||
|
domain.trim() !== "" ||
|
||||||
client.trim() !== "" ||
|
clients.length > 0 ||
|
||||||
applied.blocked !== undefined ||
|
applied.blocked !== undefined ||
|
||||||
applied.since !== undefined ||
|
applied.since !== undefined ||
|
||||||
applied.until !== undefined;
|
applied.until !== undefined;
|
||||||
@@ -513,19 +504,9 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
|||||||
<form onSubmit={flush}>
|
<form onSubmit={flush}>
|
||||||
<div {...stylex.props(styles.toolbar)}>
|
<div {...stylex.props(styles.toolbar)}>
|
||||||
<div {...stylex.props(styles.searchWrap)}>
|
<div {...stylex.props(styles.searchWrap)}>
|
||||||
<svg
|
<span aria-hidden="true" {...stylex.props(styles.searchIcon)}>
|
||||||
aria-hidden="true"
|
<MagnifyingGlass size={14} />
|
||||||
viewBox="0 0 16 16"
|
</span>
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
{...stylex.props(styles.searchIcon)}
|
|
||||||
>
|
|
||||||
<circle cx="7" cy="7" r="4.5" />
|
|
||||||
<path d="M10.5 10.5 14 14" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
aria-label="Filter domains"
|
aria-label="Filter domains"
|
||||||
@@ -537,20 +518,10 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
|||||||
{...stylex.props(shared.smallInput, styles.field, styles.searchInput, shared.focusRing)}
|
{...stylex.props(shared.smallInput, styles.field, styles.searchInput, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/*
|
<ClientFilter
|
||||||
* The client filter matches one address exactly — the server has no
|
options={clientOptions}
|
||||||
* substring match for it — so the name says so rather than leaving the
|
selected={clients}
|
||||||
* reader to discover it by typing half an address and getting nothing.
|
onChange={(next) => onApply({ client: joinClients(next) })}
|
||||||
*/}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
aria-label="Client IP (exact match)"
|
|
||||||
placeholder="Client IP…"
|
|
||||||
spellCheck={false}
|
|
||||||
autoComplete="off"
|
|
||||||
value={client}
|
|
||||||
onChange={(event) => setClient(event.target.value)}
|
|
||||||
{...stylex.props(shared.smallInput, styles.field, styles.clientInput, shared.focusRing)}
|
|
||||||
/>
|
/>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
aria-label="Result"
|
aria-label="Result"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { Check } from "@phosphor-icons/react/dist/icons/Check";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { Checkbox as AriaCheckbox } from "react-aria-components";
|
import { Checkbox as AriaCheckbox } from "react-aria-components";
|
||||||
import { colors } from "./tokens.stylex";
|
import { colors } from "./tokens.stylex";
|
||||||
@@ -100,21 +101,6 @@ const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** `currentColor` so the mark follows the selected box's foreground token. */
|
|
||||||
function CheckMark() {
|
|
||||||
return (
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 12 12" width="10" height="10" fill="none">
|
|
||||||
<path
|
|
||||||
d="M2.5 6.5 5 9l4.5-5.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Checkbox({ children, ...state }: Props) {
|
export default function Checkbox({ children, ...state }: Props) {
|
||||||
return (
|
return (
|
||||||
<AriaCheckbox {...state} className={() => stylex.props(styles.label).className ?? ""}>
|
<AriaCheckbox {...state} className={() => stylex.props(styles.label).className ?? ""}>
|
||||||
@@ -128,7 +114,8 @@ export default function Checkbox({ children, ...state }: Props) {
|
|||||||
renderProps.isDisabled && styles.boxDisabled,
|
renderProps.isDisabled && styles.boxDisabled,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderProps.isSelected && <CheckMark />}
|
{/* Bold: the regular stroke thins to near-invisible at this size. */}
|
||||||
|
{renderProps.isSelected && <Check aria-hidden="true" size={10} weight="bold" />}
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
+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