15 Commits
Author SHA1 Message Date
mokhtar 2ae0c974a4 admin: per-icon phosphor imports, the barrel loads every icon module under vitest
Gates / frontend (push) Successful in 1m51s
Gates / test (push) Successful in 2m29s
Gates / test-aarch64 (push) Successful in 7m24s
Release / guard (push) Successful in 32s
Gates / frontend (push) Successful in 2m14s
Gates / package (push) Successful in 4m56s
Gates / test (push) Successful in 2m20s
Gates / test-aarch64 (push) Successful in 7m25s
Gates / package (push) Successful in 1m3s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 26m26s
Gates / container (push) Successful in 11s
Release / gates (push) Successful in 16m6s
Release / publish (push) Successful in 6m43s
ci's frontend job timed out three page tests at 5s; the runner paid the barrel's ~1500 icon modules on first render. deep imports load only the icons the app uses; local test import time drops by a quarter.
2026-08-31 18:51:43 +02:00
mokhtar 2525b01893 admin: phosphor icons replace hand-drawn svgs and text-character glyphs
Release / guard (push) Successful in 39s
Gates / frontend (push) Failing after 2m25s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / frontend (push) Failing after 1m59s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / test-aarch64 (push) Successful in 8m18s
CI / gates (push) Failing after 11m34s
Gates / test (push) Successful in 2m18s
Gates / test-aarch64 (push) Successful in 7m23s
Release / gates (push) Failing after 18m51s
Release / publish (push) Skipped
Gates / test (push) Successful in 2m36s
health strip marks, menu ticks, chip crosses, the search magnifier, the checkbox check and the back arrows all come from @phosphor-icons/react now, pinned exactly and entered in the license ledger. changelog and version bump for 0.0.15, including the bundle budget raise to 900000 bytes.
2026-08-31 18:04:25 +02:00
mokhtar b774b05456 activity: select-only multi-client filter
the freetype client field is gone. the picker is a select-only menu of known clients with multi-select, chips for the active set, and a 32-client cap shared with the server. the api accepts a comma-separated client list and filters any-of with bound parameters. the trigger carets come from phosphor icons, newly adopted. bundle budget rises to 900000 bytes for the picker and the icon dependency.
2026-08-31 17:40:07 +02:00
mokhtar d79dd0bbcb admin: prettier formatting for the toolbar-era files, missed before ci ever ran them
Gates / frontend (push) Successful in 2m10s
Gates / test (push) Successful in 2m30s
Gates / test-aarch64 (push) Successful in 7m24s
Gates / package (push) Successful in 4m39s
Gates / container (push) Successful in 16s
CI / gates (push) Successful in 29m46s
2026-08-30 10:18:06 +02:00
mokhtar 039eeeda96 admin: activity history filters become a live-apply toolbar
Gates / frontend (push) Failing after 1m4s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / test (push) Successful in 2m33s
Gates / test-aarch64 (push) Successful in 7m33s
CI / gates (push) Failing after 10m6s
the five-field form with its apply button is gone. one row holds a search-shaped domain field and a client ip field that debounce into the url with enter flushing at once, result segments and a time menu that commit instantly, and a clear that appears only when a filter is active without reflowing the row. presets freeze both absolute bounds at click time so a bookmark describes the same investigation later, custom ranges apply atomically through set range with inline validation, and echo queues keep in-flight commits from clobbering newer typing or newer picks. live mode renders no toolbar, the availability banner became a footer note, previous rows stay visible during refetch, and every control carries a 44px hit target.
2026-08-29 14:56:40 +02:00
mokhtar c65d92d8f8 admin: title-only information becomes visible text
the client address follows its name as visible muted text in the query tables, the config lock indicator prints its reason beside the tag except in table rows where a page-level note explains the lock instead, and the locked delete buttons describe themselves through that one visible note. the chart legend tooltip is deleted because a named client is deliberately not addressed in the chart, and the dead series address field went with it. titles that merely repeat visible copyable text stay.
2026-08-29 13:03:30 +02:00
mokhtar 207252acee admin: enable toggles become rac switches
the upstream, blocklist source, and safe search enable controls mutate the row the moment they move, so they now carry the switch role via a shared drawn rac switch with a 44px hit area, a focus-visible ring, and naming modes made exclusive by a discriminated union. safe search supersedes its one-commit-old checkbox form, and the file-authority guard now names the switch role instead of relying on the bare input selector.
2026-08-29 12:40:01 +02:00
mokhtar f1de80477a admin: group sources and safe search become rac checkboxes
the assigned sources list is a rac checkboxgroup and safe search uses the same drawn checkbox, extracted to a shared ui component with grouped and standalone modes enforced by a discriminated union. the label carries a 44px pointer-target floor on both axes, the focus ring is driven from rac's focus-visible state and guarded by a test, and toggleSource is gone because the group hands back the whole set.
2026-08-29 12:33:00 +02:00
mokhtar 3c674966be admin: the mobile drawer becomes a rac disclosure, asset budget raised to 850,000 bytes
the drawer is inline flow content, so disclosure is the honest semantic: rac now owns aria-expanded, aria-controls, and the panel hidden state, while the open guard still unmounts the drawer contents so a closed drawer keeps no second pause control or health poll alive. the disclosure modules cost 3,669 bytes and the assets gate had 1,998 of headroom, so the budget moves from 800,000 to 850,000.
2026-08-29 12:20:07 +02:00
mokhtar 59d6be98f8 admin: associate inline field errors with their inputs
network assignment rows, activity filters, and the settings password pair now mark the offending input with aria-invalid and point it at the error text with aria-describedby. the prefix validator returns the row and field it is about, and any row mutation clears a message that named a position. settings numeric fields carry aria-invalid on an unparseable value.
2026-08-29 12:08:19 +02:00
mokhtar 317d5dd4f8 admin: live query detail becomes a modal, live/history and period pickers become tabs and radios, client delete confirms in a dialog
the streamed-query detail panel is now a rac modal dialog with focus containment and restore. the live/history switch is rac tabs driven by the url, the overview period picker is a rac radio group, and the clients delete flow uses the shared confirm dialog; an authority turn keeps the dialog open and withdraws only the destructive action.
2026-08-29 11:55:24 +02:00
mokhtar 79578e1d2a admin: reclaim the desktop header, move pause and log out to the sidebar
the header row survives only on narrow screens; on desktop its lone occupant, log out, joins pause in the sidebar footer, both full width. pause leaves the query detail page's related actions, where a global control had no business, and its hand-rolled duration dropdown becomes a react-aria menu with real keyboard navigation, dismissal and positioning.
2026-08-29 11:23:17 +02:00
mokhtar d5613ee718 admin: pointer cursor on every button
the shared button variants set no cursor, so only components with local one-off styles showed pointer. all nine variants and the six buttons styled outside them now carry pointer, with not-allowed when disabled.
2026-08-29 01:29:11 +02:00
mokhtar 09932b2d84 build: bump version to 0.0.14
Release / guard (push) Successful in 34s
Gates / frontend (push) Successful in 1m47s
Gates / test (push) Successful in 2m43s
Gates / test-aarch64 (push) Successful in 7m29s
Gates / package (push) Successful in 58s
Gates / container (push) Successful in 14s
Release / gates (push) Successful in 11m27s
Release / publish (push) Successful in 1m30s
2026-08-28 17:57:09 +02:00
mokhtar 272655f60c storage: version querylog.db and migrate it in place, never reset a healthy file
querylog.db carries a schema version; migrations run at startup as one transaction after a vacuumed 0600 backup, and every failure refuses startup (exit 2, no systemd restart loop) instead of starting empty. corruption is the only automatic recreate left. the cut gate now requires a fixture-proven migration or an explicit versioned break with restore instructions, and locks shipped migration files and fixtures byte-for-byte.
2026-08-28 17:56:19 +02:00
89 changed files with 8355 additions and 1095 deletions
+27
View File
@@ -4,6 +4,33 @@ 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.
## [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
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.
### Changed
- **querylog.db is migrated in place.** The file now carries a schema version, and a release that changes the schema ships a migration that runs at startup: one consistent backup (`querylog.db.pre-migrate-<timestamp>`, mode 0600, only the most recent kept), then every step and the version stamp in a single transaction. A failure before the commit rolls back and leaves your file exactly as it was.
- **The server refuses instead of resetting.** A querylog.db it cannot use — newer than the binary, older than 0.0.12, or mid-migration failure — is left untouched and the server exits with a clear message instead of setting the file aside and starting an empty log. The exit code (2) tells systemd not to restart-loop a deliberate refusal. Corruption is the only case that still sets a file aside automatically.
- **The release gate now enforces the contract.** A schema change cannot be tagged unless it either ships a working migration (proven in CI against a frozen fixture of the previous schema, with shipped migration files locked byte-for-byte once released) or explicitly declares a break — which requires a version bump the server refuses on, a reset disclosure, and step-by-step restore instructions in this file.
**One hazard to know when downgrading.** The first start under this release restamps querylog.db from the old fingerprint to version 1 (contents untouched). If you later downgrade to 0.0.13 or older, that binary treats the new stamp as a schema mismatch, moves your file aside as `querylog.db.schema-changed-<timestamp>`, and starts an empty log. To recover: return to 0.0.14 or newer, stop the server, move the empty `querylog.db` away and delete its `querylog.db-wal` and `querylog.db-shm` files (leaving them would corrupt the restored file), rename the `.schema-changed-<timestamp>` file back to `querylog.db`, and start.
## [0.0.13] - 2026-08-27
The upstream query budget becomes one honest deadline. A busy network no longer blames a healthy standby for running out of time, and a query burst no longer queues invisibly until everything answers SERVFAIL at once.
+3 -3
View File
@@ -85,13 +85,13 @@ Verified: 0.16.0 ships `std.crypto.tls.Client` only. There is no server-side TLS
Two SQLite files with opposite write profiles, isolated from each other:
- **`config.db`** — small, precious, rarely written: groups, clients, prefixes, upstreams, blocklist source metadata, rules, local records, forward zones, settings, schema version.
- **`querylog.db`** — high-churn, large, expendable: query log + its own private `domains` dimension table. Client identity stored as **IP text**, not a FK into config — log rows are immutable facts and must not point at mutable config rows. If `querylog.db` is missing or corrupt at startup, rename aside, recreate, keep serving. Log loss is not an outage.
- **`querylog.db`** — high-churn, large, expendable: query log + its own private `domains` dimension table. Client identity stored as **IP text**, not a FK into config — log rows are immutable facts and must not point at mutable config rows. If `querylog.db` is missing or corrupt at startup, rename aside, recreate, keep serving — corruption only; a healthy file whose schema this build cannot use refuses the startup instead (§3.7).
- No cross-DB references. Retention/VACUUM churn never touches `config.db`; config backup is a copy of a tiny file.
### 3.7 Upgrades: Auto-Migration (Decision J)
- `config.db`: numbered, sequential SQL migration steps compiled into the binary. At startup: read schema version row, apply newer steps inside a transaction, continue. Operator upgrade = install binary, restart. Before v0.1 the list holds one step — the baseline of §11.2, edited in place — because nxdns has no installs and a step exists only to reconcile a database somebody already has.
- `querylog.db`: **no migrations.** On schema mismatch: rename aside, recreate fresh.
- `querylog.db`: a logical version in `PRAGMA user_version`, migrated **in place** at startup by the same shape of compiled step list, inside one transaction and behind one `querylog.db.pre-migrate-<epoch>` backup (only the newest is kept). A healthy file is never renamed aside: a version this build cannot reach refuses the startup with instructions, and only corruption recreates. A deliberate break is still allowed, but it must be versioned, refused at startup, and disclosed in the changelog — the cut gate enforces that. See `docs/reference/query-log-lifecycle.md`.
### 3.8 Blocklist Storage (Decision A)
@@ -694,5 +694,5 @@ The project publishes released binaries and container images from its own Gitea
| G | SQLite vendored amalgamation + own thin wrapper |
| H | Two DBs: `config.db` (precious) + `querylog.db` (expendable, self-contained, client IP as text) |
| I | Frontend embedded in binary; dev flag serves from disk; static musl release builds |
| J | Auto-migration for `config.db` at startup; `querylog.db` recreated on mismatch |
| J | Auto-migration at startup for both databases; `querylog.db` recreated only when corrupt |
| — | Safe-search per-group; Prometheus `/metrics` in scope; CI on self-hosted Gitea Actions |
+14
View File
@@ -8,6 +8,7 @@
"name": "nxdns-admin",
"version": "0.0.0",
"dependencies": {
"@phosphor-icons/react": "2.1.10",
"@stylexjs/stylex": "0.19.0",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18",
@@ -1078,6 +1079,19 @@
"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": {
"version": "3.36.1",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz",
+1
View File
@@ -25,6 +25,7 @@
"trailingComma": "all"
},
"dependencies": {
"@phosphor-icons/react": "2.1.10",
"@stylexjs/stylex": "0.19.0",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18",
+1 -1
View File
@@ -14,7 +14,7 @@ import { readdirSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const BUDGET_BYTES = 800_000;
const BUDGET_BYTES = 900_000;
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
@@ -1,4 +1,4 @@
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -252,7 +252,7 @@ test("the back link restores the investigation the reader came from", async () =
renderDetail(19, "?mode=history&domain=shop&since=1600000000&blocked=true");
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",
domain: "shop",
since: "1600000000",
@@ -307,22 +307,23 @@ test("a row retention has pruned explains the 404 and keeps the way back to the
await screen.findByRole("alert");
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",
domain: "gone",
});
});
/**
* The related-actions region of a query detail. Scoped on purpose: the sidebar
* carries a Pause of its own, and this is the one that answers "this query was
* blocked and should not have been".
*/
/** The related-actions region of a query detail. */
function related(): HTMLElement {
return screen.getByRole("region", { name: "Related" });
}
test("a blocked query's Related offers Pause; an allowed one has nothing to pause about", async () => {
/**
* Related is links only. Pause is a resolver-wide control and lives in the
* sidebar alone, so a blocked query — the case that used to carry one here —
* offers no button of any kind.
*/
test("Related carries its four links and no control, blocked query or not", async () => {
responses["/api/queries/50"] = detail(50, {
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
route: { kind: "blocked", upstream: "" },
@@ -330,25 +331,16 @@ test("a blocked query's Related offers Pause; an allowed one has nothing to paus
renderDetail(50);
await screen.findByRole("heading", { name: "example.com" });
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
cleanup();
responses["/api/queries/51"] = detail(51, { policy: { action: "allow", reason: "no_match", matched: "" } });
renderDetail(51);
await screen.findByRole("heading", { name: "example.com" });
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
});
test("the Pause action stays away while protection is unavailable", async () => {
responses["/api/health"] = health({ protection: { state: "unavailable", until: null } });
responses["/api/queries/52"] = detail(52, {
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
route: { kind: "blocked", upstream: "" },
});
renderDetail(52);
await screen.findByRole("heading", { name: "example.com" });
await waitFor(() => expect(screen.getByText("Diagnostics around this query")).toBeTruthy());
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
expect(
within(related())
.getAllByRole("link")
.map((link) => link.textContent),
).toEqual([
"Test this domain against current policy",
"All activity for this domain",
"All activity from this client",
"Diagnostics around this query",
]);
expect(within(related()).queryByRole("button")).toBeNull();
});
@@ -1,3 +1,4 @@
import { ArrowLeft } from "@phosphor-icons/react/dist/icons/ArrowLeft";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
@@ -12,11 +13,17 @@ import type { ActivitySearch } from "./search";
const styles = stylex.create({
back: {
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
backIcon: {
display: "inline-flex",
},
loading: {
marginTop: "1rem",
color: colors.textMuted,
@@ -32,7 +39,10 @@ const styles = stylex.create({
function BackLink({ origin }: { origin: ActivitySearch }) {
return (
<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>
);
}
@@ -68,15 +78,7 @@ export default function ActivityDetailPage() {
<ProvenanceDetail
provenance={detail}
persistedId={detail.id}
relatedActions={
<RelatedActions
domain={domain}
client={client}
ts={time}
origin={origin}
blocked={detail.policy.action === "block"}
/>
}
relatedActions={<RelatedActions domain={domain} client={client} ts={time} origin={origin} />}
/>
</section>
);
@@ -0,0 +1,758 @@
/**
* The filter toolbar on its own: the controls that decide what reaches the URL,
* driven through a harness that plays the part the page plays.
*
* The timezone is fixed because the only invalid bound reachable in jsdom is a
* wall-clock time inside a spring-forward gap: jsdom applies the
* `datetime-local` value sanitization algorithm, so text that is merely
* incomplete never reaches the component at all — the input hands it back as
* the empty string, which is a bound the operator cleared.
*/
import { useCallback, useState } from "react";
import { afterAll, beforeEach, expect, test, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import { queryKeys } from "@/lib/queries";
import type { Client } from "@/lib/types";
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
import { unixToDatetimeLocal } from "./datetime";
vi.stubEnv("TZ", "Europe/Paris");
afterAll(() => vi.unstubAllEnvs());
function client(ip: string, name: string, learnedName: string): Client {
return {
id: 1,
ip,
name,
learned_name: learnedName,
group_id: 1,
group: "default",
hand_edited: name !== "",
first_seen: 1_700_000_000,
last_seen: 1_700_000_100,
};
}
/** Two named clients and one bare address: every case the picker has to show. */
const CLIENTS: Client[] = [
client("192.0.2.10", "Kitchen Pi", "pi.lan"),
client("192.0.2.11", "", "laptop.lan"),
client("192.0.2.12", "", ""),
];
/** The picker reads the clients query the query tables already load. */
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/clients") {
return Promise.resolve(
new Response(JSON.stringify({ clients: CLIENTS }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
}
return Promise.resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }));
}),
);
});
/** A household bigger than the chip row and, at 40, bigger than the 32 cap. */
function manyClients(count: number): Client[] {
return Array.from({ length: count }, (_, index) => client(`198.51.100.${index + 1}`, `Device ${index + 1}`, ""));
}
/** 02:30 does not exist on this date in Paris; the clock jumps 02:00 to 03:00. */
const GAP_WALL_TIME = "2026-03-29T02:30:00";
/** The text of the elements an input points at with `aria-describedby`. */
function describedText(input: HTMLElement): string {
const ids = input.getAttribute("aria-describedby");
if (ids === null) throw new Error("input has no aria-describedby");
return ids
.split(/\s+/)
.map((id) => {
const node = document.getElementById(id);
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
return node.textContent ?? "";
})
.join(" ");
}
/**
* The page's half of the contract: the applied state is held outside the form.
*
* `deferred` holds the patches back instead of applying them, so a test can
* land them in an order the network and the router can genuinely produce —
* an early debounce arriving after a later one, over a draft that has moved on.
*/
function renderFilters(
initial: AppliedFilters = NO_FILTERS,
deferred = false,
primeClients = true,
clients: Client[] = CLIENTS,
) {
const patches: Array<Partial<AppliedFilters>> = [];
const state: { current: AppliedFilters } = { current: initial };
const setter: { current: ((next: AppliedFilters) => void) | null } = { current: null };
function Harness() {
const [applied, setApplied] = useState(initial);
state.current = applied;
setter.current = setApplied;
const onApply = useCallback((patch: Partial<AppliedFilters>) => {
patches.push(patch);
if (!deferred) setApplied((prev) => ({ ...prev, ...patch }));
}, []);
const onClear = useCallback(() => {
patches.push({});
if (!deferred) setApplied(NO_FILTERS);
}, []);
return <ActivityFilters applied={applied} onApply={onApply} onClear={onClear} />;
}
const queryClient = createQueryClient();
// Seeded rather than fetched, so the picker has its options on the first
// render and a test of the toolbar is not also a test of a request landing.
// The one test that is about that arrival opts out and waits for the stub.
if (primeClients) queryClient.setQueryData(queryKeys.clients, clients);
render(
<QueryClientProvider client={queryClient}>
<Harness />
</QueryClientProvider>,
);
/** The URL moving under the form: a commit landing, or the back button. */
function land(next: AppliedFilters) {
act(() => setter.current!(next));
}
return { patches, state, land, queryClient };
}
/**
* A press, as react-aria hears one. `usePress` works in pointer events, or in
* mouse events where jsdom has no `PointerEvent`; a bare `click` is neither, and
* RAC's own controls do not respond to it.
*/
function press(element: HTMLElement) {
fireEvent.mouseDown(element);
fireEvent.mouseUp(element);
fireEvent.click(element);
}
/**
* The picker's trigger: the first button of the client group, whatever it reads
* as. Its label is the selection, so it cannot be looked up by a fixed name.
*/
function clientTrigger(): HTMLElement {
return within(screen.getByRole("group", { name: "Clients" })).getAllByRole("button")[0] as HTMLElement;
}
/** The removable chips, in the order they are shown, by what each one reads as. */
function clientChips(): string[] {
return within(screen.getByRole("group", { name: "Clients" }))
.getAllByRole("button")
.slice(1)
.map((chip) => chip.textContent ?? "");
}
/**
* Opens the client menu, if it is not open already.
*
* The open state is read from the trigger rather than assumed: an open RAC
* popover hides the rest of the page from the accessibility tree — the trigger
* included — so a blind second click would either fail to find it or shut the
* menu it was meant to open.
*/
function openClientMenu() {
if (clientTrigger().getAttribute("aria-expanded") === "true") return;
fireEvent.click(clientTrigger());
}
/**
* Picks one client and shuts the menu again.
*
* A multiple-selection menu stays open on a pick, which is the point of it — but
* an open RAC popover hides the rest of the page from the accessibility tree, so
* a test that wants to read the chips has to close it first, exactly as a reader
* would before looking at them.
*/
function pickClient(name: string) {
openClientMenu();
press(screen.getByRole("menuitemcheckbox", { name }));
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape", code: "Escape" });
}
function openTimeMenu() {
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
}
test("the segments commit to the applied state on the click, with no button in between", () => {
const { patches, state } = renderFilters();
expect(screen.getAllByRole("radio").map((radio) => radio.closest("label")?.textContent)).toEqual([
"Any",
"Blocked",
"Allowed",
]);
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
expect(patches).toEqual([{ blocked: true }]);
expect(state.current.blocked).toBe(true);
fireEvent.click(screen.getByRole("radio", { name: "Allowed" }));
expect(state.current.blocked).toBe(false);
fireEvent.click(screen.getByRole("radio", { name: "Any" }));
expect(state.current.blocked).toBeUndefined();
});
test("a time preset writes an absolute second, and the trigger names the preset", () => {
const now = 1_800_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const { patches } = renderFilters();
openTimeMenu();
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
"Any time",
"Past hour",
"Past 24 hours",
"Past 7 days",
"Custom…",
]);
fireEvent.click(screen.getByRole("menuitem", { name: "Past 24 hours" }));
// Both bounds, concrete: an open upper bound would keep taking in queries
// logged after the reader stopped looking, so the same link tomorrow would
// name a different day.
expect(patches).toEqual([{ since: now / 1000 - 86_400, until: now / 1000 }]);
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
// A clock that has moved on does not move the label: the URL still holds the
// second the click resolved to.
vi.spyOn(Date, "now").mockReturnValue(now + 60_000);
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
vi.restoreAllMocks();
});
test("bounds nobody picked here read as Custom, and Any time clears both", () => {
const { patches } = renderFilters({ ...NO_FILTERS, since: 1_700_000_000, until: 1_700_000_600 });
expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy();
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Any time" }));
expect(patches).toEqual([{ since: undefined, until: undefined }]);
expect(screen.getByRole("button", { name: "Time: Any time" })).toBeTruthy();
});
test("the custom range applies only through Set range, and Enter is Set range", () => {
const { patches } = renderFilters();
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
const since = screen.getByLabelText("Since") as HTMLInputElement;
fireEvent.change(since, { target: { value: "2026-03-29T04:30:00" } });
// Typing a bound is not applying it: the other half may still be half-typed.
expect(patches).toEqual([]);
const applied = { since: Math.floor(new Date("2026-03-29T04:30:00").getTime() / 1000), until: undefined };
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
expect(patches).toEqual([applied]);
// Enter inside a bound is that bound's action, not the toolbar's submit: the
// outer form flushes the text filters and would apply neither half of this.
fireEvent.change(since, { target: { value: "2026-03-29T05:30:00" } });
fireEvent.keyDown(since, { key: "Enter" });
expect(patches).toHaveLength(2);
expect(patches[1]).toEqual({
since: Math.floor(new Date("2026-03-29T05:30:00").getTime() / 1000),
until: undefined,
});
});
test("a rejected bound marks its own input and describes it", () => {
const { patches } = renderFilters();
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
const since = screen.getByLabelText("Since") as HTMLInputElement;
const until = screen.getByLabelText("Until") as HTMLInputElement;
fireEvent.change(since, { target: { value: GAP_WALL_TIME } });
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
expect(since.getAttribute("aria-invalid")).toBe("true");
expect(describedText(since)).toContain("daylight saving");
// Refused means refused, and only the offending bound carries the mark.
expect(patches).toEqual([]);
expect(until.getAttribute("aria-invalid")).toBeNull();
expect(until.getAttribute("aria-describedby")).toBeNull();
});
test("an upper bound below the lower one is refused, against the bound that is wrong", () => {
const { patches } = renderFilters();
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
const since = screen.getByLabelText("Since") as HTMLInputElement;
const until = screen.getByLabelText("Until") as HTMLInputElement;
fireEvent.change(since, { target: { value: "2026-05-02T10:00:00" } });
fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } });
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
expect(until.getAttribute("aria-invalid")).toBe("true");
expect(describedText(until)).toContain("selects nothing");
expect(since.getAttribute("aria-invalid")).toBeNull();
expect(patches).toEqual([]);
// The window is half-open, so two equal bounds are as empty as an inverted
// pair and are refused the same way.
fireEvent.change(until, { target: { value: "2026-05-02T10:00:00" } });
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
expect(until.getAttribute("aria-invalid")).toBe("true");
expect(patches).toEqual([]);
fireEvent.change(until, { target: { value: "2026-05-02T11:00:00" } });
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
expect(until.getAttribute("aria-invalid")).toBeNull();
expect(patches).toHaveLength(1);
});
test("Clear keeps its place while there is nothing to clear, and stays out of the tab order", () => {
const { state } = renderFilters();
const clear = screen.getByText("Clear");
expect(clear.getAttribute("tabindex")).toBe("-1");
expect(clear.getAttribute("aria-hidden")).toBe("true");
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
expect(clear.getAttribute("tabindex")).toBeNull();
expect(clear.getAttribute("aria-hidden")).toBeNull();
fireEvent.click(clear);
expect(state.current).toEqual(NO_FILTERS);
expect(clear.getAttribute("tabindex")).toBe("-1");
});
test("Clear empties the text drafts along with the applied filters", () => {
vi.useFakeTimers();
try {
const { state } = renderFilters();
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
fireEvent.change(domain, { target: { value: "ads" } });
act(() => vi.advanceTimersByTime(400));
expect(state.current.domain).toBe("ads");
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
act(() => vi.advanceTimersByTime(400));
expect(domain.value).toBe("");
expect(state.current).toEqual(NO_FILTERS);
} finally {
vi.useRealTimers();
}
});
test("the domain field is search-shaped, unspellchecked, and labelled without a visible label", () => {
renderFilters();
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
expect(domain.type).toBe("search");
expect(domain.getAttribute("spellcheck")).toBe("false");
expect(domain.getAttribute("autocomplete")).toBe("off");
expect(domain.getAttribute("placeholder")).toBe("Filter domains…");
// The magnifier is decoration over the field, never a second thing to read.
expect(domain.parentElement?.querySelector("[aria-hidden='true'] svg")).toBeTruthy();
});
test("a bound change does not reset the domain draft that is still being typed", () => {
const now = 1_800_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const { state } = renderFilters();
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
fireEvent.change(domain, { target: { value: "ads" } });
// A preset commits at once, while the typed word is still waiting out its
// debounce. The URL moves, but not the part of it this field is derived from.
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
expect(state.current.since).toBe(now / 1000 - 3600);
expect(domain.value).toBe("ads");
vi.restoreAllMocks();
});
test("a debounced commit landing late does not roll the field back over newer typing", () => {
vi.useFakeTimers();
try {
const { patches, land } = renderFilters(NO_FILTERS, true);
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
// Two commits in flight, and the reader is a keystroke ahead of both.
fireEvent.change(domain, { target: { value: "ad" } });
act(() => vi.advanceTimersByTime(400));
fireEvent.change(domain, { target: { value: "ads" } });
act(() => vi.advanceTimersByTime(400));
expect(patches).toEqual([{ domain: "ad" }, { domain: "ads" }]);
fireEvent.change(domain, { target: { value: "adsx" } });
// The older one lands first. It is this toolbar's own echo, two keystrokes
// stale, and seeding the field from it would delete what was typed since.
land({ ...NO_FILTERS, domain: "ad" });
expect(domain.value).toBe("adsx");
land({ ...NO_FILTERS, domain: "ads" });
expect(domain.value).toBe("adsx");
// An address that was never sent from here is someone else's — a pasted
// link, or the back button — and that one does move the field.
land({ ...NO_FILTERS, domain: "elsewhere" });
expect(domain.value).toBe("elsewhere");
} finally {
vi.useRealTimers();
}
});
test("a window moved from outside re-opens the custom row over the bounds it arrived with", () => {
const now = 1_800_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const { land } = renderFilters();
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
// A preset supersedes the custom row, so it is shut and the label is the preset.
expect(screen.queryByLabelText("Since")).toBeNull();
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
// The back button, or a pasted link: a range this form did not choose.
const since = 1_700_000_000;
land({ ...NO_FILTERS, since, until: since + 600 });
expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy();
// The label says Custom, so the fields it names have to be on screen holding
// that range — a Custom window with nothing to read is the label lying.
// jsdom's value sanitizer spells the milliseconds out, so the seeded text is
// the prefix rather than the whole of what the input holds.
expect((screen.getByLabelText("Since") as HTMLInputElement).value).toContain(unixToDatetimeLocal(since));
expect((screen.getByLabelText("Until") as HTMLInputElement).value).toContain(unixToDatetimeLocal(since + 600));
vi.restoreAllMocks();
});
test("a window moved from outside clears an error left over from the old one", () => {
const { land } = renderFilters();
openTimeMenu();
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
const until = screen.getByLabelText("Until") as HTMLInputElement;
fireEvent.change(screen.getByLabelText("Since"), { target: { value: "2026-05-02T10:00:00" } });
fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } });
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
expect(until.getAttribute("aria-invalid")).toBe("true");
const since = 1_700_000_000;
land({ ...NO_FILTERS, since, until: since + 600 });
// The bounds the message was about are gone, so the message is too.
expect((screen.getByLabelText("Until") as HTMLInputElement).getAttribute("aria-invalid")).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
});
test("the picker offers every client and names the filter it is not yet applying", () => {
renderFilters();
expect(clientTrigger().textContent).toContain("Clients");
expect(clientChips()).toEqual([]);
openClientMenu();
// The name is the way in; the address is still the filter.
expect(screen.getAllByRole("menuitemcheckbox").map((item) => item.textContent)).toEqual([
"192.0.2.12",
"Kitchen Pi — 192.0.2.10",
"laptop.lan — 192.0.2.11",
]);
});
test("picking clients commits them to the url at once, as one comma-separated value", () => {
vi.useFakeTimers();
try {
const { patches, state } = renderFilters();
pickClient("Kitchen Pi — 192.0.2.10");
// No debounce: a pick is a decision the reader has finished making, and
// nothing about it can be half-typed.
expect(patches).toEqual([{ client: "192.0.2.10" }]);
expect(state.current.client).toBe("192.0.2.10");
pickClient("laptop.lan — 192.0.2.11");
expect(patches).toEqual([{ client: "192.0.2.10" }, { client: "192.0.2.10,192.0.2.11" }]);
expect(state.current.client).toBe("192.0.2.10,192.0.2.11");
// Nothing lands later either: there is no pending commit behind these.
act(() => vi.advanceTimersByTime(400));
expect(patches).toHaveLength(2);
} finally {
vi.useRealTimers();
}
});
test("the trigger names a single client and counts several", () => {
const { state } = renderFilters();
pickClient("Kitchen Pi — 192.0.2.10");
expect(clientTrigger().textContent).toContain("Kitchen Pi — 192.0.2.10");
pickClient("laptop.lan — 192.0.2.11");
expect(clientTrigger().textContent).toContain("2 clients");
expect(state.current.client).toBe("192.0.2.10,192.0.2.11");
});
test("a picked client is a chip, and the chip takes it back off", () => {
const { state } = renderFilters();
pickClient("Kitchen Pi — 192.0.2.10");
pickClient("laptop.lan — 192.0.2.11");
// A chip is one client beside a row of others, so it wears the short form: the
// address is the half that does not fit, and the menu still spells it out.
expect(clientChips()).toEqual(["Kitchen Pi", "laptop.lan"]);
fireEvent.click(screen.getByRole("button", { name: "Remove client Kitchen Pi — 192.0.2.10" }));
expect(state.current.client).toBe("192.0.2.11");
expect(clientChips()).toEqual(["laptop.lan"]);
});
test("an address no client claims still shows as a chip and can still be removed", () => {
// The log names devices the config has never heard of, and a link filtered on
// one has to stay readable and clearable even though the menu cannot offer it.
const { state } = renderFilters({ ...NO_FILTERS, client: "203.0.113.9,192.0.2.10" });
expect(clientChips()).toEqual(["203.0.113.9", "Kitchen Pi"]);
expect(clientTrigger().textContent).toContain("2 clients");
fireEvent.click(screen.getByRole("button", { name: "Remove client 203.0.113.9" }));
expect(state.current.client).toBe("192.0.2.10");
});
test("the menu ticks the clients the url names, not the ones last picked here", () => {
const { land } = renderFilters();
pickClient("Kitchen Pi — 192.0.2.10");
// The URL moves to a different client from somewhere else.
land({ ...NO_FILTERS, client: "192.0.2.11" });
expect(clientChips()).toEqual(["laptop.lan"]);
openClientMenu();
const ticked = screen
.getAllByRole("menuitemcheckbox")
.filter((item) => item.getAttribute("aria-checked") === "true");
expect(ticked.map((item) => item.textContent)).toEqual(["laptop.lan — 192.0.2.11"]);
});
test("a rename moves the label and leaves the filter on the address", async () => {
const { state, queryClient } = renderFilters();
pickClient("Kitchen Pi — 192.0.2.10");
act(() => {
queryClient.setQueryData(
queryKeys.clients,
CLIENTS.map((entry) => (entry.ip === "192.0.2.10" ? { ...entry, name: "Hallway Pi" } : entry)),
);
});
// Nothing here holds a label, so nothing here can commit one: the chip is a
// rendering of the address, re-rendered.
await waitFor(() => expect(clientChips()).toEqual(["Hallway Pi"]));
expect(state.current.client).toBe("192.0.2.10");
});
test("Clear empties the picker along with everything else", () => {
const { state } = renderFilters();
pickClient("Kitchen Pi — 192.0.2.10");
expect(clientChips()).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
expect(clientChips()).toEqual([]);
expect(state.current).toEqual(NO_FILTERS);
});
test("with no clients loaded the trigger is disabled rather than opening on nothing", () => {
renderFilters({ ...NO_FILTERS, client: "192.0.2.10" }, false, false);
expect(clientTrigger().hasAttribute("disabled")).toBe(true);
// The filter the URL carries is still on screen and still removable, because
// none of that waits on the convenience that names it.
expect(clientChips()).toEqual(["192.0.2.10"]);
});
test("past three chips the rest are a count, and the count removes nothing", () => {
const clients = manyClients(12);
renderFilters({ ...NO_FILTERS, client: clients.map((entry) => entry.ip).join(",") }, false, true, clients);
// Three, and then how many more. A dozen chips are not more visible than
// three: they wrap the toolbar into a block and push the table off the screen.
expect(clientChips()).toEqual(["Device 1", "Device 2", "Device 3"]);
expect(screen.getByText("+9 more")).toBeTruthy();
expect(clientTrigger().textContent).toContain("12 clients");
// The summary is not a button, so there is no fourth thing to remove and no
// pointer target that does nothing.
expect(within(screen.getByRole("group", { name: "Clients" })).getAllByRole("button")).toHaveLength(4);
expect(screen.queryByRole("button", { name: /Remove client Device 4/ })).toBeNull();
});
test("every known client at once commits every one of their addresses", () => {
const { patches, state } = renderFilters();
pickClient("Kitchen Pi — 192.0.2.10");
pickClient("laptop.lan — 192.0.2.11");
expect(state.current.client).toBe("192.0.2.10,192.0.2.11");
// The whole household is still written out. What is picked is what is
// committed, so the ticks, the chips and the URL all say what the reader just
// did — and pruned clients keep history rows, so this is not "no filter".
pickClient("192.0.2.12");
expect(patches.at(-1)).toEqual({ client: "192.0.2.10,192.0.2.11,192.0.2.12" });
expect(state.current.client).toBe("192.0.2.10,192.0.2.11,192.0.2.12");
expect(clientChips()).toEqual(["Kitchen Pi", "laptop.lan", "192.0.2.12"]);
});
test("the only client on the network is still a client you can pick", () => {
// The household the owner hit: one known client, so picking it is picking all
// of them. A picker that answered that click by clearing itself would read as
// a control that did nothing at all.
const { state } = renderFilters(NO_FILTERS, false, true, [CLIENTS[0] as Client]);
pickClient("Kitchen Pi — 192.0.2.10");
expect(state.current.client).toBe("192.0.2.10");
expect(clientChips()).toEqual(["Kitchen Pi"]);
expect(clientTrigger().textContent).toContain("Kitchen Pi — 192.0.2.10");
openClientMenu();
expect(screen.getByRole("menuitemcheckbox", { name: "Kitchen Pi — 192.0.2.10" }).getAttribute("aria-checked")).toBe(
"true",
);
});
test("the menu stops at the cap the server enforces, and says so", () => {
const clients = manyClients(40);
const picked = clients.slice(0, 32).map((entry) => entry.ip);
const { patches } = renderFilters({ ...NO_FILTERS, client: picked.join(",") }, false, true, clients);
openClientMenu();
expect(screen.getByText("At most 32 clients at a time.")).toBeTruthy();
const item = screen.getByRole("menuitemcheckbox", { name: "Device 33 — 198.51.100.33" });
expect(item.getAttribute("aria-disabled")).toBe("true");
// A menu that took a 33rd pick and dropped it would look like it had worked.
press(item);
expect(patches).toEqual([]);
// What is already picked can still be unpicked, or the reader would be stuck.
const chosen = screen.getByRole("menuitemcheckbox", { name: "Device 1 — 198.51.100.1" });
expect(chosen.getAttribute("aria-disabled")).toBeNull();
});
test("the cap note appears when the cap binds, however few clients are loaded", () => {
const clients = manyClients(5);
// Two known and thirty unknown: the cap is full while the menu still has three
// rows it will not let anyone pick, and a disabled row with nothing to explain
// it is the one state this must not reach.
const unknown = Array.from({ length: 30 }, (_, index) => `203.0.113.${index + 1}`);
renderFilters(
{ ...NO_FILTERS, client: [clients[0]!.ip, clients[1]!.ip, ...unknown].join(",") },
false,
true,
clients,
);
openClientMenu();
expect(screen.getByText("At most 32 clients at a time.")).toBeTruthy();
expect(
screen.getByRole("menuitemcheckbox", { name: "Device 3 — 198.51.100.3" }).getAttribute("aria-disabled"),
).toBe("true");
});
test("with room left the cap note stays out of the menu", () => {
renderFilters();
openClientMenu();
// Three clients and nothing picked: a ceiling nobody can reach is not news.
expect(screen.queryByText("At most 32 clients at a time.")).toBeNull();
});
test("an address no client claims takes a slot in the cap like any other", () => {
const clients = manyClients(40);
// Thirty-one known and one the config has never heard of. The cap is on what
// the request may name, not on what this menu happens to be able to show.
const picked = [...clients.slice(0, 31).map((entry) => entry.ip), "203.0.113.9"];
renderFilters({ ...NO_FILTERS, client: picked.join(",") }, false, true, clients);
openClientMenu();
const item = screen.getByRole("menuitemcheckbox", { name: "Device 33 — 198.51.100.33" });
expect(item.getAttribute("aria-disabled")).toBe("true");
});
test("select-all is held to the cap, with the addresses already filtered keeping their slots", () => {
const clients = manyClients(40);
const unknown = ["203.0.113.1", "203.0.113.2", "203.0.113.3", "203.0.113.4", "203.0.113.5"];
const { state } = renderFilters({ ...NO_FILTERS, client: unknown.join(",") }, false, true, clients);
openClientMenu();
// Ctrl+A reaches the selection without pressing an item, so the disabled rows
// never see it and the cap has to hold here too.
fireEvent.keyDown(screen.getByRole("menu"), { key: "a", code: "KeyA", ctrlKey: true });
fireEvent.keyUp(screen.getByRole("menu"), { key: "a", code: "KeyA", ctrlKey: true });
const applied = state.current.client?.split(",") ?? [];
expect(applied).toHaveLength(32);
// What was already filtered keeps its slots and the new picks take what is
// left, rather than the list being cut wherever it happened to run out.
expect(applied.slice(0, 5)).toEqual(unknown);
// The rest are known clients, each once. Which 27 is the menu's own order and
// not something this test should restate.
const rest = applied.slice(5);
const addresses = new Set(clients.map((entry) => entry.ip));
expect(rest.every((ip) => addresses.has(ip))).toBe(true);
expect(new Set(rest).size).toBe(27);
});
test("removing a chip hands the focus on rather than dropping it", () => {
const clients = manyClients(5);
const { state } = renderFilters(
{
...NO_FILTERS,
client: clients
.slice(0, 4)
.map((entry) => entry.ip)
.join(","),
},
false,
true,
clients,
);
// The chip that takes the removed one's place, so a reader clearing three
// clients from the keyboard does not tab back in from the top each time.
fireEvent.click(screen.getByRole("button", { name: "Remove client Device 2 — 198.51.100.2" }));
expect(document.activeElement?.getAttribute("aria-label")).toBe("Remove client Device 3 — 198.51.100.3");
// Nothing after it, so the one before it.
fireEvent.click(screen.getByRole("button", { name: "Remove client Device 4 — 198.51.100.4" }));
fireEvent.click(screen.getByRole("button", { name: "Remove client Device 3 — 198.51.100.3" }));
expect(document.activeElement?.getAttribute("aria-label")).toBe("Remove client Device 1 — 198.51.100.1");
// No chips left, so the control the row belongs to.
fireEvent.click(screen.getByRole("button", { name: "Remove client Device 1 — 198.51.100.1" }));
expect(document.activeElement).toBe(clientTrigger());
expect(state.current.client).toBeUndefined();
});
+511 -139
View File
@@ -1,73 +1,196 @@
/**
* The filter row over the Activity table.
* The filter toolbar over the Activity history table.
*
* The applied state is the URL, never this form: what the reader sees is what
* the link they can paste to a housemate will show. So this holds a draft only,
* and the page remounts it whenever the applied search changes — a back button
* or a pasted URL has to move the form with it, and a form that seeded itself
* once would keep showing the previous investigation's filters.
* the link they can paste to a housemate will show. So this holds a draft, and
* every control writes through to the URL — the segments, the client picker and
* the time presets at once, the domain field after a pause so a five-letter word
* is one navigation rather than five.
*
* In live mode the row stays visible and disabled rather than disappearing: the
* filters are retained in the URL and apply again the moment history comes
* back, and hiding them would read as having lost them. The stream itself is
* unfiltered — the server sends every query — so a row that looked usable here
* A time preset writes both bounds as absolute seconds, resolved once at the
* click. Neither half may be left open: a bookmark has to describe the same
* investigation tomorrow, and a window that slid overnight — or one that stayed
* open at the top and swallowed everything logged since — would answer a
* different question under the same link.
*
* A URL can move under this form at any time, and the draft only follows the
* part of it that actually moved. A field is re-seeded when its own URL value
* changed and the new value is not one this toolbar just wrote: the debounce
* means the URL is always a little behind the keyboard, and a landing commit
* must not roll the input back over the letters typed since.
*
* The custom range is the one control that does not live-apply. Two half-typed
* timestamps are a normal intermediate state of typing one of them, and a lower
* bound at or above an upper bound selects nothing at all, so the pair is
* validated and applied together or not at all.
*
* Live mode does not render this at all — the page mounts it inside the History
* panel. The stream is unfiltered, and a row of controls that looked usable
* would promise filtering that is not happening.
*/
import { useState, type FormEvent } from "react";
import { MagnifyingGlass } from "@phosphor-icons/react/dist/icons/MagnifyingGlass";
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import Select from "@/ui/Select";
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import ClientFilter, { joinClients, parseClients, useClientOptions } from "./ClientFilter";
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
import type { ActivitySearch } from "./search";
const STATUS_OPTIONS = [
{ value: "any", label: "All" },
{ value: "blocked", label: "Blocked only" },
{ value: "allowed", label: "Allowed only" },
];
/** Long enough that a typed word is one navigation, short enough to feel live. */
const DEBOUNCE_MS = 350;
const RESULTS = [
{ value: "any", label: "Any" },
{ value: "blocked", label: "Blocked" },
{ value: "allowed", label: "Allowed" },
] as const;
const PRESETS = [
{ label: "Any time", seconds: null },
{ label: "Past hour", seconds: 3600 },
{ label: "Past 24 hours", seconds: 86_400 },
{ label: "Past 7 days", seconds: 604_800 },
] as const;
const CUSTOM_ITEM = "Custom…";
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
const HIT_TARGET = 44;
const styles = stylex.create({
/** One column on a phone, two from `sm`, five from `lg`. */
grid: {
toolbar: {
marginTop: "1rem",
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
},
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
label: {
display: "block",
/** The domain field is the one that grows; everything else keeps its size. */
searchWrap: {
position: "relative",
flexGrow: 1,
flexShrink: 1,
flexBasis: "14rem",
display: "flex",
},
searchIcon: {
display: "inline-flex",
position: "absolute",
insetInlineStart: "0.5rem",
top: "50%",
transform: "translateY(-50%)",
color: colors.textMuted,
pointerEvents: "none",
},
/** Every control in the row is a pointer target before it is anything else. */
field: {
minHeight: HIT_TARGET,
},
searchInput: {
width: "100%",
paddingInlineStart: "1.875rem",
},
/** A button is text-sized by default; this is the hit area around the text. */
hitTarget: {
minHeight: HIT_TARGET,
minWidth: HIT_TARGET,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
},
resultGroup: {
display: "flex",
gap: "0.25rem",
},
/** The segment styling of the Overview period picker, item for item. */
segment: {
cursor: "pointer",
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
minHeight: HIT_TARGET,
minWidth: HIT_TARGET,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
},
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
segmentFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
segmentSelected: {
backgroundColor: {
default: "oklch(92% 0.004 286.32)",
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
},
color: colors.text,
fontWeight: 500,
},
segmentIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: colors.textSecondary,
},
popover: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
color: colors.text,
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
menu: {
outlineStyle: "none",
paddingBlock: "0.25rem",
},
menuItem: {
cursor: "pointer",
paddingInline: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
whiteSpace: "nowrap",
minHeight: HIT_TARGET,
display: "flex",
alignItems: "center",
},
/** Inset because an item flush against the popover edge clips an outset ring. */
menuItemFocused: {
backgroundColor: colors.primary,
color: colors.primaryText,
outlineColor: { default: null, ":focus-visible": colors.primaryText },
},
/**
* Clear keeps its box when there is nothing to clear. It appears the moment a
* filter is set, and a control that appeared by widening the row would move
* every other control out from under the pointer that was reaching for it.
*/
clearHidden: {
visibility: "hidden",
},
customRow: {
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
alignItems: "flex-end",
gap: "0.5rem",
},
customField: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
input: {
marginTop: "0.25rem",
width: "100%",
// A disabled native input keeps its value legible but reads as inert,
// matching what RAC does to the Select trigger beside it.
cursor: { default: null, ":disabled": "not-allowed" },
opacity: { default: null, ":disabled": 0.55 },
},
buttonRow: {
display: "flex",
alignItems: "flex-end",
gap: "0.5rem",
gridColumn: {
default: null,
"@media (min-width: 640px)": "span 2 / span 2",
"@media (min-width: 1024px)": "span 5 / span 5",
},
},
toolbarButton: {
fontWeight: 500,
},
error: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.dangerText,
@@ -86,6 +209,16 @@ export const NO_FILTERS: AppliedFilters = {
until: undefined,
};
/** The half-open window the API reads: `ts >= since` and `ts < until`. */
interface Bounds {
since: number | undefined;
until: number | undefined;
}
function sameBounds(a: Bounds, b: Bounds): boolean {
return a.since === b.since && a.until === b.until;
}
function blockedOption(blocked: boolean | undefined): string {
if (blocked === undefined) return "any";
return blocked ? "blocked" : "allowed";
@@ -96,135 +229,374 @@ function optionBlocked(value: string): boolean | undefined {
return value === "allowed" ? false : undefined;
}
function boundError(label: string, reason: "unparseable" | "nonexistent"): string {
return reason === "unparseable"
? `${label} is not a complete date and time.`
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`;
/** A filter value the URL can carry: trimmed, and empty means absent. */
function textFilter(value: string): string | undefined {
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
/** The message plus the bound it belongs to, so that input can point at it. */
interface BoundError {
field: "since" | "until";
message: string;
}
/** The toolbar is rendered once per page, so the message can hold a fixed id. */
const ERROR_ID = "activity-filter-error";
function boundError(field: "since" | "until", reason: "unparseable" | "nonexistent"): BoundError {
const label = field === "since" ? "Since" : "Until";
return {
field,
message:
reason === "unparseable"
? `${label} is not a complete date and time.`
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`,
};
}
/** The preset a label is claimed for, kept only while the URL still holds it. */
interface ChosenPreset extends Bounds {
label: string;
}
/**
* What this toolbar knows about the URL, and what it is still waiting to see
* come back from it.
*
* `domain` and `bounds` are the last values looked at, so a change can be told
* apart field by field. `pendingDomain` and `pendingBounds` are every commit
* made here that the URL has not echoed yet. Both are queues rather than single
* slots: two keystrokes either side of the debounce put two domain commits in
* flight, and two menu picks in quick succession do the same to the range. In
* both cases the older echo landing second must not be mistaken for someone
* else's edit — that is what would clear the preset out from under the pick
* that is actually current.
*
* Only the domain needs any of this. Every other control commits on the action
* itself, so the URL is never behind what the reader is doing with it.
*/
interface Sync {
domain: string;
bounds: Bounds;
pendingDomain: string[];
pendingBounds: Bounds[];
}
interface Props {
applied: AppliedFilters;
isDisabled: boolean;
onApply: (filters: AppliedFilters) => void;
/** Merges a patch into the applied search. `replace` keeps typing out of history. */
onApply: (patch: Partial<AppliedFilters>, replace?: boolean) => void;
onClear: () => void;
}
export default function ActivityFilters({ applied, isDisabled, onApply, onClear }: Props) {
const [domain, setDomain] = useState(applied.domain ?? "");
const [client, setClient] = useState(applied.client ?? "");
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
export default function ActivityFilters({ applied, onApply, onClear }: Props) {
const urlDomain = applied.domain ?? "";
const urlBounds: Bounds = { since: applied.since, until: applied.until };
const clientOptions = useClientOptions();
// The picked clients are read straight off the URL: there is no draft to hold,
// because there is no state here the reader can leave half-finished.
const clients = parseClients(applied.client);
const [domain, setDomain] = useState(urlDomain);
const [preset, setPreset] = useState<ChosenPreset | null>(null);
const [showCustom, setShowCustom] = useState(applied.since !== undefined || applied.until !== undefined);
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<BoundError | null>(null);
const [sync, setSync] = useState<Sync>({
domain: urlDomain,
bounds: urlBounds,
pendingDomain: [],
pendingBounds: [],
});
function submit(event: FormEvent) {
const domainMoved = sync.domain !== urlDomain;
const boundsMoved = !sameBounds(sync.bounds, urlBounds);
if (domainMoved || boundsMoved) {
let pendingDomain = sync.pendingDomain;
let pendingBounds = sync.pendingBounds;
if (domainMoved) {
// The newest commit the URL matches, and everything before it, has now
// been accounted for; an older one landing later is not new news.
const echo = pendingDomain.findLastIndex((sent) => sent === urlDomain);
if (echo >= 0) {
pendingDomain = pendingDomain.slice(echo + 1);
} else {
pendingDomain = [];
setDomain(urlDomain);
}
}
if (boundsMoved) {
const echo = pendingBounds.findLastIndex((sent) => sameBounds(sent, urlBounds));
if (echo >= 0) {
pendingBounds = pendingBounds.slice(echo + 1);
} else {
// Someone else moved the window — the back button, or a pasted link.
// Whatever this form was showing about the old one is now wrong: the
// preset it was named after, an error against bounds that are gone,
// and a custom row that is open or shut for the wrong range.
pendingBounds = [];
setPreset(null);
setError(null);
setShowCustom(urlBounds.since !== undefined || urlBounds.until !== undefined);
setSince(datetimeField(urlBounds.since));
setUntil(datetimeField(urlBounds.until));
}
}
setSync({ domain: urlDomain, bounds: urlBounds, pendingDomain, pendingBounds });
}
const dirty = textFilter(domain) !== applied.domain;
const commitDomain = useCallback(
(replace: boolean) => {
const next = textFilter(domain);
setSync((prev) => ({ ...prev, pendingDomain: [...prev.pendingDomain, next ?? ""] }));
onApply({ domain: next }, replace);
},
[domain, onApply],
);
function commitBounds(bounds: Bounds) {
setSync((prev) => ({ ...prev, pendingBounds: [...prev.pendingBounds, bounds] }));
onApply(bounds);
}
useEffect(() => {
if (!dirty) return;
const id = setTimeout(() => commitDomain(true), DEBOUNCE_MS);
return () => clearTimeout(id);
}, [dirty, commitDomain]);
function flush(event: FormEvent) {
event.preventDefault();
if (dirty) commitDomain(true);
}
function selectPreset(label: string) {
if (label === CUSTOM_ITEM) {
setShowCustom(true);
return;
}
const option = PRESETS.find((candidate) => candidate.label === label);
if (option === undefined) return;
setShowCustom(false);
setError(null);
if (option.seconds === null) {
setPreset(null);
setSince(datetimeField(undefined));
setUntil(datetimeField(undefined));
commitBounds({ since: undefined, until: undefined });
return;
}
// Both bounds, resolved once, here. An open upper bound would keep taking
// in queries logged after the reader stopped looking, so "the past hour"
// would name a different hour every time the link was opened.
const now = Math.floor(Date.now() / 1000);
const bounds: Bounds = { since: now - option.seconds, until: now };
setPreset({ label, ...bounds });
setSince(datetimeField(bounds.since));
setUntil(datetimeField(bounds.until));
commitBounds(bounds);
}
function setRange() {
const sinceValue = resolveDatetimeField(since);
if (!sinceValue.ok) {
setError(boundError("Since", sinceValue.reason));
setError(boundError("since", sinceValue.reason));
return;
}
const untilValue = resolveDatetimeField(until);
if (!untilValue.ok) {
setError(boundError("Until", untilValue.reason));
setError(boundError("until", untilValue.reason));
return;
}
// The window is half-open — `ts >= since` and `ts < until` — so two equal
// bounds are as empty as an inverted pair, and neither is worth applying.
if (sinceValue.value !== undefined && untilValue.value !== undefined && untilValue.value <= sinceValue.value) {
setError({
field: "until",
message: "Until must be after Since, or the range selects nothing.",
});
return;
}
setError(null);
onApply({
domain: domain.trim() === "" ? undefined : domain.trim(),
client: client.trim() === "" ? undefined : client.trim(),
blocked: optionBlocked(blocked),
since: sinceValue.value,
until: untilValue.value,
});
setPreset(null);
commitBounds({ since: sinceValue.value, until: untilValue.value });
}
function clear() {
setDomain("");
setClient("");
setBlocked("any");
setPreset(null);
setShowCustom(false);
setSince(datetimeField(undefined));
setUntil(datetimeField(undefined));
setError(null);
setSync((prev) => ({
...prev,
pendingDomain: [],
pendingBounds: [...prev.pendingBounds, { since: undefined, until: undefined }],
}));
onClear();
}
return (
<>
<form onSubmit={submit} {...stylex.props(styles.grid)}>
<label {...stylex.props(styles.label)}>
Domain contains
<input
type="text"
value={domain}
disabled={isDisabled}
onChange={(event) => setDomain(event.target.value)}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
/>
</label>
<label {...stylex.props(styles.label)}>
Client (exact)
<input
type="text"
value={client}
disabled={isDisabled}
onChange={(event) => setClient(event.target.value)}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
/>
</label>
<Select
variant="compactField"
label="Result"
value={blocked}
isDisabled={isDisabled}
onChange={setBlocked}
options={STATUS_OPTIONS}
const active =
domain.trim() !== "" ||
clients.length > 0 ||
applied.blocked !== undefined ||
applied.since !== undefined ||
applied.until !== undefined;
// The label the URL earns on its own, overridden only while a preset click is
// still the whole of what the URL says. Bounds nobody here chose read as
// "Custom": that is what a pasted link or an edited range is.
let timeLabel = "Any time";
if (applied.since !== undefined || applied.until !== undefined) {
timeLabel = preset !== null && sameBounds(preset, urlBounds) ? preset.label : "Custom";
}
/** True for the one bound the current message is about; nothing else is marked. */
const invalid = (field: BoundError["field"]): true | undefined =>
error !== null && error.field === field ? true : undefined;
function boundInput(field: BoundError["field"]) {
const state = field === "since" ? since : until;
const set = field === "since" ? setSince : setUntil;
return (
<label {...stylex.props(styles.customField)}>
{field === "since" ? "Since" : "Until"}
<input
type="datetime-local"
step={1}
value={state.text}
aria-invalid={invalid(field)}
aria-describedby={invalid(field) && ERROR_ID}
onChange={(event) => set(editDatetimeField(state, event.target.value))}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
// Enter here means this range, not the toolbar's text filters:
// the two bounds only ever apply together, and the outer form's
// submit would apply neither of them.
if (event.key !== "Enter") return;
event.preventDefault();
setRange();
}}
onBlur={() => {
const resolved = resolveDatetimeField(state);
if (!resolved.ok) setError(boundError(field, resolved.reason));
else if (invalid(field)) setError(null);
}}
{...stylex.props(shared.smallInput, styles.field, shared.focusRing)}
/>
<label {...stylex.props(styles.label)}>
Since
{invalid(field) && (
<span id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
{error?.message}
</span>
)}
</label>
);
}
return (
<form onSubmit={flush}>
<div {...stylex.props(styles.toolbar)}>
<div {...stylex.props(styles.searchWrap)}>
<span aria-hidden="true" {...stylex.props(styles.searchIcon)}>
<MagnifyingGlass size={14} />
</span>
<input
type="datetime-local"
step={1}
value={since.text}
disabled={isDisabled}
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
type="search"
aria-label="Filter domains"
placeholder="Filter domains…"
spellCheck={false}
autoComplete="off"
value={domain}
onChange={(event) => setDomain(event.target.value)}
{...stylex.props(shared.smallInput, styles.field, styles.searchInput, shared.focusRing)}
/>
</label>
<label {...stylex.props(styles.label)}>
Until
<input
type="datetime-local"
step={1}
value={until.text}
disabled={isDisabled}
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
/>
</label>
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={isDisabled}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
</div>
<ClientFilter
options={clientOptions}
selected={clients}
onChange={(next) => onApply({ client: joinClients(next) })}
/>
<RadioGroup
aria-label="Result"
orientation="horizontal"
value={blockedOption(applied.blocked)}
onChange={(next) => onApply({ blocked: optionBlocked(next) })}
className={() => stylex.props(styles.resultGroup).className ?? ""}
>
{RESULTS.map((option) => (
<Radio
key={option.value}
value={option.value}
className={({ isSelected, isFocusVisible }) =>
stylex.props(
styles.segment,
isSelected ? styles.segmentSelected : styles.segmentIdle,
isFocusVisible && styles.segmentFocusVisible,
).className ?? ""
}
>
{option.label}
</Radio>
))}
</RadioGroup>
<MenuTrigger>
<Button
className={() =>
stylex.props(shared.button, styles.hitTarget, shared.focusRing).className ?? ""
}
>
Apply filters
</button>
Time: {timeLabel}
</Button>
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
<Menu {...stylex.props(styles.menu)}>
{[...PRESETS.map((option) => option.label), CUSTOM_ITEM].map((label) => (
<MenuItem
key={label}
onAction={() => selectPreset(label)}
className={({ isFocused }) =>
stylex.props(
styles.menuItem,
shared.insetFocusRing,
isFocused && styles.menuItemFocused,
).className ?? ""
}
>
{label}
</MenuItem>
))}
</Menu>
</Popover>
</MenuTrigger>
<button
type="button"
onClick={clear}
tabIndex={active ? undefined : -1}
aria-hidden={active ? undefined : true}
{...stylex.props(shared.button, styles.hitTarget, shared.focusRing, !active && styles.clearHidden)}
>
Clear
</button>
</div>
{showCustom && (
<div {...stylex.props(styles.customRow)}>
{boundInput("since")}
{boundInput("until")}
<button
type="button"
onClick={clear}
disabled={isDisabled}
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
onClick={setRange}
{...stylex.props(shared.button, styles.hitTarget, shared.focusRing)}
>
Clear
Set range
</button>
</div>
</form>
{error !== null && (
<p role="alert" {...stylex.props(styles.error)}>
{error}
</p>
)}
</>
</form>
);
}
+210 -22
View File
@@ -12,6 +12,7 @@ import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
import { queryRow } from "@/features/provenance/provenanceFixture";
import { FakeEventSource } from "./fakeEventSource";
function client(id: number, ip: string, name: string, learnedName: string): Client {
return {
@@ -135,6 +136,22 @@ function queryCalls(): string[] {
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
}
/** The toolbar's search field, which is how a domain filter is entered now. */
function domainInput(): HTMLInputElement {
return screen.getByLabelText("Filter domains") as HTMLInputElement;
}
/** Enter in a text field: the debounce's escape hatch, and the fast path here. */
function submitFilters() {
fireEvent.submit(domainInput().closest("form")!);
}
/** The custom range lives behind the Time menu; the two bounds only exist there. */
function openCustomRange() {
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
}
test("renders the first page with the seven columns filled in", async () => {
renderPage();
await screen.findByText("first.example");
@@ -162,7 +179,7 @@ test("renders the first page with the seven columns filled in", async () => {
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
});
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
test("resolves each row's client to its display name, reading the IP out with it", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
@@ -182,19 +199,22 @@ test("resolves each row's client to its display name, keeping the IP as the tool
// A hand-typed name wins outright; the learned name never surfaces for it.
const named = await screen.findByText("Kitchen Pi");
expect(named.getAttribute("title")).toBe("192.0.2.10");
// The address reads out with the name it replaced, rather than sitting in a
// title only a mouse can reach.
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
expect(named.getAttribute("title")).toBeNull();
expect(screen.queryByText("pi.lan")).toBeNull();
// A learned name reads muted and nothing more here: the "learned" tag would
// repeat on every row of the table, so the Clients page carries it instead.
const learned = screen.getByText("laptop.lan");
expect(learned.getAttribute("title")).toBe("192.0.2.11");
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
// A known client with neither name, and a client the loaded list has never
// seen, both fall back to the bare address with no tooltip standing in.
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
// seen, both fall back to the bare address with nothing standing in for it.
expect(screen.getByText("192.0.2.12").textContent).toBe("192.0.2.12");
expect(screen.getByText("192.0.2.99").textContent).toBe("192.0.2.99");
});
test("load more appends the next page and stops at the end of the log", async () => {
@@ -216,8 +236,8 @@ test("applying a filter puts it in the url, refetches, and resets the accumulate
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await screen.findByText("older.example");
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
await screen.findByText(/Showing 1 query /);
expect(history.location.search).toContain("domain=ads");
@@ -242,8 +262,8 @@ test("a load-more that resolves after a filter change is discarded", async () =>
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
await screen.findByText(/Showing 1 query /);
releaseLoadMore();
@@ -281,8 +301,8 @@ test("load more is disabled while a filter change shows placeholder data, then u
renderPage();
await screen.findByText("first.example");
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
fireEvent.change(domainInput(), { target: { value: "ads" } });
submitFilters();
const staleButton = await screen.findByRole("button", { name: "Load more" });
expect(staleButton).toHaveProperty("disabled", true);
@@ -416,7 +436,7 @@ test("a ?domain= link seeds the filter form and fetches that domain on arrival",
renderPage("/activity?domain=ads");
await screen.findByText("ads.example");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
expect(domainInput()).toHaveProperty("value", "ads");
expect(screen.queryByText("first.example")).toBeNull();
});
@@ -441,7 +461,7 @@ test("a rejected search parameter is dropped rather than guessed at", async () =
await screen.findByText("first.example");
// Nothing survived validation, so the request is the unfiltered one.
expect(queryCalls()).toEqual(["/api/queries"]);
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
expect(domainInput()).toHaveProperty("value", "");
});
test("the form draft follows the url back and forward, seconds included", async () => {
@@ -454,25 +474,33 @@ test("the form draft follows the url back and forward, seconds included", async
const seeded = 1_700_000_017;
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
const domainInput = await screen.findByLabelText("Domain contains");
expect(domainInput).toHaveProperty("value", "first");
await screen.findByLabelText("Filter domains");
expect(domainInput()).toHaveProperty("value", "first");
// A seeded custom range opens its row, so the link's bounds are visible.
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
expect(sinceInput.value).toContain(":37");
fireEvent.change(domainInput, { target: { value: "second" } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
fireEvent.change(domainInput(), { target: { value: "second" } });
submitFilters();
await waitFor(() => expect(history.location.search).toContain("domain=second"));
// The untouched Since bound applied as the exact second it was seeded with.
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);
// A pasted link, then the buttons over it: the draft is derived from the URL,
// so whichever way the browser moves it the field has to move with it.
act(() => history.push(`/activity?mode=history&domain=third&since=${seeded}`));
await waitFor(() => {
expect(domainInput()).toHaveProperty("value", "third");
});
act(() => history.back());
await waitFor(() => {
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
expect(domainInput()).toHaveProperty("value", "second");
});
act(() => history.forward());
await waitFor(() => {
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
expect(domainInput()).toHaveProperty("value", "third");
});
});
@@ -508,8 +536,9 @@ test("a wall-clock time the daylight-saving jump skips is refused, not silently
const callsBefore = queryCalls().length;
const searchBefore = history.location.search;
openCustomRange();
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
if (inGap) {
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
@@ -544,6 +573,40 @@ test("the policy simulation is reachable from the header, with no rows to click
expect(history.location.pathname).toBe("/activity/test");
});
test("the mode switch is a tab list whose selection is the url, and it keeps the filters", async () => {
// Live opens a stream as soon as its panel mounts, so the switch cannot be
// exercised without one; the fake stands in for the browser's EventSource.
vi.stubGlobal(
"EventSource",
class {
constructor(url: string) {
return new FakeEventSource(url) as unknown as EventSource;
}
},
);
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
await screen.findByText("ads.example");
const tabs = within(screen.getByRole("tablist", { name: "Activity mode" }));
expect(tabs.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["History", "Live"]);
expect(tabs.getByRole("tab", { name: "History", selected: true })).toBeTruthy();
expect(tabs.getByRole("tab", { name: "Live", selected: false })).toBeTruthy();
fireEvent.click(tabs.getByRole("tab", { name: "Live" }));
await waitFor(() => expect(history.location.search).toContain("mode=live"));
// The investigation survives the switch: both filters are still in the URL.
expect(history.location.search).toContain("domain=ads");
expect(history.location.search).toContain("blocked=true");
expect(
within(screen.getByRole("tablist", { name: "Activity mode" })).getByRole("tab", {
name: "Live",
selected: true,
}),
).toBeTruthy();
expect(await screen.findByText(/the History filters apply to history only/)).toBeTruthy();
});
test("Clear empties the url as well as the form", async () => {
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
await screen.findByText("ads.example");
@@ -553,5 +616,130 @@ test("Clear empties the url as well as the form", async () => {
expect(history.location.search).not.toContain("domain");
});
expect(history.location.search).not.toContain("blocked");
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
expect(domainInput()).toHaveProperty("value", "");
});
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { history } = renderPage();
await screen.findByText("first.example");
fireEvent.change(domainInput(), { target: { value: "a" } });
fireEvent.change(domainInput(), { target: { value: "ad" } });
fireEvent.change(domainInput(), { target: { value: "ads" } });
// Mid-word the URL has not moved: three keystrokes are one investigation,
// not three, and each one would otherwise be a request and a history entry.
expect(history.location.search).not.toContain("domain");
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(history.location.search).toContain("domain=ads");
// Replaced, not pushed: Back leaves the page, it does not retype the word.
expect(history.length).toBe(1);
fireEvent.change(domainInput(), { target: { value: "first" } });
submitFilters();
await waitFor(() => expect(history.location.search).toContain("domain=first"));
} finally {
vi.useRealTimers();
}
});
test("the field being typed in keeps the focus when the debounce commits", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const { history } = renderPage();
await screen.findByText("first.example");
const input = domainInput();
input.focus();
fireEvent.change(input, { target: { value: "ads" } });
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
await waitFor(() => expect(history.location.search).toContain("domain=ads"));
// The same node, still focused, still holding the caret: a toolbar that
// remounted on the URL it just wrote would drop the next keystroke.
expect(domainInput()).toBe(input);
expect(document.activeElement).toBe(input);
} finally {
vi.useRealTimers();
}
});
test("a result segment commits on the click, with no wait and no button", async () => {
const { history } = renderPage("/activity?domain=ads");
await screen.findByText("ads.example");
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
await waitFor(() => expect(history.location.search).toContain("blocked=true"));
expect(history.location.search).toContain("domain=ads");
});
test("a time preset writes the second it resolved to, not a rolling window", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
});
const { history } = renderPage();
await screen.findByText("No queries logged yet.");
fireEvent.click(screen.getByRole("button", { name: "Time: Any time" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
// Both bounds are concrete, so the link names a closed hour rather than one
// that keeps growing at the top as the log does.
const since = now / 1000 - 3600;
const until = now / 1000;
await waitFor(() => expect(history.location.search).toContain(`since=${since}`));
expect(history.location.search).toContain(`until=${until}`);
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
expect(queryCalls()).toContain(`/api/queries?since=${since}&until=${until}`);
vi.restoreAllMocks();
});
test("Live shows no toolbar at all", async () => {
vi.stubGlobal(
"EventSource",
class {
constructor(url: string) {
return new FakeEventSource(url) as unknown as EventSource;
}
},
);
renderPage("/activity?mode=live&domain=ads");
await screen.findByText(/the History filters apply to history only/);
// The stream is unfiltered, so a control here would promise filtering that is
// not happening; the filters are still in the URL, waiting for History.
expect(screen.queryByLabelText("Filter domains")).toBeNull();
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
});
test("the coverage watermark reads under the results, never over them", async () => {
stubFetch((url) => {
if (url === "/api/clients") return json({ clients: CLIENTS });
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return json({
queries: [row(20, "kept.example")],
next_before: null,
coverage: { complete: false, available_since: 1_700_000_000 },
} satisfies QueriesPage);
});
renderPage();
await screen.findByText("kept.example");
const watermark = screen.getByText(/Query history is available from/);
const count = screen.getByText(/Showing 1 query/);
// After the count in document order, which is what "footer" means here.
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
+86 -47
View File
@@ -8,8 +8,10 @@
* recipient should be looking at.
*/
import { useCallback } from "react";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
@@ -53,6 +55,13 @@ const styles = stylex.create({
fontWeight: 500,
cursor: "pointer",
},
/** A Tab is a `div` with a roving tabindex, so RAC drives the ring, not `:focus-visible`. */
modeFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
modeIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: { default: colors.textSecondary, ":hover": colors.text },
@@ -76,13 +85,33 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textMuted,
},
panel: {
outlineStyle: "none",
},
/**
* A panel with nothing tabbable in it — an empty or loading history — is given
* a tabindex by RAC so the reader can still reach its content, so it has to be
* able to show that it holds the focus.
*/
panelFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
tabsRoot: {
display: "block",
},
});
function panelClass({ isFocusVisible }: { isFocusVisible: boolean }): string {
return stylex.props(styles.panel, isFocusVisible && styles.panelFocusVisible).className ?? "";
}
export default function ActivityPage() {
const search = useSearch({ from: "/shell/activity" });
const navigate = useNavigate({ from: "/activity" });
const live = search.mode === "live";
// The functional form, not a replacement object: the filters are retained
// across a mode switch on purpose, and spelling out a new search here would
// drop every one of them on the way to Live and back.
@@ -91,62 +120,72 @@ export default function ActivityPage() {
void navigate({ search: (prev) => ({ ...prev, mode }) });
}
function apply(filters: AppliedFilters) {
void navigate({ search: { mode: search.mode, ...filters } });
}
// A patch, merged into whatever the URL already says: a segment click must not
// spell out the four filters it is not about. `replace` is the debounced text
// commits, so the back button steps between investigations, not keystrokes.
const apply = useCallback(
(patch: Partial<AppliedFilters>, replace = false) => {
void navigate({ search: (prev) => ({ ...prev, ...patch }), replace });
},
[navigate],
);
const clear = useCallback(() => {
void navigate({ search: (prev) => ({ mode: prev.mode, ...NO_FILTERS }) });
}, [navigate]);
return (
<section>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Activity</h1>
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}>
{MODES.map((option) => {
const selected = option.mode === search.mode;
return (
<button
{/*
* The selected tab is the URL's `mode` and nothing else. RAC would hold
* the selection itself, but a second copy of it would fight the back
* button, so the search parameter stays the only state there is.
*/}
<Tabs
selectedKey={search.mode}
onSelectionChange={(key) => selectMode(key as ActivityMode)}
className={() => stylex.props(styles.tabsRoot).className ?? ""}
>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Activity</h1>
<TabList aria-label="Activity mode" className={() => stylex.props(styles.switch).className ?? ""}>
{MODES.map((option) => (
<Tab
key={option.mode}
type="button"
aria-pressed={selected}
onClick={() => selectMode(option.mode)}
{...stylex.props(
styles.modeButton,
selected ? styles.modeSelected : styles.modeIdle,
shared.focusRing,
)}
id={option.mode}
className={({ isSelected, isFocusVisible }) =>
stylex.props(
styles.modeButton,
isSelected ? styles.modeSelected : styles.modeIdle,
isFocusVisible && styles.modeFocusVisible,
).className ?? ""
}
>
{option.label}
</button>
);
})}
</Tab>
))}
</TabList>
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
Current policy simulation
</Link>
</div>
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
Current policy simulation
</Link>
</div>
{/*
* Remounted whenever the applied search changes, which is what makes
* the back button work: the draft is derived state, and the browser
* moving the URL under it has to move the form with it.
*/}
<ActivityFilters
key={`${search.domain ?? ""}|${search.client ?? ""}|${String(search.blocked)}|${String(search.since)}|${String(search.until)}`}
applied={search}
isDisabled={live}
onApply={apply}
onClear={() => apply(NO_FILTERS)}
/>
{live ? (
<>
{/*
* The toolbar belongs to History alone. It is not remounted on a search
* change: it resyncs its draft from the URL instead, because a remount
* mid-debounce would take the focus out of the input being typed in.
*/}
<TabPanel id="history" className={panelClass}>
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
<HistoryActivity search={search} />
</TabPanel>
<TabPanel id="live" className={panelClass}>
<p {...stylex.props(styles.liveNote)}>
The stream carries every query the server answers; these filters apply to history only.
The stream carries every query the server answers; the History filters apply to history only.
</p>
<LiveActivity origin={search} />
</>
) : (
<HistoryActivity search={search} />
)}
</TabPanel>
</Tabs>
</section>
);
}
@@ -0,0 +1,369 @@
/**
* The client filter: the loaded clients as a list you pick from, and the picked
* ones as chips you can take back off.
*
* There is nothing to type here, deliberately. The filter is exact — the server
* matches whole addresses — so a half-typed address is not a narrower filter but
* a wrong one, and a field that applied as you typed emptied the table under
* every reader who started with a digit. Choosing from a list cannot be
* half-done: every state this control can be in is a filter someone meant.
*
* Several clients at once, because the question is usually about a group — the
* two phones, the television and the console — and one address at a time makes
* that several passes over the same window.
*
* The chips carry the whole selection, including addresses no client claims. The
* log names devices the config has never heard of, and those are the ones an
* operator is most likely hunting; a link filtered on one has to stay readable
* and clearable even though the menu below cannot offer it.
*
* A list that has not loaded, or failed to, leaves the trigger disabled rather
* than opening on nothing. Chips from the URL still show, so the filter stays
* visible and removable either way.
*/
import { CaretDown } from "@phosphor-icons/react/dist/icons/CaretDown";
import { Check } from "@phosphor-icons/react/dist/icons/Check";
import { X } from "@phosphor-icons/react/dist/icons/X";
import { useEffect, useMemo, useRef } from "react";
import * as stylex from "@stylexjs/stylex";
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
import { clientLabel, useClientNames } from "@/features/clients/clientNames";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { MAX_CLIENTS } from "./search";
/**
* One client as the picker shows it.
*
* `label` identifies it outright and `name` is the short form. The menu is a
* list of every client at once, where two devices can share a name and only the
* address tells them apart; a chip is one client the reader picked a moment ago,
* beside a row of others, where the address is the part that does not fit.
*/
export interface ClientOption {
ip: string;
label: string;
name: string | null;
}
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
const HIT_TARGET = 44;
/**
* How many chips are shown before the rest become a count.
*
* The chips exist so an active filter is visible without opening the menu. A
* dozen of them are not more visible than three — they wrap the toolbar into a
* block of its own and push the table off the screen — so past this the row says
* how many more there are and the menu remains where they are managed.
*/
const MAX_CHIPS = 3;
const styles = stylex.create({
root: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.25rem",
},
caret: {
display: "inline-flex",
color: colors.textMuted,
},
trigger: {
minHeight: HIT_TARGET,
minWidth: HIT_TARGET,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: "0.375rem",
},
popover: {
maxHeight: "16rem",
overflowY: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
color: colors.text,
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
menu: {
outlineStyle: "none",
paddingBlock: "0.25rem",
},
item: {
cursor: "pointer",
paddingInline: "0.75rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
whiteSpace: "nowrap",
minHeight: HIT_TARGET,
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
/** Inset because an item flush against a scrolling popover clips an outset ring. */
itemFocused: {
backgroundColor: colors.primary,
color: colors.primaryText,
outlineColor: { default: null, ":focus-visible": colors.primaryText },
},
/** The tick keeps its column when absent, so the labels do not shift on select. */
tick: {
width: "0.75rem",
flexShrink: 0,
display: "inline-flex",
},
chip: {
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
minHeight: HIT_TARGET,
paddingInline: "0.625rem",
borderRadius: "999px",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: { default: colors.surfaceHover, ":hover": colors.surfaceRaised },
color: colors.text,
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
chipCross: {
display: "inline-flex",
color: colors.textMuted,
},
/** Not a button: it removes nothing, and nothing about it is pressable. */
chipMore: {
display: "inline-flex",
alignItems: "center",
minHeight: HIT_TARGET,
paddingInline: "0.625rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
capNote: {
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
});
/** The em dash pairs the two halves without reading as part of either. */
function optionLabel(ip: string, name: string | null): string {
return name === null ? ip : `${name}${ip}`;
}
/**
* The loaded clients, by the name the query tables already give them.
*
* This is the same cached query those tables read, so opening the list costs no
* request; an empty map — still loading, or failed — yields no options.
*/
export function useClientOptions(): ClientOption[] {
const names = useClientNames();
return useMemo(() => {
const options = [...names.keys()].map((ip) => {
const name = clientLabel(ip, names)?.text ?? null;
return { ip, name, label: optionLabel(ip, name) };
});
return options.sort((a, b) => a.label.localeCompare(b.label));
}, [names]);
}
/** How an address reads in full: its client's line, or the bare address. */
export function displayFor(ip: string, options: readonly ClientOption[]): string {
return options.find((option) => option.ip === ip)?.label ?? ip;
}
/** How an address reads on a chip: its client's name, or the bare address. */
export function chipFor(ip: string, options: readonly ClientOption[]): string {
return options.find((option) => option.ip === ip)?.name ?? ip;
}
/**
* The URL carries the selection as one comma-separated value; absent means none.
*
* A plain split, because `validateClients` has already made the value canonical
* — trimmed, no blanks, no repeats, within the cap. Dropping anything a second
* time here is what would let the chips and the request disagree.
*/
export function parseClients(value: string | undefined): string[] {
if (value === undefined) return [];
return value.split(",");
}
export function joinClients(ips: readonly string[]): string | undefined {
return ips.length === 0 ? undefined : ips.join(",");
}
/**
* The trigger's label. A single client is named outright, because that is the
* one case where the whole filter fits on the button; more than one would not,
* and the chips beside it say which ones anyway.
*/
function triggerLabel(selected: readonly string[], options: readonly ClientOption[]): string {
if (selected.length === 0) return "Clients";
if (selected.length === 1) return displayFor(selected[0] as string, options);
return `${selected.length} clients`;
}
interface Props {
options: readonly ClientOption[];
selected: readonly string[];
/** Every change here is a decision already made, so it applies at once. */
onChange: (next: string[]) => void;
}
export default function ClientFilter({ options, selected, onChange }: Props) {
const known = new Set(options.map((option) => option.ip));
// The menu is offered only the addresses it can account for. An address no
// client claims is not in its collection, so handing it over as a selected key
// would be handing over a key that resolves to nothing.
const chosen = new Set(selected.filter((ip) => known.has(ip)));
// Every address the URL carries takes a slot, whether or not a client claims
// it: the cap is on what the request may name, not on what this menu can show.
const atCap = selected.length >= MAX_CLIENTS;
// True only when the cap is actually holding something back. Unknown addresses
// spend slots too, so a short list of clients can be closed off while the menu
// still has room in it — and a disabled row with nothing to explain it is the
// one state this must not reach.
const capBinds = atCap && chosen.size < options.length;
const chips = useRef(new Map<string, HTMLButtonElement>());
const trigger = useRef<HTMLButtonElement>(null);
/** Where focus goes once the removed chip is gone; null means the trigger. */
const focusAfterRemoval = useRef<string | null | undefined>(undefined);
useEffect(() => {
const next = focusAfterRemoval.current;
if (next === undefined) return;
focusAfterRemoval.current = undefined;
// Removing a chip unmounts the element that had the focus. Left alone the
// browser drops focus to the document, and a reader clearing three clients
// from the keyboard would have to tab back in from the top each time.
(next === null ? trigger.current : (chips.current.get(next) ?? trigger.current))?.focus();
});
function remove(ip: string) {
const next = selected.filter((entry) => entry !== ip);
const visible = next.slice(0, MAX_CHIPS);
const index = selected.indexOf(ip);
// The chip that takes this one's place, or the one before it at the end of
// the row, or the trigger when the row is empty.
focusAfterRemoval.current = visible[index] ?? visible[index - 1] ?? null;
onChange(next);
}
/**
* A selection from the menu, merged back over the addresses the menu could not
* see and turned into what the URL should carry.
*
* What is picked is what is committed, always — even every known client at
* once. The tick, the chip and the URL then say what the reader did, and a
* picker whose feedback for "you selected everything" is to erase the
* selection reads as a control that ignored the click; with one client on the
* network that is every click it will ever get. Nor is the full set a no-op:
* the history keeps rows for clients the inventory has since pruned, so "all
* known clients" and "no filter" are different questions.
*
* The cap is enforced here as well as on the items, because select-all reaches
* this without passing an item at all. What was already filtered keeps its
* slots and the new picks take what is left, so a selection too big to send is
* cut somewhere the reader can predict rather than wherever the URL ran out.
*/
function apply(next: Set<string>) {
const kept = selected.filter((ip) => !known.has(ip) || next.has(ip));
const added = [...next].filter((ip) => !chosen.has(ip));
onChange([...kept, ...added].slice(0, MAX_CLIENTS));
}
return (
// The picker and the chips it fills are one control between them: the group
// says so, and names the chips' "Remove …" buttons as part of it.
<div role="group" aria-label="Clients" {...stylex.props(styles.root)}>
<MenuTrigger>
<Button
// Nothing to open, and a trigger that opened on an empty popover would
// promise a list that is not there.
ref={trigger}
isDisabled={options.length === 0}
className={() => stylex.props(shared.button, styles.trigger, shared.focusRing).className ?? ""}
>
{triggerLabel(selected, options)}
<span aria-hidden="true" {...stylex.props(styles.caret)}>
<CaretDown size={12} />
</span>
</Button>
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
{/* Said where it is doing something, and nowhere else. */}
{capBinds && <p {...stylex.props(styles.capNote)}>At most {MAX_CLIENTS} clients at a time.</p>}
<Menu
aria-label="Clients"
selectionMode="multiple"
selectedKeys={chosen}
onSelectionChange={(keys) => {
// Ctrl/Cmd+A hands back the literal "all"; taking every known
// client is its only reading, and apply() caps the result.
if (keys === "all") apply(new Set(options.map((option) => option.ip)));
else apply(new Set([...keys].map(String)));
}}
autoFocus={false}
{...stylex.props(styles.menu)}
>
{options.map((option) => (
<MenuItem
key={option.ip}
id={option.ip}
// At the cap, what is already picked can still be unpicked and
// nothing else can be added. A menu that took a 33rd pick and
// dropped it would look like it had worked.
isDisabled={atCap && !chosen.has(option.ip)}
textValue={option.label}
className={({ isFocused }) =>
stylex.props(styles.item, shared.insetFocusRing, isFocused && styles.itemFocused)
.className ?? ""
}
>
<span aria-hidden="true" {...stylex.props(styles.tick)}>
{chosen.has(option.ip) && <Check size={12} />}
</span>
{option.label}
</MenuItem>
))}
</Menu>
</Popover>
</MenuTrigger>
{selected.slice(0, MAX_CHIPS).map((ip) => (
<button
key={ip}
type="button"
ref={(node) => {
if (node === null) chips.current.delete(ip);
else chips.current.set(ip, node);
}}
// The chip reads as a name and removes an address, so the name alone
// would not say what the button does to a reader who cannot see it.
aria-label={`Remove client ${displayFor(ip, options)}`}
onClick={() => remove(ip)}
{...stylex.props(styles.chip, shared.focusRing)}
>
{chipFor(ip, options)}
<span aria-hidden="true" {...stylex.props(styles.chipCross)}>
<X size={10} />
</span>
</button>
))}
{selected.length > MAX_CHIPS && (
<span {...stylex.props(styles.chipMore)}>+{selected.length - MAX_CHIPS} more</span>
)}
</div>
);
}
@@ -56,7 +56,8 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textMuted,
},
refetching: {
/** The gap a line standing on its own needs from the block above it. */
spacedTop: {
marginTop: "0.75rem",
},
moreButton: {
@@ -81,7 +82,9 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
const pages = base.data?.pages ?? [];
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
const coverage = pages[0]?.coverage;
// Only from the settled response. Placeholder pages belong to the previous
// filter, and a watermark is a claim about the window being displayed.
const coverage = base.isPlaceholderData ? undefined : pages[0]?.coverage;
const filterActive = Object.keys(filter).length > 0;
// `base.hasNextPage` reads the query state, which is empty while placeholder
// data stands in for a filter change; derive the cursor from what is on
@@ -112,16 +115,27 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
return (
<>
{/*
* A quiet line, never a skeleton: the rows on screen stay put while a
* filter change is in flight, so a keystroke must not blank the table
* it is narrowing.
*/}
{base.isFetching && (
<p {...stylex.props(styles.note, styles.refetching)} role="status">
Loading
<p {...stylex.props(styles.note, styles.spacedTop)} role="status">
Updating
</p>
)}
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
{rows.length === 0 ? (
<p {...stylex.props(styles.empty)}>
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
</p>
<>
<p {...stylex.props(styles.empty)}>
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
</p>
{coverage !== undefined && (
<div {...stylex.props(styles.spacedTop)}>
<CoverageNotice coverage={coverage} variant="note" />
</div>
)}
</>
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
@@ -158,6 +172,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
{hasMore ? "" : " — end of log"}
</p>
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
{hasMore && (
<button
type="button"
+213 -107
View File
@@ -57,7 +57,17 @@ function json(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
}
function stubFetch(handler: (url: string) => Response | Promise<Response> = () => json({})) {
/**
* The empty history page. Live mode asks for no query pages, but switching back
* to History does, and a page-shaped response is the only honest answer there.
*/
const EMPTY_PAGE = { queries: [], next_before: null, coverage: { complete: true, available_since: 0 } };
function defaultHandler(url: string): Response {
return json(url === "/api/queries" || url.startsWith("/api/queries?") ? EMPTY_PAGE : {});
}
function stubFetch(handler: (url: string) => Response | Promise<Response> = defaultHandler) {
fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/version") return Promise.resolve(json(VERSION));
@@ -168,7 +178,7 @@ test("streams rows, flags blocked ones, and freezes the display", async () => {
expect(screen.getByText("later.example")).toBeTruthy();
});
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
test("resolves each row's client to its display name, reading the IP out with it", async () => {
await openLive();
act(() => {
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
@@ -178,11 +188,14 @@ test("resolves each row's client to its display name, keeping the IP as the tool
});
const named = await screen.findByText("Kitchen Pi");
expect(named.getAttribute("title")).toBe("192.0.2.10");
// The address reads out with the name it replaced, rather than sitting in a
// title only a mouse can reach.
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
expect(named.getAttribute("title")).toBeNull();
expect(screen.queryByText("pi.lan")).toBeNull();
const learned = screen.getByText("laptop.lan");
expect(learned.getAttribute("title")).toBe("192.0.2.11");
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
@@ -256,7 +269,40 @@ test("a recovered row links to its stored detail; a streamed one opens in place
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
});
test("a streamed row opens its own provenance, from the keyboard as well as the pointer", async () => {
/** The open detail. Named by its heading, so the query proves the name is visible. */
function detailDialog(): HTMLElement {
return screen.getByRole("dialog", { name: "Streamed query" });
}
/** React Aria's ModalOverlay, two levels out from the dialog it wraps. */
function backdrop(): HTMLElement {
return detailDialog().parentElement!.parentElement!;
}
/**
* Activate a control the way a keyboard does. jsdom runs no default action for
* Enter on a button, so the click a browser would then dispatch is issued here;
* `detail: 0` is what marks it as keyboard-driven rather than pointer-driven,
* and is the flag React Aria itself reads.
*/
function pressWithKeyboard(control: HTMLElement) {
act(() => control.focus());
fireEvent.keyDown(control, { key: "Enter" });
fireEvent.click(control, { detail: 0 });
fireEvent.keyUp(control, { key: "Enter" });
}
/** Activate a control the way a mouse does, through the full pointer sequence. */
function pressWithMouse(control: HTMLElement) {
fireEvent.pointerDown(control, { pointerType: "mouse", button: 0 });
fireEvent.pointerUp(control, { pointerType: "mouse", button: 0 });
fireEvent.click(control, { detail: 1 });
}
test.each([
["the pointer", pressWithMouse],
["the keyboard", pressWithKeyboard],
])("a streamed row opens its provenance in a named dialog, from %s", async (_label, press) => {
await openLive();
act(() =>
sources[0]!.emit(
@@ -273,69 +319,108 @@ test("a streamed row opens its own provenance, from the keyboard as well as the
// only way to lose that is to opt out of it, which nothing here may do.
expect(trigger.tagName).toBe("BUTTON");
expect(trigger.getAttribute("tabindex")).toBeNull();
act(() => trigger.focus());
expect(document.activeElement).toBe(trigger);
// The row opens a dialog, so it says so; what it no longer claims is to
// expand a region that stays in the page.
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
expect(trigger.getAttribute("aria-expanded")).toBeNull();
expect(trigger.getAttribute("aria-controls")).toBeNull();
fireEvent.click(trigger);
const heading = screen.getByRole("heading", { level: 1, name: "streamed.example" });
expect(heading).toBeTruthy();
const panel = heading.closest("div")!.parentElement!;
expect(within(panel).getByText("Blocked locally")).toBeTruthy();
expect(panel.textContent).toContain("the query log may not have written it yet");
press(trigger);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("heading", { level: 1, name: "streamed.example" })).toBeNull();
const dialog = detailDialog();
expect(within(dialog).getByRole("heading", { level: 1, name: "streamed.example" })).toBeTruthy();
expect(within(dialog).getByText("Blocked locally")).toBeTruthy();
expect(dialog.textContent).toContain("the query log may not have written it yet");
// React Aria may defer the move by a frame, depending on the modality it read
// from the activation, so the wait is the assertion rather than a workaround.
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
expect(within(dialog).getByRole("button", { name: "Close" })).toBeTruthy();
});
test("the detail takes focus when a row opens it and hands it back when it closes", async () => {
test("tabbing forward and backward stays inside the open dialog", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
const trigger = screen.getByRole("button", { name: "streamed.example" });
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.getAttribute("aria-controls")).toBeNull();
const dialog = detailDialog();
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
// The related links land asynchronously; tabbing before them would walk a
// shorter dialog than the reader ever sees.
await waitFor(() => expect(within(dialog).getAllByRole("link").length).toBeGreaterThan(1));
// A native button activates on Enter and Space; jsdom does not synthesize
// the click those keys fire, so the click is the activation.
act(() => trigger.focus());
fireEvent.click(trigger);
const panel = screen.getByRole("group", { name: "Streamed query" });
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(trigger.getAttribute("aria-controls")).toBe(panel.id);
// The panel is inserted above the table, behind the trigger in tab order, so
// the only thing that keeps a forward tab inside it is focus moving in.
expect(panel.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(document.activeElement).toBe(panel);
expect(panel.contains(screen.getByRole("button", { name: "Close" }))).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("group", { name: "Streamed query" })).toBeNull();
expect(document.activeElement).toBe(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.getAttribute("aria-controls")).toBeNull();
const visited = new Set<Element>();
for (const shiftKey of [false, false, false, false, false, false, true, true, true, true]) {
fireEvent.keyDown(document.activeElement!, { key: "Tab", shiftKey });
fireEvent.keyUp(document.activeElement!, { key: "Tab", shiftKey });
expect(dialog.contains(document.activeElement)).toBe(true);
visited.add(document.activeElement!);
}
// Containment that never moved focus would satisfy the check above without
// trapping anything, so the walk has to have actually walked.
expect(visited.size).toBeGreaterThan(1);
});
test("opening a second row moves the expanded state and the focus with it", async () => {
test.each([
["the Close button", () => fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }))],
["Escape", () => fireEvent.keyDown(detailDialog(), { key: "Escape" })],
[
"a click on the backdrop",
() => {
const overlay = backdrop();
fireEvent.pointerDown(overlay, { pointerType: "mouse", button: 0 });
fireEvent.pointerUp(overlay, { pointerType: "mouse", button: 0 });
fireEvent.click(overlay, { detail: 1 });
},
],
])("%s closes the dialog and returns focus to the row that opened it", async (_label, dismiss) => {
await openLive();
act(() => {
sources[0]!.emit("query", frame(1000, "first.example"));
sources[0]!.emit("query", frame(1001, "second.example"));
});
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
const trigger = screen.getByRole("button", { name: "streamed.example" });
act(() => trigger.focus());
fireEvent.click(trigger);
expect(detailDialog()).toBeTruthy();
const first = screen.getByRole("button", { name: "first.example" });
const second = screen.getByRole("button", { name: "second.example" });
fireEvent.click(first);
fireEvent.click(second);
dismiss();
const panel = screen.getByRole("group", { name: "Streamed query" });
expect(within(panel).getByRole("heading", { level: 1, name: "second.example" })).toBeTruthy();
expect(document.activeElement).toBe(panel);
expect(first.getAttribute("aria-expanded")).toBe("false");
expect(second.getAttribute("aria-expanded")).toBe("true");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(document.activeElement).toBe(screen.getByRole("button", { name: "streamed.example" }));
});
test("the stream runs on behind the open dialog, and its rows land in the table on close", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
const snapshot = detailDialog().textContent;
act(() => sources[0]!.emit("query", frame(1001, "arrived-while-open.example")));
// The connection is untouched: no close, no second EventSource.
expect(sources).toHaveLength(1);
expect(sources[0]!.closed).toBe(false);
// And the snapshot is a snapshot: nothing that arrives rewrites it.
expect(detailDialog().textContent).toBe(snapshot);
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.getByRole("button", { name: "arrived-while-open.example" })).toBeTruthy();
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
});
test("the rows and toolbar behind the dialog are out of reach while it is open", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
// React Aria hides everything outside the modal from assistive technology
// and from the pointer alike, so the row and the toolbar are unreachable by
// role: nothing behind the dialog can be operated while it is open.
expect(screen.queryByRole("button", { name: "streamed.example" })).toBeNull();
expect(screen.queryByRole("button", { name: "Freeze" })).toBeNull();
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(document.activeElement).toBe(second);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.getByRole("button", { name: "Freeze" })).toBeTruthy();
});
/**
@@ -359,23 +444,57 @@ function renderLiveWithCapacity(capacity: number) {
);
}
test("an open streamed detail survives the row being evicted from the ring buffer", () => {
renderLiveWithCapacity(5);
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
// One ringful more: the ring keeps the newest 5, so the selected row is gone
// from the table. The detail is a snapshot, not a lookup into the ring.
/** Push one ringful of filler through a 5-row ring, evicting whatever was there. */
function evictWithFiller() {
act(() => {
for (let index = 0; index < 5; index += 1) {
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
}
});
}
test("an open dialog's snapshot survives its row being evicted from the ring buffer", async () => {
renderLiveWithCapacity(5);
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
const snapshot = detailDialog().textContent;
expect(within(detailDialog()).getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
// One ringful more: the ring keeps the newest 5, so the selected row is gone.
// The dialog holds the frame itself, not a lookup into the ring, so it neither
// blanks out nor closes.
evictWithFiller();
expect(detailDialog().textContent).toBe(snapshot);
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.getAllByRole("row")).toHaveLength(6);
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
});
test("closing after the source row is evicted anchors focus in the results region", async () => {
renderLiveWithCapacity(5);
act(() => sources[0]!.emit("open"));
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
const trigger = screen.getByRole("button", { name: "evicted.example" });
act(() => trigger.focus());
fireEvent.click(trigger);
evictWithFiller();
expect(trigger.isConnected).toBe(false);
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
// Never the body, never whichever row happens to sit where the old one did,
// and never the Freeze button: a stable anchor in the region the reader was
// reading. React Aria's own deferred restore runs after this and, finding
// focus already placed, leaves it alone.
const region = screen.getByRole("region", { name: "Live queries" });
expect(document.activeElement).toBe(region);
await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))));
expect(document.activeElement).toBe(region);
});
test("the route renders the live ring at its production capacity", async () => {
@@ -391,34 +510,29 @@ test("an open streamed detail survives Freeze and Resume", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
const snapshot = detailDialog().textContent;
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
// Freeze is behind the dialog, so it is reached the way the code reaches it
// rather than by role, which the modal deliberately hides.
const freeze = () => screen.getByText("Freeze") as HTMLButtonElement;
act(() => freeze().click());
expect(detailDialog().textContent).toBe(snapshot);
act(() => (screen.getByText("Resume") as HTMLButtonElement).click());
expect(detailDialog().textContent).toBe(snapshot);
});
test("the filter row stays visible, keeps its values, and is out of the tab order", async () => {
test("live renders no filter toolbar, not even a disabled one", async () => {
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
const domain = screen.getByLabelText("Domain contains") as HTMLInputElement;
expect(domain.value).toBe("ads");
expect(domain.disabled).toBe(true);
expect((screen.getByLabelText("Client (exact)") as HTMLInputElement).disabled).toBe(true);
expect((screen.getByLabelText("Since") as HTMLInputElement).disabled).toBe(true);
expect((screen.getByLabelText("Until") as HTMLInputElement).disabled).toBe(true);
const form = domain.closest("form")!;
const controls = [...form.querySelectorAll("input, button, select, textarea, a[href], [tabindex]")];
expect(controls.length).toBeGreaterThan(0);
for (const control of controls) {
// A disabled form control is skipped by the browser's tab order, and RAC
// pins its own trigger out of it as well. Nothing in the row may
// reintroduce itself with a reachable tabindex.
expect(control.hasAttribute("disabled")).toBe(true);
const tabindex = control.getAttribute("tabindex");
expect(tabindex === null || tabindex === "-1").toBe(true);
}
// The stream is unfiltered — the server sends every query — so a row of
// controls here would promise filtering that is not happening. The filters
// are not lost: they are in the URL, and History applies them on the way back.
expect(screen.queryByLabelText("Filter domains")).toBeNull();
expect(screen.queryByLabelText("Client IP (exact match)")).toBeNull();
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
expect(screen.queryByText("Clear")).toBeNull();
expect(screen.getByText(/the History filters apply to history only/)).toBeTruthy();
});
test("live mode asks for no query pages, whatever filters the url retained", async () => {
@@ -431,31 +545,30 @@ test("live mode asks for no query pages, whatever filters the url retained", asy
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
await openLive("/activity?mode=live&domain=ads");
fireEvent.click(screen.getByRole("button", { name: "History" }));
await screen.findByRole("button", { name: "Apply filters" });
fireEvent.click(screen.getByRole("tab", { name: "History" }));
await screen.findByLabelText("Filter domains");
expect(sources).toHaveLength(1);
expect(sources[0]!.closed).toBe(true);
// The filters came along, which is the point of switching rather than
// navigating: the reader keeps the question they were asking.
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads");
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
expect((screen.getByLabelText("Filter domains") as HTMLInputElement).value).toBe("ads");
fireEvent.click(screen.getByRole("button", { name: "Live" }));
fireEvent.click(screen.getByRole("tab", { name: "Live" }));
await screen.findByRole("button", { name: "Freeze" });
expect(sources).toHaveLength(2);
expect(sources[1]!.closed).toBe(false);
});
/**
* The related-actions region of a query detail. Scoped on purpose: the sidebar
* carries a Pause of its own, and this is the one that answers "this query was
* blocked and should not have been".
*/
/** The related-actions region of a query detail. */
function related(): HTMLElement {
return screen.getByRole("region", { name: "Related" });
}
test("a streamed blocked row carries the same Pause action as the persisted detail", async () => {
/**
* The streamed detail carries the same Related as the persisted one: four links
* and no control. Pause is resolver-wide and lives in the sidebar alone.
*/
test("a streamed blocked row's Related carries links only", async () => {
await openLive();
act(() =>
sources[0]!.emit(
@@ -468,14 +581,7 @@ test("a streamed blocked row carries the same Pause action as the persisted deta
);
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
});
test("a streamed row that was allowed offers nothing to pause", async () => {
await openLive();
act(() => sources[0]!.emit("query", frame(1001, "allowed.example", { policy: { action: "allow" } })));
fireEvent.click(screen.getByRole("button", { name: "allowed.example" }));
await screen.findByRole("heading", { level: 1, name: "allowed.example" });
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
expect(within(related()).getAllByRole("link")).toHaveLength(4);
expect(within(related()).queryByRole("button")).toBeNull();
});
+108 -134
View File
@@ -1,6 +1,6 @@
/**
* Activity in live mode: the SSE stream, its bounded ring buffer, and the
* in-place detail a streamed row opens.
* Activity in live mode: the SSE stream, its bounded ring buffer, and the modal
* detail a streamed row opens.
*
* This subtree is mounted only while the URL says `mode=live`, which is what
* closes the EventSource on the way back to history: the connection is a
@@ -17,6 +17,7 @@ import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { useClientNames } from "@/features/clients/clientNames";
import { summarizeEvent } from "@/features/provenance/querySummary";
import Dialog from "@/ui/Dialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
@@ -88,6 +89,7 @@ const styles = stylex.create({
lineHeight: "1.25rem",
},
dismiss: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
@@ -161,29 +163,6 @@ const styles = stylex.create({
textDecorationLine: "underline",
textDecorationStyle: "dotted",
},
detailPanel: {
marginTop: "1rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surface,
padding: "1rem",
},
detailBar: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: "0.75rem",
},
detailLabel: {
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 600,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: colors.textMuted,
},
footnote: {
marginTop: "0.75rem",
fontSize: "0.875rem",
@@ -192,10 +171,6 @@ const styles = stylex.create({
},
});
/** One panel at a time, so the trigger that opened it can name it in `aria-controls`. */
const DETAIL_PANEL_ID = "live-query-detail";
const DETAIL_LABEL_ID = "live-query-detail-label";
const PILL_LABELS: Record<StreamStatus, string> = {
connecting: "Connecting…",
open: "Live",
@@ -225,40 +200,15 @@ function StatusPill({ status }: { status: StreamStatus }) {
*
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by
* key would go stale under the reader while they were still reading it, and the
* panel would blank out for no reason they could see. The snapshot is the whole
* fact — a streamed frame carries its own provenance — so it survives eviction,
* a merge and a Freeze/Resume, and closes only when the reader closes it or
* leaves live mode.
* dialog would blank out or swap under their eyes for no reason they could see.
* The snapshot is the whole fact — a streamed frame carries its own provenance
* — so it survives eviction, a merge and a Freeze/Resume, and closes only when
* the reader closes it or leaves live mode. The stream behind it never stops.
*/
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
const summary = summarizeEvent(row.event);
const panel = useRef<HTMLDivElement>(null);
// The panel opens above the table, behind the trigger in tab order, so a
// forward tab from the row would walk past it. Focus moves in on open —
// keyed on the row, so choosing a second row moves it again — and the
// closer puts it back on the trigger.
useEffect(() => {
panel.current?.focus();
}, [row.key]);
return (
<div
ref={panel}
id={DETAIL_PANEL_ID}
tabIndex={-1}
role="group"
aria-labelledby={DETAIL_LABEL_ID}
{...stylex.props(styles.detailPanel)}
>
<div {...stylex.props(styles.detailBar)}>
<span id={DETAIL_LABEL_ID} {...stylex.props(styles.detailLabel)}>
Streamed query
</span>
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
Close
</button>
</div>
<Dialog title="Streamed query" size="detail" isOpen onClose={onClose}>
<ProvenanceDetail
provenance={row.event}
persistedId={null}
@@ -268,11 +218,10 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi
client={summary.client_ip}
ts={summary.ts}
origin={origin}
blocked={row.event.policy.action === "block"}
/>
}
/>
</div>
</Dialog>
);
}
@@ -292,21 +241,36 @@ export default function LiveActivity({
const clientNames = useClientNames();
const [selected, setSelected] = useState<StreamedRow | null>(null);
const trigger = useRef<HTMLButtonElement | null>(null);
const results = useRef<HTMLDivElement>(null);
const restoring = useRef(false);
/**
* Where focus lands when the dialog closes.
*
* React Aria restores focus itself, but in a `requestAnimationFrame` and
* only while focus is still on the body — and its target is the row button,
* which the ring may have evicted while the reader was reading. So this runs
* in the effect that follows the focus scope's teardown and puts focus on a
* connected element first: the row if it is still there, the results region
* if it is not. React Aria's deferred pass then finds focus already placed
* and does nothing, so the two never fight over it. If a navigation unmounts
* this component the effect never runs, which is the right answer — there is
* no longer a table to return to.
*/
useEffect(() => {
if (selected !== null || !restoring.current) return;
restoring.current = false;
const from = trigger.current;
trigger.current = null;
(from?.isConnected === true ? from : results.current)?.focus();
}, [selected]);
function open(row: StreamedRow, from: HTMLButtonElement) {
trigger.current = from;
restoring.current = true;
setSelected(row);
}
// The row that opened the panel takes focus back, unless the ring has
// already evicted it: a detached button cannot be focused, and the browser
// falls back to the document, which is the best available answer.
function close() {
setSelected(null);
trigger.current?.focus();
trigger.current = null;
}
return (
<>
<div {...stylex.props(styles.toolbar)}>
@@ -362,71 +326,81 @@ export default function LiveActivity({
</div>
)}
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
{/*
* The region is the anchor focus falls back to when the row that
* opened the dialog is gone, so it is rendered unconditionally: an
* anchor that disappears with the last row is no anchor at all.
*/}
<div
ref={results}
tabIndex={-1}
role="region"
aria-label="Live queries"
{...stylex.props(shared.focusRing)}
>
{live.rows.length === 0 ? (
live.status !== "capped" && (
<p {...stylex.props(styles.empty)}>
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
</p>
)
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
{live.rows.map((row) => {
const summary = summaryOf(row);
return (
<tr
key={row.key}
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
>
<ActivityCells
row={summary}
clientNames={clientNames}
renderDomain={(_id, children) =>
row.kind === "streamed" ? (
<button
type="button"
aria-haspopup="dialog"
onClick={(event) => open(row, event.currentTarget)}
{...stylex.props(
styles.domainButton,
activityDomainLink,
shared.focusRing,
)}
>
{children}
</button>
) : (
<Link
to="/activity/queries/$id"
params={{ id: String(row.row.id) }}
search={origin}
{...stylex.props(activityDomainLink, shared.focusRing)}
>
{children}
</Link>
)
}
/>
</tr>
);
})}
</tbody>
</table>
</div>
<p {...stylex.props(styles.footnote)}>
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first,
last {capacity} kept).
</p>
</>
)}
</div>
{live.rows.length === 0 ? (
live.status !== "capped" && (
<p {...stylex.props(styles.empty)}>
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
</p>
)
) : (
<>
<div {...stylex.props(styles.tableWrap)}>
<table {...stylex.props(styles.table)}>
<ActivityTableHead />
<tbody>
{live.rows.map((row) => {
const summary = summaryOf(row);
return (
<tr
key={row.key}
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
>
<ActivityCells
row={summary}
clientNames={clientNames}
renderDomain={(_id, children) =>
row.kind === "streamed" ? (
<button
type="button"
aria-expanded={selected?.key === row.key}
aria-controls={
selected?.key === row.key ? DETAIL_PANEL_ID : undefined
}
onClick={(event) => open(row, event.currentTarget)}
{...stylex.props(
styles.domainButton,
activityDomainLink,
shared.focusRing,
)}
>
{children}
</button>
) : (
<Link
to="/activity/queries/$id"
params={{ id: String(row.row.id) }}
search={origin}
{...stylex.props(activityDomainLink, shared.focusRing)}
>
{children}
</Link>
)
}
/>
</tr>
);
})}
</tbody>
</table>
</div>
<p {...stylex.props(styles.footnote)}>
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
{capacity} kept).
</p>
</>
)}
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={() => setSelected(null)} />}
</>
);
}
@@ -11,7 +11,6 @@
import { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import PauseControl from "@/features/pause/PauseControl";
import { styles as shared } from "@/ui/styles";
import { provenanceRelatedLink } from "./ProvenanceDetail";
import { diagnosticsBounds, relatedBounds } from "./relatedLinks";
@@ -24,15 +23,9 @@ interface Props {
ts: number;
/** The Activity search the reader came from; its bounds win over the defaults. */
origin: Pick<ActivitySearch, "since" | "until">;
/**
* This query was blocked. Pausing is a valid answer to a block the reader
* disagrees with, and to nothing else here — so the control appears for a
* block and not beside an allowed query it could not have caused.
*/
blocked: boolean;
}
export default function RelatedActions({ domain, client, ts, origin, blocked }: Props) {
export default function RelatedActions({ domain, client, ts, origin }: Props) {
const bounds = relatedBounds(ts, origin);
const window = diagnosticsBounds(ts);
return (
@@ -61,7 +54,6 @@ export default function RelatedActions({ domain, client, ts, origin, blocked }:
>
Diagnostics around this query
</Link>
{blocked && <PauseControl />}
</>
);
}
+29 -1
View File
@@ -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", () => {
expect(validateMode("live")).toBe("live");
@@ -111,3 +119,23 @@ test("a search of junk applies nothing", () => {
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(","));
});
+32 -1
View File
@@ -57,6 +57,37 @@ export function validateText(value: unknown): string | undefined {
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.
*
@@ -82,7 +113,7 @@ export function validateActivitySearch(search: Record<string, unknown>): Activit
since: validateTimestamp(search["since"]),
until: validateTimestamp(search["until"]),
domain: validateText(search["domain"]),
client: validateText(search["client"]),
client: validateClients(search["client"]),
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" });
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 () => {
@@ -1,4 +1,5 @@
import { useState } from "react";
import { ArrowLeft } from "@phosphor-icons/react/dist/icons/ArrowLeft";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
@@ -17,11 +18,17 @@ const nowInSeconds = () => Math.floor(Date.now() / 1000);
const styles = stylex.create({
back: {
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
backIcon: {
display: "inline-flex",
},
heading: {
marginTop: "0.5rem",
fontSize: "1.5rem",
@@ -95,7 +102,10 @@ const styles = stylex.create({
function BackLink() {
return (
<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>
);
}
@@ -18,16 +18,10 @@ interface Props {
}
const styles = stylex.create({
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1rem",
},
fieldLabel: {
display: "block",
@@ -67,8 +61,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
const readOnly = useReadOnlyConfig();
return (
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
<Dialog title={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<form
{...stylex.props(styles.form)}
onSubmit={(event) => {
+207 -21
View File
@@ -1,6 +1,20 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { ClientName, type ClientNames } from "./clientNames";
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
import { BASE, CLIENTS, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
/** The text of the elements an input points at with `aria-describedby`. */
function describedText(input: HTMLElement): string {
const ids = input.getAttribute("aria-describedby");
if (ids === null) throw new Error("input has no aria-describedby");
return ids
.split(/\s+/)
.map((id) => {
const node = document.getElementById(id);
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
return node.textContent ?? "";
})
.join(" ");
}
function clientRow(ip: string): HTMLElement {
const row = screen.getByText(ip).closest("tr");
@@ -73,6 +87,39 @@ test("the address links to the client's detail page", async () => {
expect(link.getAttribute("href")).toBe("/clients/1");
});
test("delete asks first, naming the row, and the confirmation carries out the delete", async () => {
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
// The operator has to be able to tell from the dialog alone which row this is.
expect(within(dialog).getByText(/kids-tablet\.lan \(192\.168\.1\.11\)/)).toBeTruthy();
expect(within(dialog).getByText(/re-materialize on their next DNS query/)).toBeTruthy();
// Asking is not deleting.
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2")),
).toHaveLength(1),
);
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
});
test("cancelling the confirmation keeps the client", async () => {
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(screen.getByText("192.168.1.11")).toBeTruthy();
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
});
test("shows the DNS-activity empty state when there are no clients", async () => {
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
@@ -140,9 +187,15 @@ test("an unknown group id filters to nothing and offers a way out", async () =>
expect(router.state.location.search).toEqual({});
});
// Both statuses lock the declared delete, but only file authority proves the
// file declares the row; the anchors keep the two sentences apart.
const DECLARED_NOTE = /^This client is declared in the configuration file/;
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
await screen.findAllByLabelText(/Managed by \/etc\/nxdns\/config\.zon/);
// The settled sentence, not the tag: "Locked" is already on screen while
// authority is pending, so waiting on it would not wait for this status.
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
@@ -150,6 +203,34 @@ test("file mode drops every edit affordance and keeps the observed delete live (
const observed = clientRow("192.168.1.11");
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
// Why the locked Delete will not answer, in visible text and exactly once:
// per row it would repeat down the whole page, and on the button it was a
// title that a keyboard and a touch screen never reached.
expect(screen.getAllByText(DECLARED_NOTE, { selector: "p" })).toHaveLength(1);
expect(within(declared).queryByText(DECLARED_NOTE, { selector: "p" })).toBeNull();
// The description stays on the button itself too, for a reader on that control.
const locked = within(declared).getByRole("button", { name: "Delete" });
expect(document.getElementById(locked.getAttribute("aria-describedby") ?? "")?.textContent).toMatch(DECLARED_NOTE);
});
test("an all-observed page still says why Edit is gone, with no delete note to carry it", async () => {
// Every row observed, so no Delete is locked. The edit lock is still real, and
// "Locked" appearing with nothing to explain it is the failure this guards.
const observedOnly = { clients: [CLIENTS.clients[1]] };
await renderClientsPage({
...BASE,
"GET /api/clients": observedOnly,
"GET /api/config/status": MANAGED_FILE,
});
// The settled sentence is both the anchor and the assertion: it is the whole
// explanation for the missing Edit action.
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
expect(await screen.findAllByText("Locked")).not.toHaveLength(0);
// The delete sentence belongs only to a row that has one.
expect(screen.queryByText(DECLARED_NOTE)).toBeNull();
expect((screen.getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
});
test("file mode renders network assignments with no mutation control at all (R2-4)", async () => {
@@ -170,7 +251,7 @@ test("file mode renders network assignments with no mutation control at all (R2-
test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": undefined });
await screen.findAllByLabelText(/Configuration status unavailable/);
await screen.findAllByText(/^Configuration status unavailable/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
@@ -186,10 +267,6 @@ test("a failed config status exposes no configuration mutation, and still delete
// confirmation is already open. `undefined` is the failed status: the fetch stub
// answers 404 for a key it does not hold.
//
// Both statuses lock the declared delete, but only file authority proves the
// file declares the row; the anchors keep the two sentences apart.
const DECLARED_NOTE = /^This client is declared in the configuration file/;
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
describe.each([
["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE],
["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE],
@@ -212,13 +289,13 @@ describe.each([
expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull();
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy();
expect(within(dialog).getByLabelText(/^Locked\./)).toBeTruthy();
expect(within(dialog).getByText("Locked")).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
test("locks an open declared-client delete confirmation and leaves cancel working", async () => {
test("locks an open declared-client delete confirmation in place and leaves cancel working", async () => {
const map = { ...BASE };
const { queryClient, fetchMock } = await renderClientsPage(map);
// The Edit affordance appearing is the proof authority resolved to
@@ -227,23 +304,68 @@ describe.each([
const declared = clientRow("192.168.1.10");
fireEvent.click(within(declared).getByRole("button", { name: "Delete" }));
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Confirm delete" })).toBeTruthy();
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
await setConfigStatus(map, queryClient, status);
const confirming = clientRow("192.168.1.10");
expect(within(confirming).queryByRole("button", { name: "Confirm delete" })).toBeNull();
expect(within(confirming).getByText(lockNote)).toBeTruthy();
expect(within(confirming).queryByText(otherNote)).toBeNull();
expect(within(confirming).getByLabelText(/^Locked\./)).toBeTruthy();
// The dialog stays put. Closing it would throw focus at the Delete button
// the same turn disabled, and the reason would survive only as a title
// attribute; here the reason is the dialog's own message.
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
expect(within(dialog).getByText(lockNote)).toBeTruthy();
expect(within(dialog).queryByText(otherNote)).toBeNull();
expect(within(dialog).getByText("Locked")).toBeTruthy();
fireEvent.click(within(confirming).getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })).toBeTruthy(),
);
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
});
test("a cancelled confirmation stays closed when authority comes back", async () => {
const map = { ...BASE };
const { queryClient } = await renderClientsPage(map);
await unlockedEdit(0);
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
// A poll that fails and then recovers must not raise a destructive
// question the operator already answered.
await setConfigStatus(map, queryClient, status);
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
await waitFor(() => expect(screen.getAllByRole("button", { name: "Edit" }).length).toBeGreaterThan(0));
expect(screen.queryByRole("alertdialog")).toBeNull();
});
test("the confirm action comes back when authority does, without a second prompt", async () => {
const map = { ...BASE, "DELETE /api/clients/1": {} };
const { queryClient, fetchMock } = await renderClientsPage(map);
await unlockedEdit(0);
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
await setConfigStatus(map, queryClient, status);
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
// The question was never withdrawn, so the answer returns to the same
// dialog rather than asking the operator to start again.
const confirm = await within(dialog).findByRole("button", { name: "Delete" });
fireEvent.click(confirm);
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(
([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1"),
),
).toHaveLength(1),
);
});
test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
const map = { ...BASE, "DELETE /api/clients/2": {} };
const { queryClient, fetchMock } = await renderClientsPage(map);
@@ -253,10 +375,11 @@ describe.each([
const observed = clientRow("192.168.1.11");
fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
await setConfigStatus(map, queryClient, status);
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Confirm delete" }));
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(
@@ -271,7 +394,7 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
// The status request never settles, so authority stays pending for the whole
// test: nothing configuration owns may be offered on that guess.
await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER });
await screen.findAllByLabelText(/Checking which configuration source/);
await screen.findAllByText(/^Checking which configuration source/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
@@ -280,3 +403,66 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
const observed = clientRow("192.168.1.11");
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
});
test("a save-time problem marks the input it is about and describes it", async () => {
await renderClientsPage();
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
fireEvent.change(range, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
expect(range.getAttribute("aria-invalid")).toBe("true");
expect(describedText(range)).toBe("Row 1: prefix is required.");
// Only the offending input is marked; the priority beside it is untouched.
const priority = screen.getByLabelText("Priority for range 1");
expect(priority.getAttribute("aria-invalid")).toBeNull();
expect(priority.getAttribute("aria-describedby")).toBeNull();
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
fireEvent.change(screen.getByLabelText("Priority for range 1"), { target: { value: "abc" } });
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
expect(range.getAttribute("aria-invalid")).toBeNull();
expect(describedText(screen.getByLabelText("Priority for range 1"))).toBe(
"Row 1: priority must be a whole number.",
);
});
test("removing a row drops the message rather than moving it to another input", async () => {
await renderClientsPage();
// Row 1 is the offending one, so removing it is what would slide the stale
// index onto row 2 — an input that validated cleanly.
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
fireEvent.change(range, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "Add range" }));
fireEvent.change(screen.getByLabelText("Range 2"), { target: { value: "10.0.0.0/8" } });
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
expect(range.getAttribute("aria-invalid")).toBe("true");
expect(describedText(range)).toBe("Row 1: prefix is required.");
const section = assignmentsSection();
fireEvent.click(within(section).getAllByRole("button", { name: "Remove" })[0] as HTMLButtonElement);
const survivor = screen.getByLabelText("Range 1") as HTMLInputElement;
expect(survivor.value).toBe("10.0.0.0/8");
expect(survivor.getAttribute("aria-invalid")).toBeNull();
expect(survivor.getAttribute("aria-describedby")).toBeNull();
expect(within(section).queryByRole("alert")).toBeNull();
expect(screen.queryByLabelText("Range 2")).toBeNull();
});
test("editing a row clears the message it was about", async () => {
await renderClientsPage();
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
fireEvent.change(range, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
expect(range.getAttribute("aria-invalid")).toBe("true");
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
expect(range.getAttribute("aria-invalid")).toBeNull();
expect(within(assignmentsSection()).queryByRole("alert")).toBeNull();
});
+88 -79
View File
@@ -9,7 +9,8 @@ import ClientEditDialog from "./ClientEditDialog";
import NetworkAssignments from "./NetworkAssignments";
import { ClientDisplayName } from "./clientIdentity";
import InlineError from "@/lib/InlineError";
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
import ConfirmDialog from "@/ui/ConfirmDialog";
import ConfigLockIndicator, { lockReason } from "@/features/configuration/ConfigLockIndicator";
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -24,6 +25,9 @@ import { colors } from "@/ui/tokens.stylex";
* alone cannot say which operator surface set the row so the sentence names
* the doubt rather than asserting a declaration, the way `provenanceOf` does.
*/
/** The one note every locked Delete on this page describes itself with. */
const DELETE_LOCK_NOTE_ID = "clients-delete-locked-note";
function declaredDeleteNote(authority: Authority): string {
if (authority.state === "resolved") {
return "This client is declared in the configuration file; remove it there and restart.";
@@ -31,6 +35,16 @@ function declaredDeleteNote(authority: Authority): string {
return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked.";
}
/**
* How the confirmation names the row. The address is always there and always
* unique, so it carries the sentence; a name the operator recognizes leads when
* the row has one.
*/
function clientLabel(client: Client): string {
const name = client.name !== "" ? client.name : client.learned_name;
return name === "" ? client.ip : `${name} (${client.ip})`;
}
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
@@ -41,6 +55,15 @@ const styles = stylex.create({
marginTop: "1rem",
color: colors.textMuted,
},
lockNote: {
marginTop: "1rem",
display: "flex",
flexDirection: "column",
gap: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
filterBar: {
marginTop: "1rem",
display: "flex",
@@ -79,23 +102,11 @@ const styles = stylex.create({
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
confirmGroup: {
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: "0.5rem",
},
actionGroup: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
},
note: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
dangerText: {
color: colors.danger,
},
@@ -112,7 +123,7 @@ export default function ClientsPage() {
const queryClient = useQueryClient();
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
const [editing, setEditing] = useState<Client | null>(null);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const [pendingDelete, setPendingDelete] = useState<Client | null>(null);
const readOnly = useReadOnlyConfig();
const authority = useAuthority();
@@ -122,6 +133,17 @@ export default function ClientsPage() {
const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group);
const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group);
// Authority is polled, so it can turn while the confirmation is open. The
// dialog reads it on every render rather than trusting the state that opened
// it, and withdraws the answer that would now fail instead of withdrawing the
// question: the operator is told why, and only they close the dialog.
const deleteLocked = pendingDelete !== null && readOnly && pendingDelete.hand_edited;
// The reason a locked Delete will not answer, printed once above the table.
// Per row it would repeat down the whole page; on the button it was a `title`
// that a keyboard and a touch screen never reached.
const deletesLocked = readOnly && rows.some((client) => client.hand_edited);
return (
<section>
<h1 {...stylex.props(styles.heading)}>Clients</h1>
@@ -137,6 +159,15 @@ export default function ClientsPage() {
</Link>
</div>
)}
{/* The whole explanation for this page's locks, printed once. Per row it
would repeat down the table; on the controls it was a `title` that a
keyboard and a touch screen never reached. */}
{readOnly && (
<div {...stylex.props(styles.lockNote)}>
<p>{lockReason(authority)}.</p>
{deletesLocked && <p id={DELETE_LOCK_NOTE_ID}>{declaredDeleteNote(authority)}</p>}
</div>
)}
{clients.length === 0 ? (
<p {...stylex.props(styles.empty)}>
No clients yet. Rows appear automatically as devices on the network make DNS queries there is
@@ -178,77 +209,37 @@ export default function ClientsPage() {
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
<td {...stylex.props(styles.cell, styles.right)}>
{confirmingId === client.id ? (
<span {...stylex.props(styles.confirmGroup)}>
{/* Authority is polled, so it can turn while a confirmation
sits open. The confirm path reads it on every render
rather than trusting the state that opened it. */}
<span {...stylex.props(styles.note)}>
{readOnly && client.hand_edited
? declaredDeleteNote(authority)
: "Deleted clients re-materialize on their next DNS query."}
</span>
{readOnly && client.hand_edited ? (
<ConfigLockIndicator />
) : (
<button
type="button"
onClick={() => {
setConfirmingId(null);
deleteMutation.mutate(client.id);
}}
{...stylex.props(
shared.smallButton,
styles.dangerText,
shared.focusRing,
)}
>
Confirm delete
</button>
)}
<span {...stylex.props(styles.actionGroup)}>
{/* Naming a client writes configuration, so the affordance is
absent not disabled wherever the write cannot land. */}
{readOnly ? (
<ConfigLockIndicator compact />
) : (
<button
type="button"
onClick={() => setConfirmingId(null)}
onClick={() => setEditing(client)}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Cancel
Edit
</button>
</span>
) : (
<span {...stylex.props(styles.actionGroup)}>
{/* Naming a client writes configuration, so the affordance is
absent not disabled wherever the write cannot land. */}
{readOnly ? (
<ConfigLockIndicator />
) : (
<button
type="button"
onClick={() => setEditing(client)}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Edit
</button>
)}
<button
type="button"
onClick={() => setPendingDelete(client)}
disabled={readOnly && client.hand_edited}
aria-describedby={
readOnly && client.hand_edited ? DELETE_LOCK_NOTE_ID : undefined
}
{...stylex.props(
shared.smallButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
<button
type="button"
onClick={() => setConfirmingId(client.id)}
disabled={readOnly && client.hand_edited}
title={
readOnly && client.hand_edited
? declaredDeleteNote(authority)
: undefined
}
{...stylex.props(
shared.smallButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
</span>
)}
>
Delete
</button>
</span>
</td>
</tr>
))}
@@ -256,6 +247,24 @@ export default function ClientsPage() {
</table>
</div>
)}
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete client"
message={
pendingDelete === null
? ""
: deleteLocked
? declaredDeleteNote(authority)
: `Delete ${clientLabel(pendingDelete)}? Deleted clients re-materialize on their next DNS query.`
}
confirmLabel="Delete"
lock={deleteLocked ? <ConfigLockIndicator /> : undefined}
onConfirm={() => {
if (pendingDelete !== null) deleteMutation.mutate(pendingDelete.id);
setPendingDelete(null);
}}
onCancel={() => setPendingDelete(null)}
/>
<InlineError error={deleteMutation.error} />
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
<NetworkAssignments prefixes={prefixes} groups={groups} />
@@ -4,7 +4,15 @@ import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import {
firstProblem,
initPrefixEditor,
isDirty,
prefixEditorReducer,
toInputs,
type PrefixEditorAction,
type PrefixProblem,
} from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import AuthorityGate from "@/features/configuration/AuthorityGate";
import Select from "@/ui/Select";
@@ -16,6 +24,9 @@ interface Props {
groups: Group[];
}
/** The editor is rendered once per page, so the message can hold a fixed id. */
const VALIDATION_ID = "network-assignments-validation";
const styles = stylex.create({
section: {
marginTop: "2.5rem",
@@ -65,6 +76,7 @@ const styles = stylex.create({
width: "5rem",
},
removeButton: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
@@ -161,12 +173,24 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
function AssignmentsEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<string | null>(null);
const [state, apply] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<PrefixProblem | null>(null);
const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups);
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
// A problem names a row by position, and every action here can move, add or
// delete a position. The message describes the rows Save read, so it dies
// with them rather than drifting onto whatever row inherits the index.
const dispatch = (action: PrefixEditorAction) => {
setValidation(null);
apply(action);
};
/** True for the one input the current message is about; nothing else is marked. */
const invalid = (index: number, field: PrefixProblem["field"]): true | undefined =>
validation !== null && validation.index === index && validation.field === field ? true : undefined;
const save = () => {
const problem = firstProblem(state.rows);
setValidation(problem);
@@ -187,6 +211,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
<input
type="text"
aria-label={`Range ${index + 1}`}
aria-invalid={invalid(index, "prefix")}
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
placeholder="192.168.1.0/24"
value={row.prefix}
onChange={(event) =>
@@ -207,6 +233,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
type="text"
inputMode="numeric"
aria-label={`Priority for range ${index + 1}`}
aria-invalid={invalid(index, "priority")}
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
placeholder="100"
value={row.priority}
onChange={(event) =>
@@ -226,8 +254,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
</ul>
)}
{validation !== null && (
<p role="alert" {...stylex.props(styles.validation)}>
{validation}
<p id={VALIDATION_ID} role="alert" {...stylex.props(styles.validation)}>
{validation.message}
</p>
)}
<InlineError error={mutation.error} />
@@ -250,10 +278,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
{dirty && (
<button
type="button"
onClick={() => {
setValidation(null);
dispatch({ type: "reset", prefixes });
}}
onClick={() => dispatch({ type: "reset", prefixes })}
{...stylex.props(shared.button, shared.focusRing)}
>
Discard changes
+13 -3
View File
@@ -16,6 +16,14 @@ import * as stylex from "@stylexjs/stylex";
import { clientsQuery } from "@/lib/queries";
import type { Client } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
/** Secondary to the name it qualifies, and never the only thing in the cell. */
address: {
color: colors.textMuted,
},
});
export type ClientNames = ReadonlyMap<string, Pick<Client, "name" | "learned_name">>;
@@ -53,11 +61,13 @@ export function clientLabel(ip: string, names: ClientNames): { text: string; lea
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
const label = clientLabel(ip, names);
if (label === null) return <span {...stylex.props(shared.mono)}>{ip}</span>;
// The name replaces the address on screen, so the address stays reachable
// as the tooltip rather than disappearing from the row entirely.
// The name replaces the address, so the address follows it as real text that
// anyone can read and copy. A `title` carried it before, which reaches
// neither a keyboard nor a touch screen.
return (
<span title={ip} {...stylex.props(label.learned && shared.learnedName)}>
<span {...stylex.props(label.learned && shared.learnedName)}>
{label.text}
<span {...stylex.props(styles.address)}> ({ip})</span>
</span>
);
}
@@ -76,11 +76,15 @@ test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
test("firstProblem flags empty prefixes and non-integer priorities", () => {
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required.");
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toEqual({
index: 0,
field: "prefix",
message: "Row 1: prefix is required.",
});
expect(
firstProblem([
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
]),
).toBe("Row 2: priority must be a whole number.");
).toEqual({ index: 1, field: "priority", message: "Row 2: priority must be a whole number." });
});
+13 -4
View File
@@ -55,11 +55,20 @@ export function isDirty(state: PrefixEditorState): boolean {
});
}
export function firstProblem(rows: PrefixRow[]): string | null {
for (const [i, row] of rows.entries()) {
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`;
/** Which input the message is about, so the editor can point that input at it. */
export interface PrefixProblem {
index: number;
field: "prefix" | "priority";
message: string;
}
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
for (const [index, row] of rows.entries()) {
if (row.prefix.trim() === "")
return { index, field: "prefix", message: `Row ${index + 1}: prefix is required.` };
const priority = row.priority.trim();
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`;
if (priority !== "" && !/^\d+$/.test(priority))
return { index, field: "priority", message: `Row ${index + 1}: priority must be a whole number.` };
}
return null;
}
@@ -61,17 +61,20 @@ test("the lock is silent when the database owns the configuration", async () =>
test("under file authority the lock names the file, in words a reader hears", async () => {
renderIndicator(MANAGED_FILE);
// The reason is visible text beside the tag, not a title and not a label: a
// tooltip reaches neither a keyboard nor a touch screen, and screen-reader-only
// text is the same failure pointed the other way.
const lock = await screen.findByText("Locked");
await waitFor(() =>
expect(lock.getAttribute("aria-label")).toBe(
`Locked. Managed by ${CONFIG_PATH}; edit the file and restart nxdns.`,
),
expect(screen.getByText(`Managed by ${CONFIG_PATH}; edit the file and restart nxdns`)).toBeTruthy(),
);
expect(lock.getAttribute("title")).toBeNull();
expect(lock.getAttribute("aria-label")).toBeNull();
});
test("an unanswered status still locks, and says that is why", async () => {
renderIndicator("failed");
const lock = await screen.findByText("Locked");
await waitFor(() => expect(lock.getAttribute("aria-label")).toContain("Configuration status unavailable"));
await screen.findByText("Locked");
await waitFor(() => expect(screen.getByText(/^Configuration status unavailable/)).toBeTruthy());
});
@@ -1,8 +1,20 @@
import * as stylex from "@stylexjs/stylex";
import { colors } from "@/ui/tokens.stylex";
import { useAuthority } from "./authority";
import { useAuthority, type Authority } from "./authority";
const styles = stylex.create({
row: {
display: "inline-flex",
alignItems: "baseline",
flexWrap: "wrap",
gap: "0.375rem",
},
/** The sentence is secondary to the word, and wraps rather than stretching a row. */
reason: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
tag: {
marginLeft: "0.5rem",
borderWidth: 1,
@@ -26,20 +38,34 @@ const styles = stylex.create({
* The word is real text, not colour or an icon, so a screen reader announces
* the reason the control will not answer.
*/
export default function ConfigLockIndicator() {
/**
* Why a configuration control will not answer. Exported because a page that
* shows the compact tag has to print this sentence itself, once, somewhere the
* tag can point at.
*/
export function lockReason(authority: Authority): string {
if (authority.state === "pending") return "Checking which configuration source this server obeys";
if (authority.state === "failed") return "Configuration status unavailable, so edits are held back";
return `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
}
export default function ConfigLockIndicator({ compact = false }: { compact?: boolean }) {
const authority = useAuthority();
if (authority.state === "resolved" && authority.status.authority === "database") return null;
const reason =
authority.state === "pending"
? "Checking which configuration source this server obeys"
: authority.state === "failed"
? "Configuration status unavailable, so edits are held back"
: `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
const reason = lockReason(authority);
// A compact caller has no room for the sentence and must print it once
// nearby instead: the Clients table would otherwise repeat it down every row.
if (compact) return <span {...stylex.props(styles.tag)}>Locked</span>;
// Everywhere else the reason is visible text rather than a `title` or an
// `aria-label`. A tooltip reaches neither a keyboard nor a touch screen, and
// screen-reader-only text is the same failure pointed the other way.
return (
<span title={reason} aria-label={`Locked. ${reason}.`} {...stylex.props(styles.tag)}>
Locked
<span {...stylex.props(styles.row)}>
<span {...stylex.props(styles.tag)}>Locked</span>
<span {...stylex.props(styles.reason)}>{reason}</span>
</span>
);
}
@@ -3,7 +3,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet";
import { sameSet } from "./sourceSet";
import Checkbox, { CheckboxGroup } from "@/ui/Checkbox";
import InlineError from "@/lib/InlineError";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -28,13 +29,6 @@ const styles = stylex.create({
flexDirection: "column",
gap: "0.25rem",
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
buttonRow: {
marginTop: "0.75rem",
display: "flex",
@@ -66,21 +60,22 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
return (
<div {...stylex.props(styles.root)}>
<ul {...stylex.props(styles.list)}>
{blocklists.map((blocklist) => (
<li key={blocklist.id}>
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={current.includes(blocklist.id)}
onChange={() => setSelected(toggleSource(current, blocklist.id))}
{...stylex.props(shared.focusRing)}
/>
{blocklist.name}
</label>
</li>
))}
</ul>
{/* The section's own "Assigned sources" heading is the visible label; a
Label here would put the same words on screen twice. Ids cross the
React Aria boundary as strings, the same convention as Select. */}
<CheckboxGroup
aria-label="Assigned sources"
value={current.map(String)}
onChange={(values) => setSelected(values.map(Number).sort((a, b) => a - b))}
>
<ul {...stylex.props(styles.list)}>
{blocklists.map((blocklist) => (
<li key={blocklist.id}>
<Checkbox value={String(blocklist.id)}>{blocklist.name}</Checkbox>
</li>
))}
</ul>
</CheckboxGroup>
<InlineError error={mutation.error} />
<div {...stylex.props(styles.buttonRow)}>
<button
@@ -21,6 +21,7 @@ import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from
import ConfirmDialog from "@/ui/ConfirmDialog";
import DefinitionList from "@/ui/DefinitionList";
import Select from "@/ui/Select";
import Switch from "@/ui/Switch";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import AuthorityGate from "./AuthorityGate";
@@ -68,13 +69,6 @@ const styles = stylex.create({
alignItems: "center",
gap: "0.75rem",
},
checkboxLabel: {
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
spacer: {
marginLeft: "auto",
},
@@ -355,21 +349,15 @@ function GroupDetailEditable({ group }: { group: Group }) {
)}
<div {...stylex.props(styles.controlRow)}>
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={group.safe_search}
disabled={update.isPending}
{...stylex.props(shared.focusRing)}
onChange={(event) =>
update.mutate({
id: group.id,
input: { name: group.name, safe_search: event.target.checked },
})
}
/>
<Switch
isSelected={group.safe_search}
isDisabled={update.isPending}
onChange={(safeSearch) =>
update.mutate({ id: group.id, input: { name: group.name, safe_search: safeSearch } })
}
>
Safe search
</label>
</Switch>
<span {...stylex.props(styles.spacer)}>
{!renaming && (
<button
@@ -46,7 +46,7 @@ test("another group can be renamed and deleted, and carries its safe-search stat
await screen.findByRole("heading", { name: "kids", level: 2 });
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
expect((screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Delete group" }));
const dialog = await screen.findByRole("alertdialog");
@@ -60,7 +60,7 @@ test("toggling safe search resends the whole group row", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
fireEvent.click(screen.getByRole("checkbox", { name: "Safe search" }));
fireEvent.click(screen.getByRole("switch", { name: "Safe search" }));
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
expect(writes("PUT")[0]).toMatchObject({
@@ -89,6 +89,31 @@ test("the source assignment saves the full set via PUT", async () => {
});
});
test("the source checkboxes form one named group, and each carries its own state", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
// The section heading names the group; the boxes belong to it rather than
// sitting loose beside the group's other checkboxes.
const group = await screen.findByRole("group", { name: "Assigned sources" });
const ads = within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement;
expect(ads.checked).toBe(false);
// Safe search is the group's own field, not one of its sources, and it is a
// switch rather than a checkbox because it applies the moment it moves.
expect(within(group).queryByRole("switch")).toBeNull();
expect(screen.getByRole("switch", { name: "Safe search" })).toBeTruthy();
fireEvent.click(ads);
await waitFor(() => expect(ads.checked).toBe(true));
// Discard returns the group to the server's set rather than clearing it.
fireEvent.click(screen.getByRole("button", { name: "Discard" }));
await waitFor(() =>
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(false),
);
expect(writes("PUT")).toEqual([]);
});
test("only the selected group's rules are listed", async () => {
await openProtection(2);
await screen.findByRole("heading", { name: "kids", level: 2 });
@@ -212,11 +237,33 @@ test("the Sources tab lists the catalogue with both skipped columns and their no
expect(
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
).toBeTruthy();
expect((screen.getByLabelText("Ads enabled") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Trackers enabled") as HTMLInputElement).checked).toBe(false);
// Switches, not checkboxes: the row applies the moment it moves, and the role
// is what tells a screen reader so.
expect((screen.getByRole("switch", { name: "Ads enabled" }) as HTMLInputElement).checked).toBe(true);
expect((screen.getByRole("switch", { name: "Trackers enabled" }) as HTMLInputElement).checked).toBe(false);
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
});
test("a source switch resends the whole row immediately, with no Save step", async () => {
calls = stubApi(DATABASE);
await renderPage("/configuration/protection?tab=sources", "Protection");
fireEvent.click(await screen.findByRole("switch", { name: "Trackers enabled" }));
// No Save button stands between the switch and the write: one click, one PUT.
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
expect(writes("PUT")[0]).toMatchObject({
url: "/api/blocklists/2",
// The whole row goes back, not a patch of the one field that moved.
body: {
url: "https://example.com/trackers.txt",
name: "Trackers",
enabled: true,
is_suggested: true,
},
});
});
test("Update now says it started, and says nothing once it succeeds", async () => {
let release: ((response: Response) => void) | null = null;
calls = stubApi(DATABASE, {
@@ -14,6 +14,7 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import Switch from "@/ui/Switch";
import AuthorityGate from "./AuthorityGate";
import BlocklistForm from "./BlocklistForm";
import FileModeNote from "./FileModeNote";
@@ -249,13 +250,11 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
</span>
</td>
<td {...stylex.props(shared.td)}>
<input
type="checkbox"
<Switch
aria-label={`${b.name} enabled`}
checked={b.enabled}
disabled={toggle.isPending}
isSelected={b.enabled}
isDisabled={toggle.isPending}
onChange={() => toggleEnabled(b)}
{...stylex.props(shared.focusRing)}
/>
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
@@ -13,6 +13,9 @@ import { styles as config } from "./styles";
const DARK = "@media (prefers-color-scheme: dark)";
/** The form is rendered once per page, so the message can hold a fixed id. */
const MISMATCH_ID = "web.password_mismatch";
const styles = stylex.create({
form: {
marginTop: "1rem",
@@ -100,6 +103,7 @@ const styles = stylex.create({
gap: "0.75rem",
},
save: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "1rem",
@@ -174,12 +178,15 @@ function FieldRow({
}
if (def.kind === "number") {
const numeric = value as number;
// An empty or unparseable number reads back as NaN. The form already refuses
// to submit on it; this is what says so to a screen reader.
return (
<div {...stylex.props(styles.field)}>
<FieldLabel id={id} text={def.key} restart={restart} />
<input
id={id}
type="number"
aria-invalid={Number.isNaN(numeric) || undefined}
value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -285,6 +292,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
id="web.password"
type="password"
autoComplete="new-password"
aria-invalid={passwordsMismatch || undefined}
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
value={password}
onChange={(e) => setPassword(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -298,6 +307,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
id="web.password_confirm"
type="password"
autoComplete="new-password"
aria-invalid={passwordsMismatch || undefined}
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
{...stylex.props(styles.fieldInput, shared.focusRing)}
@@ -310,7 +321,7 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
</p>
)}
{passwordsMismatch && (
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
<p id={MISMATCH_ID} {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
Passwords do not match.
</p>
)}
@@ -176,12 +176,52 @@ test("enum and boolean fields diff as their own types", async () => {
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
});
/** The text of the elements an input points at with `aria-describedby`. */
function describedText(input: HTMLElement): string {
const ids = input.getAttribute("aria-describedby");
if (ids === null) throw new Error("input has no aria-describedby");
return ids
.split(/\s+/)
.map((id) => {
const node = document.getElementById(id);
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
return node.textContent ?? "";
})
.join(" ");
}
test("clearing a number field disables Save instead of sending NaN", async () => {
await openSystem();
const cache = screen.getByRole("group", { name: "Cache" });
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
const size = within(cache).getByLabelText("size");
fireEvent.change(size, { target: { value: "" } });
expect(saveButton().disabled).toBe(true);
// The refusal is on the field itself, not only on the Save button.
expect(size.getAttribute("aria-invalid")).toBe("true");
fireEvent.change(size, { target: { value: "512" } });
expect(size.getAttribute("aria-invalid")).toBeNull();
});
test("the mismatch message is attached to both password inputs", async () => {
await openSystem();
const web = screen.getByRole("group", { name: "Web" });
const passwordInput = within(web).getByLabelText("password");
const confirmInput = within(web).getByLabelText("confirm password");
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
for (const input of [passwordInput, confirmInput]) {
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(describedText(input)).toBe("Passwords do not match.");
}
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
for (const input of [passwordInput, confirmInput]) {
expect(input.getAttribute("aria-invalid")).toBeNull();
expect(input.getAttribute("aria-describedby")).toBeNull();
}
});
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {
@@ -7,6 +7,7 @@ import type { Upstream, UpstreamInput } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import Switch from "@/ui/Switch";
import AuthorityGate from "./AuthorityGate";
import FileModeNote from "./FileModeNote";
import QueryPanel from "./QueryPanel";
@@ -165,13 +166,11 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
</td>
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
<td {...stylex.props(shared.td)}>
<input
type="checkbox"
<Switch
aria-label={`${u.url} enabled`}
checked={u.enabled}
disabled={toggle.isPending}
isSelected={u.enabled}
isDisabled={toggle.isPending}
onChange={() => toggleEnabled(u)}
{...stylex.props(shared.focusRing)}
/>
</td>
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
@@ -24,7 +24,7 @@ afterEach(() => {
function mutationControls(): Element[] {
return [
...contentArea().querySelectorAll(
'input, textarea, select, [role="combobox"], [role="checkbox"], [contenteditable]',
'input, textarea, select, [role="combobox"], [role="checkbox"], [role="switch"], [contenteditable]',
),
];
}
@@ -1,18 +1,4 @@
import { sameSet, toggleSource } from "./sourceSet";
test("toggleSource adds a missing id keeping ascending order", () => {
expect(toggleSource([1, 3], 2)).toEqual([1, 2, 3]);
expect(toggleSource([], 5)).toEqual([5]);
});
test("toggleSource removes a present id", () => {
expect(toggleSource([1, 2, 3], 2)).toEqual([1, 3]);
expect(toggleSource([5], 5)).toEqual([]);
});
test("toggleSource twice is a no-op set-wise", () => {
expect(toggleSource(toggleSource([1, 2], 3), 3)).toEqual([1, 2]);
});
import { sameSet } from "./sourceSet";
test("sameSet compares regardless of order", () => {
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
@@ -1,8 +1,3 @@
export function toggleSource(ids: number[], id: number): number[] {
if (ids.includes(id)) return ids.filter((existing) => existing !== id);
return [...ids, id].sort((a, b) => a - b);
}
export function sameSet(a: number[], b: number[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort((x, y) => x - y);
@@ -195,7 +195,7 @@ test("every code renders its own title, impact and remediation", async () => {
test("an event retention has removed shows the server's message, not an empty page", async () => {
renderDetail(999);
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 () => {
@@ -219,5 +219,5 @@ test("an unavailable store reports the failure instead of loading forever", asyn
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("The server is starting or degraded.");
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 { ArrowLeft } from "@phosphor-icons/react/dist/icons/ArrowLeft";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
@@ -13,11 +14,17 @@ import { componentLabel, copyFor } from "./eventCopy";
const styles = stylex.create({
back: {
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
textDecorationLine: "none",
},
backIcon: {
display: "inline-flex",
},
headingRow: {
marginTop: "0.5rem",
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() {
const { id } = useParams({ from: "/shell/diagnostics/$id" });
const eventId = Number(id);
@@ -132,9 +150,7 @@ export default function DiagnosticDetailPage() {
if (data === undefined) {
return (
<section>
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
All diagnostics
</Link>
<BackLink />
<InlineError error={error} onRetry={() => void refetch()} />
</section>
);
@@ -146,9 +162,7 @@ export default function DiagnosticDetailPage() {
return (
<section>
<Link to="/diagnostics" {...stylex.props(styles.back, shared.focusRing)}>
All diagnostics
</Link>
<BackLink />
<div {...stylex.props(styles.headingRow)}>
<h1 {...stylex.props(styles.heading)}>{copy.title}</h1>
<SeverityBadge severity={data.severity} />
+15 -2
View File
@@ -15,6 +15,11 @@
* 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 { Link } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
@@ -95,6 +100,9 @@ const styles = stylex.create({
danger: {
color: colors.dangerText,
},
icon: {
display: "inline-flex",
},
message: {
marginTop: "0.5rem",
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;
/** 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 }) {
if (link.kind === "filter") {
@@ -151,7 +164,7 @@ function FactLinkAnchor({ link }: { link: FactLink }) {
function Fact({ fact }: { fact: HealthFact }) {
return (
<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]}
</span>
<span {...stylex.props(styles.label)}>{fact.label}</span>
+2 -8
View File
@@ -69,8 +69,6 @@ const styles = stylex.create({
interface Series {
key: string;
label: string;
/** Kept beside the label so a renamed client is still identifiable by address. */
address: string | null;
color: string;
buckets: number[];
}
@@ -89,15 +87,11 @@ function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
// the same precedence and the same lookup the query tables use. The colour
// keys on the address regardless, so naming a client never repaints it.
label: clientLabel(client.client, names)?.text ?? client.client,
address: client.client,
color: seriesColor(clientKey(client.client)),
buckets: client.buckets,
}));
if (data.other.every((count) => count === 0)) return named;
return [
...named,
{ key: OTHER_KEY, label: "Other", address: null, color: seriesColor(OTHER_KEY), buckets: data.other },
];
return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }];
}
/**
@@ -219,7 +213,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
)}
<ul {...stylex.props(styles.legend)}>
{series.map((one) => (
<li key={one.key} title={one.address ?? undefined} {...stylex.props(styles.legendItem)}>
<li key={one.key} {...stylex.props(styles.legendItem)}>
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
{one.label}
</li>
+27 -12
View File
@@ -10,6 +10,7 @@
import * as stylex from "@stylexjs/stylex";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { Radio, RadioGroup } from "react-aria-components";
import type { Period } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
@@ -38,6 +39,7 @@ const styles = stylex.create({
gap: "0.25rem",
},
period: {
cursor: "pointer",
borderStyle: "none",
borderRadius: "0.25rem",
paddingInline: "0.625rem",
@@ -45,6 +47,13 @@ const styles = stylex.create({
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
periodFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
periodSelected: {
backgroundColor: {
@@ -67,23 +76,29 @@ const styles = stylex.create({
export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
return (
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
<RadioGroup
aria-label="Period"
orientation="horizontal"
value={period}
onChange={(next) => onChange(next as Period)}
className={() => stylex.props(styles.periodGroup).className ?? ""}
>
{PERIODS.map((option) => (
<button
<Radio
key={option}
type="button"
aria-pressed={option === period}
onClick={() => onChange(option)}
{...stylex.props(
styles.period,
option === period ? styles.periodSelected : styles.periodIdle,
shared.focusRing,
)}
value={option}
className={({ isSelected, isFocusVisible }) =>
stylex.props(
styles.period,
isSelected ? styles.periodSelected : styles.periodIdle,
isFocusVisible && styles.periodFocusVisible,
).className ?? ""
}
>
{option}
</button>
</Radio>
))}
</div>
</RadioGroup>
);
}
@@ -189,7 +189,7 @@ test("a request in flight leaves the heading and the picker usable behind one lo
renderApp();
await screen.findByRole("heading", { name: "Overview", level: 1 });
expect(screen.getByRole("button", { name: "1h" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
// One loading state for the whole page, not one per panel.
const loading = await screen.findByText("Loading…");
expect(loading.getAttribute("role")).toBe("status");
@@ -214,6 +214,16 @@ test("a registered client is named in the chart, an unregistered one keeps its a
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
expect(within(chart).queryByText("192.0.2.30")).toBeNull();
expect(within(chart).getAllByText("192.0.2.31")).toHaveLength(2);
// Nor on hover: a pointer-only tooltip would say what the design just chose
// not to, and only to a reader holding a mouse.
const legendItem = within(chart)
.getAllByText("kitchen-pi")
.map((node) => node.closest("li"))
.find((node) => node !== null);
expect(legendItem).toBeTruthy();
expect(legendItem?.getAttribute("title")).toBeNull();
expect(within(chart).getByRole("list").querySelectorAll("[title]")).toHaveLength(0);
});
test("a client named only by reverse DNS is named by it too", async () => {
@@ -335,14 +345,23 @@ test("an empty window says so in every panel instead of drawing nothing", async
test("a deep link opens on the period it names", async () => {
renderApp("/overview?period=1h");
await screen.findByText("12");
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
// One radio group named Period, holding the four periods and exactly one
// selection: the segmented picker is a single choice, not four toggles.
const picker = within(screen.getByRole("radiogroup", { name: "Period" }));
expect(picker.getAllByRole("radio").map((radio) => radio.getAttribute("value"))).toEqual([
"1h",
"24h",
"7d",
"30d",
]);
expect(picker.getByRole("radio", { name: "1h", checked: true })).toBeTruthy();
expect(picker.getByRole("radio", { name: "24h", checked: false })).toBeTruthy();
});
test("a period the API does not have falls back to the default without carrying it in the url", async () => {
const router = renderApp("/overview?period=90d");
await screen.findByText("1,000");
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("radio", { name: "24h", checked: true })).toBeTruthy();
expect(router.state.location.search).toEqual({});
});
@@ -350,7 +369,7 @@ test("the picker rescopes every panel and writes the period into the url", async
const router = renderApp();
await screen.findByText("1,000");
fireEvent.click(screen.getByRole("button", { name: "1h" }));
fireEvent.click(screen.getByRole("radio", { name: "1h" }));
await screen.findByText("12");
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
@@ -369,7 +388,7 @@ test("a failed request is one error for the whole page, stated once and retryabl
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
// The heading and the picker survive it, so the reader can rescope or retry.
expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy();
expect(screen.getByRole("button", { name: "1h" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
expect(screen.queryByText("Something went wrong")).toBeNull();
failing = false;
+25 -18
View File
@@ -91,11 +91,14 @@ test("unpaused: duration menu pauses with the picked duration_seconds", async ()
fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
expect(screen.getByRole("button", { name: label })).toBeTruthy();
}
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
"60 seconds",
"5 minutes",
"30 minutes",
"Indefinitely",
]);
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
await screen.findByRole("button", { name: "Resume" });
@@ -105,7 +108,7 @@ test("indefinite pause sends no duration_seconds", async () => {
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Indefinitely" }));
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
await screen.findByRole("button", { name: "Resume" });
@@ -151,11 +154,15 @@ test("an active resolver states nothing: the button already says Pause", async (
test("escape closes the duration menu", async () => {
renderControl();
const trigger = await findPauseTrigger();
fireEvent.click(trigger);
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
fireEvent.keyDown(trigger, { key: "Escape" });
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
fireEvent.click(await findPauseTrigger());
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
// Opening the menu moves focus into it, so Escape is pressed where the reader
// is, not on the trigger they left.
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull());
expect(screen.getByRole("button", { name: "Pause" }).getAttribute("aria-expanded")).toBe("false");
});
test("failed pause with 429 shows a ticking retry countdown", async () => {
@@ -168,7 +175,7 @@ test("failed pause with 429 shows a ticking retry countdown", async () => {
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
@@ -187,7 +194,7 @@ test("failed pause with 503 shows the degraded message", async () => {
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
fireEvent.click(screen.getByRole("menuitem", { name: "60 seconds" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
@@ -202,12 +209,12 @@ test("a successful pause clears the previous mutation error", async () => {
renderControl();
fireEvent.click(await findPauseTrigger());
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
await screen.findByRole("alert");
postFailure = null;
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
await screen.findByRole("button", { name: "Resume" });
expect(screen.queryByRole("alert")).toBeNull();
@@ -240,7 +247,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
vi.useFakeTimers({ shouldAdvanceTime: true });
renderControl();
fireEvent.click(await findPauseTrigger());
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
protection = { state: "unavailable", until: null };
await vi.advanceTimersByTimeAsync(11_000);
@@ -253,7 +260,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
// reader opened, and nobody opened this one.
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
vi.useRealTimers();
});
@@ -261,7 +268,7 @@ test("a menu open when someone else pauses does not reopen when that pause ends"
vi.useFakeTimers({ shouldAdvanceTime: true });
renderControl();
fireEvent.click(await findPauseTrigger());
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
// Filtering is paused from somewhere else, and this browser learns it from
// the poll. The Resume rendering has no menu.
@@ -274,7 +281,7 @@ test("a menu open when someone else pauses does not reopen when that pause ends"
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
vi.useRealTimers();
});
+60 -67
View File
@@ -1,8 +1,7 @@
/**
* The Pause/Resume control, in the two places a pause is a valid answer to what
* the reader is looking at: the foot of the sidebar, where it belongs to the
* resolver rather than to any page, and beside the detail of a query that was
* blocked.
* The Pause/Resume control, at the foot of the sidebar. A pause stops filtering
* for the whole resolver, so it belongs to the shell rather than to any page,
* and it has no second placement.
*
* It reads `Health.protection` rather than `/api/pause` so it cannot contradict
* the Diagnostics health strip, and it renders nothing at all while protection
@@ -20,9 +19,10 @@
* line: the button says Pause, which is the whole message.
*/
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
import { formatClock } from "@/lib/format";
import { pauseMutation } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
@@ -48,6 +48,8 @@ const styles = stylex.create({
":disabled": "oklch(70.5% 0.015 286.067)",
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
},
/** The sidebar foot is the only placement, and there Log out sets the width. */
width: "100%",
},
row: {
display: "flex",
@@ -61,43 +63,44 @@ const styles = stylex.create({
lineHeight: "1rem",
color: colors.textSecondary,
},
anchor: {
position: "relative",
},
menu: {
position: "absolute",
left: 0,
top: "100%",
zIndex: 10,
marginTop: "0.25rem",
display: "flex",
width: "9rem",
flexDirection: "column",
/** `--trigger-width` is RAC's: the menu is as wide as the button that opened it. */
popover: {
width: "var(--trigger-width)",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
paddingBlock: "0.25rem",
color: colors.text,
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
menu: {
outlineStyle: "none",
paddingBlock: "0.25rem",
},
menuItem: {
borderStyle: "none",
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: "inherit",
cursor: "pointer",
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/**
* RAC focuses the item's own node, so the shared ring applies; it is inset
* because an item flush against the popover edge clips an outset one, and
* recoloured because the focus token is the blue this row just painted.
*/
menuItemFocused: {
backgroundColor: colors.primary,
color: colors.primaryText,
outlineColor: { default: null, ":focus-visible": colors.primaryText },
},
});
export default function PauseControl() {
const queryClient = useQueryClient();
const protection = useProtection();
const mutation = useMutation(pauseMutation(queryClient));
const [menuOpen, setMenuOpen] = useState(false);
const paused = protection.state === "paused";
const { reset } = mutation;
useEffect(() => reset(), [paused, reset]);
@@ -108,15 +111,6 @@ export default function PauseControl() {
// the wrong action, which is the contradiction this control exists to end.
const actionable = protection.state === "active" || protection.state === "paused";
// Leaving `active` unmounts the menu but not the state that opened it, and
// the menu belongs to the active rendering alone — a pause someone else
// started, seen through the poll, takes it away exactly as a withdrawal does.
// Closing on the way out rather than on the way back means the trigger can
// only ever come back shut, however long it was gone.
useEffect(() => {
if (protection.state !== "active") setMenuOpen(false);
}, [protection.state]);
if (!actionable) return null;
if (paused) {
@@ -140,41 +134,40 @@ export default function PauseControl() {
}
return (
<div
{...stylex.props(styles.anchor)}
onKeyDown={(e) => {
if (e.key === "Escape") setMenuOpen(false);
}}
>
<button
type="button"
aria-expanded={menuOpen}
aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending}
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
>
Pause
</button>
{menuOpen && (
<div id="pause-menu" {...stylex.props(styles.menu)}>
{DURATIONS.map(({ label, seconds }) => (
<button
key={label}
type="button"
onClick={() => {
setMenuOpen(false);
mutation.mutate(
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
);
}}
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
>
{label}
</button>
))}
</div>
)}
<div {...stylex.props(styles.row)}>
<MenuTrigger>
<Button
isDisabled={mutation.isPending}
className={() => stylex.props(shared.button, styles.trigger, shared.focusRing).className ?? ""}
>
Pause
</Button>
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
<Menu {...stylex.props(styles.menu)}>
{DURATIONS.map(({ label, seconds }) => (
<MenuItem
key={label}
onAction={() =>
mutation.mutate(
seconds === null
? { paused: true }
: { paused: true, duration_seconds: seconds },
)
}
className={({ isFocused }) =>
stylex.props(
styles.menuItem,
shared.insetFocusRing,
isFocused && styles.menuItemFocused,
).className ?? ""
}
>
{label}
</MenuItem>
))}
</Menu>
</Popover>
</MenuTrigger>
<InlineError error={mutation.error} />
</div>
);
+18 -2
View File
@@ -17,6 +17,12 @@ const styles = stylex.create({
lineHeight: "1.25rem",
color: colors.textSecondary,
},
/** The same sentence with no box, for a results footer that already has one. */
note: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
});
/**
@@ -27,11 +33,21 @@ const styles = stylex.create({
* watermark and nothing more: the same incompleteness covers a log retention
* has pruned and one that simply has not been running long enough, and the
* response does not say which.
*
* `note` is the same sentence without the panel, for a place that already sits
* under the data it qualifies a results footer states the reach of the count
* beside it, where a full-width banner would announce a limit as news.
*/
export default function CoverageNotice({ coverage }: { coverage: Coverage }) {
export default function CoverageNotice({
coverage,
variant = "banner",
}: {
coverage: Coverage;
variant?: "banner" | "note";
}) {
if (coverage.complete) return null;
return (
<p role="status" {...stylex.props(styles.notice)}>
<p role="status" {...stylex.props(variant === "note" ? styles.note : styles.notice)}>
Query history is available from {formatTime(coverage.available_since)}.
</p>
);
+1
View File
@@ -12,6 +12,7 @@ const styles = stylex.create({
color: colors.danger,
},
retry: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
+69 -2
View File
@@ -214,12 +214,45 @@ test("mount probe reveals the logout button and a failed logout surfaces inline"
renderShell();
fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
// Both renderings are mounted; the viewport paints the sidebar one on WIDE
// and the header one below it.
await waitFor(() => expect(screen.getAllByRole("button", { name: "Log out" })).toHaveLength(2));
const aside = document.querySelector("aside") as HTMLElement;
fireEvent.click(within(aside).getByRole("button", { name: "Log out" }));
await screen.findByText("Rate limited. Try again in 7s.");
await within(aside).findByText("Rate limited. Try again in 7s.");
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
});
test("Log out sits in the sidebar on wide, and only in the header below it", async () => {
stubFetch((url) =>
url === "/api/auth/login"
? new Response(JSON.stringify({ error: "password required" }), {
status: 401,
headers: { "content-type": "application/json" },
})
: null,
);
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const aside = document.querySelector("aside") as HTMLElement;
const header = document.querySelector("header") as HTMLElement;
await waitFor(() => expect(within(aside).getByRole("button", { name: "Log out" })).toBeTruthy());
expect(within(header).getByRole("button", { name: "Log out" })).toBeTruthy();
// The header is the narrow rendering now: the Menu button is its only nav.
expect(within(header).getByRole("button", { name: "Menu" })).toBeTruthy();
// In the sidebar the control precedes the version label rather than crowding it.
const version = within(aside).getByText(/^nxdns v/);
const logout = within(aside).getByRole("button", { name: "Log out" });
expect(logout.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// The drawer keeps no third copy: the header already carries the narrow one.
fireEvent.click(within(header).getByRole("button", { name: "Menu" }));
const drawer = document.getElementById("mobile-nav") as HTMLElement;
expect(within(drawer).queryByRole("button", { name: "Log out" })).toBeNull();
});
test("the header carries no protection display at all any more", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
@@ -257,6 +290,40 @@ test("the mobile drawer carries the same control, not a header one it lost", asy
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test("the Menu button is a disclosure trigger, and it names the panel it opens", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const menu = screen.getByRole("button", { name: "Menu" });
// The trigger states the drawer's state, and points at the drawer itself.
expect(menu.getAttribute("aria-expanded")).toBe("false");
expect(menu.getAttribute("aria-controls")).toBe("mobile-nav");
const drawer = document.getElementById("mobile-nav") as HTMLElement;
expect(drawer.getAttribute("hidden")).not.toBeNull();
fireEvent.click(menu);
expect(menu.getAttribute("aria-expanded")).toBe("true");
expect(drawer.getAttribute("hidden")).toBeNull();
expect(within(drawer).getByRole("navigation", { name: "Main" })).toBeTruthy();
fireEvent.click(menu);
expect(menu.getAttribute("aria-expanded")).toBe("false");
expect(drawer.getAttribute("hidden")).not.toBeNull();
});
test("following a drawer link closes the drawer behind it", async () => {
renderShell();
await screen.findByRole("heading", { name: "Overview" });
const menu = screen.getByRole("button", { name: "Menu" });
fireEvent.click(menu);
const drawer = document.getElementById("mobile-nav") as HTMLElement;
fireEvent.click(within(drawer).getByRole("link", { name: "Clients" }));
await waitFor(() => expect(menu.getAttribute("aria-expanded")).toBe("false"));
expect(drawer.getAttribute("hidden")).not.toBeNull();
});
test("a paused resolver says so in both renderings, not only on Diagnostics", async () => {
// The trace a pause leaves on every page. With the header indicator and the
// Overview status row both gone, a reader who is not on Diagnostics has only
+41 -20
View File
@@ -2,6 +2,7 @@ import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { Button, Disclosure, DisclosurePanel } from "react-aria-components";
import { useAuth } from "@/auth/store";
import InlineError from "@/lib/InlineError";
import { healthQuery, versionQuery } from "@/lib/queries";
@@ -110,6 +111,17 @@ const styles = stylex.create({
alignItems: "center",
gap: "0.5rem",
},
/**
* In a 14rem column the button and its error stack, so the button spans the
* sidebar and matches the Pause trigger below it.
*/
sidebarLogout: {
flexDirection: "column",
alignItems: "stretch",
gap: "0.25rem",
paddingInline: "1rem",
paddingTop: "0.75rem",
},
shell: {
minHeight: "100dvh",
height: { default: null, [WIDE]: "100dvh" },
@@ -147,8 +159,9 @@ const styles = stylex.create({
minWidth: { default: null, [WIDE]: 0 },
flexDirection: "column",
},
/** Narrow only: on WIDE the sidebar carries everything this row held. */
header: {
display: "flex",
display: { default: "flex", [WIDE]: "none" },
alignItems: "center",
gap: "0.75rem",
borderBottomWidth: 1,
@@ -284,13 +297,13 @@ function VersionFooter() {
);
}
function LogoutButton() {
function LogoutButton({ style }: { style?: stylex.StyleXStyles }) {
const { authRequired, logout } = useAuth();
const navigate = useNavigate();
const [error, setError] = useState<unknown>(null);
if (authRequired !== true) return null;
return (
<div {...stylex.props(styles.logoutRow)}>
<div {...stylex.props(styles.logoutRow, style)}>
<button
type="button"
onClick={() => {
@@ -318,37 +331,45 @@ export default function AppShell() {
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
<NavLinks />
</nav>
<LogoutButton style={styles.sidebarLogout} />
<SidebarFooter />
</aside>
<div {...stylex.props(styles.column)}>
{/* The column itself is the disclosure: the trigger sits in the header
and the panel opens below the notices, so any wrapper around only
the two would have to cut across the column's own flex children. */}
<Disclosure isExpanded={drawerOpen} onExpandedChange={setDrawerOpen} {...stylex.props(styles.column)}>
<header {...stylex.props(styles.header)}>
<button
type="button"
aria-expanded={drawerOpen}
aria-controls="mobile-nav"
onClick={() => setDrawerOpen((open) => !open)}
{...stylex.props(shared.button, styles.narrowOnly, shared.focusRing)}
<Button
slot="trigger"
className={() =>
stylex.props(shared.button, styles.narrowOnly, shared.focusRing).className ?? ""
}
>
Menu
</button>
</Button>
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
<div {...stylex.props(styles.headerRight)}>
<LogoutButton />
</div>
</header>
<ConfigStatusNotices />
{drawerOpen && (
<div id="mobile-nav" {...stylex.props(styles.drawer)}>
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
<NavLinks onNavigate={() => setDrawerOpen(false)} />
</nav>
<SidebarFooter />
</div>
)}
<DisclosurePanel id="mobile-nav" {...stylex.props(styles.drawer)}>
{/* React Aria only hides a collapsed panel; the shell drops it
instead, so a closed drawer keeps no second health poll and
no second Pause control alive behind the header. */}
{drawerOpen && (
<>
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
<NavLinks onNavigate={() => setDrawerOpen(false)} />
</nav>
<SidebarFooter />
</>
)}
</DisclosurePanel>
<main id="main-content" data-scroll-restoration-id="main" {...stylex.props(styles.main)}>
<Outlet />
</main>
</div>
</Disclosure>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import Checkbox, { CheckboxGroup } from "./Checkbox";
/**
* The drawn box, which is the label's one child that does not hold the hidden
* input. StyleX compiles to class names and jsdom loads no stylesheet, so the
* class list is the only place the composed ring is observable.
*/
function indicator(input: HTMLElement): HTMLElement {
const label = input.closest("label") as HTMLElement;
const spans = [...label.querySelectorAll("span")];
const box = spans.find((span) => !span.contains(input));
if (box === undefined) throw new Error("the checkbox drew no indicator");
return box;
}
test("a standalone checkbox reports its state and reads its label as its name", () => {
const onChange = vi.fn();
render(
<Checkbox isSelected onChange={onChange}>
Safe search
</Checkbox>,
);
const box = screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement;
expect(box.checked).toBe(true);
fireEvent.click(box);
// The caller owns the state, so the box reports the value it would move to
// rather than moving there itself.
expect(onChange).toHaveBeenCalledWith(false);
});
test("a disabled checkbox keeps its state on screen but takes no input", () => {
render(
<Checkbox isSelected isDisabled onChange={vi.fn()}>
Safe search
</Checkbox>,
);
const box = screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement;
expect(box.checked).toBe(true);
// The disabled input is the whole guard: a browser fires no click on one, and
// it is out of the tab order. Clicking it here would prove nothing either way,
// because `fireEvent` dispatches straight at the node and skips that check.
expect(box.disabled).toBe(true);
});
test("keyboard focus composes the ring onto the drawn box", () => {
render(
<Checkbox isSelected={false} onChange={vi.fn()}>
Ads
</Checkbox>,
);
const input = screen.getByRole("checkbox", { name: "Ads" });
const box = indicator(input);
const idle = box.className.split(" ");
expect(input.closest("label")?.getAttribute("data-focus-visible")).toBeNull();
// React Aria only calls focus visible after the modality is keyboard, which
// is why a bare focus() is not enough to raise the ring.
act(() => {
fireEvent.keyDown(document.body, { key: "Tab" });
input.focus();
});
expect(input.closest("label")?.getAttribute("data-focus-visible")).toBe("true");
const ringed = box.className.split(" ");
// The box gained classes it did not have: the ring is composed onto it, not
// merely reported by React Aria on the root.
expect(ringed.length).toBeGreaterThan(idle.length);
expect(idle.every((name) => ringed.includes(name))).toBe(true);
});
test("inside a group the group owns the selection, and the group carries the name", () => {
const onChange = vi.fn();
render(
<CheckboxGroup aria-label="Assigned sources" value={["1"]} onChange={onChange}>
<Checkbox value="1">Ads</Checkbox>
<Checkbox value="2">Trackers</Checkbox>
</CheckboxGroup>,
);
const group = screen.getByRole("group", { name: "Assigned sources" });
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(true);
expect((within(group).getByRole("checkbox", { name: "Trackers" }) as HTMLInputElement).checked).toBe(false);
// The group reports the whole set, not the box that moved.
fireEvent.click(within(group).getByRole("checkbox", { name: "Trackers" }));
expect(onChange).toHaveBeenCalledWith(["1", "2"]);
});
+125
View File
@@ -0,0 +1,125 @@
/**
* The checkbox, wrapping React Aria's.
*
* React Aria hides the real input and leaves the mark to the call site, so the
* box below is what the reader sees. It is drawn to the native control's size
* so a row that held a native checkbox keeps its height and its baseline.
*
* One component covers both uses: pass `value` for a box inside a
* `CheckboxGroup`, which owns the selection, or `isSelected`/`onChange` for a
* standalone box that owns its own.
*/
import type { ReactNode } from "react";
import { Check } from "@phosphor-icons/react/dist/icons/Check";
import * as stylex from "@stylexjs/stylex";
import { Checkbox as AriaCheckbox } from "react-aria-components";
import { colors } from "./tokens.stylex";
/**
* Re-exported rather than wrapped: the group adds no styling of its own, and
* routing it through here keeps React Aria's checkbox parts to one import site,
* the same rule `Select` follows.
*/
export { CheckboxGroup } from "react-aria-components";
interface Common {
/** The visible label, which is also the accessible name. */
children: ReactNode;
/** Visible but inert; React Aria also drops it from the tab order. */
isDisabled?: boolean;
}
/** Inside a `CheckboxGroup`, which holds the selection for every box in it. */
interface Grouped extends Common {
value: string;
isSelected?: never;
onChange?: never;
}
/** On its own, where the caller holds the state and is told to move it. */
interface Standalone extends Common {
value?: never;
isSelected: boolean;
onChange: (isSelected: boolean) => void;
}
/**
* The two modes are exclusive: a grouped box that also carried `isSelected`
* would have two sources of truth, and a standalone one without `onChange`
* could never move. The union is what makes both unrepresentable.
*/
type Props = Grouped | Standalone;
const styles = stylex.create({
/**
* The whole label is the hit area, so it carries the 44px pointer-target
* floor the dialog's Close button already sets, on both axes. The text is
* 20px tall, and a one-word source name is narrower than 44px again.
*/
label: {
minHeight: 44,
minWidth: 44,
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
box: {
flexShrink: 0,
boxSizing: "border-box",
width: "0.875rem",
height: "0.875rem",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.1875rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
},
boxSelected: {
borderColor: colors.primary,
backgroundColor: colors.primary,
color: colors.primaryText,
},
/**
* The shared ring keys off `:focus-visible`, which lands on the hidden input
* rather than on this box, so the ring follows the state React Aria reports.
*/
boxFocused: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** The box dims, not the words: the label still has to be readable. */
boxDisabled: {
opacity: 0.5,
},
});
export default function Checkbox({ children, ...state }: Props) {
return (
<AriaCheckbox {...state} className={() => stylex.props(styles.label).className ?? ""}>
{(renderProps) => (
<>
<span
{...stylex.props(
styles.box,
renderProps.isSelected && styles.boxSelected,
renderProps.isFocusVisible && styles.boxFocused,
renderProps.isDisabled && styles.boxDisabled,
)}
>
{/* Bold: the regular stroke thins to near-invisible at this size. */}
{renderProps.isSelected && <Check aria-hidden="true" size={10} weight="bold" />}
</span>
{children}
</>
)}
</AriaCheckbox>
);
}
+22 -8
View File
@@ -8,6 +8,7 @@
* and a stray outside click is not one. Escape still cancels.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
@@ -19,6 +20,14 @@ interface Props {
/** The full sentence the operator reads before confirming; names the entity. */
message: string;
confirmLabel: string;
/**
* Stands in for the confirm action when the write can no longer land, the way
* an open edit dialog drops its Save. Closing the dialog instead would throw
* focus at a control that is now disabled and say nothing about why, so the
* question stays on screen and only the answer that would fail is withdrawn.
* Cancel is always live.
*/
lock?: ReactNode;
onConfirm: () => void;
onCancel: () => void;
}
@@ -67,6 +76,7 @@ const styles = stylex.create({
marginTop: "1.5rem",
},
dangerButton: {
cursor: { default: "pointer", ":disabled": "not-allowed" },
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: colors.danger,
@@ -79,7 +89,7 @@ const styles = stylex.create({
},
});
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, onConfirm, onCancel }: Props) {
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, lock, onConfirm, onCancel }: Props) {
return (
<ModalOverlay
isOpen={isOpen}
@@ -98,13 +108,17 @@ export default function ConfirmDialog({ isOpen, title, message, confirmLabel, on
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
{...stylex.props(styles.dangerButton, shared.focusRing)}
>
{confirmLabel}
</button>
{lock === undefined ? (
<button
type="button"
onClick={onConfirm}
{...stylex.props(styles.dangerButton, shared.focusRing)}
>
{confirmLabel}
</button>
) : (
lock
)}
</div>
</AriaDialog>
</Modal>
+83 -10
View File
@@ -4,18 +4,25 @@
* React Aria owns the focus trap, the Escape handler and the `aria-modal`
* wiring that the hand-rolled overlay only approximated. State is controlled by
* the caller because the trigger is a table row button, not a `DialogTrigger`.
*
* The title is both the visible heading and the accessible name: `Heading
* slot="title"` is what React Aria points `aria-labelledby` at, so a caller
* cannot name the dialog one thing and show another.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Modal, ModalOverlay } from "react-aria-components";
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
import { styles as shared } from "./styles";
interface Props {
/** The dialog's accessible name. */
label: string;
/** The dialog's heading, and with it the dialog's accessible name. */
title: string;
isOpen: boolean;
onClose: () => void;
/** `detail` is the wider panel a record needs; `form` fits a column of fields. */
size?: "form" | "detail";
children: ReactNode;
}
@@ -30,25 +37,74 @@ const styles = stylex.create({
padding: "1rem",
backgroundColor: "rgba(0, 0, 0, 0.4)",
},
/**
* A column so the header stays put and the body scrolls under it. The height
* cap is what keeps a long record inside the viewport instead of running off
* the bottom of a short one.
*/
panel: {
width: "100%",
maxWidth: "28rem",
maxHeight: "85vh",
display: "flex",
flexDirection: "column",
borderRadius: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
color: colors.text,
padding: "1.5rem",
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
/** The panel already draws the boundary; the dialog's own ring would double it. */
panelForm: {
maxWidth: "28rem",
},
panelDetail: {
maxWidth: "52rem",
},
/**
* The panel already draws the boundary; the dialog's own ring would double
* it. `minHeight: 0` is what lets the body shrink far enough to scroll.
*/
body: {
outlineStyle: "none",
display: "flex",
flexDirection: "column",
minHeight: 0,
},
header: {
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
paddingInline: "1.5rem",
paddingBlock: "0.75rem",
},
title: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
/** 44px on both axes: the pointer-target floor, which the word alone misses. */
close: {
minWidth: 44,
minHeight: 44,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
},
content: {
minHeight: 0,
overflowY: "auto",
paddingInline: "1.5rem",
paddingBlock: "1.5rem",
},
});
export default function Dialog({ label, isOpen, onClose, children }: Props) {
export default function Dialog({ title, isOpen, onClose, size = "form", children }: Props) {
return (
<ModalOverlay
isOpen={isOpen}
@@ -58,9 +114,26 @@ export default function Dialog({ label, isOpen, onClose, children }: Props) {
isDismissable
className={() => stylex.props(styles.overlay).className ?? ""}
>
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
{children}
<Modal
className={() =>
stylex.props(styles.panel, size === "detail" ? styles.panelDetail : styles.panelForm).className ??
""
}
>
<AriaDialog {...stylex.props(styles.body)}>
<div {...stylex.props(styles.header)}>
<Heading slot="title" level={2} {...stylex.props(styles.title)}>
{title}
</Heading>
<button
type="button"
onClick={onClose}
{...stylex.props(shared.button, styles.close, shared.focusRing)}
>
Close
</button>
</div>
<div {...stylex.props(styles.content)}>{children}</div>
</AriaDialog>
</Modal>
</ModalOverlay>
+3 -1
View File
@@ -10,6 +10,7 @@
* models an id converts on both edges.
*/
import { CaretDown } from "@phosphor-icons/react/dist/icons/CaretDown";
import * as stylex from "@stylexjs/stylex";
import {
Button,
@@ -83,6 +84,7 @@ const styles = stylex.create({
whiteSpace: "nowrap",
},
chevron: {
display: "inline-flex",
color: colors.textMuted,
},
description: {
@@ -155,7 +157,7 @@ export default function Select({
<Button className={() => stylex.props(base, block, styles.trigger, shared.focusRing).className ?? ""}>
<SelectValue className={() => stylex.props(styles.value).className ?? ""} />
<span aria-hidden="true" {...stylex.props(styles.chevron)}>
<CaretDown size={12} />
</span>
</Button>
{description !== undefined && (
+91
View File
@@ -0,0 +1,91 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import Switch from "./Switch";
/**
* The drawn track, which is the label's one span that does not hold the hidden
* input. StyleX compiles to class names and jsdom loads no stylesheet, so the
* class list is the only place the composed state is observable.
*/
function track(input: HTMLElement): HTMLElement {
const label = input.closest("label") as HTMLElement;
const spans = [...label.querySelectorAll("span")];
const drawn = spans.find((span) => !span.contains(input));
if (drawn === undefined) throw new Error("the switch drew no track");
return drawn;
}
test("a switch reports the switch role, not a checkbox one", () => {
const onChange = vi.fn();
render(
<Switch isSelected onChange={onChange}>
Safe search
</Switch>,
);
// The role is the whole point: it tells a screen reader the setting moves now
// rather than on some later Save.
const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
expect(control.checked).toBe(true);
expect(screen.queryByRole("checkbox")).toBeNull();
fireEvent.click(control);
// The caller owns the state, so the switch reports the value it would move to.
expect(onChange).toHaveBeenCalledWith(false);
});
test("a bare switch takes its name from aria-label", () => {
render(<Switch aria-label="udp://1.1.1.1:53 enabled" isSelected={false} onChange={vi.fn()} />);
const control = screen.getByRole("switch", { name: "udp://1.1.1.1:53 enabled" }) as HTMLInputElement;
expect(control.checked).toBe(false);
});
test("a disabled switch keeps its state on screen but takes no input", () => {
render(
<Switch isSelected isDisabled onChange={vi.fn()}>
Safe search
</Switch>,
);
const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
expect(control.checked).toBe(true);
// The disabled input is the whole guard: a browser fires no click on one, and
// it is out of the tab order. Clicking it here would prove nothing either way,
// because `fireEvent` dispatches straight at the node and skips that check.
expect(control.disabled).toBe(true);
});
test("the track is drawn from the selected state, not left to the browser", () => {
const { rerender } = render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);
const control = screen.getByRole("switch");
const off = track(control).className.split(" ");
rerender(<Switch aria-label="Safe search" isSelected onChange={vi.fn()} />);
const on = track(control).className.split(" ");
// The two states compose to different class sets, so the thumb and the fill
// actually move rather than the input alone changing.
expect(on).not.toEqual(off);
});
test("keyboard focus composes the ring onto the drawn track", () => {
render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);
const control = screen.getByRole("switch");
const drawn = track(control);
const idle = drawn.className.split(" ");
expect(control.closest("label")?.getAttribute("data-focus-visible")).toBeNull();
// React Aria only calls focus visible after the modality is keyboard, which
// is why a bare focus() is not enough to raise the ring.
act(() => {
fireEvent.keyDown(document.body, { key: "Tab" });
control.focus();
});
expect(control.closest("label")?.getAttribute("data-focus-visible")).toBe("true");
const ringed = drawn.className.split(" ");
expect(ringed.length).toBeGreaterThan(idle.length);
expect(idle.every((name) => ringed.includes(name))).toBe(true);
});
+121
View File
@@ -0,0 +1,121 @@
/**
* The on/off switch, wrapping React Aria's.
*
* A switch, not a checkbox: every call site flips a setting that takes effect
* the moment it moves, with no Save between. A checkbox states what a form will
* submit, which is a promise these controls do not make.
*
* React Aria hides the real input and leaves the track to the call site, so the
* track and thumb below are what the reader sees. The caller always holds the
* state none of these controls owns the value it shows, because the server's
* answer is the value.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Switch as AriaSwitch } from "react-aria-components";
import { colors } from "./tokens.stylex";
interface Common {
isSelected: boolean;
onChange: (isSelected: boolean) => void;
/** Visible but inert; React Aria also drops it from the tab order. */
isDisabled?: boolean;
}
/** With visible words beside the track, which name it. */
interface Labelled extends Common {
children: ReactNode;
"aria-label"?: never;
}
/** Bare, in a table cell whose column heading cannot name a single row. */
interface Named extends Common {
children?: never;
"aria-label": string;
}
/**
* A switch has to be named, and exactly one of the two ways: visible words that
* `aria-label` would then override and hide from the reader who can see them,
* or no words and a label only the screen reader gets.
*/
type Props = Labelled | Named;
const styles = stylex.create({
/**
* The whole label is the hit area, so it carries the 44px pointer-target
* floor on both axes, the same floor `Checkbox` and the dialog's Close
* button already set. The track itself is far under it.
*/
label: {
minWidth: 44,
minHeight: 44,
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
/** Bare switches sit in a table cell, where the row sets the rhythm. */
track: {
flexShrink: 0,
boxSizing: "border-box",
width: 28,
height: 16,
display: "inline-flex",
alignItems: "center",
justifyContent: "flex-start",
borderRadius: 8,
padding: 2,
backgroundColor: colors.borderStrong,
},
/**
* The thumb moves by the box's own alignment rather than by a transform, so
* there is no transition to withhold from a reader who asked for less motion.
*/
trackSelected: {
justifyContent: "flex-end",
backgroundColor: colors.primary,
},
/** As on `Checkbox`: the ring follows the state React Aria reports, because
* `:focus-visible` lands on the hidden input rather than on this track. */
trackFocused: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
/** The track dims, not the words: the label still has to be readable. */
trackDisabled: {
opacity: 0.5,
},
thumb: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: colors.surfaceRaised,
},
});
export default function Switch({ children, ...state }: Props) {
return (
<AriaSwitch {...state} className={() => stylex.props(styles.label).className ?? ""}>
{(renderProps) => (
<>
<span
{...stylex.props(
styles.track,
renderProps.isSelected && styles.trackSelected,
renderProps.isFocusVisible && styles.trackFocused,
renderProps.isDisabled && styles.trackDisabled,
)}
>
<span {...stylex.props(styles.thumb)} />
</span>
{children}
</>
)}
</AriaSwitch>
);
}
+9
View File
@@ -60,6 +60,7 @@ export const styles = stylex.create({
},
button: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
@@ -70,6 +71,7 @@ export const styles = stylex.create({
lineHeight: "1.25rem",
},
smallButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
@@ -80,6 +82,7 @@ export const styles = stylex.create({
lineHeight: "1.25rem",
},
largeButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
@@ -90,6 +93,7 @@ export const styles = stylex.create({
},
primaryButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: colors.primary,
@@ -102,6 +106,7 @@ export const styles = stylex.create({
opacity: { default: 1, [DISABLED]: 0.5 },
},
largePrimaryButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: colors.primary,
@@ -113,6 +118,7 @@ export const styles = stylex.create({
},
rowButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: "transparent",
@@ -123,6 +129,7 @@ export const styles = stylex.create({
color: colors.primaryOnSurface,
},
linkButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
@@ -132,6 +139,7 @@ export const styles = stylex.create({
color: colors.primaryOnSurface,
},
dangerLinkButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
borderStyle: "none",
backgroundColor: "transparent",
padding: 0,
@@ -142,6 +150,7 @@ export const styles = stylex.create({
opacity: { default: 1, [DISABLED]: 0.5 },
},
retryButton: {
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
marginTop: "0.75rem",
borderRadius: "0.25rem",
borderWidth: 1,
+1 -1
View File
@@ -1,6 +1,6 @@
.{
.name = .nxdns,
.version = "0.0.13",
.version = "0.0.15",
.minimum_zig_version = "0.16.0",
.paths = .{""},
.fingerprint = 0x3307b311dded1d91,
+1
View File
@@ -37,6 +37,7 @@ Descriptions of what is there. No procedures, no advice.
- [reference/api.md](reference/api.md) — every REST route, authentication and the event stream.
- [reference/cli.md](reference/cli.md) — the six subcommands, every flag, every exit code.
- [reference/files-and-directories.md](reference/files-and-directories.md) — the data directory layout and file modes.
- [reference/query-log-lifecycle.md](reference/query-log-lifecycle.md) — how `querylog.db` is versioned, migrated, backed up and, rarely, recreated.
- [reference/performance.md](reference/performance.md) — the targets and the measured numbers.
## Explanation
+2 -2
View File
@@ -27,7 +27,7 @@ Directories:
| `src/cache/` | `dns_cache.zig`: bounded in-memory TTL cache of whole response messages, keyed by the question. The clock arrives as a parameter. |
| `src/upstream/` | Upstream resolution: shared vocabulary and the `Client` interface (`transport.zig`), DoH client (RFC 8484), DoT client (RFC 7858), per-endpoint health and backoff (`health.zig`), and `pool.zig` — priority-ordered failover that is itself a `transport.Client`, so the handler sees one interface. |
| `src/server/` | The serving side: UDP/TCP/DoH/DoT listeners, `handler.zig` (the whole query pipeline), `cert_store.zig` (refcounted TLS cert holder), `rate_limiter.zig`, `pause.zig`, `clients.zig` (client auto-materialisation), `local_tables.zig` (published local-answer tables), `query_sink.zig` (log and SSE fanout), `shutdown.zig` (SIGINT/SIGTERM into one `std.Io.Event`). |
| `src/storage/` | SQLite ownership: `db.zig` is the only file that calls SQLite, `config_schema.zig` + `migrations.zig` for `config.db`, `querylog_schema.zig` (open-or-recreate), async query `logger.zig`, `retention.zig`, `disk_monitor.zig`, and one repository per table under `repositories/`. |
| `src/storage/` | SQLite ownership: `db.zig` is the only file that calls SQLite, `config_schema.zig` + `migrations.zig` for `config.db`, `querylog_schema.zig` + `querylog_versions.zig` + `querylog_migrations.zig` for `querylog.db`, async query `logger.zig`, `retention.zig`, `disk_monitor.zig`, and one repository per table under `repositories/`. |
| `src/config/` | The one configuration model (`model.zig`), the pure validator (`validate.zig`), `import.zig`/`export.zig` (ZON to and from `config.db`, byte-stable round trip), `loader.zig` (read/parse/validate a named file, with the shared fault mapping), `reconcile.zig` (converge the database onto a parsed config by row identity). |
| `src/web/` | The admin HTTP layer: `server.zig` (listener), `router.zig`/`routes.zig`, one file per resource under `handlers/`, `auth.zig` (sessions), `sse.zig` (live query fanout), `static.zig` (embedded SPA), `metrics.zig` (Prometheus), `openapi.zig` (served contract), `api_limiter.zig`, `http_util.zig`. |
| `src/platform/` | OS and TLS edges: IP address values, the `std.log` sink (`logging.zig`), `statfs.zig` (free-space query via libc), client TLS over `std.crypto.tls` (`tls_client.zig`), server TLS over vendored Mbed TLS (`tls_server.zig`). |
@@ -115,7 +115,7 @@ Two databases with opposite contracts, in one data directory (see [reference/fil
Which of the file and the database is *authoritative* is chosen by the invocation, not by state: bare `nxdns run` serves the database, and `nxdns run --config FILE` makes the file authoritative and reconciles the database onto it at every start. `reconcile.zig` is that convergence, matching rows by identity and writing only differences, so runtime state — blocklist checksums, compiled snapshots, client history — survives. Why it works that way is [configuration-model.md](configuration-model.md).
**`querylog.db` is expendable.** It is never migrated. Its schema carries a fingerprint derived from the DDL text, and at open, a missing, corrupt, non-database, `quick_check`-failing or fingerprint-mismatched file is moved aside and recreated empty — the old file is kept under a new name rather than deleted, so an operator can still look at it. Retention deletes old rows daily and periodically rewrites the file to reclaim space.
**`querylog.db` is the expendable one, but its history is not thrown away.** It carries a logical version in `PRAGMA user_version`, and an older supported version is migrated in place at open: one transaction, behind one `querylog.db.pre-migrate-<epoch>` backup, of which only the newest is kept. Only real damage recreates the file — missing, corrupt, not a database, or failing `quick_check` — and then the old file is kept under a new name rather than deleted, so an operator can still look at it. A healthy file this build cannot read is neither migrated nor moved aside: the startup refuses and says why, because losing months of history to a rollback is worse than a server that will not start. Retention deletes old rows daily and periodically rewrites the file to reclaim space. The whole contract is [reference/query-log-lifecycle.md](../reference/query-log-lifecycle.md).
The split exists so that the churn of the second database can never endanger the first. Query logs are high-volume, disposable, and the thing most likely to be corrupted by a power cut on an SD card; configuration is small, irreplaceable, and the thing an operator would have to reconstruct by hand. Giving them one file would force the careful contract onto the noisy data or the loose contract onto the valuable data.
+34
View File
@@ -339,3 +339,37 @@ nxdns run failed: SchemaTooNew
```
**Fix.** There is no downgrade. Import the export you took before upgrading into a fresh data directory with the older binary; see [Upgrade nxdns](upgrade.md).
## The server refuses to start over querylog.db
**Symptom.** The process stops at startup naming the query log, and the error is one of four names:
```
error(querylog_schema): refusing to open querylog database '/var/lib/nxdns/querylog.db': it is stamped 7, and this build supports schema versions 1 to 1 (SchemaTooNew). The file is left exactly as it is; see docs/how-to/troubleshoot.md, "The server refuses to start over querylog.db"
nxdns run failed: SchemaTooNew
```
This is a refusal, not damage. nxdns will not replace a healthy query log to get itself started, so the file is left exactly as it was — schema, rows, coverage watermark and version stamp all unchanged — and the startup fails instead. All four exit 2, the code that means an operator has to act, because none of them resolves on a retry — the shipped systemd unit's `RestartPreventExitStatus=2 64` stops the unit on the first refusal instead of restart-looping it. `systemctl status nxdns` shows the refusal. `nxdns check` does not grade the query log at all, so it will not reproduce any of these.
[The query-log lifecycle](../reference/query-log-lifecycle.md) is the full contract behind this page.
**Fixes by name.**
- `SchemaTooNew` — the file was stamped by a newer nxdns than the one you are running, which normally means a binary was rolled back. Put the newer release back and start it: the file is exactly as that release left it. If you mean to stay on the older release, that release cannot read this file, so restore the `querylog.db.pre-migrate-<unix-seconds>` copy the upgrade left beside it — stop the server, move `querylog.db` and its `querylog.db-wal` and `querylog.db-shm` out of the way, rename the backup to `querylog.db`, and start. Starting empty is also an option: with the server stopped, move `querylog.db` and both sidecars aside and the next start creates a fresh log.
- `SchemaUnsupported` — the stamp is not a version this build can reach. Either the file predates 0.0.12, or it came from somewhere else, or a release since deliberately broke the schema; the changelog section for the release you are running says so when it is the third case. There is no migration path, by contract. Keep the file if the history matters — copy it somewhere and read it with the `sqlite3` shell — and if starting with an empty log is acceptable, stop the server, move `querylog.db`, `querylog.db-wal` and `querylog.db-shm` out of the data directory by hand, and start again.
- `MigrationBackupFailed` — a migration was due and the pre-migration backup could not be written, so nothing was migrated. The line above names the destination and the reason, which is almost always a full or read-only data directory. Free space or fix the permissions and start again.
- `MigrationFailed` — read the log line above it, because two different states wear this one name.
**Which `MigrationFailed` you have.** The distinction is in the line the migration logged, and it decides whether you do anything at all:
- Before the commit: `querylog migration 1 -> 2 failed before commit (...); the database is unchanged`. Nothing was applied. The file still carries its old version and every row, and this run's backup was deleted because the original is intact. Restarting will attempt the same migration and fail the same way, so this needs the underlying cause — the log line names it — or a report.
- After the commit: `querylog migration 1 -> 2 COMMITTED and the database IS at version 2, but the connection could not be restored: ...; the backup '...' is kept and the next start will open the migrated file normally`. The migration DID complete. Only that one startup is refused, the pre-migration backup is kept, and the next start opens the migrated file on the ordinary current-version path. Start the server again.
In neither case does the server start with an empty log on its own. Recreating a query log automatically is reserved for real corruption; see [why a query log is moved aside](../reference/files-and-directories.md#why-a-query-log-is-moved-aside).
> Not reproduced against a running service: the four refusals are covered by the
> test suite rather than by a hand-driven install, and the released chain has no
> migration step in it yet, so no upgrade produces a `pre-migrate` backup today.
> The messages above are the ones `src/storage/querylog_schema.zig` and
> `src/storage/querylog_migrations.zig` emit, with a data directory path and
> example version numbers filled in.
+1 -1
View File
@@ -263,7 +263,7 @@ nxdns run failed: SchemaTooNew
> hand and `nxdns run` was pointed at it. The two lines above are that run's
> output.
That run exits 1. Recovering means importing the export you took in step 1 into a fresh data directory with the older binary.
That run exits 2, and the shipped unit stops rather than restart-loops it. Recovering means importing the export you took in step 1 into a fresh data directory with the older binary.
### Rolling back from file mode
+3 -1
View File
@@ -214,7 +214,7 @@ Prints the usage text to stdout and exits 0. `nxdns --help` and `nxdns -h` do th
| --- | --- |
| 0 | Success. |
| 1 | Runtime failure — I/O, database, out of memory. A partial diagnostic report caused by an allocation failure is a runtime failure, not a verdict on the configuration. |
| 2 | A configuration problem the operator can fix, or a `check` that found one. |
| 2 | A configuration problem the operator can fix, a `check` that found one, or a deliberate refusal to run that no retry will clear. |
| 64 | Usage error — unknown command or flag, a flag without its value, a missing or extra argument. |
Code 2 means the same thing from every subcommand. `src/config/faults.zig` holds the one list of errors that mean "the configuration the operator supplied is wrong", and `run`, `check` and `import` all ask it, so a rejected file exits 2 whichever command read it. The list is every error the validator raises, plus `ParseZon`, `ConfigTooLarge`, `NoUsableUpstreams`, `BadCertificate` and `ManagedConfigUnreadable`. In practice that covers a file with a syntax error, one larger than 4 MiB, one with no `default` group (`MissingDefaultGroup`), one with no enabled upstream (`NoUpstreams`), a bad bind address, a bad rate limit, an unusable certificate, `password` and `password_hash` set together, and a `--config` path that is absent or unreadable.
@@ -237,6 +237,8 @@ load one with `nxdns import <file>`, or make a file the source of truth with `nx
`import` exits 2 for those faults and for `DestructiveImport`. That last one is deliberately not a configuration fault — it reports what applying the file would delete, rather than anything wrong with its content — and `import` decides it for itself; the answer to it is `--allow-delete`, not an edit.
`run` also exits 2 on the four query-log schema refusals — `SchemaTooNew`, `SchemaUnsupported`, `MigrationFailed` and `MigrationBackupFailed` — which are in the same list. They are not a verdict on a file the operator wrote, but they share the property exit 2 exists to signal: the server is refusing on purpose, an operator has to act, and a restart will only repeat the refusal. Exit 1 would put them under the unit's `Restart=on-failure` and loop them. See [the server refuses to start over querylog.db](../how-to/troubleshoot.md#the-server-refuses-to-start-over-querylogdb).
`OutOfMemory` is exit 1 even when problems were recorded, because the report is then incomplete. Every other error is 1.
Where an exit code sends you next: [troubleshoot](../how-to/troubleshoot.md).
+11 -4
View File
@@ -16,10 +16,12 @@ Default `/var/lib/nxdns`, overridable with `--data-dir DIR`. `nxdns run` and `nx
| --- | --- | --- |
| `config.db` | The configuration database, including `web.password_hash`. The source of truth in database mode; in file mode it is the runtime substrate the file is reconciled onto (see [the configuration file](#the-configuration-file)). | 0600 |
| `config.db-wal`, `config.db-shm` | SQLite write-ahead log and shared-memory index for `config.db`. Created by `run`, `import` and `export` when WAL is enabled, inheriting the main file's permissions. `check` creates neither. | 0600 |
| `querylog.db` | The query log: every domain every client asked for. Expendable — if it is missing or unusable it is recreated empty. | 0600 |
| `querylog.db` | The query log: every domain every client asked for. Missing or damaged, it is recreated empty; an older schema is migrated in place, and a schema this build cannot use refuses the startup. See [the query-log lifecycle](query-log-lifecycle.md). | 0600 |
| `querylog.db-wal`, `querylog.db-shm` | WAL sidecars for `querylog.db`. | 0600 |
| `querylog.db.<reason>-<unix-seconds>` | A `querylog.db` this build could not use, moved aside before an empty one was created in its place. Kept, never overwritten. `<reason>` is one of `corrupt`, `not-a-database`, `quick-check-failed` or `schema-changed`; see [why a query log is moved aside](#why-a-query-log-is-moved-aside). | Whatever the renamed file had — no chmod reaches it |
| `querylog.db.<reason>-<unix-seconds>` | A damaged `querylog.db`, moved aside before an empty one was created in its place. Kept, never overwritten. `<reason>` is one of `corrupt`, `not-a-database` or `quick-check-failed`; see [why a query log is moved aside](#why-a-query-log-is-moved-aside). | Whatever the renamed file had — no chmod reaches it |
| `querylog.db.<reason>-<unix-seconds>-<n>` | The same, when the plain name is taken — `<n>` counts from 1 and rises until the name is free. Two recreates within one second is the case it exists for. | The same |
| `querylog.db.pre-migrate-<unix-seconds>` | A complete copy of `querylog.db` taken immediately before a schema migration. Exactly one survives: a successful migration deletes every other one, and a later successful start retries that cleanup. Written by `VACUUM INTO`, so it holds the committed database including anything still only in the write-ahead log, and it needs no sidecars of its own. | 0600 |
| `querylog.db.pre-migrate-<unix-seconds>-<n>` | The same, when the plain name is taken — `<n>` counts from 2. | The same |
| `blocklists/` | Compiled blocklist snapshots, one subdirectory of the data directory. | 0700 |
| `blocklists/<id>.list` | Exact domains for blocklist source `<id>`, one per line, behind a header. | 0600 |
| `blocklists/<id>.wild` | Wildcard entries for the same source. | 0600 |
@@ -52,14 +54,15 @@ The temporaries of a source that still exists are cleaned by the refresh that ow
### Why a query log is moved aside
A `querylog.db` is moved aside when it is missing nothing but usability, and the name it is given says which of the four cases it hit:
A `querylog.db` is moved aside only when it is genuinely damaged, and the name it is given says which of the three cases it hit:
| `<reason>` | What happened |
| --- | --- |
| `corrupt` | SQLite reported the file as damaged. |
| `not-a-database` | The file is not a SQLite database at all. |
| `quick-check-failed` | `PRAGMA quick_check` did not answer `ok`. |
| `schema-changed` | Nothing is wrong with the file. Its `user_version` fingerprint does not match this build's schema, so this build cannot read it. Upgrades that touch the query-log schema produce this one, and the file they set aside is a healthy database. |
There is no fourth case. A healthy file carrying a schema version this build cannot use is neither migrated nor renamed: the startup refuses and the file stays where it is, which is [the query-log lifecycle](query-log-lifecycle.md). You can still find a `querylog.db.schema-changed-<unix-seconds>` in a data directory, because 0.0.13 and older produced one on any schema change — and still do, if you downgrade to one of them. This build never writes that name.
Only the main file is renamed — its `-wal` and `-shm` are deleted, because a stale WAL would be replayed into the fresh database. A missing `querylog.db` is created without any aside file. The rename happens inside `querylog_schema.open`, before the 0600 chmod, and that chmod names `querylog.db` and its two sidecars only — so an aside file keeps the mode the file had at rename time, which for a `querylog.db` nxdns itself created is 0600 and for one an operator put there is whatever they left it at. Nothing prunes the aside files; they accumulate until an operator removes them, and each one holds the same browsing history the live query log holds.
@@ -67,6 +70,10 @@ Only the main file is renamed — its `-wal` and `-shm` are deleted, because a s
The 0600 modes are not cosmetic. `config.db` holds the argon2id password hash and `querylog.db` holds the browsing history of every client on the LAN, so both are as sensitive as each other, and a WAL file holds the same rows as the database it belongs to. SQLite creates the main database at `0644 & ~umask`; nxdns chmods it to 0600 before enabling WAL, so the sidecars inherit 0600 rather than being created world-readable.
A `pre-migrate` backup holds that same browsing history, so it is chmodded 0600 the way the live file is: SQLite's `VACUUM INTO` creates it at `0644 & ~umask` and nxdns restricts it immediately afterwards. A backup it cannot restrict is a failed backup — the partial file is deleted and the startup refuses with `MigrationBackupFailed`, rather than leaving a world-readable copy behind.
The aside files are the exception: they get no chmod at all, and an aside keeps whatever mode it had at rename time. For a `querylog.db` nxdns itself created that is 0600; for one an operator put there it is whatever they left it at.
## The configuration file
There is no default path. `--config FILE` names the file, and without that flag no file is read at all — a `config.zon` sitting in `/etc/nxdns` that no invocation names is inert. `/etc/nxdns/config.zon` is a convention the packaging follows, not a location nxdns probes.
+70
View File
@@ -0,0 +1,70 @@
# The query log's lifecycle
What happens to `querylog.db` when nxdns opens it: how the file is versioned, when it is migrated, when the server refuses to start over it, and the one case in which it is still replaced. Source of truth: `src/storage/querylog_versions.zig` (the version metadata), `src/storage/querylog_schema.zig` (the open path) and `src/storage/querylog_migrations.zig` (the migration runner).
The rule this page exists to state: **a healthy `querylog.db` is never replaced and never moved aside.** A schema this build cannot use refuses the startup instead. Your query history is not the server's to discard.
## The version stamp
Every `querylog.db` carries a logical schema version in SQLite's `PRAGMA user_version`. It is a small counter — 1 in this release — and not a hash of anything. A file created by this build is stamped as it is created.
Two other values matter, both in `querylog_versions.zig`:
| Constant | Today | What it means |
| --- | --- | --- |
| `current_version` | 1 | The version this build creates and reads. |
| `minimum_supported_version` | 1 | The oldest stamped version this build can migrate up to `current_version`. |
| `legacy_fingerprint` | 1975011655 | The `user_version` the 0.0.12 and 0.0.13 binaries wrote: a CRC32 of their schema text, under the older policy where a mismatch meant "replace the file". |
`legacy_fingerprint` is frozen forever. Those two releases stamped a hash rather than a version, so this build recognises that one literal number as "version 1" and restamps the file as 1 on the first open. The restamp runs in its own transaction; if it fails, the old stamp and every row stay exactly as they were and the startup refuses.
## What an open does
nxdns opens `querylog.db` once at startup, before it serves anything, and no second process shares a data directory. On a file that is readable and passes `PRAGMA quick_check`, the stamp decides:
| Stamp | What happens |
| --- | --- |
| `current_version` | Opens. Nothing is migrated. |
| `legacy_fingerprint` | Read as version 1: restamped to 1, then treated as version 1 by the rows above and below. |
| Between `minimum_supported_version` and `current_version` | Migrated in place, then opens. |
| Above `current_version`, up to 1000000 | REFUSE: `SchemaTooNew`. |
| Anything else — 0, a negative, another fingerprint, a version below the minimum | REFUSE: `SchemaUnsupported`. |
A refusal changes nothing. The schema, the rows, the coverage watermark and the stamp are all left as they are, no file is set aside, no new file is created, and `nxdns run` exits. The log line names the path, the stamp it found, the range this build supports and [the troubleshooting section](../how-to/troubleshoot.md#the-server-refuses-to-start-over-querylogdb).
## Migrating in place
A migration is one backup and one transaction.
1. **Back up.** `VACUUM INTO` writes a complete copy — including anything still only in the write-ahead log — to `querylog.db.pre-migrate-<unix-seconds>` beside the database. If that name is taken, `-2`, `-3` and so on are tried. A backup that cannot be written is `MigrationBackupFailed`, and the partial copy is deleted; an older backup beside it survives.
2. **Migrate.** `BEGIN IMMEDIATE`, re-read the stamp under the lock, run every step, run `PRAGMA foreign_key_check`, stamp the new version, `COMMIT`. One transaction covers the whole chain, so the file is either at the old version or at the new one and never in between.
3. **Clean up.** Every other `querylog.db.pre-migrate-*` beside the file is deleted. **One backup is kept**: the one this migration just took. A later successful start retries that cleanup if it failed.
If a step fails before the commit, the transaction rolls back, this run's backup is deleted, and the startup refuses with `MigrationFailed`. The database keeps the version and the rows it had.
If the commit succeeds and something after it fails, the log says so plainly — the migration DID complete and the file IS at the new version. The backup is kept, the startup still refuses with `MigrationFailed`, and the next start opens the migrated file normally.
## Corruption is the only automatic recreate
Four conditions still create a fresh, empty `querylog.db`: the file is missing, SQLite reports it as corrupt, it is not a SQLite database at all, or `PRAGMA quick_check` does not answer `ok`. Except for the missing case, the unusable file is renamed to `querylog.db.<reason>-<unix-seconds>` and kept. See [why a query log is moved aside](files-and-directories.md#why-a-query-log-is-moved-aside).
Every other failure — a lock held elsewhere, a permission problem, a full disk, a version this build cannot reach — propagates and leaves the file alone.
## Downgrading
**Downgrading to 0.0.13 or older resets your query log.** Those binaries predate this contract: they compare `user_version` against a hash of their own schema text, find this build's version stamp instead, and treat that as a mismatch — so they rename `querylog.db` to `querylog.db.schema-changed-<unix-seconds>` and start an empty log. Nothing is destroyed, but the live log is empty until you put the aside file back, and the restamp that provoked it takes no backup of its own.
To recover, go back to a migration-aware release, stop the server, then, in the data directory:
1. Move the empty `querylog.db` the old binary created out of the way.
2. Delete its `querylog.db-wal` and `querylog.db-shm`. This is not optional: replaying the empty file's write-ahead log into the restored history would corrupt it.
3. Rename `querylog.db.schema-changed-<unix-seconds>` back to `querylog.db`.
4. Start the server.
Downgrading between two migration-aware releases is safe in the sense that matters: a build that finds a stamp above its own `current_version` refuses to start with `SchemaTooNew` and touches nothing. Go forward again, or restore the `pre-migrate` backup the upgrade left.
## Breaking the schema on purpose
A release may still break the query-log schema outright rather than migrate it. That is allowed, and it is never silent. Such a release raises `current_version`, sets `minimum_supported_version` to the same value, and ships no migration step — so files from before the break classify as below the minimum and `open` refuses them with `SchemaUnsupported` rather than replacing them. The release notes carry the phrase `resets your query history` and a `Restoring your query history` section, and the cut gate refuses to build the release without both.
So the contract is: a break is always versioned, always refused at startup with the file intact, and always disclosed in the changelog.
+2
View File
@@ -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/number 3.6.7 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
@stylexjs/stylex 0.19.0 MIT
@swc/helpers 0.5.23 Apache-2.0
@@ -129,6 +130,7 @@ alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695
[npm packages bundled into admin/dist]
@internationalized/string
@phosphor-icons/react
@stylexjs/stylex
@tanstack/history
@tanstack/query-core
+6
View File
@@ -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.",
.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",
.version = "clsx 2.1.1",
+1
View File
@@ -52,6 +52,7 @@ pub const texts: []const Text = &.{
.{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-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 = "phosphor-mit.txt", .body = @embedFile("phosphor-mit.txt") },
.{ .name = "clsx-mit.txt", .body = @embedFile("clsx-mit.txt") },
.{ .name = "stylex-mit.txt", .body = @embedFile("stylex-mit.txt") },
.{ .name = "styleq-mit.txt", .body = @embedFile("styleq-mit.txt") },
+21
View File
@@ -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.
+192
View File
@@ -0,0 +1,192 @@
# Milestone 38: querylog schema migrations
Stop the recurring query-history loss: schema changes migrate querylog.db in place; the automatic reset survives only for real corruption; explicit breaks stay possible but must be versioned, refused by `open`, and ship recovery instructions.
Owner rulings (2026-08-28): baseline is the 0.0.12/0.0.13 schema — nothing older is migratable; breaking changes remain allowed but must be explicit with clear changelog instructions; keep only the most recent pre-migration backup.
## Sessions
A (storage framework) first. B (cut gate) needs A's modules. C (docs) after A (documents A's behavior; shares no files with B). The orchestrator writes the changelog.
---
## Session A: migration framework in storage
### A.1 Version metadata module (pure, no SQLite)
New file `src/storage/querylog_versions.zig` — importable by `tools/cut.zig` without linking SQLite. ONLY comptime data:
- `pub const current_version: i32 = 1;`
- `pub const minimum_supported_version: i32 = 1;` — files stamped below this refuse. An EXPLICIT BREAK in a future release is expressed here: bump `current_version`, set `minimum_supported_version = current_version`, ship no step. The chain then cannot reach the new version from below the minimum and `open` refuses the old file — a break is always versioned, always refused at runtime, never silent.
- `pub const legacy_fingerprint: i32 = 1975011655;` — the literal `user_version` stamp the 0.0.12/0.0.13 binaries wrote (CRC32 of their DDL text). FROZEN literal, derived from nothing; comment cites v0.0.12.
- `pub const version_floor_guard: i32 = 1_000_000;`
- Comptime asserts: `minimum_supported_version >= 1`; `minimum_supported_version <= current_version`; `current_version <= version_floor_guard`; `legacy_fingerprint` outside `[0, version_floor_guard]`; `step_sql.len == current_version - minimum_supported_version`.
- `pub const step_sql: []const [:0]const u8 = &.{};` — step i migrates version `minimum_supported_version + i` to `+ i + 1`; each entry is `@embedFile("migrations/v<from>.sql")`. EMPTY this milestone.
- **Steps are SQL-only. There are no migration hooks.** A rebuild that m36-style projections would need is expressible as plain SQL (the recompute statements are SQL); a future change that truly cannot be SQL must amend this design explicitly in its own spec. This keeps every shipped migration byte-comparable (B.2 Gate 2) with no mutable code path.
- Shipped step files `src/storage/migrations/v<from>.sql` and fixtures (B.1) are immutable once released; the cut gate byte-compares them against the previous tag.
### A.2 Runner module and the rebuild rule
New file `src/storage/querylog_migrations.zig` (SQLite side): the runner and the equivalence oracle.
- `pub fn migrateSteps(database: *db.Db, sql: []const [:0]const u8, from: i32, target: i32) (db.Error || error{TransactionViolation})!void` — runs the steps and the final `PRAGMA user_version = target` stamp inside the caller's already-open transaction. SLICING CONTRACT: `sql` is exactly the `[from, target)` suffix — `sql[0]` migrates `from -> from + 1`; asserted: `sql.len == @intCast(target - from)`. Production callers slice `step_sql[from - minimum_supported_version ..]`. While steps execute, the runner installs SQLite's authorizer (`sqlite3_set_authorizer`; expose a scoped install/clear pair on the db wrapper) denying `SQLITE_TRANSACTION` — a step cannot BEGIN/COMMIT/ROLLBACK at all, which is the only reliable guard (a step containing `COMMIT; BEGIN IMMEDIATE;` would pass a post-step autocommit check while breaking atomicity; that exact bypass is a required negative test, and the test must also assert the authorizer is cleared after the rejection: the rollback succeeds and the SAME connection can then execute transaction statements normally — a leaked authorizer would block cleanup and strand the connection inside the migration transaction). The authorizer is cleared on every exit path. Belt: the post-step `sqlite3_get_autocommit(db) == 0` check stays. `migrateSteps`'s error set is `(db.Error || error{TransactionViolation})`; `runMigration` maps `TransactionViolation` to `error.MigrationFailed`. The no-transaction-statements rule is also in the step-authoring doc comment.
- `pub fn runMigration(io: std.Io, dir: std.Io.Dir, path: [:0]const u8, database: *db.Db, sql: []const [:0]const u8, from: i32, target: i32) Error!void` — the full orchestration seam: backup (A.4 step 1), transaction + `migrateSteps` + commit (step 2), failure handling (step 3), retention (step 4). `open` calls it with production metadata; synthetic tests call it directly with test chains, so the REAL backup/collision/retention/error paths are what the tests prove.
- **Rebuild rule** (doc comment on `step_sql`): a step that changes a table's shape must produce a table whose stored CREATE text is byte-identical to the fresh DDL's. The RUNNER brackets every migration with: `PRAGMA foreign_keys = OFF` and `PRAGMA legacy_alter_table = ON` BEFORE `BEGIN IMMEDIATE` (with `foreign_keys` on — which `db.applyPragmas` enables — a rename of a referenced parent rewrites child tables' FK text to `<t>_old`, corrupting them the moment the old table drops; `legacy_alter_table` alone does not prevent that), and restores both pragmas on EVERY exit path, success or failure (they are connection-global and non-transactional). Before COMMIT the runner runs `PRAGMA foreign_key_check` and fails the migration on any row. Step sequence: `ALTER TABLE <t> RENAME TO <t>_old`, `CREATE TABLE <t> ...` pasted VERBATIM from the target `querylog_schema.ddl`, `INSERT INTO <t> SELECT ...` mapping, `DROP TABLE <t>_old`, recreate EVERY dependent object of `<t>` verbatim from the target DDL — indexes AND triggers (both dropped with `<t>_old`). Views are NOT dropped by the rename or the drop (with `legacy_alter_table` on they keep naming `<t>`), so a step DROPs each view over `<t>` FIRST and recreates it verbatim LAST — recreating without the drop fails with "view already exists". `ALTER TABLE ... ADD/RENAME COLUMN` on a kept table is forbidden — SQLite rewrites stored CREATE text under it and the oracle's text layer would rightly fail.
### A.3 The open path (rework `querylog_schema.open`)
`open` owns the file exclusively: nxdns opens querylog.db once at startup before serving, and no other process shares a data dir (existing deployment contract; restate in `open`'s doc comment — the backup-then-lock sequence relies on it).
The version-handling half of `open` is factored as `openVersioned(io, dir, path, handle, plan) Error!void` where `handle: *?db.Db` is an optional SLOT: `openVersioned` closes and nulls it on every error path, so the caller's `errdefer` no-ops and single-close is structural rather than a convention (as built 2026-08-28; the post-commit test asserts `handle == null`). `plan: Plan = .{ .minimum: i32, .current: i32, .legacy_fingerprint: i32, .step_sql: []const [:0]const u8 }`. Production `open` passes the constant plan from `querylog_versions`; tests inject synthetic plans, which is what makes classification, migration, the post-commit mapping, and the sole-close ownership all testable through the REAL open path even while the production chain is empty. Classification itself stays a pure function of `(stamped, plan)`.
Classify a healthy existing file: read `PRAGMA user_version` as `stamped`, map to a logical version FIRST, mutate NOTHING during classification:
| condition | logical version | action |
| --- | --- | --- |
| `stamped == legacy_fingerprint` | 1 | classify version 1 by the rows below; if it lands on "current" or "supported older", first restamp to 1 (one transaction, A.5 error mapping), then proceed |
| `stamped == current_version` | stamped | open as today |
| `minimum_supported_version <= v < current_version` | v | migrate via `runMigration` |
| `current_version < v <= version_floor_guard` | v | REFUSE: `error.SchemaTooNew` |
| anything else (0, negatives, other fingerprints, below minimum) | — | REFUSE: `error.SchemaUnsupported` |
The order matters: after a future explicit break raises the minimum above 1, a legacy-fingerprint file maps to version 1, classifies as below-minimum, and refuses WITHOUT the restamp — an unsupported file is never modified.
REFUSE: the canonical file stays in place, logically untouched (schema, rows, watermark, stamp unchanged — WAL/SHM sidecar bytes may change from the probe; not a violation), nothing set aside, no new file, `open` errors, the server does not start. The log line names the path, the stamped value, the supported range, and `docs/how-to/troubleshoot.md` ("The server refuses to start over querylog.db").
Recreate lanes `missing`, `not_a_database`, `corrupt`, `quick_check_failed` unchanged. `RecreateReason.fingerprint_mismatch` and the `schema-changed` aside tag are DELETED.
Fresh files: after executing `ddl`, stamp `PRAGMA user_version = current_version` (the stamp is already a separate statement; the DDL text does not change this milestone, so `querylog_schema.fingerprint` does not move).
Backup retention has two passes with different authority. A migration's step 4 KNOWS the newest backup — this run's exact filename — and deletes every other `querylog.db.pre-migrate-*`; it is the primary mechanism. A plain successful open at current version runs a CONSERVATIVE retry for cleanups that once failed: parse `<epoch>` and the optional `-N` collision suffix from each name, delete only files whose epoch is STRICTLY below the maximum, keep every file tied at the maximum epoch, and never delete a name that does not parse. This pass EXPLICITLY assumes forward-moving wall clock between migrations (record the assumption in its doc comment): under a clock rollback an older high-epoch name could outrank a genuinely newer backup, which is why the authoritative exact-name pass in step 4 is the primary mechanism and this pass is only the retry for its failures.
### A.4 Running a migration (`runMigration`)
1. **Backup.** `VACUUM INTO` on the live connection (no open transaction) to `querylog.db.pre-migrate-<epoch>` in the database's directory. Destination must not pre-exist: on collision retry `-<epoch>-2`, `-3`, … The path enters the statement through an SQL string-literal quoting helper (double every `'`), never raw interpolation. On failure: delete the partial destination just created (only that file; an older valid backup survives), REFUSE with `error.MigrationBackupFailed`.
2. **One transaction.** `BEGIN IMMEDIATE`; re-read `user_version` under the lock. If it no longer equals `from`: ROLLBACK, delete this run's backup, REFUSE with `error.MigrationFailed` (exclusive ownership makes this outside interference). Otherwise `migrateSteps(db, sql, from, target)` — every step and the stamp in this one transaction — then COMMIT once.
3. **On PRE-COMMIT failure:** ROLLBACK, delete this run's backup, REFUSE with `error.MigrationFailed`, log the failing step index. Canonical file keeps its logical state. Never fall through to recreate.
3b. **On POST-COMMIT failure** (pragma restore or anything after a successful COMMIT): the file IS at `target` and that is said plainly in the log; the backup is KEPT (never deleted on this path). `runMigration` does NOT close the borrowed connection — it returns the distinct internal error `error.MigrationCommittedButUnclean`, and `querylog_schema.open`, which owns the handle and already has the sole error-path close, performs that one close and surfaces `error.MigrationFailed` to its caller. The next start takes the current-version lane cleanly. No post-commit path may claim the file unchanged or delete the backup.
4. **On success:** best-effort delete of every OTHER `querylog.db.pre-migrate-*` (keep this run's). Deletion errors warn and do not fail startup; A.3's every-open retention retries later. Log one line naming `from -> target` and the kept backup.
### A.5 Legacy restamp error mapping
The fingerprint→1 restamp is this milestone's only real mutation of operator data. It runs in one transaction; any failure (statement or commit) maps to `error.MigrationFailed`, rolls back, and leaves the legacy stamp and every row intact — REFUSE semantics, never recreate. Session A adds a test-only fault-injection seam to the db wrapper (`src/storage/db.zig`, following its existing `ReadTx.commit` injection style): one SQL-substring-matched one-shot seam on `Db.exec` covers statement and commit alike (both restamp statements pass through `Db.exec`), and the same seam drives the post-commit pragma-restore failure. Refusal paths log at `err`, which the test runner treats as failure, so `querylog_migrations.expected_failures` (begin/end/capturing, modelled on `db.read_tx_faults`) captures EXPECTED refusal logs per test; an unexpected refusal elsewhere still fails its test (as built 2026-08-28). Acceptance tests: the restamp forced to fail at (a) the statement and (b) the commit each leave `user_version == legacy_fingerprint` and the rows readable by a subsequent successful open.
### A.6 Schema equivalence oracle
`pub fn schemaEquivalent(gpa: std.mem.Allocator, a: *db.Db, b: *db.Db) (db.Error || std.mem.Allocator.Error)!bool` in `querylog_migrations.zig`. Two layers, both must agree:
1. **Textual, exact:** for every non-`sqlite_` object in `sqlite_schema` (tables, indexes, views, triggers), compare `(type, name, tbl_name, sql)` with `sql` compared byte-for-byte. No normalization: the A.2 rebuild rule guarantees a migrated table carries the verbatim fresh CREATE text, and a fresh file trivially does. This layer sees CHECK constraints, foreign keys, WITHOUT ROWID, partial-index predicates, trigger/view bodies.
2. **Structural belt:** per table, `PRAGMA table_xinfo` rows and `pragma_table_list` `wr`/`strict` flags; per table, `PRAGMA foreign_key_list`; per index, `PRAGMA index_xinfo` plus `index_list` `unique`/`origin`/`partial` flags.
Sort object and row lists before comparison. Negative tests: dropped `CHECK (rcode BETWEEN 0 AND 4095)`; dropped `REFERENCES domains(id)`; dropped `WITHOUT ROWID`; added column; and a table rebuilt via `ALTER TABLE ... RENAME` WITHOUT the verbatim-text rule compares UNEQUAL (proves the text layer catches SQLite's rename rewrite).
### A.7 Acceptance criteria
- [ ] Fresh file stamps `user_version = 1`, opens as current.
- [ ] A file stamped `1975011655` opens, restamps to 1, keeps every row; second open takes the current lane.
- [ ] `SchemaTooNew` and `SchemaUnsupported` refuse: schema dump, row count, watermark, stamp unchanged after refusal; no aside, no new file. One byte-hash variant on a checkpointed, sidecar-free fixture.
- [ ] Legacy-below-minimum ordering: with a test-local metadata view where minimum > 1 (drive the classification helper directly with injected constants — classification must be a pure function of `(stamped, minimum, current)` for exactly this reason), a legacy-fingerprint stamp classifies as REFUSE and no restamp happens.
- [ ] A.5 restamp-failure test.
- [ ] Synthetic chain through `runMigration` (1→3, two SQL steps, the second using the full A.2 rebuild sequence on a real table): backup exists, is a valid db, contains pre-migration rows; `user_version` lands on 3; rows survived; the rebuilt table's CREATE text equals the injected target text.
- [ ] Referenced-parent rebuild: a synthetic step rebuilds `domains` (referenced by `query_log`); after the migration, `query_log`'s stored FK text still says `REFERENCES domains(id)` (not `domains_old`), `PRAGMA foreign_key_check` is empty, and both pragmas read their defaults (`foreign_keys` per `applyPragmas`, `legacy_alter_table` off) after success AND after a forced failure.
- [ ] Mid-chain failure (step 2's SQL errors): canonical file logically unchanged (still version 1, rows intact), this run's backup deleted, an older backup preserved, `error.MigrationFailed`.
- [ ] `legacy_alter_table` pragma is OFF after both success and failure paths.
- [ ] Post-commit failure branch, driven through `openVersioned` with an injected synthetic plan (not by calling `runMigration` directly): force the pragma restore to fail after a successful COMMIT (fault seam) and assert: the file is at the target version with the migrated schema, the backup remains, the connection is closed exactly once (by the open path), that startup refuses with `error.MigrationFailed`, and the NEXT `openVersioned` under the same plan succeeds through the current-version lane.
- [ ] Backup retention: two successful synthetic migrations leave exactly one `pre-migrate-*`, the newer (step-4 authority, exact name). A directory seeded with an older epoch, a newest epoch, and a `-2` suffix tied at the newest epoch has a plain successful open delete only the older epoch — both max-epoch ties survive; an unparseable `pre-migrate-*` name survives untouched.
- [ ] Backup consistency: a row committed but not checkpointed (WAL-only) is present in the backup.
- [ ] `PRAGMA user_version` transactionality: set inside a transaction, ROLLBACK, original value observed.
- [ ] Oracle: fresh==fresh true; every A.6 negative test false; a `runMigration`-migrated file vs a fresh file at the target schema true.
- [ ] Grep scoped to `src/` and `tools/`: the `fingerprint_mismatch` identifier and the `schema-changed` aside-tag string are gone from active code (docs, specs, and changelog legitimately keep the words — the downgrade recovery text names the aside). Both suites green.
---
## Session B: cut gate inversion + fixture proof
### B.1 Fixtures
- `src/storage/testdata/querylog-v1-schema.sql` — the version-1 DDL frozen verbatim (today's `querylog_schema.ddl` text; the stamp is NOT part of it — the loader applies `PRAGMA user_version = 1`).
- `src/storage/testdata/querylog-v1-data.sql` — representative COHERENT content: query_log rows covering every `route_kind` and the NULL variants (qtype, cache_hit, response_time_us, upstream, forward_zone), matching `domains` rows, a non-default `available_since`, and `bucket_*` projection rows consistent with the raw rows. A fixture-validity test loads it and runs the projection-coherence oracle BEFORE any migration, so an incoherent fixture fails on its own.
- Immutable once shipped (header comment). From here on, every supported logical version in `[minimum_supported_version, current_version]` has a fixture pair — the current version's pair is the next migration's starting fixture, and an explicit break ships the new baseline pair.
The **fixture proof tests** (appended to `querylog_migrations.zig` by Session B, sequenced after A):
1. For EVERY starting version in `[minimum_supported_version, current_version)`: load that version's fixture pair, stamp it, run the real production chain, assert `schemaEquivalent` against a fresh-`ddl` db, every row survived, `available_since` preserved, projection coherence holds. Empty today; load-bearing without edits the day the chain grows.
2. The CURRENT version's fixture pair, stamped `current_version`, opens on the current lane, is `schemaEquivalent` to a fresh-`ddl` db, and passes projection coherence — the pair whose existence Gate 2 requires is thereby proven coherent, since the `[minimum, current)` loop never exercises it.
3. The legacy-stamp variant: a v1-fixture file stamped `1975011655` — while `minimum_supported_version == 1` it opens, restamps, and passes the same assertions as (2); the test is written against the classification helper's injected constants so that when a future break raises the minimum above 1, its companion assertion (legacy stamp + minimum > 1 REFUSES with `error.SchemaUnsupported`, file untouched) is already in the suite.
### B.2 The gate in tools/cut.zig
`cut` imports `querylog_versions` (pure, no SQLite — the link contract is why A.1 is separate). Two INDEPENDENT gates replace the disclose-a-reset gate. Let `prev_version` be the previous tag's `current_version` (parse `git show <tag>:src/storage/querylog_versions.zig` with the existing simple-extraction style; a tag predating the module means 1).
**Gate 1 — schema text.** Fingerprint the previous tag's DDL text vs the tree's. If changed, require ONE of:
- **Migration lane:** `current_version > prev_version` AND `prev_version >= minimum_supported_version` (the previous release's files are actually reachable — an explicit break can never wear this lane) AND the chain covers `[prev_version, current_version)` (with contiguous per-step files, that is `step_sql.len == current_version - minimum_supported_version` plus the fixture/file checks of Gate 2).
- **Explicit-break lane:** `current_version > prev_version` AND `minimum_supported_version == current_version` AND the changelog section contains BOTH "resets your query history" AND a `### Restoring your query history` heading with a non-empty body.
- Neither: FAIL.
**Gate 2 — migration metadata.** Runs INDEPENDENTLY of Gate 1 (catches data-only migrations and prefix edits when the DDL is unchanged):
- Every `src/storage/migrations/v<from>.sql` present at the previous tag: byte-identical in the tree; missing: FAIL.
- Every `src/storage/testdata/querylog-v*-{schema,data}.sql` present at the previous tag: byte-identical; missing: FAIL.
- A fixture pair exists for every version in `[minimum_supported_version, current_version]`: else FAIL.
- `current_version < prev_version`: FAIL (never regresses).
- `current_version > prev_version` with neither a new step file nor a break (`minimum == current`): FAIL.
- `current_version > prev_version` via new step(s) — REGARDLESS of whether the DDL fingerprint moved (data-only migrations included): the changelog section must contain "migrates your query log in place"; else FAIL.
- Let `prev_minimum` be the previous tag's `minimum_supported_version` (module absent at tag: 1). `minimum_supported_version < prev_minimum`: FAIL. `minimum_supported_version > prev_minimum` is ONLY acceptable as the full explicit break — `minimum == current` AND `current_version > prev_version` AND the break-lane changelog requirements — REGARDLESS of the DDL fingerprint; any other raise: FAIL (a release must never silently drop supported schemas).
- The tree's `legacy_fingerprint` is not the literal `1975011655`: FAIL (the legacy anchor is frozen forever; editing it strands unupgraded 0.0.12/0.0.13 files).
### B.3 Acceptance criteria
- [ ] Gate unit tests (pure functions over injected inputs, house style): unchanged schema + unchanged metadata passes; migration lane passes; explicit-break lane passes; changed schema with neither FAILS; break metadata (`minimum == current`) presented with the migration phrase FAILS Gate 1's migration lane; version bump with short chain FAILS; edited shipped step FAILS despite a version append; edited fixture FAILS; deleted step file FAILS; missing target-version fixture pair FAILS; version regression FAILS; version bump with no step and no break FAILS; data-only step (unchanged DDL) without the migration phrase FAILS; minimum regression FAILS; minimum raised without the full break FAILS (unchanged DDL variant included); edited `legacy_fingerprint` FAILS; previous tag without `querylog_versions.zig` maps to `prev_version == 1` and `prev_minimum == 1`.
- [ ] Fixture-validity test and fixture proof loop pass in the plain suite.
- [ ] `zig build cut` compiles; both suites green.
---
## Session C: docs (after A)
- `docs/how-to/troubleshoot.md`: new section "The server refuses to start over querylog.db" — `SchemaTooNew` (downgraded binary: return to the newer release, or restore the matching `pre-migrate` backup), `SchemaUnsupported` (file predates 0.0.12 or is foreign: not migratable; how to set it aside by hand if starting empty is acceptable), `MigrationFailed`/`MigrationBackupFailed` (the server never starts empty on its own; before the migration committed the file is untouched, and in the rare committed-but-unclean case the log says the migration DID complete, the backup is kept, and the next start simply proceeds).
- `docs/reference/` page on the query-log lifecycle: version stamp, in-place migration, one kept backup, the honest downgrade contract (downgrading to 0.0.13 or older RESETS the log — those binaries predate this contract; migration-aware binaries refuse cleanly), corruption as the only automatic recreate, the explicit-break contract (versioned, refused at startup, changelog carries restore instructions).
- Update the documents that still state the old contract: `PLAN.md`, `docs/explanation/architecture.md`, `specs/release-cut.md` — surgical edits to the stale sentences only.
Acceptance: prose accurate against A/B behavior, unwrapped lines, both suites still green.
---
## Module Layout
- `src/storage/querylog_versions.zig` — NEW: pure version/step metadata (cut-importable, no hooks by design).
- `src/storage/querylog_migrations.zig` — NEW: `migrateSteps`, `runMigration`, `schemaEquivalent`, fixture proof tests.
- `src/storage/migrations/` — one immutable SQL file per shipped step. NOT created this milestone (empty chain; git carries no empty directory) — the first real step creates it.
- `src/storage/querylog_schema.zig` — open-path rework, stamp change, lane deletions, every-open retention.
- `src/storage/testdata/querylog-v1-schema.sql`, `querylog-v1-data.sql` — NEW frozen fixtures.
- `src/storage/querylog_fixtures.zig` — NEW (Session B, as built): fixture loading and the survival oracle — full-content comparison against a pristine copy, each value encoded type-tag + byte-length + bytes so the comparison is injective (review round 2026-08-28).
- `tools/cut.zig` — two-gate rework.
- Session C's doc files.
## File Ownership
A: both new storage modules, `migrations/` dir, `querylog_schema.zig`, callers touched by lane deletion. B (after A): `tools/cut.zig`, `testdata/`, appends tests to `querylog_migrations.zig`, and makes the projection-coherence checker in `queries_repo.zig` `pub` (export-only edit — the checker is currently private to that file, which no session otherwise owns; B's fixture tests need it). C (after A): docs, `PLAN.md`, `specs/release-cut.md`. Orchestrator: CHANGELOG.md, spec sync.
A also owns the fault-injection seam addition in `src/storage/db.zig` (A.5).
## Changelog requirement (orchestrator)
This milestone's own changelog entry must disclose the one hazard neither gate can see: opening querylog.db under this release restamps it from the legacy fingerprint to version 1, so a LATER downgrade to 0.0.13 or older treats the numeric stamp as a fingerprint mismatch, renames the file to a `.schema-changed-<epoch>` aside, and starts an empty log. The restamp itself creates NO backup, so the accurate recovery is: return to a migration-aware release; stop the server; move the empty downgrade-created `querylog.db` out of the way AND delete its `querylog.db-wal`/`querylog.db-shm` sidecars (replaying the empty file's sidecars into the restored history would corrupt it — the recreate code documents this); move the downgrade-created `.schema-changed-<epoch>` aside back to `querylog.db`; start. The entry states the hazard and exactly that procedure.
## Acceptance Criteria (Milestone Complete)
- [ ] No code path recreates or sets aside a healthy querylog.db (grep proves the lane gone).
- [ ] A 0.0.13-created file (v1 schema + `1975011655` stamp) opens under the new binary with every row intact.
- [ ] Refusals and pre-commit migration failures leave the file logically untouched; a post-commit `MigrationFailed` leaves it successfully migrated to `target` (backup kept) and only refuses that one startup; the restamp is this milestone's only real mutation and its failure refuses without loss.
- [ ] The cut gate refuses: a schema change with neither lane, any edit to shipped steps or fixtures, a data-only migration without disclosure, and an explicit break without versioning + restore instructions.
- [ ] Both suites green, fmt clean.
## Anti-Requirements
- NO migration steps for pre-0.0.12 schemas (refusal with instructions is the contract).
- NO real chain step this milestone; synthetic chains live in tests only.
- NO migration hooks — steps are SQL files, period; a future need amends the design in its own spec.
- NO generic column-intersection salvage.
- NO `ALTER TABLE ADD/RENAME COLUMN` on kept tables in future steps (rebuild rule; recorded in doc comments, machine-enforced only via the oracle's exact-text layer).
- NO admin UI/API surface for migrations; startup log lines are the interface.
- NO config knob for backup retention.
- NO change to config.db handling.
+3 -1
View File
@@ -57,7 +57,9 @@ Pure functions unit-tested: semver validation (accept/reject table incl. leading
## Addendum: the schema gate (post-0.0.9)
0.0.9 changed the `query_log` DDL and its announcement said nothing about it. `querylog.db` is never migrated: the server stamps `PRAGMA user_version` with a CRC32 of the DDL text, and on a mismatch it renames the file aside and creates an empty one, so the first start after such a release destroys the operator's query history. Nothing in the cut noticed, because nothing in the cut had ever read the schema.
> Superseded by milestone 38. The addendum below records the gate as it was first built, when `querylog.db` was never migrated. The server now versions and migrates that file in place (`docs/reference/query-log-lifecycle.md`), and the single disclose-a-reset check described here was replaced by the two independent gates of `specs/milestone-38.md` §B.2.
0.0.9 changed the `query_log` DDL and its announcement said nothing about it. At the time `querylog.db` was never migrated: the server stamped `PRAGMA user_version` with a CRC32 of the DDL text, and on a mismatch it renamed the file aside and created an empty one, so the first start after such a release destroyed the operator's query history. Nothing in the cut noticed, because nothing in the cut had ever read the schema.
`schema-gate` is a read-only preflight check beside the others. It compares releases, not commits:
+59 -63
View File
@@ -58,6 +58,7 @@ const model = @import("config/model.zig");
const pause = @import("server/pause.zig");
const queries_repo = @import("storage/repositories/queries_repo.zig");
const query_sink = @import("server/query_sink.zig");
const querylog_migrations = @import("storage/querylog_migrations.zig");
const querylog_schema = @import("storage/querylog_schema.zig");
const rate_limiter = @import("server/rate_limiter.zig");
const reconcile = @import("config/reconcile.zig");
@@ -1316,9 +1317,9 @@ test "the recreated detail names the aside and the new coverage start" {
var buf: [events.Store.max_detail_len]u8 = undefined;
try std.testing.expectEqualStrings(
"previous file kept as 'querylog.db.schema-changed-1700000000'; " ++
"previous file kept as 'querylog.db.quick-check-failed-1700000000'; " ++
"query history is available from 1700000001",
recreatedDetail(&buf, "querylog.db.schema-changed-1700000000", 1700000001),
recreatedDetail(&buf, "querylog.db.quick-check-failed-1700000000", 1700000001),
);
// A fresh file that will not answer is a separate failure; the line still
@@ -1437,7 +1438,7 @@ const m29_ddl: [:0]const u8 =
\\VALUES (1, unixepoch(), unixepoch() + 1);
;
test "an m29 query log is set aside and recreated without the upstream-history tables" {
test "an m29 query log refuses the startup and is left exactly as it is" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
@@ -1448,10 +1449,6 @@ test "an m29 query log is set aside and recreated without the upstream-history t
var path_buf: [256]u8 = undefined;
const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path});
var fx: events_fixture.Fixture = .{};
try fx.init(io, 1000);
defer fx.deinit();
// The fixture is only worth anything while it is still a *different*
// schema from this build's, and one that carries the deleted tables.
try testing.expect(!std.mem.eql(u8, m29_ddl, querylog_schema.ddl));
@@ -1461,9 +1458,8 @@ test "an m29 query log is set aside and recreated without the upstream-history t
// edit to the literal cannot satisfy by changing what it is compared to.
try testing.expectEqual(m29_fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(m29_ddl))));
// A healthy m29 file, stamped with the fingerprint m29's own DDL produced
// and backdated so its coverage promise is visibly the older one.
const m29_coverage = blk: {
// A healthy m29 file, stamped with the fingerprint m29's own DDL produced.
{
var m29 = try db.Db.open(path, .{ .mode = .read_write_create });
defer m29.close();
try db.applyPragmas(&m29, .{});
@@ -1481,43 +1477,47 @@ test "an m29 query log is set aside and recreated without the upstream-history t
"PRAGMA user_version = {d};",
.{m29_fingerprint},
));
break :blk try m29.queryInt("SELECT available_since FROM querylog_meta");
};
try testing.expectEqual(m29_available_since, m29_coverage);
var opened = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer opened.database.close();
// Set aside under the name that says the file was healthy and this build
// moved, and still on disk for an operator who wants it.
try testing.expectEqual(querylog_schema.RecreateReason.fingerprint_mismatch, opened.recreated.?);
try testing.expect(std.mem.indexOf(u8, opened.aside(), ".schema-changed-") != null);
try tmp.dir.access(io, std.fs.path.basename(opened.aside()), .{});
// The two tables are gone from the file this process will write to.
for ([_][]const u8{ "upstream_targets", "upstream_minute", "idx_upstream_minute_ts" }) |name| {
var stmt = try opened.database.prepare("SELECT count(*) FROM sqlite_schema WHERE name = ?1");
defer stmt.deinit();
try stmt.bindText(1, name);
try testing.expect(try stmt.step());
try testing.expectEqual(@as(i64, 0), stmt.columnInt(0));
}
// Coverage restarts: the new file does not inherit the replaced one's
// promise about what it can answer. Strictly newer, not merely not-older —
// a recreation that copied the watermark across would pass the weaker test.
const coverage = try queries_repo.availableSince(&opened.database);
try testing.expect(coverage > m29_coverage);
// m29 predates the version stamp entirely: its `user_version` is a CRC of a
// schema no migration chain starts from, so the only honest answer is to
// refuse and say so. The pre-0.0.12 contract — set it aside and start empty
// — is gone.
querylog_migrations.expected_failures.begin();
defer querylog_migrations.expected_failures.end();
try testing.expectError(
error.SchemaUnsupported,
querylog_schema.open(io, std.Io.Dir.cwd(), path),
);
reportQuerylogRecreated(&fx.store, io, 2000, &opened, &opened.database);
try testing.expectEqualStrings("query_log.recreated", try fx.text("SELECT code FROM operational_events"));
try testing.expectEqualStrings(
"fingerprint_mismatch",
try fx.text("SELECT subject_key FROM operational_events"),
// Nothing was renamed, nothing was created, and the file still answers for
// itself: the operator can downgrade and keep the history.
var entries: usize = 0;
var it = tmp.dir.iterate();
while (try it.next(io)) |entry| {
try testing.expect(std.mem.startsWith(u8, entry.name, "querylog.db"));
try testing.expect(std.mem.indexOfScalar(u8, entry.name[10..], '.') == null);
entries += 1;
}
try testing.expect(entries >= 1);
var reopened = try db.Db.open(path, .{ .mode = .read_write_existing });
defer reopened.close();
try testing.expectEqual(
@as(i64, m29_fingerprint),
try reopened.queryInt("PRAGMA user_version"),
);
try testing.expectEqual(
m29_available_since,
try reopened.queryInt("SELECT available_since FROM querylog_meta"),
);
try testing.expectEqual(
@as(i64, 1),
try reopened.queryInt("SELECT count(*) FROM upstream_targets"),
);
}
test "a fingerprint recreate files a resolved event naming the real aside and watermark" {
test "a recreate files a resolved event naming the real aside and watermark" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
@@ -1539,27 +1539,17 @@ test "a fingerprint recreate files a resolved event naming the real aside and wa
created.database.close();
try testing.expectEqual(@as(i64, 0), try fx.count("SELECT count(*) FROM operational_events"));
// A healthy file this build's DDL no longer matches, which is what an
// upgrade that edits the schema produces.
{
var stamped = try db.Db.open(path, .{ .mode = .read_write_existing });
defer stamped.close();
var sql_buf: [64]u8 = undefined;
try stamped.exec(try std.fmt.bufPrintZ(
&sql_buf,
"PRAGMA user_version = {d};",
.{querylog_schema.fingerprint +% 1},
));
}
// Real damage: corruption is the only thing that recreates now.
try tmp.dir.writeFile(io, .{ .sub_path = "querylog.db", .data = "not a database at all" });
var recreated = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer recreated.database.close();
try testing.expectEqual(querylog_schema.RecreateReason.fingerprint_mismatch, recreated.recreated.?);
try testing.expectEqual(querylog_schema.RecreateReason.not_a_database, recreated.recreated.?);
reportQuerylogRecreated(&fx.store, io, 2000, &recreated, &recreated.database);
try testing.expectEqualStrings("query_log.recreated", try fx.text("SELECT code FROM operational_events"));
try testing.expectEqualStrings("fingerprint_mismatch", try fx.text("SELECT subject_key FROM operational_events"));
try testing.expectEqualStrings("not_a_database", try fx.text("SELECT subject_key FROM operational_events"));
try testing.expectEqualStrings("warning", try fx.text("SELECT severity FROM operational_events"));
// One-shot: already over when it is filed, so it never becomes an open
// episode `/api/health` counts.
@@ -1612,18 +1602,17 @@ test "a recreate under a long data directory keeps the watermark and a usable na
{
var created = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer created.database.close();
var sql_buf: [64]u8 = undefined;
try created.database.exec(try std.fmt.bufPrintZ(
&sql_buf,
"PRAGMA user_version = {d};",
.{querylog_schema.fingerprint +% 1},
));
created.database.close();
}
{
var deep_dir = try tmp.dir.openDir(io, nested, .{});
defer deep_dir.close(io);
try deep_dir.writeFile(io, .{ .sub_path = "querylog.db", .data = "not a database at all" });
}
var recreated = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer recreated.database.close();
try testing.expectEqual(querylog_schema.RecreateReason.fingerprint_mismatch, recreated.recreated.?);
try testing.expectEqual(querylog_schema.RecreateReason.not_a_database, recreated.recreated.?);
const line_overhead = "previous file kept as ''; query history is available from ".len;
try testing.expect(recreated.aside().len + line_overhead > events.Store.max_detail_len);
@@ -1682,6 +1671,13 @@ test "run maps a rejected configuration to exit 2 and everything else to exit 1"
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.ParseZon));
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.NoUsableUpstreams));
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.BadCertificate));
// The query-log schema refusals: `run` is the only command that reaches
// them, and exit 1 would put a deliberate refusal under the unit's
// `Restart=on-failure`.
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.SchemaTooNew));
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.SchemaUnsupported));
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.MigrationFailed));
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.MigrationBackupFailed));
try std.testing.expectEqual(cli.exit_runtime, failureExitCode(error.AccessDenied));
try std.testing.expectEqual(cli.exit_runtime, failureExitCode(error.OutOfMemory));
}
+25 -1
View File
@@ -13,7 +13,7 @@ const validate = @import("validate.zig");
/// `ValidateError` enters as a whole set rather than variant by variant, so a
/// variant added to the validator cannot silently fall through to exit 1. The
/// five extras are the configuration faults raised outside the validator: the
/// first five extras are the configuration faults raised outside the validator: the
/// ZON reader (`ParseZon`), the file size limit (`ConfigTooLarge`), the managed
/// file the operator named and this process cannot open
/// (`ManagedConfigUnreadable`, milestone-20 ruling 2), the composition root's
@@ -26,6 +26,16 @@ const validate = @import("validate.zig");
/// missing file anywhere else stays a runtime failure. `config/loader.zig` owns
/// the conversion and the closed set of open errors that qualify.
///
/// The four querylog schema refusals are here for the exit code, not because a
/// `.zon` file is wrong: `SchemaTooNew` and `SchemaUnsupported` are a deliberate
/// refusal to touch a `querylog.db` this binary does not understand, and
/// `MigrationFailed` and `MigrationBackupFailed` are a deliberate refusal to run
/// on a database whose migration or pre-migration backup did not complete. All
/// four need an operator, and none of them will resolve on a retry — exit 1 puts
/// them under systemd's `Restart=on-failure` and restart-loops a server that is
/// refusing on purpose. The unit's `RestartPreventExitStatus=2 64` is what exit
/// 2 buys them.
///
/// Not here on purpose: `error.DestructiveImport`, which reports what an import
/// would do to the database rather than the content of a file, and is the one
/// config-shaped exit 2 `cli` decides for itself.
@@ -35,6 +45,10 @@ const ConfigFault = validate.ValidateError || error{
ManagedConfigUnreadable,
NoUsableUpstreams,
BadCertificate,
SchemaTooNew,
SchemaUnsupported,
MigrationFailed,
MigrationBackupFailed,
};
const faults: []const anyerror = blk: {
@@ -119,6 +133,16 @@ test "the seed-file errors that used to exit 1 from run are configuration faults
try testing.expect(isConfigFault(error.NoUpstreams));
}
test "the querylog schema refusals exit 2 so systemd does not restart-loop them" {
try testing.expect(isConfigFault(error.SchemaTooNew));
try testing.expect(isConfigFault(error.SchemaUnsupported));
try testing.expect(isConfigFault(error.MigrationFailed));
try testing.expect(isConfigFault(error.MigrationBackupFailed));
// The refusals are a closed set. A neighbouring schema error is a corrupt
// database, not a refusal, and stays a runtime failure.
try testing.expect(!isConfigFault(error.SchemaCorrupt));
}
test "a runtime failure is not a configuration fault" {
try testing.expect(!isConfigFault(error.OutOfMemory));
try testing.expect(!isConfigFault(error.AccessDenied));
+183
View File
@@ -54,9 +54,24 @@ pub const c = struct {
pub extern fn sqlite3_column_count(stmt: *c.Stmt) c_int;
pub extern fn sqlite3_column_type(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_column_int64(stmt: *c.Stmt, col: c_int) i64;
pub extern fn sqlite3_column_double(stmt: *c.Stmt, col: c_int) f64;
pub extern fn sqlite3_column_text(stmt: *c.Stmt, col: c_int) ?[*]const u8;
pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_set_authorizer(db: *Sqlite3, xAuth: ?*const AuthCallback, user_data: ?*anyopaque) c_int;
pub extern fn sqlite3_get_autocommit(db: *Sqlite3) c_int;
pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64;
/// `int (*)(void*, int, const char*, const char*, const char*, const char*)`.
/// The four name arguments are NULL for actions that do not use them, which
/// `SQLITE_TRANSACTION` is except for its operation name.
pub const AuthCallback = fn (
user_data: ?*anyopaque,
action: c_int,
arg1: ?[*:0]const u8,
arg2: ?[*:0]const u8,
database: ?[*:0]const u8,
trigger_or_view: ?[*:0]const u8,
) callconv(.c) c_int;
pub extern fn sqlite3_changes(db: *Sqlite3) c_int;
pub extern fn sqlite3_total_changes(db: *Sqlite3) c_int;
};
@@ -105,6 +120,20 @@ pub const open_flag = struct {
pub const exrescode: c_int = 0x2000000;
};
/// The authorizer verdicts and the one action code nxdns denies, from the
/// vendored `sqlite3.h` (3.53.4). `SQLITE_DENY` fails the *prepare* with
/// `SQLITE_AUTH`, which is what makes it a real guard: the statement never
/// runs at all.
pub const auth = struct {
pub const deny: c_int = 1;
pub const transaction: c_int = 22;
/// `SAVEPOINT`, `RELEASE` and `ROLLBACK TO` report under this code, not
/// under `transaction`. A savepoint at the outermost level opens a real
/// transaction and `RELEASE` commits it, so a step using one splits the
/// migration exactly as a bare `COMMIT` would.
pub const savepoint: c_int = 32;
};
/// Column type codes returned by `sqlite3_column_type`.
pub const column_type = struct {
pub const integer: c_int = 1;
@@ -367,6 +396,7 @@ pub const Db = struct {
/// message is read back through `sqlite3_errmsg`, so there is no
/// `sqlite3_free` obligation.
pub fn exec(self: *Db, sql: [:0]const u8) Error!void {
if (execFaultTripped(sql)) return error.Internal;
return check(c.sqlite3_exec(self.handle, sql.ptr, null, null, null));
}
@@ -424,6 +454,38 @@ pub const Db = struct {
return value;
}
/// False while a transaction is open on this connection. The migration
/// runner's belt check: a step that somehow ended the runner's transaction
/// must not be allowed to look like a success.
pub fn inTransaction(self: *Db) bool {
return c.sqlite3_get_autocommit(self.handle) == 0;
}
/// Denies every transaction statement — `BEGIN`, `COMMIT`, `ROLLBACK`,
/// `SAVEPOINT`, `RELEASE` — until `clearAuthorizer` runs.
///
/// `guard` must outlive the installed window: SQLite keeps the pointer.
/// Install and clear are a scoped pair; the migration runner clears on
/// every exit path, because a leaked authorizer would go on denying the
/// ROLLBACK that cleans up after the very statement it rejected.
pub fn denyTransactions(self: *Db, guard: *TransactionGuard) Error!void {
guard.* = .{};
return check(c.sqlite3_set_authorizer(self.handle, transactionAuthorizer, guard));
}
/// Never fails in a way the caller can act on: passing a null callback only
/// clears state SQLite already holds. A failure is logged and swallowed so
/// this stays usable in `defer`.
pub fn clearAuthorizer(self: *Db) void {
const rc = c.sqlite3_set_authorizer(self.handle, null, null);
if (rc != result.ok) {
log.err("sqlite3_set_authorizer(null) returned {s} (code {d})", .{
std.mem.span(c.sqlite3_errstr(rc)),
rc,
});
}
}
pub fn lastInsertRowid(self: *Db) i64 {
return c.sqlite3_last_insert_rowid(self.handle);
}
@@ -442,6 +504,34 @@ pub const Db = struct {
}
};
/// Records whether the authorizer installed by `Db.denyTransactions` actually
/// rejected anything. The rejection reaches the caller as `error.Auth`, which
/// is indistinguishable from any other authorization failure; this flag is what
/// lets the migration runner name the real cause.
pub const TransactionGuard = struct {
denied: bool = false,
};
fn transactionAuthorizer(
user_data: ?*anyopaque,
action: c_int,
arg1: ?[*:0]const u8,
arg2: ?[*:0]const u8,
database: ?[*:0]const u8,
trigger_or_view: ?[*:0]const u8,
) callconv(.c) c_int {
_ = arg2;
_ = database;
_ = trigger_or_view;
if (action != auth.transaction and action != auth.savepoint) return result.ok;
const guard: *TransactionGuard = @ptrCast(@alignCast(user_data.?));
guard.denied = true;
log.warn("migration step attempted a transaction statement: {s}", .{
if (arg1) |op| std.mem.span(op) else "(unnamed)",
});
return auth.deny;
}
fn openHandle(filename: [:0]const u8, flags: c_int) Error!*c.Sqlite3 {
var handle: ?*c.Sqlite3 = null;
const rc = c.sqlite3_open_v2(filename.ptr, &handle, flags, null);
@@ -963,6 +1053,45 @@ pub const read_tx_faults = if (builtin.is_test) struct {
}
} else struct {};
/// Fails one chosen `Db.exec` so a test can drive a failure SQLite itself will
/// not produce on demand. Test builds only, same shape as `read_tx_seam`.
///
/// The migration paths this exists for — the legacy restamp and the
/// post-commit pragma restore — run statements that always succeed against a
/// healthy file, and their recovery behaviour is the whole point of the
/// milestone. Matching on the SQL text rather than counting calls keeps a test
/// naming the statement it means.
const exec_seam = if (builtin.is_test) struct {
var fail_matching: ?[]const u8 = null;
} else struct {};
fn execFaultTripped(sql: []const u8) bool {
if (!builtin.is_test) return false;
const needle = exec_seam.fail_matching orelse return false;
if (std.mem.indexOf(u8, sql, needle) == null) return false;
exec_seam.fail_matching = null;
return true;
}
/// The seam's controls, for tests in this file and in the storage layer.
pub const exec_faults = if (builtin.is_test) struct {
/// Arms the next `Db.exec` whose SQL contains `needle` to fail with
/// `error.Internal` before the statement reaches SQLite. One shot: it
/// disarms itself when it trips. Pair it with `defer disarm()` so a test
/// that never trips the fault cannot leak it into the next one.
pub fn failNextMatching(needle: []const u8) void {
exec_seam.fail_matching = needle;
}
pub fn disarm() void {
exec_seam.fail_matching = null;
}
pub fn armed() bool {
return exec_seam.fail_matching != null;
}
} else struct {};
const testing = std.testing;
fn openMemory() Error!Db {
@@ -1479,3 +1608,57 @@ test "a duplicate insert into a UNIQUE column returns error.Constraint" {
try stmt.bindText(1, "only");
try testing.expectError(error.Constraint, stmt.step());
}
test "the transaction authorizer denies transaction statements and clears cleanly" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
try db.exec("BEGIN IMMEDIATE;");
try testing.expect(db.inTransaction());
var guard: TransactionGuard = .{};
try db.denyTransactions(&guard);
// Ordinary work still runs: only the transaction statements are refused.
try db.exec("INSERT INTO t (id) VALUES (1);");
try testing.expect(!guard.denied);
try testing.expectError(error.Auth, db.exec("COMMIT;"));
try testing.expect(guard.denied);
// The deny happens at prepare, so the transaction is still open.
try testing.expect(db.inTransaction());
// SAVEPOINT and its RELEASE report under a different action code, and they
// are the same bypass: at the outermost level they are a transaction under
// another name, and inside one they can still discard the migration's work.
guard.denied = false;
try testing.expectError(error.Auth, db.exec("SAVEPOINT half_a_migration;"));
try testing.expect(guard.denied);
guard.denied = false;
try testing.expectError(error.Auth, db.exec("RELEASE half_a_migration;"));
try testing.expect(guard.denied);
try testing.expect(db.inTransaction());
db.clearAuthorizer();
// The same connection is usable again — a leaked authorizer would strand it
// inside the transaction by denying this too.
try db.exec("ROLLBACK;");
try testing.expect(!db.inTransaction());
try testing.expectEqual(@as(i64, 0), try db.queryInt("SELECT count(*) FROM t"));
}
test "the exec fault seam fires once, on the statement it names" {
var db = try openMemory();
defer db.close();
try db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY);");
exec_faults.failNextMatching("COMMIT");
defer exec_faults.disarm();
try db.exec("BEGIN IMMEDIATE;");
try db.exec("INSERT INTO t (id) VALUES (1);");
try testing.expectError(error.Internal, db.exec("COMMIT;"));
try testing.expect(!exec_faults.armed());
// Disarmed: the retry is a real COMMIT.
try db.exec("COMMIT;");
try testing.expectEqual(@as(i64, 1), try db.queryInt("SELECT count(*) FROM t"));
}
+2 -1
View File
@@ -27,6 +27,7 @@ const disk_monitor = @import("disk_monitor.zig");
const logger = @import("logger.zig");
const queries_repo = @import("repositories/queries_repo.zig");
const querylog_schema = @import("querylog_schema.zig");
const querylog_versions = @import("querylog_versions.zig");
const retention = @import("retention.zig");
const testing = std.testing;
@@ -233,7 +234,7 @@ test "S8 case 1: the logger writes a real querylog.db end to end" {
try testing.expectEqual(@as(i64, 250), try queries_repo.countRows(log_db.database()));
try testing.expectEqual(@as(i64, 10), try queries_repo.countDomains(log_db.database()));
try testing.expectEqual(
@as(i64, querylog_schema.fingerprint),
@as(i64, querylog_versions.current_version),
try log_db.database().queryInt("PRAGMA user_version"),
);
}
+535
View File
@@ -0,0 +1,535 @@
//! The shipped `querylog.db` fixtures, and the proof that every supported
//! schema version reaches the current one with the operator's rows intact.
//!
//! A file of its own, not a section of `querylog_migrations.zig`, because of the
//! link contract that split `querylog_versions.zig` out in the first place. The
//! assertions here need `repositories/queries_repo.zig`'s projection-coherence
//! oracle, and that file reaches across `src/` for the config and filter types;
//! importing it from `querylog_migrations.zig` would pull all of it into the
//! module `tools/cut.zig` builds `querylog_schema.zig` as, where those paths lie
//! outside the module root and do not compile.
//!
//! The fixtures themselves — `testdata/querylog-v<N>-{schema,data}.sql` — are
//! immutable once released. Every version in `[minimum_supported_version,
//! current_version]` has a pair: the current version's pair is the next
//! migration's starting point, and an explicit break ships the new baseline.
const std = @import("std");
const db = @import("db.zig");
const migrations = @import("querylog_migrations.zig");
const queries_repo = @import("repositories/queries_repo.zig");
const querylog_schema = @import("querylog_schema.zig");
const versions = @import("querylog_versions.zig");
const testing = std.testing;
/// A temporary directory and the `querylog.db` path inside it. Deliberately not
/// `querylog_migrations.zig`'s test harness: that one builds synthetic schemas,
/// while everything here starts from the shipped fixture files.
const Harness = struct {
threaded: std.Io.Threaded,
tmp: std.testing.TmpDir,
buf: [256]u8 = undefined,
fn init() Harness {
return .{
.threaded = .init(testing.allocator, .{}),
.tmp = testing.tmpDir(.{ .iterate = true }),
};
}
fn deinit(self: *Harness) void {
self.tmp.cleanup();
self.threaded.deinit();
}
fn io(self: *Harness) std.Io {
return self.threaded.io();
}
fn path(self: *Harness) [:0]const u8 {
return std.fmt.bufPrintZ(&self.buf, ".zig-cache/tmp/{s}/querylog.db", .{self.tmp.sub_path}) catch
unreachable;
}
fn openLive(self: *Harness) !db.Db {
var database = try db.Db.open(self.path(), .{ .mode = .read_write_existing });
errdefer database.close();
try db.applyPragmas(&database, .{});
return database;
}
};
/// One shipped schema version's frozen pair. Both halves are immutable once
/// released — the release cut byte-compares them against the previous tag — and
/// every version in `[minimum_supported_version, current_version]` must have a
/// pair, which the cut gate also enforces.
const Fixture = struct {
version: i32,
schema: [:0]const u8,
data: [:0]const u8,
};
const fixtures = [_]Fixture{
.{
.version = 1,
.schema = @embedFile("testdata/querylog-v1-schema.sql"),
.data = @embedFile("testdata/querylog-v1-data.sql"),
},
};
fn fixtureFor(version: i32) ?Fixture {
for (fixtures) |fixture| {
if (fixture.version == version) return fixture;
}
return null;
}
/// Writes a fixture pair to `path` and stamps it. `stamp` is a parameter rather
/// than `fixture.version` because the legacy-fingerprint file is the same
/// version-1 bytes under a different stamp.
fn writeFixture(path: [:0]const u8, fixture: Fixture, stamp: i32) !void {
var database = try db.Db.open(path, .{ .mode = .read_write_create });
defer database.close();
try db.applyPragmas(&database, .{});
try database.exec(fixture.schema);
try database.exec(fixture.data);
var buf: [64]u8 = undefined;
const sql = std.fmt.bufPrintZ(&buf, "PRAGMA user_version = {d};", .{stamp}) catch unreachable;
try database.exec(sql);
}
/// A second path in the harness's directory, for the reference databases the
/// assertions below compare against.
fn sidePath(h: *Harness, buf: []u8, name: []const u8) [:0]const u8 {
return std.fmt.bufPrintZ(buf, ".zig-cache/tmp/{s}/{s}", .{ h.tmp.sub_path, name }) catch unreachable;
}
/// A database holding nothing but the current `ddl`, which is what a file
/// created by this build is.
fn openFreshCurrent(h: *Harness, buf: []u8) !db.Db {
var database = try db.Db.open(sidePath(h, buf, "fresh.db"), .{ .mode = .read_write_create });
errdefer database.close();
try db.applyPragmas(&database, .{});
try database.exec(querylog_schema.ddl);
return database;
}
/// An untouched load of `fixture`, to compare a migrated or opened file against
/// rather than restating the fixture's contents in the assertions.
fn openPristine(h: *Harness, buf: []u8, fixture: Fixture) !db.Db {
const path = sidePath(h, buf, "pristine.db");
try writeFixture(path, fixture, fixture.version);
var database = try db.Db.open(path, .{ .mode = .read_write_existing });
errdefer database.close();
try db.applyPragmas(&database, .{});
return database;
}
/// Every row of every table, as one canonical text. Exact equality is only the
/// right question for a file no migration has reshaped; a migrated file is
/// checked by the counts and the watermark instead.
fn dumpContent(gpa: std.mem.Allocator, database: *db.Db, out: *std.ArrayList(u8)) !void {
var tables: std.ArrayList([]u8) = .empty;
defer freeOwned(gpa, &tables);
{
var stmt = try database.prepare(
\\SELECT name FROM sqlite_schema
\\WHERE type = 'table' AND name NOT LIKE 'sqlite\_%' ESCAPE '\'
\\ORDER BY name
);
defer stmt.deinit();
// Copied out before the per-table statements step: a borrowed
// `columnText` would not survive them.
while (try stmt.step()) try tables.append(gpa, try stmt.columnTextAlloc(gpa, 0));
}
// The lines are sorted here rather than by the query: the four `bucket_*`
// tables are WITHOUT ROWID, so `ORDER BY rowid` is not available to all of
// them and no single column list is.
var lines: std.ArrayList([]u8) = .empty;
defer freeOwned(gpa, &lines);
for (tables.items) |table| {
var sql_buf: [256]u8 = undefined;
const sql = std.fmt.bufPrint(&sql_buf, "SELECT * FROM \"{s}\"", .{table}) catch unreachable;
var stmt = try database.prepare(sql);
defer stmt.deinit();
while (try stmt.step()) {
var line: std.ArrayList(u8) = .empty;
errdefer line.deinit(gpa);
try line.print(gpa, "R|{s}", .{table});
var col: c_int = 0;
const columns: c_int = db.c.sqlite3_column_count(stmt.handle);
while (col < columns) : (col += 1) {
try line.print(gpa, "|{s}", .{stmt.columnTextOrNull(col) orelse "<null>"});
}
try lines.append(gpa, try line.toOwnedSlice(gpa));
}
}
std.mem.sortUnstable([]u8, lines.items, {}, struct {
fn lessThan(_: void, a: []u8, b: []u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lessThan);
for (lines.items) |line| {
try out.appendSlice(gpa, line);
try out.append(gpa, '\n');
}
}
/// One value, encoded so that no two different values can produce the same
/// bytes: a type tag, the byte length, then the bytes themselves.
///
/// Nothing here is a sentinel and nothing is escaped, which is the point. A
/// serialization that wrote NULL as `<null>` cannot tell a NULL apart from the
/// six-character string of the same name, and one that separated values with
/// `|` cannot tell `a|b` in one column from `a` and `b` in two — so a migration
/// that turned a NULL `upstream` into text, or shifted a value from one column
/// into its neighbour, would compare EQUAL to the original. The length prefix
/// makes the stream uniquely decodable, so equal encodings mean equal rows.
///
/// A float travels as its bit pattern rather than as printed digits: the
/// question here is whether the value survived, not whether it rounds the same.
fn writeValue(gpa: std.mem.Allocator, stmt: *db.Stmt, col: c_int, out: *std.ArrayList(u8)) !void {
switch (db.c.sqlite3_column_type(stmt.handle, col)) {
db.column_type.null_value => try out.print(gpa, "n0:", .{}),
db.column_type.integer => {
var buf: [24]u8 = undefined;
const text = std.fmt.bufPrint(&buf, "{d}", .{stmt.columnInt(col)}) catch unreachable;
try out.print(gpa, "i{d}:{s}", .{ text.len, text });
},
db.column_type.float => {
const bits: u64 = @bitCast(db.c.sqlite3_column_double(stmt.handle, col));
var buf: [24]u8 = undefined;
const text = std.fmt.bufPrint(&buf, "{d}", .{bits}) catch unreachable;
try out.print(gpa, "f{d}:{s}", .{ text.len, text });
},
db.column_type.blob => {
// `columnText` on a blob hands back the same bytes SQLite stores,
// which is what this compares; it is not read as text.
const bytes = stmt.columnText(col);
try out.print(gpa, "b{d}:{s}", .{ bytes.len, bytes });
},
else => {
const bytes = stmt.columnText(col);
try out.print(gpa, "t{d}:{s}", .{ bytes.len, bytes });
},
}
}
/// One query's rows, in the order the query returns them, tagged with `label` so
/// that a difference names the relation it came from. The column count is part
/// of each row for the same reason the lengths are part of each value.
fn dumpQuery(
gpa: std.mem.Allocator,
database: *db.Db,
label: []const u8,
sql: [:0]const u8,
out: *std.ArrayList(u8),
) !void {
var stmt = try database.prepare(sql);
defer stmt.deinit();
while (try stmt.step()) {
const columns: c_int = db.c.sqlite3_column_count(stmt.handle);
try out.print(gpa, "{s}:{d}:", .{ label, columns });
var col: c_int = 0;
while (col < columns) : (col += 1) {
try writeValue(gpa, &stmt, col, out);
}
try out.append(gpa, '\n');
}
}
/// The operator's data as the application means it, in an order a migration
/// cannot permute.
///
/// `q.*` rather than a column list on purpose: a migration that adds a column
/// must show that column here, and a hand-written list would quietly stop
/// covering the table the day it grows. `d.domain` rides along so that the text
/// a row names is compared, not only the id it happens to hold.
const logical_relations = [_]struct { label: []const u8, sql: [:0]const u8 }{
.{
.label = "query_log",
.sql =
\\SELECT q.*, d.domain FROM query_log q
\\JOIN domains d ON d.id = q.domain_id
\\ORDER BY q.id
,
},
.{ .label = "domains", .sql = "SELECT * FROM domains ORDER BY id" },
.{ .label = "querylog_meta", .sql = "SELECT * FROM querylog_meta ORDER BY id" },
};
fn dumpLogical(gpa: std.mem.Allocator, database: *db.Db, out: *std.ArrayList(u8)) !void {
for (logical_relations) |relation| {
try dumpQuery(gpa, database, relation.label, relation.sql, out);
}
}
fn freeOwned(gpa: std.mem.Allocator, list: *std.ArrayList([]u8)) void {
for (list.items) |item| gpa.free(item);
list.deinit(gpa);
}
fn expectSameContent(a: *db.Db, b: *db.Db) !void {
var text_a: std.ArrayList(u8) = .empty;
defer text_a.deinit(testing.allocator);
var text_b: std.ArrayList(u8) = .empty;
defer text_b.deinit(testing.allocator);
try dumpContent(testing.allocator, a, &text_a);
try dumpContent(testing.allocator, b, &text_b);
try testing.expectEqualStrings(text_a.items, text_b.items);
}
fn rowCount(database: *db.Db, table: []const u8) !i64 {
var buf: [128]u8 = undefined;
return database.queryInt(std.fmt.bufPrint(&buf, "SELECT count(*) FROM \"{s}\"", .{table}) catch unreachable);
}
/// What must hold after a fixture has come through `openVersioned`, whatever
/// path it took: every row the operator had is still there with the same
/// CONTENT, and the projections still agree with the raw rows.
///
/// Counting rows and checking one watermark is what this used to do, and a
/// migration that shifted a timestamp, dropped a `qtype` or crossed two rows'
/// `client_ip` values passed it. The comparison is therefore the full logical
/// content of the three operator tables, `available_since` included as one
/// column of `querylog_meta` among the rest. The counts stay because a count
/// difference is the failure worth naming plainly.
///
/// The relations are named rather than derived because they are the ones holding
/// operator data; the `bucket_*` projections are derived from them, which
/// `expectProjectionsMatchRecompute` is the right check for. A future migration
/// that renames a table amends this alongside the step that does it. So does one
/// that renumbers `id` values: this asserts they survive, which every rebuild
/// written to the A.2 rule does.
fn expectFixtureSurvived(opened: *db.Db, pristine: *db.Db) !void {
for ([_][]const u8{ "query_log", "domains", "querylog_meta" }) |table| {
try testing.expectEqual(try rowCount(pristine, table), try rowCount(opened, table));
}
var opened_text: std.ArrayList(u8) = .empty;
defer opened_text.deinit(testing.allocator);
var pristine_text: std.ArrayList(u8) = .empty;
defer pristine_text.deinit(testing.allocator);
try dumpLogical(testing.allocator, opened, &opened_text);
try dumpLogical(testing.allocator, pristine, &pristine_text);
try testing.expectEqualStrings(pristine_text.items, opened_text.items);
try queries_repo.expectProjectionsMatchRecompute(opened);
}
test "the survival comparison tells a NULL apart from text that looks like one" {
// The mutation a count-and-watermark check misses entirely, and a
// sentinel-string serialization misses just as completely: one column of one
// row stops being NULL and becomes the very text the sentinel used. Every
// count, the watermark and the projections all still agree.
const fixture = fixtureFor(1) orelse return error.MissingFixture;
var h: Harness = .init();
defer h.deinit();
try writeFixture(h.path(), fixture, fixture.version);
var side: [256]u8 = undefined;
var pristine = try openPristine(&h, &side, fixture);
defer pristine.close();
var corrupted = try h.openLive();
defer corrupted.close();
try testing.expectEqual(@as(i64, 0), corrupted.changes());
try corrupted.exec(
\\UPDATE query_log SET upstream = '<null>'
\\WHERE id = (SELECT min(id) FROM query_log WHERE upstream IS NULL)
);
// The fixture has to actually carry a NULL `upstream` for this to be a test
// of anything.
try testing.expectEqual(@as(i64, 1), corrupted.changes());
// Everything the old check looked at still agrees, which is why it passed.
for ([_][]const u8{ "query_log", "domains", "querylog_meta" }) |table| {
try testing.expectEqual(try rowCount(&pristine, table), try rowCount(&corrupted, table));
}
try testing.expectEqual(
try pristine.queryInt("SELECT available_since FROM querylog_meta WHERE id = 1"),
try corrupted.queryInt("SELECT available_since FROM querylog_meta WHERE id = 1"),
);
try queries_repo.expectProjectionsMatchRecompute(&corrupted);
// The dumps are compared here rather than through `expectFixtureSurvived`
// so that a PASSING run stays silent: `expectEqualStrings` prints the whole
// diff before it returns its error, and this is the comparison that function
// makes.
var corrupted_text: std.ArrayList(u8) = .empty;
defer corrupted_text.deinit(testing.allocator);
var pristine_text: std.ArrayList(u8) = .empty;
defer pristine_text.deinit(testing.allocator);
try dumpLogical(testing.allocator, &corrupted, &corrupted_text);
try dumpLogical(testing.allocator, &pristine, &pristine_text);
try testing.expect(!std.mem.eql(u8, corrupted_text.items, pristine_text.items));
}
test "every supported version ships a fixture pair" {
// The cut gate enforces this against a release; the suite enforces it
// against a commit, so a version bump that forgot its fixtures fails here
// long before anyone reaches for `zig build cut`.
var version = versions.minimum_supported_version;
while (version <= versions.current_version) : (version += 1) {
try testing.expect(fixtureFor(version) != null);
}
}
test "each shipped fixture pair is coherent before any migration touches it" {
for (fixtures) |fixture| {
var h: Harness = .init();
defer h.deinit();
try writeFixture(h.path(), fixture, fixture.version);
var database = try h.openLive();
defer database.close();
try testing.expectEqual(
@as(i64, fixture.version),
try database.queryInt("PRAGMA user_version"),
);
// An incoherent fixture has to fail as a fixture, not later as a
// migration that appears to have corrupted the projections.
try queries_repo.expectProjectionsMatchRecompute(&database);
try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM pragma_foreign_key_check"));
}
}
test "every fixture below the current version migrates to it through the shipped chain" {
// Empty while the chain is: `minimum_supported_version == current_version`
// today. It is written as the loop so that the day a step ships, the
// fixture it starts from is proved through the REAL production plan with no
// edit to this test.
var version = versions.minimum_supported_version;
while (version < versions.current_version) : (version += 1) {
const fixture = fixtureFor(version) orelse return error.MissingFixture;
var h: Harness = .init();
defer h.deinit();
try writeFixture(h.path(), fixture, version);
var side: [256]u8 = undefined;
var pristine = try openPristine(&h, &side, fixture);
defer pristine.close();
var result = try querylog_schema.open(h.io(), std.Io.Dir.cwd(), h.path());
defer result.database.close();
try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), result.recreated);
try testing.expectEqual(
@as(i64, versions.current_version),
try result.database.queryInt("PRAGMA user_version"),
);
var fresh_buf: [256]u8 = undefined;
var fresh = try openFreshCurrent(&h, &fresh_buf);
defer fresh.close();
try testing.expect(try migrations.schemaEquivalent(testing.allocator, &result.database, &fresh));
try expectFixtureSurvived(&result.database, &pristine);
}
}
test "the current version's fixture pair opens on the current lane unchanged" {
// The loop above never reaches this pair, and Gate 2 of the cut requires it
// to exist. This is what proves it is a real, coherent file rather than one
// shipped to satisfy a gate.
const fixture = fixtureFor(versions.current_version) orelse return error.MissingFixture;
var h: Harness = .init();
defer h.deinit();
try writeFixture(h.path(), fixture, versions.current_version);
var side: [256]u8 = undefined;
var pristine = try openPristine(&h, &side, fixture);
defer pristine.close();
var result = try querylog_schema.open(h.io(), std.Io.Dir.cwd(), h.path());
defer result.database.close();
try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), result.recreated);
try testing.expectEqual(
@as(i64, versions.current_version),
try result.database.queryInt("PRAGMA user_version"),
);
var fresh_buf: [256]u8 = undefined;
var fresh = try openFreshCurrent(&h, &fresh_buf);
defer fresh.close();
try testing.expect(try migrations.schemaEquivalent(testing.allocator, &result.database, &fresh));
try expectFixtureSurvived(&result.database, &pristine);
// No migration ran, so nothing reshaped anything: byte-for-byte the rows
// that were loaded.
try expectSameContent(&result.database, &pristine);
}
test "a fixture carrying the 0.0.12 fingerprint restamps, and refuses once the minimum rises" {
const fixture = fixtureFor(1) orelse return error.MissingFixture;
// Version 1 is the legacy fingerprint's logical version, so the pair only
// has anything to say while 1 is still supported.
if (versions.minimum_supported_version <= 1 and versions.current_version >= 1) {
var h: Harness = .init();
defer h.deinit();
try writeFixture(h.path(), fixture, versions.legacy_fingerprint);
var side: [256]u8 = undefined;
var pristine = try openPristine(&h, &side, fixture);
defer pristine.close();
var result = try querylog_schema.open(h.io(), std.Io.Dir.cwd(), h.path());
defer result.database.close();
try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), result.recreated);
try testing.expectEqual(
@as(i64, versions.current_version),
try result.database.queryInt("PRAGMA user_version"),
);
var fresh_buf: [256]u8 = undefined;
var fresh = try openFreshCurrent(&h, &fresh_buf);
defer fresh.close();
try testing.expect(try migrations.schemaEquivalent(testing.allocator, &result.database, &fresh));
try expectFixtureSurvived(&result.database, &pristine);
}
// The companion, already in the suite for the day a break raises the
// minimum above 1: the same bytes under the same stamp are then a file this
// build cannot reach, and it is refused without being touched.
var h: Harness = .init();
defer h.deinit();
try writeFixture(h.path(), fixture, versions.legacy_fingerprint);
const after_break: querylog_schema.Plan = .{
.minimum = 2,
.current = 2,
.legacy_fingerprint = versions.legacy_fingerprint,
.step_sql = &.{},
};
try testing.expect(querylog_schema.classify(versions.legacy_fingerprint, after_break).action ==
.refuse_unsupported);
try testing.expect(!querylog_schema.classify(versions.legacy_fingerprint, after_break).restamp);
var handle: ?db.Db = try h.openLive();
defer if (handle) |*open_db| open_db.close();
migrations.expected_failures.begin();
defer migrations.expected_failures.end();
try testing.expectError(
error.SchemaUnsupported,
querylog_schema.openVersioned(h.io(), std.Io.Dir.cwd(), h.path(), &handle, after_break),
);
var check = try h.openLive();
defer check.close();
try testing.expectEqual(
@as(i64, versions.legacy_fingerprint),
try check.queryInt("PRAGMA user_version"),
);
var pristine_buf: [256]u8 = undefined;
var pristine = try openPristine(&h, &pristine_buf, fixture);
defer pristine.close();
try expectSameContent(&check, &pristine);
}
File diff suppressed because it is too large Load Diff
+722 -40
View File
@@ -1,14 +1,15 @@
//! The `querylog.db` schema and its open-or-recreate policy.
//! The `querylog.db` schema and its open policy.
//!
//! `querylog.db` is never migrated (PLAN §3.7). It holds expendable log rows,
//! so a schema change replaces the file instead of upgrading it. The
//! replacement trigger is a fingerprint derived from the DDL text itself, so
//! editing the schema below automatically invalidates every existing file — the
//! policy cannot drift out of sync with the SQL.
//! `querylog.db` carries a logical schema version in `PRAGMA user_version`
//! (`querylog_versions.zig`), and a file stamped below the current version is
//! MIGRATED in place. A healthy file is never replaced and never set aside: a
//! version this build cannot reach refuses the startup with instructions
//! instead, because the operator's query history is not this program's to
//! discard.
//!
//! **Recreating is destructive, so the predicate is a positive whitelist.** Only
//! a missing file, `error.Corrupt`, `error.NotADb`, a failed `PRAGMA
//! quick_check` and a fingerprint mismatch recreate. Every other error
//! a missing file, `error.Corrupt`, `error.NotADb` and a failed `PRAGMA
//! quick_check` recreate — genuine corruption, nothing else. Every other error
//! propagates and the file on disk is not touched. `error.Busy` / `error.Locked`
//! mean another process holds the write lock — waiting is right, deleting is
//! catastrophic. `error.OutOfMemory` is this process's problem. `error.CantOpen`
@@ -19,9 +20,21 @@
const std = @import("std");
const db = @import("db.zig");
const migrations = @import("querylog_migrations.zig");
const versions = @import("querylog_versions.zig");
const log = std.log.scoped(.querylog_schema);
/// See `querylog_migrations.fail`: `err`, unless a test has said it is causing
/// this refusal on purpose.
fn fail(comptime fmt: []const u8, args: anytype) void {
if (migrations.expected_failures.capturing()) {
log.warn(fmt, args);
} else {
log.err(fmt, args);
}
}
/// PLAN §11.3, plus the coverage watermark of milestone 28. Multi-statement
/// text — it goes through `db.Db.exec`, never through `prepare`.
///
@@ -139,14 +152,21 @@ pub const fingerprint: i32 = blk: {
break :blk fingerprintOf(ddl);
};
const set_user_version = std.fmt.comptimePrint("PRAGMA user_version = {d};", .{fingerprint});
/// What a fresh file is stamped with. The logical version, not the fingerprint:
/// from this release on, `user_version` is a version number.
const set_user_version = std.fmt.comptimePrint(
"PRAGMA user_version = {d};",
.{versions.current_version},
);
/// Long enough for any path this program will be handed, plus the aside suffix.
/// A longer path is `error.NameTooLong`, which is what the filesystem calls
/// would have returned anyway.
const path_buf_len = 4096 + 64;
pub const RecreateReason = enum { missing, corrupt, not_a_database, quick_check_failed, fingerprint_mismatch };
/// Corruption, and nothing else. A healthy file with a version this build does
/// not handle refuses the startup; it is never recreated and never set aside.
pub const RecreateReason = enum { missing, corrupt, not_a_database, quick_check_failed };
pub const OpenResult = struct {
database: db.Db,
@@ -166,10 +186,75 @@ pub const OpenResult = struct {
}
};
pub const Error = db.Error || error{AsideNameCollision} ||
std.Io.Dir.RenamePreserveError || std.Io.Dir.DeleteFileError || std.Io.Dir.AccessError;
pub const Error = db.Error || error{
AsideNameCollision,
/// The file's version is above this build's. A downgrade, almost always.
SchemaTooNew,
/// The file's version is one this build cannot migrate from: older than
/// `minimum_supported_version`, or not a stamp nxdns ever wrote.
SchemaUnsupported,
MigrationFailed,
MigrationBackupFailed,
NameTooLong,
} || std.Io.Dir.RenamePreserveError || std.Io.Dir.DeleteFileError || std.Io.Dir.AccessError;
/// Opens `path`, recreating it if and only if it is genuinely unusable.
/// The version metadata `openVersioned` works against. Production passes
/// `production_plan`; tests inject synthetic chains, which is what makes
/// migration, the post-commit branch and the ownership of the handle testable
/// through the real open path while the shipped chain is still empty.
pub const Plan = struct {
minimum: i32,
current: i32,
legacy_fingerprint: i32,
step_sql: []const [:0]const u8,
};
pub const production_plan: Plan = .{
.minimum = versions.minimum_supported_version,
.current = versions.current_version,
.legacy_fingerprint = versions.legacy_fingerprint,
.step_sql = versions.step_sql,
};
/// What a stamped `user_version` means. A pure function of the stamp and the
/// plan's three numbers — no file, no clock, no mutation.
pub const Action = enum { open_current, migrate, refuse_too_new, refuse_unsupported };
pub const Classification = struct {
/// The stamp mapped onto the version line. Equal to the stamp except for
/// the legacy fingerprint, which IS version 1.
logical: i32,
action: Action,
/// The legacy fingerprint must be replaced by its logical number before the
/// file is used — but only on a lane that accepts the file. An unsupported
/// file is never modified.
restamp: bool,
};
/// The classification table. The order is load-bearing: the legacy fingerprint
/// becomes version 1 FIRST, and only then is version 1 judged against the
/// plan's range. After a future explicit break raises the minimum above 1, a
/// legacy-stamped file therefore classifies as below-minimum and refuses
/// without ever being restamped.
pub fn classify(stamped: i32, plan: Plan) Classification {
const legacy = stamped == plan.legacy_fingerprint;
const logical: i32 = if (legacy) 1 else stamped;
const action: Action = if (logical == plan.current)
.open_current
else if (logical >= plan.minimum and logical < plan.current)
.migrate
else if (logical > plan.current and logical <= versions.version_floor_guard)
.refuse_too_new
else
.refuse_unsupported;
const accepted = action == .open_current or action == .migrate;
return .{ .logical = logical, .action = action, .restamp = legacy and accepted };
}
/// Opens `path`, recreating it if and only if it is genuinely unusable, and
/// migrating it if and only if it carries an older supported version.
///
/// `path` is resolved twice by two different mechanisms: `dir`-relative for the
/// filesystem calls, and process-cwd-relative by SQLite's VFS, which knows
@@ -180,7 +265,7 @@ pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult {
var handle: ?db.Db = null;
errdefer if (handle) |*h| h.close();
const reason: ?RecreateReason = probe: {
const cause: RecreateReason = probe: {
dir.access(io, path, .{}) catch |e| switch (e) {
error.FileNotFound => break :probe .missing,
else => |other| return other,
@@ -197,15 +282,10 @@ pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult {
break :probe recreatable(e) orelse return e;
if (!healthy) break :probe .quick_check_failed;
const stamped = opened.queryInt("PRAGMA user_version") catch |e|
break :probe recreatable(e) orelse return e;
if (stamped != fingerprint) break :probe .fingerprint_mismatch;
break :probe null;
try openVersioned(io, dir, path, &handle, production_plan);
return .{ .database = handle.?, .recreated = null };
};
const cause = reason orelse return .{ .database = handle.?, .recreated = null };
// Close first, so SQLite checkpoints and drops `-wal`/`-shm` where it can.
if (handle) |*h| h.close();
handle = null;
@@ -240,6 +320,131 @@ pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult {
return result;
}
/// The version half of `open`, against an injectable `plan`.
///
/// `handle` is the slot holding the healthy, pragma-applied connection to
/// `path`. On success the connection stays in it, at `plan.current`. On every
/// failure this function closes the connection and sets the slot to null, so
/// the caller's own error-path close cannot double-close it — including the
/// committed-but-unclean path, which is the one place the handle must be
/// dropped even though the file on disk is fine.
///
/// **nxdns owns `path` exclusively.** It opens `querylog.db` once at startup,
/// before it serves anything, and no second process shares a data directory —
/// the standing deployment contract. The backup-then-lock sequence in
/// `querylog_migrations.runMigration` relies on it: between the `VACUUM INTO`
/// and the `BEGIN IMMEDIATE` there is no lock, and the re-read of
/// `user_version` under the lock is what turns a violation of that contract
/// into a refusal instead of a corrupted migration.
pub fn openVersioned(
io: std.Io,
dir: std.Io.Dir,
path: [:0]const u8,
handle: *?db.Db,
plan: Plan,
) Error!void {
const database = &(handle.*.?);
errdefer closeSlot(handle);
const stamped64 = try database.queryInt("PRAGMA user_version");
const stamped = std.math.cast(i32, stamped64) orelse {
// `user_version` is a signed 32-bit field, so this cannot come from
// SQLite. Refusing is the same answer any other foreign stamp gets.
refusalLog(path, stamped64, plan, "SchemaUnsupported");
return error.SchemaUnsupported;
};
const verdict = classify(stamped, plan);
switch (verdict.action) {
.refuse_too_new => {
refusalLog(path, stamped64, plan, "SchemaTooNew");
return error.SchemaTooNew;
},
.refuse_unsupported => {
refusalLog(path, stamped64, plan, "SchemaUnsupported");
return error.SchemaUnsupported;
},
.open_current, .migrate => {},
}
if (verdict.restamp) try restampLegacy(database, path, verdict.logical);
switch (verdict.action) {
.migrate => {
const first = @as(usize, @intCast(verdict.logical - plan.minimum));
migrations.runMigration(
io,
dir,
path,
database,
plan.step_sql[first..],
verdict.logical,
plan.current,
) catch |e| switch (e) {
// Two different states of the FILE — migrated and kept with its
// backup, or logically untouched — and one shared state of the
// CONNECTION: its pragmas are not what `applyPragmas`
// guarantees, so it must not serve. Closing it here is the
// single close either path gets. After a commit the next start
// opens the migrated file on the current-version lane; after a
// failure it retries the migration from the top.
error.MigrationCommittedButUnclean, error.MigrationFailedUnclean => {
closeSlot(handle);
return error.MigrationFailed;
},
error.MigrationBackupFailed => return error.MigrationBackupFailed,
error.MigrationFailed, error.NameTooLong => return error.MigrationFailed,
else => |other| return other,
};
},
.open_current => migrations.pruneBackupsConservative(io, dir, path),
else => unreachable,
}
}
/// Replaces the 0.0.12/0.0.13 fingerprint stamp with the logical version it
/// stands for. This is the milestone's only real mutation of operator data, so
/// it runs in its own transaction and any failure leaves the legacy stamp and
/// every row exactly as they were — a refusal, never a recreate.
fn restampLegacy(database: *db.Db, path: []const u8, logical: i32) Error!void {
var stamp_buf: [64]u8 = undefined;
const stamp = std.fmt.bufPrintZ(&stamp_buf, "PRAGMA user_version = {d};", .{logical}) catch
unreachable; // an i32 and a fixed prefix cannot overrun 64 bytes
restamp: {
var tx = db.Tx.begin(database) catch break :restamp;
database.exec(stamp) catch {
tx.rollback();
break :restamp;
};
tx.commit() catch {
tx.rollback();
break :restamp;
};
log.info("querylog database '{s}' carried the 0.0.12 schema fingerprint; " ++
"restamped as schema version {d}", .{ path, logical });
return;
}
var buf: [256]u8 = undefined;
fail("cannot restamp querylog database '{s}' as schema version {d}: {s}; " ++
"the file is unchanged", .{ path, logical, database.lastError(&buf) });
return error.MigrationFailed;
}
fn closeSlot(handle: *?db.Db) void {
if (handle.*) |*h| h.close();
handle.* = null;
}
fn refusalLog(path: []const u8, stamped: i64, plan: Plan, name: []const u8) void {
fail("refusing to open querylog database '{s}': it is stamped {d}, and this build " ++
"supports schema versions {d} to {d} ({s}). The file is left exactly as it is; " ++
"see docs/how-to/troubleshoot.md, \"The server refuses to start over querylog.db\"", .{
path, stamped, plan.minimum, plan.current, name,
});
}
/// An additional connection to a `querylog.db` that `open` has already
/// established, with the pragmas every connection to the file needs.
///
@@ -285,16 +490,15 @@ fn quickCheck(database: *db.Db) db.Error!bool {
/// What the aside file's name calls the reason it was set aside.
///
/// The name is the only account of the reason an operator gets: the log line
/// naming it scrolls away, the file stays for months. `fingerprint_mismatch` is
/// a database with nothing wrong with it — this build's DDL moved — so calling
/// its file "corrupt" invites the operator to delete evidence of a healthy file.
/// naming it scrolls away, the file stays for months. Every tag here names real
/// damage, which is the whole set of reasons left — a healthy file whose
/// version this build cannot handle refuses the startup and is not renamed.
fn asideTag(reason: RecreateReason) []const u8 {
return switch (reason) {
.missing => unreachable, // there is no file to rename
.corrupt => "corrupt",
.not_a_database => "not-a-database",
.quick_check_failed => "quick-check-failed",
.fingerprint_mismatch => "schema-changed",
};
}
@@ -455,11 +659,14 @@ test "querylog_meta is seeded with one row the schema will not let a second join
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM querylog_meta"));
}
test "the user_version statement stamps the fingerprint" {
test "the user_version statement stamps the current schema version" {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try database.exec(set_user_version);
try testing.expectEqual(@as(i64, fingerprint), try database.queryInt("PRAGMA user_version"));
try testing.expectEqual(
@as(i64, versions.current_version),
try database.queryInt("PRAGMA user_version"),
);
}
// The behaviour these two cases describe — a resource error leaves the file on
@@ -488,7 +695,6 @@ test "the aside name says why, and a healthy file is never called corrupt" {
try testing.expectEqualStrings("corrupt", asideTag(.corrupt));
try testing.expectEqualStrings("not-a-database", asideTag(.not_a_database));
try testing.expectEqualStrings("quick-check-failed", asideTag(.quick_check_failed));
try testing.expectEqualStrings("schema-changed", asideTag(.fingerprint_mismatch));
}
test "recreatable selects exactly two of db.Error's members" {
@@ -532,7 +738,7 @@ test "a recreate returns the aside name by value and a fresh create returns none
try tmp.dir.access(io, kept, .{});
}
test "a recreate resets coverage to the new file and keeps the old one aside" {
test "a corrupt file is recreated, coverage restarts, and the old one is kept aside" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
@@ -549,21 +755,14 @@ test "a recreate resets coverage to the new file and keeps the old one aside" {
try created.database.exec("INSERT INTO domains (domain) VALUES ('old.example');");
created.database.close();
// A healthy file this build's DDL no longer matches — the case milestone
// 28's own schema edit produces on every upgrade.
{
var stamped = try db.Db.open(path, .{ .mode = .read_write_existing });
defer stamped.close();
var sql_buf: [64]u8 = undefined;
try stamped.exec(try std.fmt.bufPrintZ(&sql_buf, "PRAGMA user_version = {d};", .{fingerprint +% 1}));
}
// Real damage, which is now the only thing that recreates.
try tmp.dir.writeFile(io, .{ .sub_path = "querylog.db", .data = "not a database at all" });
var recreated = try open(io, std.Io.Dir.cwd(), path);
defer recreated.database.close();
try testing.expectEqual(RecreateReason.fingerprint_mismatch, recreated.recreated.?);
// The name says the file was healthy and this build moved, not that it rotted.
try testing.expect(std.mem.indexOf(u8, recreated.aside(), ".schema-changed-") != null);
try testing.expectEqual(RecreateReason.not_a_database, recreated.recreated.?);
try testing.expect(std.mem.indexOf(u8, recreated.aside(), ".not-a-database-") != null);
try tmp.dir.access(io, std.fs.path.basename(recreated.aside()), .{});
// Exactly one meta row, and coverage starts at the recreate rather than
@@ -602,3 +801,486 @@ test "a clean reopen reports no recreate and no aside" {
try testing.expectEqual(@as(?RecreateReason, null), second.recreated);
try testing.expectEqualStrings("", second.aside());
}
test "classification is a pure function of the stamp and the plan's three numbers" {
const plan: Plan = .{
.minimum = 1,
.current = 3,
.legacy_fingerprint = versions.legacy_fingerprint,
.step_sql = &.{},
};
try testing.expectEqual(Action.open_current, classify(3, plan).action);
try testing.expectEqual(Action.migrate, classify(1, plan).action);
try testing.expectEqual(Action.migrate, classify(2, plan).action);
try testing.expectEqual(Action.refuse_too_new, classify(4, plan).action);
try testing.expectEqual(Action.refuse_too_new, classify(versions.version_floor_guard, plan).action);
// Above the floor guard is not a version this project ever wrote.
try testing.expectEqual(
Action.refuse_unsupported,
classify(versions.version_floor_guard + 1, plan).action,
);
for ([_]i32{ 0, -1, -1_000_000, 603440875 }) |foreign| {
try testing.expectEqual(Action.refuse_unsupported, classify(foreign, plan).action);
try testing.expect(!classify(foreign, plan).restamp);
}
// The legacy fingerprint IS version 1, and being version 1 is what decides
// its lane.
const legacy = classify(versions.legacy_fingerprint, plan);
try testing.expectEqual(@as(i32, 1), legacy.logical);
try testing.expectEqual(Action.migrate, legacy.action);
try testing.expect(legacy.restamp);
}
test "a legacy stamp below a raised minimum refuses without a restamp" {
// What a future explicit break looks like from this side: the minimum has
// moved past 1, so the 0.0.12 file is no longer reachable. The ORDER is the
// point — mapping to 1 first and judging second is what stops the restamp
// from mutating a file this build will refuse anyway.
const after_break: Plan = .{
.minimum = 3,
.current = 3,
.legacy_fingerprint = versions.legacy_fingerprint,
.step_sql = &.{},
};
const legacy = classify(versions.legacy_fingerprint, after_break);
try testing.expectEqual(@as(i32, 1), legacy.logical);
try testing.expectEqual(Action.refuse_unsupported, legacy.action);
try testing.expect(!legacy.restamp);
// And versions 1 and 2, which the break dropped, refuse the same way.
try testing.expectEqual(Action.refuse_unsupported, classify(1, after_break).action);
try testing.expectEqual(Action.refuse_unsupported, classify(2, after_break).action);
try testing.expectEqual(Action.open_current, classify(3, after_break).action);
}
test "the production plan classifies a fresh stamp as current" {
try testing.expectEqual(
Action.open_current,
classify(versions.current_version, production_plan).action,
);
try testing.expect(classify(versions.legacy_fingerprint, production_plan).restamp);
}
/// The five lines every file-backed test below opens with.
const Fixture = struct {
threaded: std.Io.Threaded,
tmp: std.testing.TmpDir,
buf: [256]u8 = undefined,
fn init() Fixture {
return .{
.threaded = .init(testing.allocator, .{}),
.tmp = testing.tmpDir(.{ .iterate = true }),
};
}
fn deinit(self: *Fixture) void {
self.tmp.cleanup();
self.threaded.deinit();
}
fn io(self: *Fixture) std.Io {
return self.threaded.io();
}
fn path(self: *Fixture) [:0]const u8 {
return std.fmt.bufPrintZ(&self.buf, ".zig-cache/tmp/{s}/querylog.db", .{self.tmp.sub_path}) catch
unreachable;
}
fn stamp(self: *Fixture, value: i32) !void {
var database = try db.Db.open(self.path(), .{ .mode = .read_write_existing });
defer database.close();
var sql: [64]u8 = undefined;
try database.exec(try std.fmt.bufPrintZ(&sql, "PRAGMA user_version = {d};", .{value}));
}
fn liveHandle(self: *Fixture) !?db.Db {
var database = try db.Db.open(self.path(), .{ .mode = .read_write_existing });
errdefer database.close();
try db.applyPragmas(&database, .{});
return database;
}
fn countMatching(self: *Fixture, prefix: []const u8) !usize {
var found: usize = 0;
var it = self.tmp.dir.iterate();
while (try it.next(self.io())) |entry| {
if (std.mem.startsWith(u8, entry.name, prefix)) found += 1;
}
return found;
}
fn expectModeOfOnlyMatch(self: *Fixture, prefix: []const u8, expected: std.posix.mode_t) !void {
var it = self.tmp.dir.iterate();
while (try it.next(self.io())) |entry| {
if (!std.mem.startsWith(u8, entry.name, prefix)) continue;
const stat = try self.tmp.dir.statFile(self.io(), entry.name, .{});
const mode = stat.permissions.toMode() & 0o777;
if (mode != expected) {
std.debug.print("mode of '{s}' is {o}, expected {o}\n", .{ entry.name, mode, expected });
return error.TestUnexpectedResult;
}
return;
}
std.debug.print("no file starting with '{s}'\n", .{prefix});
return error.TestUnexpectedResult;
}
};
test "a fresh file is stamped with the current schema version" {
var f: Fixture = .init();
defer f.deinit();
var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
defer created.database.close();
try testing.expectEqual(RecreateReason.missing, created.recreated.?);
try testing.expectEqual(
@as(i64, versions.current_version),
try created.database.queryInt("PRAGMA user_version"),
);
}
test "a 0.0.13 file is restamped as version 1 and keeps every row" {
var f: Fixture = .init();
defer f.deinit();
{
var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
defer created.database.close();
try created.database.exec("INSERT INTO domains (domain) VALUES ('kept.example');");
}
// Exactly what 0.0.12 and 0.0.13 wrote: the CRC of their DDL, which is this
// build's DDL unchanged.
try f.stamp(versions.legacy_fingerprint);
try testing.expectEqual(versions.legacy_fingerprint, fingerprint);
{
var upgraded = try open(f.io(), std.Io.Dir.cwd(), f.path());
defer upgraded.database.close();
try testing.expectEqual(@as(?RecreateReason, null), upgraded.recreated);
try testing.expectEqual(@as(i64, 1), try upgraded.database.queryInt("PRAGMA user_version"));
try testing.expectEqual(
@as(i64, 1),
try upgraded.database.queryInt("SELECT count(*) FROM domains WHERE domain = 'kept.example'"),
);
}
// Nothing was set aside on the way, and the second start is an ordinary
// current-version open.
try testing.expectEqual(@as(usize, 0), try f.countMatching("querylog.db.schema"));
var again = try open(f.io(), std.Io.Dir.cwd(), f.path());
defer again.database.close();
try testing.expectEqual(@as(?RecreateReason, null), again.recreated);
try testing.expectEqual(@as(i64, 1), try again.database.queryInt("PRAGMA user_version"));
}
test "a version this build cannot handle refuses and leaves the file alone" {
var f: Fixture = .init();
defer f.deinit();
{
var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
defer created.database.close();
try created.database.exec("INSERT INTO domains (domain) VALUES ('kept.example');");
}
const watermark = blk: {
var probe = try db.Db.open(f.path(), .{ .mode = .read_write_existing });
defer probe.close();
break :blk try probe.queryInt("SELECT available_since FROM querylog_meta");
};
migrations.expected_failures.begin();
defer migrations.expected_failures.end();
const lanes = [_]struct { stamp: i32, expected: anyerror }{
.{ .stamp = versions.current_version + 1, .expected = error.SchemaTooNew },
.{ .stamp = versions.version_floor_guard, .expected = error.SchemaTooNew },
.{ .stamp = 0, .expected = error.SchemaUnsupported },
.{ .stamp = -3, .expected = error.SchemaUnsupported },
.{ .stamp = 603440875, .expected = error.SchemaUnsupported },
};
for (lanes) |lane| {
try f.stamp(lane.stamp);
try testing.expectError(lane.expected, open(f.io(), std.Io.Dir.cwd(), f.path()));
// Schema, rows, watermark and stamp all as they were, and nothing new
// beside the file.
var probe = try db.Db.open(f.path(), .{ .mode = .read_write_existing });
defer probe.close();
try testing.expectEqual(@as(i64, lane.stamp), try probe.queryInt("PRAGMA user_version"));
try testing.expectEqual(
@as(i64, 1),
try probe.queryInt("SELECT count(*) FROM domains WHERE domain = 'kept.example'"),
);
try testing.expectEqual(
watermark,
try probe.queryInt("SELECT available_since FROM querylog_meta"),
);
try testing.expectEqual(@as(usize, 0), try f.countMatching("querylog.db."));
}
}
test "a restamp that fails at the statement or at the commit refuses without loss" {
for ([_][]const u8{ "PRAGMA user_version = 1;", "COMMIT;" }) |failing| {
var f: Fixture = .init();
defer f.deinit();
{
var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
defer created.database.close();
try created.database.exec("INSERT INTO domains (domain) VALUES ('kept.example');");
}
try f.stamp(versions.legacy_fingerprint);
migrations.expected_failures.begin();
defer migrations.expected_failures.end();
db.exec_faults.failNextMatching(failing);
defer db.exec_faults.disarm();
try testing.expectError(
error.MigrationFailed,
open(f.io(), std.Io.Dir.cwd(), f.path()),
);
try testing.expect(!db.exec_faults.armed());
// The legacy stamp and every row are exactly as they were: this is a
// refusal, and a refusal never costs the operator anything.
{
var probe = try db.Db.open(f.path(), .{ .mode = .read_write_existing });
defer probe.close();
try testing.expectEqual(
@as(i64, versions.legacy_fingerprint),
try probe.queryInt("PRAGMA user_version"),
);
}
// And the next start, with nothing injected, does the restamp properly.
var recovered = try open(f.io(), std.Io.Dir.cwd(), f.path());
defer recovered.database.close();
try testing.expectEqual(@as(i64, 1), try recovered.database.queryInt("PRAGMA user_version"));
try testing.expectEqual(
@as(i64, 1),
try recovered.database.queryInt("SELECT count(*) FROM domains WHERE domain = 'kept.example'"),
);
}
}
/// A one-step chain from the real current version to one above it. Nothing in
/// the shipped chain can exercise migration while `step_sql` is empty, so the
/// open path's migration lanes are driven through this instead.
const synthetic_plan: Plan = .{
.minimum = versions.current_version,
.current = versions.current_version + 1,
.legacy_fingerprint = versions.legacy_fingerprint,
.step_sql = &.{"CREATE TABLE migration_marker (id INTEGER PRIMARY KEY);"},
};
test "a post-commit failure keeps the migration, keeps the backup, and refuses once" {
var f: Fixture = .init();
defer f.deinit();
{
var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
defer created.database.close();
try created.database.exec("INSERT INTO domains (domain) VALUES ('kept.example');");
}
{
var handle = try f.liveHandle();
errdefer if (handle) |*h| h.close();
migrations.expected_failures.begin();
defer migrations.expected_failures.end();
// The first statement of the pragma restore, which runs only after a
// successful COMMIT.
db.exec_faults.failNextMatching("legacy_alter_table = OFF");
defer db.exec_faults.disarm();
try testing.expectError(
error.MigrationFailed,
openVersioned(f.io(), std.Io.Dir.cwd(), f.path(), &handle, synthetic_plan),
);
// Closed exactly once, by the open path: the slot it was handed is
// empty, so no caller can close it again.
try testing.expect(handle == null);
}
// The file IS migrated. The log said so, and this is what it meant.
{
var probe = try db.Db.open(f.path(), .{ .mode = .read_write_existing });
defer probe.close();
try testing.expectEqual(
@as(i64, synthetic_plan.current),
try probe.queryInt("PRAGMA user_version"),
);
try testing.expectEqual(
@as(i64, 1),
try probe.queryInt("SELECT count(*) FROM sqlite_schema WHERE name = 'migration_marker'"),
);
try testing.expectEqual(
@as(i64, 1),
try probe.queryInt("SELECT count(*) FROM domains WHERE domain = 'kept.example'"),
);
}
try testing.expectEqual(@as(usize, 1), try f.countMatching("querylog.db.pre-migrate-"));
// The backup is the whole query history at the moment of the migration, so
// it carries the live file's mode and not SQLite's `0644 & ~umask`.
try f.expectModeOfOnlyMatch("querylog.db.pre-migrate-", 0o600);
// The next start is ordinary: the current-version lane, no second
// migration, and the one backup still there for the operator.
var handle = try f.liveHandle();
defer if (handle) |*h| h.close();
try openVersioned(f.io(), std.Io.Dir.cwd(), f.path(), &handle, synthetic_plan);
try testing.expect(handle != null);
try testing.expectEqual(
@as(i64, synthetic_plan.current),
try handle.?.queryInt("PRAGMA user_version"),
);
try testing.expectEqual(@as(usize, 1), try f.countMatching("querylog.db.pre-migrate-"));
}
/// The same shape as `synthetic_plan`, with a step SQLite refuses to prepare. It
/// drives the pre-commit failure path without any fault seam, leaving the seam
/// free for the pragma restore.
const failing_plan: Plan = .{
.minimum = versions.current_version,
.current = versions.current_version + 1,
.legacy_fingerprint = versions.legacy_fingerprint,
.step_sql = &.{"CREATE TABLE migration_marker (id INTEGER PRIMARY KEY) NOT A STATEMENT;"},
};
test "a pre-commit failure whose pragma restore also fails closes the connection" {
var f: Fixture = .init();
defer f.deinit();
{
var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
defer created.database.close();
try created.database.exec("INSERT INTO domains (domain) VALUES ('kept.example');");
}
// The runner's own answer first: a failed restore is a DIFFERENT error from
// a failed migration, because the two leave the connection in different
// states even though they leave the file in the same one.
{
var handle = try f.liveHandle();
defer if (handle) |*h| h.close();
migrations.expected_failures.begin();
defer migrations.expected_failures.end();
db.exec_faults.failNextMatching("legacy_alter_table = OFF");
defer db.exec_faults.disarm();
try testing.expectError(error.MigrationFailedUnclean, migrations.runMigration(
f.io(),
std.Io.Dir.cwd(),
f.path(),
&handle.?,
failing_plan.step_sql,
failing_plan.minimum,
failing_plan.current,
));
try testing.expect(!db.exec_faults.armed());
}
// And the open path's answer: the handle is closed, exactly as it is after a
// post-commit restore failure. A connection that may still hold
// `foreign_keys = OFF` never reaches the server.
{
var handle = try f.liveHandle();
errdefer if (handle) |*h| h.close();
migrations.expected_failures.begin();
defer migrations.expected_failures.end();
db.exec_faults.failNextMatching("legacy_alter_table = OFF");
defer db.exec_faults.disarm();
try testing.expectError(
error.MigrationFailed,
openVersioned(f.io(), std.Io.Dir.cwd(), f.path(), &handle, failing_plan),
);
try testing.expect(handle == null);
}
// The contrast that makes the rule visible, back at the runner, where the
// connection survives to be inspected: the same failing step with the
// restore working is a plain `MigrationFailed`, and that error promises the
// pragmas `applyPragmas` guarantees.
{
var handle = try f.liveHandle();
defer if (handle) |*h| h.close();
migrations.expected_failures.begin();
defer migrations.expected_failures.end();
try testing.expectError(error.MigrationFailed, migrations.runMigration(
f.io(),
std.Io.Dir.cwd(),
f.path(),
&handle.?,
failing_plan.step_sql,
failing_plan.minimum,
failing_plan.current,
));
try testing.expectEqual(@as(i64, 1), try handle.?.queryInt("PRAGMA foreign_keys"));
try testing.expectEqual(@as(i64, 0), try handle.?.queryInt("PRAGMA legacy_alter_table"));
}
// No path committed anything, and every run deleted its own backup.
var probe = try db.Db.open(f.path(), .{ .mode = .read_write_existing });
defer probe.close();
try testing.expectEqual(
@as(i64, failing_plan.minimum),
try probe.queryInt("PRAGMA user_version"),
);
try testing.expectEqual(
@as(i64, 1),
try probe.queryInt("SELECT count(*) FROM domains WHERE domain = 'kept.example'"),
);
try testing.expectEqual(@as(usize, 0), try f.countMatching("querylog.db.pre-migrate-"));
}
test "a plain open retries a retention cleanup that once failed" {
var f: Fixture = .init();
defer f.deinit();
{
var created = try open(f.io(), std.Io.Dir.cwd(), f.path());
created.database.close();
}
// What a migration whose step-4 cleanup failed leaves behind: an older
// epoch, the newest epoch, and a same-second collision name tied with it.
const seeded = [_][]const u8{
"querylog.db.pre-migrate-1600000000",
"querylog.db.pre-migrate-1700000000",
"querylog.db.pre-migrate-1700000000-2",
"querylog.db.pre-migrate-handwritten",
};
for (seeded) |name| {
try f.tmp.dir.writeFile(f.io(), .{ .sub_path = name, .data = "x" });
}
var opened = try open(f.io(), std.Io.Dir.cwd(), f.path());
defer opened.database.close();
try testing.expectEqual(@as(?RecreateReason, null), opened.recreated);
// Only the strictly older epoch goes: both files tied at the newest epoch
// survive, because this pass cannot tell which of them a migration made,
// and a name it did not write is never its to delete.
try testing.expect(!try exists(&f, seeded[0]));
for (seeded[1..]) |name| try testing.expect(try exists(&f, name));
}
fn exists(f: *Fixture, name: []const u8) !bool {
f.tmp.dir.access(f.io(), name, .{}) catch |e| switch (e) {
error.FileNotFound => return false,
else => return e,
};
return true;
}
+89
View File
@@ -0,0 +1,89 @@
//! The `querylog.db` schema version chain: comptime metadata and nothing else.
//!
//! Separate from `querylog_migrations.zig` so `tools/cut.zig` can import it
//! without linking SQLite. Nothing in this file may reach for `db.zig`, for a
//! C symbol, or for an allocator — the release gate reads these constants at
//! build time, and a dependency here would drag the whole storage layer into
//! the cut tool.
//!
//! **There are no migration hooks.** A step is a SQL file, period. Every
//! shipped step is therefore byte-comparable against the previous tag, which is
//! what lets the cut gate prove a released migration was never edited. A future
//! change that genuinely cannot be expressed in SQL must amend this design in
//! its own spec rather than adding a code path here.
const std = @import("std");
/// The version a file created by this build carries in `PRAGMA user_version`.
pub const current_version: i32 = 1;
/// The oldest stamped version this build can reach `current_version` from.
/// A file stamped below this refuses to open.
///
/// An EXPLICIT BREAK in a future release is expressed here and only here: bump
/// `current_version`, set `minimum_supported_version = current_version`, and
/// ship no step. The chain then cannot reach the new version from below the
/// minimum, so `open` refuses the old file by the ordinary rules. A break is
/// always versioned, always refused at runtime, and never silent.
pub const minimum_supported_version: i32 = 1;
/// The literal `user_version` the 0.0.12 and 0.0.13 binaries stamped: the CRC32
/// of their DDL text, under the pre-migration policy where a stamp mismatch
/// meant "replace the file".
///
/// FROZEN. It is derived from nothing at build time on purpose — recomputing it
/// from today's DDL would silently stop recognising the files it exists to
/// recognise the moment the schema moves. Editing it strands every 0.0.12 and
/// 0.0.13 file that has not yet been opened by a migration-aware build, which
/// is why the cut gate fails on any change to this line.
pub const legacy_fingerprint: i32 = 1975011655;
/// Logical versions live far below any plausible CRC32 stamp. A value above
/// this is not a version this project ever wrote, so it classifies as
/// unsupported rather than as a from-the-future schema.
pub const version_floor_guard: i32 = 1_000_000;
/// One entry per shipped step: `step_sql[i]` migrates version
/// `minimum_supported_version + i` to `minimum_supported_version + i + 1`.
/// Each entry is `@embedFile("migrations/v<from>.sql")`, and each such file is
/// immutable once released.
///
/// **Step-authoring rules** (the runner enforces the first, the equivalence
/// oracle catches violations of the rest):
///
/// - A step contains no transaction statement. No `BEGIN`, no `COMMIT`, no
/// `ROLLBACK`, no `SAVEPOINT`: the runner wraps the whole chain in one
/// transaction and installs an authorizer that denies them outright.
/// - A step that changes a table's shape must REBUILD it, so that the CREATE
/// text SQLite stores ends up byte-identical to the fresh DDL's:
/// `DROP` every view over `<t>` first; `ALTER TABLE <t> RENAME TO <t>_old`;
/// `CREATE TABLE <t> ...` pasted verbatim from `querylog_schema.ddl`;
/// `INSERT INTO <t> SELECT ... FROM <t>_old`; `DROP TABLE <t>_old`; recreate
/// every index and trigger of `<t>` verbatim; recreate the dropped views
/// verbatim last.
/// - `ALTER TABLE ... ADD COLUMN` and `ALTER TABLE ... RENAME COLUMN` on a kept
/// table are forbidden. SQLite rewrites the stored CREATE text under them,
/// and the oracle's exact-text layer would rightly call the result unequal.
pub const step_sql: []const [:0]const u8 = &.{};
comptime {
std.debug.assert(minimum_supported_version >= 1);
std.debug.assert(minimum_supported_version <= current_version);
std.debug.assert(current_version <= version_floor_guard);
std.debug.assert(legacy_fingerprint < 0 or legacy_fingerprint > version_floor_guard);
std.debug.assert(step_sql.len == @as(usize, @intCast(current_version - minimum_supported_version)));
}
test "the chain covers exactly the supported range" {
try std.testing.expectEqual(
@as(usize, @intCast(current_version - minimum_supported_version)),
step_sql.len,
);
}
test "the legacy anchor is the literal 0.0.12 stamp" {
// Not `fingerprintOf(ddl)`. The number is a historical fact about released
// binaries, so a test that recomputed it would move with the schema and
// prove nothing.
try std.testing.expectEqual(@as(i32, 1975011655), legacy_fingerprint);
}
+113 -14
View File
@@ -674,7 +674,10 @@ pub const QueryFilter = struct {
/// Matched case-insensitively for ASCII, which is what SQLite's `LIKE`
/// does and what a domain search wants.
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,
since: ?i64 = null,
until: ?i64 = null,
@@ -684,6 +687,12 @@ pub const QueryFilter = struct {
/// that forgets cannot ask this connection for the whole table.
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 =
\\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,
@@ -697,7 +706,10 @@ const like_escape = '\\';
const where_before = " q.id < ?";
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_since = " 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,
/// so counting six of it bounds every reachable combination.
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;
buf: [capacity]u8 = undefined,
@@ -734,6 +746,19 @@ const Sql = struct {
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 {
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
/// 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) {
// 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 = .{};
sql.put(select_head);
if (filter.before != null) sql.predicate(where_before);
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.since != null) sql.predicate(where_since);
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;
try stmt.bindText(idx, try likePattern(arena, v));
}
if (filter.client) |v| {
for (filter.clients) |client| {
idx += 1;
try stmt.bindText(idx, v);
try stmt.bindText(idx, client);
}
if (filter.blocked) |v| {
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" });
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));
// 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);
const only_blocked = try selectQueries(&database, arena, .{ .blocked = true });
@@ -2049,7 +2079,7 @@ test "each filter narrows the result on its own" {
.limit = 10,
.before = 3,
.domain_substring = "ads",
.client = "192.0.2.20",
.clients = &.{"192.0.2.20"},
.blocked = true,
.since = 200,
.until = 300,
@@ -2125,18 +2155,84 @@ test "the built SQL never carries a filter value and fits its buffer" {
sql.put(select_head);
sql.predicate(where_before);
sql.predicate(where_domain);
sql.predicate(where_client);
sql.clientPredicate(max_clients);
sql.predicate(where_blocked);
sql.predicate(where_since);
sql.predicate(where_until);
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.expectEqual(@as(usize, 1), std.mem.count(u8, sql.text(), " WHERE"));
try testing.expectEqual(@as(usize, 5), std.mem.count(u8, sql.text(), " AND"));
// Six filters plus the LIMIT, each a bare parameter.
try testing.expectEqual(@as(usize, 7), std.mem.count(u8, sql.text(), "?"));
// Five scalar filters plus the LIMIT, plus one per address, each a bare
// 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" {
@@ -2518,7 +2614,10 @@ const recompute_checks = [_]struct { projection: []const u8, recompute: []const
},
};
fn expectProjectionsMatchRecompute(database: *db.Db) !void {
/// Exported for `querylog_migrations.zig`'s fixture and migration tests: the
/// authority on projection coherence is this file, and a second copy of the
/// recompute SQL there would be free to drift from the writer it checks.
pub fn expectProjectionsMatchRecompute(database: *db.Db) !void {
for (recompute_checks) |check| {
var buf: [4096]u8 = undefined;
const sql = try std.fmt.bufPrint(
+40 -25
View File
@@ -28,7 +28,9 @@ const model = @import("../config/model.zig");
const validate = @import("../config/validate.zig");
const db = @import("db.zig");
const migrations = @import("migrations.zig");
const querylog_migrations = @import("querylog_migrations.zig");
const querylog_schema = @import("querylog_schema.zig");
const querylog_versions = @import("querylog_versions.zig");
const testing = std.testing;
@@ -354,7 +356,7 @@ test "S7 case 1: querylog open on a fresh directory creates the schema" {
try testing.expectEqual(querylog_schema.RecreateReason.missing, result.recreated.?);
try testing.expectEqual(
@as(i64, querylog_schema.fingerprint),
@as(i64, querylog_versions.current_version),
try result.database.queryInt("PRAGMA user_version"),
);
try testing.expectEqual(
@@ -387,7 +389,7 @@ test "S7 case 2: reopening a healthy querylog recreates nothing" {
try testing.expectEqual(@as(usize, 0), asides.items.items.len);
}
test "S7 case 3: a wrong user_version recreates and keeps the old file aside" {
test "S7 case 3: a version this build cannot handle refuses and touches nothing" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
@@ -396,29 +398,42 @@ test "S7 case 3: a wrong user_version recreates and keeps the old file aside" {
var buf: [path_buf_len]u8 = undefined;
const path = try f.pathZ(&buf, "querylog.db");
try stampUserVersion(path, querylog_schema.fingerprint +% 1);
const original = try f.read("querylog.db");
defer testing.allocator.free(original);
querylog_migrations.expected_failures.begin();
defer querylog_migrations.expected_failures.end();
var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer result.database.close();
try testing.expectEqual(
querylog_schema.RecreateReason.fingerprint_mismatch,
result.recreated.?,
);
// Both refusal lanes, against a checkpointed file with no sidecars beside
// it: a stamp above this build's version (a downgrade) and a stamp that is
// not a version at all (a pre-0.0.12 fingerprint, or a foreign file).
const refusals = [_]struct { stamp: i32, expected: anyerror }{
.{ .stamp = querylog_versions.current_version + 1, .expected = error.SchemaTooNew },
.{ .stamp = 0, .expected = error.SchemaUnsupported },
.{ .stamp = -7, .expected = error.SchemaUnsupported },
.{ .stamp = 603440875, .expected = error.SchemaUnsupported },
};
var asides = try collectAsides(&f);
defer asides.deinit();
try testing.expectEqual(@as(usize, 1), asides.items.items.len);
for (refusals) |lane| {
try stampUserVersion(path, lane.stamp);
try testing.expect(!try f.exists("querylog.db-wal"));
// The file was healthy: this build's schema moved, the database did not rot.
// An operator who reads "corrupt" here deletes a file that was never broken.
try testing.expect(std.mem.startsWith(u8, asides.items.items[0], "querylog.db.schema-changed-"));
const original = try f.read("querylog.db");
defer testing.allocator.free(original);
const kept = try f.read(asides.items.items[0]);
defer testing.allocator.free(kept);
try testing.expectEqualSlices(u8, original, kept);
try testing.expectError(
lane.expected,
querylog_schema.open(io, std.Io.Dir.cwd(), path),
);
// Byte-identical, not merely "still readable": nothing was rewritten,
// no aside was made, and no fresh database was created beside it.
const after = try f.read("querylog.db");
defer testing.allocator.free(after);
try testing.expectEqualSlices(u8, original, after);
var asides = try collectAsides(&f);
defer asides.deinit();
try testing.expectEqual(@as(usize, 0), asides.items.items.len);
}
}
test "S7 case 4: a garbage file recreates and the garbage is preserved" {
@@ -468,11 +483,11 @@ test "S7 case 5: two recreates in the same second produce two distinct aside fil
var round: usize = 0;
while (round < 2) : (round += 1) {
try stampUserVersion(path, querylog_schema.fingerprint +% 1);
try f.write("querylog.db", "not a database at all");
var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer result.database.close();
try testing.expectEqual(
querylog_schema.RecreateReason.fingerprint_mismatch,
querylog_schema.RecreateReason.not_a_database,
result.recreated.?,
);
}
@@ -492,7 +507,7 @@ test "S7 case 6: a stale write-ahead log is removed before the fresh database is
var buf: [path_buf_len]u8 = undefined;
const path = try f.pathZ(&buf, "querylog.db");
try stampUserVersion(path, querylog_schema.fingerprint +% 1);
try f.write("querylog.db", "not a database at all");
// Existence alone proves nothing: the fresh database turns WAL on again and
// writes its own `-wal`. The marker is what distinguishes the stale file
@@ -503,7 +518,7 @@ test "S7 case 6: a stale write-ahead log is removed before the fresh database is
var result = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
defer result.database.close();
try testing.expectEqual(
querylog_schema.RecreateReason.fingerprint_mismatch,
querylog_schema.RecreateReason.not_a_database,
result.recreated.?,
);
@@ -591,7 +606,7 @@ test "S7 case 23: a locked querylog propagates Busy and is never destroyed" {
defer reopened.database.close();
try testing.expectEqual(@as(?querylog_schema.RecreateReason, null), reopened.recreated);
try testing.expectEqual(
@as(i64, querylog_schema.fingerprint),
@as(i64, querylog_versions.current_version),
try reopened.database.queryInt("PRAGMA user_version"),
);
+88
View File
@@ -0,0 +1,88 @@
-- FROZEN FIXTURE. Representative content for a querylog.db at schema version 1,
-- loaded on top of `querylog-v1-schema.sql`.
--
-- IMMUTABLE once released, for the same reason as its schema half: the release
-- gate byte-compares it against the previous tag. A future schema version ships
-- a NEW pair rather than editing this one.
--
-- The rows are chosen to be hard on a migration rather than realistic: every
-- `route_kind`, the NULL variants of `qtype`, `cache_hit`, `response_time_us`,
-- `upstream` and `forward_zone`, a non-IN qclass with a non-zero rcode, the
-- group and source id/name pairs, `cname_target`/`safe_search_target`, an
-- `available_since` that has been advanced away from its DDL default, and
-- timestamps that straddle two 30-minute projection buckets.
--
-- The `bucket_*` rows below are the recomputation of the raw rows, transcribed
-- from the same SQL `queries_repo`'s coherence oracle recomputes with. A
-- fixture-validity test runs that oracle over this file BEFORE any migration,
-- so an incoherent transcription fails on its own rather than as a migration
-- bug.
UPDATE querylog_meta SET created_at = 1699998000, available_since = 1699998600 WHERE id = 1;
INSERT INTO domains (id, domain) VALUES
(1, 'ads.example'),
(2, 'news.example'),
(3, 'chat.example'),
(4, 'printer.lan'),
(5, 'nas.lan'),
(6, 'cdn.example');
INSERT INTO query_log (
id, timestamp, domain_id, client_ip, qtype, blocked, response_time_us,
cache_hit, upstream, qclass, rcode, group_id, group_name, policy_action,
policy_reason, matched, source_id, source_name, cname_target,
safe_search_target, route_kind, forward_zone
) VALUES
(1, 1699999260, 1, '10.0.0.1', 1, 1, NULL, 0, NULL, 1, 0, 7, 'kids',
'block', 'blocklist_domain', 'ads.example', 3, 'stevenblack', NULL, NULL,
'blocked', NULL),
(2, 1699999320, 2, '10.0.0.1', 28, 0, 1500, 0, '9.9.9.9:853', 1, 0, NULL,
NULL, 'allow', 'no_match', NULL, NULL, NULL, NULL, NULL, 'upstream', NULL),
(3, 1699999380, 2, '10.0.0.2', 1, 0, 90, 1, NULL, 1, 0, NULL, NULL,
'allow', 'no_match', NULL, NULL, NULL, NULL, NULL, 'cache', NULL),
(4, 1699999440, 3, '10.0.0.2', NULL, 0, NULL, NULL, NULL, 3, 4, NULL, NULL,
'not_evaluated', 'non_in_class', NULL, NULL, NULL, NULL, NULL, 'rejected',
NULL),
(5, 1699999500, 4, '10.0.0.3', 1, 0, 200, 0, NULL, 1, 0, NULL, NULL,
'not_evaluated', 'local_record', NULL, NULL, NULL, NULL, NULL, 'local',
NULL),
(6, 1700000700, 5, '10.0.0.3', 15, 0, 3400, 0, NULL, 1, 0, NULL, NULL,
'not_evaluated', 'forward_zone', NULL, NULL, NULL, NULL, NULL,
'forward_zone', 'lan.example'),
(7, 1700001060, 1, '10.0.0.1', 1, 1, NULL, 0, NULL, 1, 0, 7, 'kids',
'block', 'blocklist_wildcard', '*.ads.example', 3, 'stevenblack', NULL,
NULL, 'blocked', NULL),
(8, 1700001120, 6, '10.0.0.4', 65, 0, 2500, 1, '1.1.1.1:853', 1, 0, NULL,
NULL, 'allow', 'no_match', NULL, NULL, NULL, 'edge.cdn.example',
'forcesafesearch.example', 'upstream', NULL);
INSERT INTO bucket_totals (bucket, queries, blocked, cached, rt_sum, rt_count) VALUES
(1699999200, 6, 1, 1, 5190, 4),
(1700001000, 2, 1, 1, 2500, 1);
INSERT INTO bucket_clients (bucket, client_ip, queries) VALUES
(1699999200, '10.0.0.1', 2),
(1699999200, '10.0.0.2', 2),
(1699999200, '10.0.0.3', 2),
(1700001000, '10.0.0.1', 1),
(1700001000, '10.0.0.4', 1);
-- qtype -1 is the lossless encoding of the NULL qtype on row 4.
INSERT INTO bucket_types (bucket, qtype, count) VALUES
(1699999200, -1, 1),
(1699999200, 1, 3),
(1699999200, 15, 1),
(1699999200, 28, 1),
(1700001000, 1, 1),
(1700001000, 65, 1);
INSERT INTO bucket_routes (bucket, route_kind, source_present, source_text, count) VALUES
(1699999200, 'blocked', 0, '', 1),
(1699999200, 'cache', 0, '', 1),
(1699999200, 'forward_zone', 1, 'lan.example', 1),
(1699999200, 'local', 0, '', 1),
(1699999200, 'rejected', 0, '', 1),
(1699999200, 'upstream', 1, '9.9.9.9:853', 1),
(1700001000, 'blocked', 0, '', 1),
(1700001000, 'upstream', 1, '1.1.1.1:853', 1);
+88
View File
@@ -0,0 +1,88 @@
-- FROZEN FIXTURE. querylog.db schema version 1: byte-for-byte the `ddl` text of
-- `src/storage/querylog_schema.zig`, which is the schema the 0.0.12 and 0.0.13
-- binaries created and the one version 1 names.
--
-- IMMUTABLE once released. The release gate byte-compares this file against the
-- previous tag and fails the cut on any edit, because it is the starting point
-- every future migration is proved against: editing it would prove a migration
-- against a file no operator ever had. A new schema version ships a NEW pair.
--
-- The `PRAGMA user_version` stamp is deliberately NOT part of this file. The
-- loader applies it, which is what lets one fixture serve both the version-1
-- stamp and the 0.0.12/0.0.13 legacy fingerprint.
CREATE TABLE domains (
id INTEGER PRIMARY KEY,
domain TEXT NOT NULL UNIQUE
);
CREATE TABLE query_log (
id INTEGER PRIMARY KEY,
timestamp INTEGER NOT NULL,
domain_id INTEGER NOT NULL REFERENCES domains(id),
client_ip TEXT NOT NULL, -- text, not a FK: log rows are immutable facts
qtype INTEGER,
blocked INTEGER NOT NULL,
response_time_us INTEGER,
cache_hit INTEGER,
upstream TEXT,
qclass INTEGER NOT NULL,
rcode INTEGER NOT NULL,
group_id INTEGER, -- text/id pairs, not FKs: a renamed
group_name TEXT, -- group must not rewrite history
policy_action TEXT NOT NULL,
policy_reason TEXT NOT NULL,
matched TEXT,
source_id INTEGER,
source_name TEXT,
cname_target TEXT,
safe_search_target TEXT,
route_kind TEXT NOT NULL,
forward_zone TEXT,
CHECK (rcode BETWEEN 0 AND 4095) -- twelve bits (RFC 6891 6.1.3)
);
CREATE INDEX idx_query_log_ts ON query_log(timestamp);
CREATE INDEX idx_query_log_client ON query_log(client_ip);
CREATE INDEX idx_query_log_domain ON query_log(domain_id);
CREATE TABLE querylog_meta (
id INTEGER PRIMARY KEY CHECK (id = 1), -- one row, enforced by the schema
created_at INTEGER NOT NULL,
available_since INTEGER NOT NULL
);
INSERT INTO querylog_meta (id, created_at, available_since)
VALUES (1, unixepoch(), unixepoch() + 1);
CREATE TABLE bucket_totals (
bucket INTEGER PRIMARY KEY,
queries INTEGER NOT NULL,
blocked INTEGER NOT NULL,
cached INTEGER NOT NULL,
rt_sum INTEGER NOT NULL, -- sum(response_time_us) over timed rows
rt_count INTEGER NOT NULL -- count(response_time_us)
) WITHOUT ROWID;
CREATE TABLE bucket_clients (
bucket INTEGER NOT NULL,
client_ip TEXT NOT NULL,
queries INTEGER NOT NULL,
PRIMARY KEY (bucket, client_ip)
) WITHOUT ROWID;
CREATE TABLE bucket_types (
bucket INTEGER NOT NULL,
qtype INTEGER NOT NULL, -- -1 encodes a NULL qtype, losslessly
count INTEGER NOT NULL,
PRIMARY KEY (bucket, qtype)
) WITHOUT ROWID;
CREATE TABLE bucket_routes (
bucket INTEGER NOT NULL,
route_kind TEXT NOT NULL,
source_present INTEGER NOT NULL, -- 0: source NULL; 1: source = source_text
source_text TEXT NOT NULL, -- '' when source_present = 0
count INTEGER NOT NULL,
PRIMARY KEY (bucket, route_kind, source_present, source_text),
CHECK (source_present IN (0, 1)),
CHECK (source_present = 1 OR source_text = '')
) WITHOUT ROWID;
+3
View File
@@ -40,7 +40,10 @@ comptime {
_ = @import("config/faults.zig");
_ = @import("storage/config_schema.zig");
_ = @import("storage/migrations.zig");
_ = @import("storage/querylog_migrations.zig");
_ = @import("storage/querylog_schema.zig");
_ = @import("storage/querylog_versions.zig");
_ = @import("storage/querylog_fixtures.zig");
_ = @import("storage/provenance.zig");
_ = @import("storage/repositories/context.zig");
_ = @import("storage/repositories/crud.zig");
+84 -6
View File
@@ -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_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,
/// so it must not outlive the buffers — in the handler both live in the same
/// 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 {
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 {
@@ -82,8 +104,22 @@ pub fn parseFilter(query: []const u8, buffers: *Buffers) FilterError!queries_rep
if (domain.len != 0) filter.domain_substring = domain;
}
if (http_util.queryValue(query, "client", &buffers.client) catch return error.BadClient) |client| {
if (client.len != 0) filter.client = client;
// A comma-separated list of exact addresses, matched any-of. An empty entry
// 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;
@@ -98,7 +134,7 @@ pub fn message(err: FilterError) []const u8 {
error.BadLimit => "limit must be between 1 and 1000",
error.BadBefore => "before must be a positive row id",
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.BadSince => "since 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(?i64, 900), filter.before);
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(?i64, 100), filter.since);
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 = .{};
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.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" {
+4 -2
View File
@@ -200,8 +200,10 @@ paths:
schema: { type: string, maxLength: 253 }
- name: client
in: query
description: Exact client address.
schema: { type: string, maxLength: 64 }
description: >-
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
in: query
schema: { type: boolean }
+826 -33
View File
@@ -57,6 +57,13 @@ const http = std.http;
/// the expression that computes it.
const querylog_schema = @import("querylog_schema");
/// The migration metadata the gates below judge: the supported version range
/// and the step chain, as `querylog_versions.zig` declares it and
/// `querylog_schema.open` runs it. Reached through `production_plan` rather
/// than as a second module because `querylog_schema.zig` already imports that
/// file, and one source file cannot belong to two modules.
const querylog_versions = querylog_schema.production_plan;
const max_input_bytes = 1 << 30;
/// The only repository this program can ever act on. There is no flag for it:
@@ -561,9 +568,283 @@ fn disclosesHistoryReset(section: []const u8) bool {
return std.mem.indexOf(u8, section, history_reset_phrase) != null;
}
/// The file whose DDL decides whether `querylog.db` survives an upgrade.
/// The phrase a changelog section must carry to release a MIGRATION. It is the
/// other operator-facing consequence: the history survives, and the first start
/// after the upgrade rewrites the file to get there.
const migration_phrase = "migrates your query log in place";
fn disclosesMigration(section: []const u8) bool {
return std.mem.indexOf(u8, section, migration_phrase) != null;
}
/// The heading under which an explicit break tells the operator how to get
/// their history back. A break is allowed; a break with nowhere to turn is not.
const restore_heading = "### Restoring your query history";
/// Whether the section carries `restore_heading` AND something under it. An
/// empty section under the heading is the failure mode this exists to catch:
/// the heading alone would satisfy a substring check while telling the operator
/// nothing at all.
fn disclosesRestoreInstructions(section: []const u8) bool {
var lines = std.mem.splitScalar(u8, section, '\n');
var under_heading = false;
while (lines.next()) |raw| {
const line = std.mem.trim(u8, std.mem.trimEnd(u8, raw, "\r"), " \t");
if (under_heading) {
if (std.mem.startsWith(u8, line, "#")) return false;
if (!isBlank(line)) return true;
continue;
}
if (std.mem.eql(u8, line, restore_heading)) under_heading = true;
}
return false;
}
// ---------------------------------------------------------------------------
// the two migration gates (specs/milestone-38.md B.2)
// ---------------------------------------------------------------------------
/// A file that is immutable once released, and what became of it in this tree.
///
/// The gate never sees the bytes. Reading two revisions of a file is the
/// driver's job; deciding what a difference means is a pure function of these
/// three states, which is what makes every rule below a unit test.
const ShippedFile = struct {
kind: enum { step, fixture },
path: []const u8,
status: enum { identical, differs, missing },
};
/// One link of the chain this build ships: the bytes `querylog_versions.step_sql`
/// carries for it, and the bytes of the tree file it is supposed to be an
/// `@embedFile` of.
///
/// The pair is what makes "a step is a SQL file, period" checkable. Counting
/// steps proves only that the chain is the right LENGTH; comparing these two
/// byte strings proves each link is the frozen file the previous release can be
/// diffed against, so inline SQL, a reordered chain and an edited file all fail.
const ChainStep = struct {
embedded: []const u8,
/// The tree's `src/storage/migrations/v<from>.sql`, or null when that file
/// does not exist.
on_disk: ?[]const u8,
};
/// Everything the gates judge: the tree's migration metadata, the previous
/// release's, whether the schema text moved, what became of the files the
/// previous release froze, and the changelog section for this version.
const GateInput = struct {
ddl_changed: bool,
current_version: i32,
minimum_version: i32,
legacy_fingerprint: i32,
/// The chain in `step_sql` order: `chain[i]` migrates
/// `minimum_version + i` to `+ i + 1`.
chain: []const ChainStep,
prev_version: i32,
prev_minimum: i32,
/// One entry per step file and fixture file the PREVIOUS tag shipped.
shipped: []const ShippedFile,
/// The versions in the tree that have BOTH halves of a fixture pair.
fixture_versions: []const i32,
/// The `## [<version>]` section, or empty when CHANGELOG.md could not be
/// read — which fails every rule that needs a disclosure, on purpose.
changelog_section: []const u8,
};
/// Which lane, if any, a schema text change is released under.
const Gate1 = enum {
/// The DDL is byte-identical to the previous release's, so this gate has
/// nothing to say. Gate 2 still runs.
unchanged,
migration_lane,
break_lane,
/// The schema moved under neither lane. This is the v0.0.9 failure.
no_lane,
};
/// The metadata a release can only have by being an explicit break: a new
/// version, no way back from the previous one, and a changelog that says so and
/// says how to recover.
fn isExplicitBreak(in: GateInput) bool {
return in.current_version > in.prev_version and
in.minimum_version == in.current_version and
disclosesHistoryReset(in.changelog_section) and
disclosesRestoreInstructions(in.changelog_section);
}
fn gate1(in: GateInput) Gate1 {
if (!in.ddl_changed) return .unchanged;
// `prev_minimum <= prev_version` is what makes the previous release's files
// reachable. An explicit break sets `minimum == current > prev_version`, so
// it fails this test and can never wear the migration lane.
const chain_spans_range = in.chain.len == stepsBetween(in.minimum_version, in.current_version);
if (in.current_version > in.prev_version and
in.prev_version >= in.minimum_version and
chain_spans_range) return .migration_lane;
if (isExplicitBreak(in)) return .break_lane;
return .no_lane;
}
/// How many steps a contiguous chain from `from` to `to` has. Zero when the
/// range is empty or inverted, so a regressed version cannot produce a negative
/// count that would wrap.
fn stepsBetween(from: i32, to: i32) usize {
if (to <= from) return 0;
return @intCast(to - from);
}
/// Everything Gate 2 refuses. It runs whether or not the DDL moved: a
/// data-only migration and an edit to a released step file both leave the
/// schema text alone.
const Gate2Reason = enum {
step_edited,
step_missing,
step_has_no_file,
step_not_its_file,
fixture_edited,
fixture_missing,
fixture_pair_absent,
legacy_fingerprint_edited,
version_regressed,
minimum_regressed,
minimum_raised_without_break,
bump_without_step_or_break,
migration_undisclosed,
};
const Gate2Problem = struct {
reason: Gate2Reason,
/// The file or version the reason is about, for the message. Empty when the
/// reason is about the metadata as a whole.
subject: []const u8 = "",
};
/// The literal `querylog_versions.legacy_fingerprint` is frozen forever:
/// editing it strands every 0.0.12/0.0.13 file that has not yet been opened by
/// a migration-aware build. The gate holds the same number the module does.
const frozen_legacy_fingerprint: i32 = 1975011655;
fn gate2(arena: Allocator, in: GateInput) ?Gate2Problem {
if (in.legacy_fingerprint != frozen_legacy_fingerprint) {
return .{ .reason = .legacy_fingerprint_edited };
}
for (in.shipped) |file| {
const reason: ?Gate2Reason = switch (file.status) {
.identical => null,
.differs => switch (file.kind) {
.step => .step_edited,
.fixture => .fixture_edited,
},
.missing => switch (file.kind) {
.step => .step_missing,
.fixture => .fixture_missing,
},
};
if (reason) |r| return .{ .reason = r, .subject = file.path };
}
// Every link of the chain is the frozen file at its own index. The path is
// computed here rather than taken from the input, so a step can only clear
// this rule by being the `@embedFile` of the one file the next release will
// byte-compare against its predecessor.
for (in.chain, 0..) |step, index| {
const from = in.minimum_version + @as(i32, @intCast(index));
const path = std.fmt.allocPrint(arena, "{s}/v{d}.sql", .{ migrations_dir, from }) catch @panic("OOM");
const on_disk = step.on_disk orelse return .{ .reason = .step_has_no_file, .subject = path };
if (!std.mem.eql(u8, on_disk, step.embedded)) {
return .{ .reason = .step_not_its_file, .subject = path };
}
}
var version = in.minimum_version;
while (version <= in.current_version) : (version += 1) {
if (std.mem.indexOfScalar(i32, in.fixture_versions, version) == null) {
return .{
.reason = .fixture_pair_absent,
.subject = std.fmt.allocPrint(arena, "{d}", .{version}) catch @panic("OOM"),
};
}
}
if (in.current_version < in.prev_version) return .{ .reason = .version_regressed };
if (in.minimum_version < in.prev_minimum) return .{ .reason = .minimum_regressed };
// Raising the minimum drops support for schemas the previous release
// carried. That is allowed exactly once per break and never quietly, and
// the DDL fingerprint has no say in it — a break can leave the text alone.
if (in.minimum_version > in.prev_minimum and !isExplicitBreak(in)) {
return .{ .reason = .minimum_raised_without_break };
}
if (in.current_version > in.prev_version) {
const new_steps = in.chain.len > stepsBetween(in.prev_minimum, in.prev_version);
const a_break = in.minimum_version == in.current_version;
if (!new_steps and !a_break) return .{ .reason = .bump_without_step_or_break };
// A break discloses under Gate 1's break lane instead: its history does
// not migrate, it is thrown away.
if (new_steps and !a_break and !disclosesMigration(in.changelog_section)) {
return .{ .reason = .migration_undisclosed };
}
}
return null;
}
/// The file whose DDL decides what shape `querylog.db` has.
const querylog_schema_path = "src/storage/querylog_schema.zig";
/// The file whose constants decide whether an existing `querylog.db` survives
/// the upgrade, and how.
const querylog_versions_path = "src/storage/querylog_versions.zig";
const migrations_dir = "src/storage/migrations";
const fixtures_dir = "src/storage/testdata";
/// A `pub const <name>: i32 = <literal>;` out of any revision of
/// `querylog_versions.zig`, read as text for the same reason `extractDdl` reads
/// the DDL as text: the previous release's copy only exists as `git show`
/// output. Null when the declaration is absent or is not a plain literal, which
/// is a refusal rather than a default — guessing a version would let a gate
/// pass a release it never measured.
fn extractVersionConst(file_text: []const u8, name: []const u8) ?i32 {
var lines = std.mem.splitScalar(u8, file_text, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, std.mem.trimEnd(u8, raw, "\r"), " \t");
var prefix_buf: [64]u8 = undefined;
const prefix = std.fmt.bufPrint(&prefix_buf, "pub const {s}: i32 = ", .{name}) catch return null;
if (!std.mem.startsWith(u8, line, prefix)) continue;
const rest = line[prefix.len..];
const end = std.mem.indexOfScalar(u8, rest, ';') orelse return null;
var digits: [32]u8 = undefined;
var len: usize = 0;
for (std.mem.trim(u8, rest[0..end], " \t")) |ch| {
if (ch == '_') continue;
if (len == digits.len) return null;
digits[len] = ch;
len += 1;
}
return std.fmt.parseInt(i32, digits[0..len], 10) catch null;
}
return null;
}
/// The version a fixture path names, for either half of a pair. Null for any
/// name that is not one, so an unrelated file in `testdata/` is ignored rather
/// than parsed into a version that does not exist.
fn fixtureVersionOf(name: []const u8) ?i32 {
const prefix = "querylog-v";
if (!std.mem.startsWith(u8, name, prefix)) return null;
const rest = name[prefix.len..];
const dash = std.mem.indexOfScalar(u8, rest, '-') orelse return null;
const suffix = rest[dash..];
if (!std.mem.eql(u8, suffix, "-schema.sql") and !std.mem.eql(u8, suffix, "-data.sql")) return null;
return std.fmt.parseInt(i32, rest[0..dash], 10) catch null;
}
/// The declaration line the DDL follows, matched whole so no other `ddl` in the
/// file can be mistaken for it.
const ddl_declaration = "pub const ddl: [:0]const u8 =";
@@ -1561,22 +1842,26 @@ fn preflight(ctx: *Ctx, version: []const u8, bump_needed: bool, plan: Plan) !Pre
return result;
}
/// Refuses a release that changes the querylog schema without saying so.
/// Refuses a release whose querylog schema or migration metadata moved without
/// the release saying what that costs the operator.
///
/// `querylog.db` is never migrated: the server compares the file's stamped
/// fingerprint against this build's and, on a mismatch, sets the file aside and
/// creates an empty one. Every query the operator ever logged is gone on the
/// first start after the upgrade. v0.0.9 shipped exactly that while its
/// announcement claimed no such change, which is what this check exists to stop.
/// TWO INDEPENDENT GATES, both measured against the previous release TAG rather
/// than the last commit, because the tag is what an operator upgrades from.
///
/// The comparison is between the DDL of the previous release tag and this
/// tree's, so it measures the release, not the last commit. Every step that can
/// fail — listing the tags, reading the old file, parsing it — is a refusal
/// naming the step: a gate that cannot tell whether the schema moved must not
/// report that it did not.
/// Gate 1 is about the schema TEXT. A changed DDL has to be released under one
/// of exactly two lanes: a migration that carries the file forward, or an
/// explicit break that throws the history away and says how to get it back.
/// v0.0.9 shipped a silent break while its announcement claimed no such change,
/// which is what this gate exists to stop.
///
/// Gate 2 is about the migration METADATA, and it runs whether or not the text
/// moved: a data-only migration, an edit to a step that has already shipped, an
/// edited fixture and a quietly raised minimum all leave the DDL alone.
///
/// Every step that can fail — listing the tags, reading the old files, parsing
/// them — is a refusal naming the step. A gate that cannot tell whether
/// something moved must not report that it did not.
fn schemaGate(ctx: *Ctx, version: []const u8, target: Semver, changelog: ?[]const u8) !void {
const current = querylog_schema.fingerprint;
const tags = try gitCapture(ctx, &.{ "git", "ls-remote", "--tags", "origin" }, git_network_timeout_s);
if (!tags.ok()) {
ctx.soft("schema-gate", "`git ls-remote --tags origin` exited {d}: {s}", .{
@@ -1611,33 +1896,184 @@ fn schemaGate(ctx: *Ctx, version: []const u8, target: Semver, changelog: ?[]cons
});
return;
};
const old = querylog_schema.fingerprintOf(old_ddl);
const old_fingerprint = querylog_schema.fingerprintOf(old_ddl);
const current_fingerprint = querylog_schema.fingerprint;
if (old == current) {
ctx.pass("schema-gate", "the querylog schema is unchanged since {s} (fingerprint {d})", .{ previous_tag, current });
return;
// The previous release's metadata. `querylog_versions.zig` did not exist
// before milestone 38, and every file such a release created is a version-1
// file — that is what the legacy fingerprint stands for — so an absent
// module is 1 and 1 rather than a refusal. The object itself is known good
// by now: the DDL above came out of it.
var prev_version: i32 = 1;
var prev_minimum: i32 = 1;
const old_versions = try gitCapture(ctx, &.{
"git", "show", ctx.fmt("{s}:{s}", .{ previous.object, querylog_versions_path }),
}, git_local_timeout_s);
if (old_versions.ok()) {
prev_version = extractVersionConst(old_versions.stdout, "current_version") orelse {
ctx.soft("schema-gate", "cannot read `current_version` out of {s}:{s} ({s})", .{
previous.object, querylog_versions_path, previous_tag,
});
return;
};
prev_minimum = extractVersionConst(old_versions.stdout, "minimum_supported_version") orelse {
ctx.soft("schema-gate", "cannot read `minimum_supported_version` out of {s}:{s} ({s})", .{
previous.object, querylog_versions_path, previous_tag,
});
return;
};
} else {
ctx.note("schema-gate: {s} predates {s}, so it is read as schema version 1", .{
previous_tag, querylog_versions_path,
});
}
const source = changelog orelse {
const shipped = frozenFiles(ctx, previous.object, previous_tag) catch |err| switch (err) {
error.CheckFailed => return,
else => return err,
};
const in: GateInput = .{
.ddl_changed = old_fingerprint != current_fingerprint,
.current_version = querylog_versions.current,
.minimum_version = querylog_versions.minimum,
.legacy_fingerprint = querylog_versions.legacy_fingerprint,
.chain = treeChain(ctx),
.prev_version = prev_version,
.prev_minimum = prev_minimum,
.shipped = shipped,
.fixture_versions = treeFixtureVersions(ctx),
.changelog_section = if (changelog) |source| changelogSection(source, version) orelse "" else "",
};
if (changelog == null) {
// The changelog check already reported why it could not be read; this
// reports what that costs, because the gate has no way to clear itself.
ctx.soft("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and CHANGELOG.md could not be read to check the disclosure", .{
previous_tag, old, current,
// reports what that costs, because neither gate can clear itself
// without the disclosure it is looking for.
ctx.soft("schema-gate", "CHANGELOG.md could not be read, so no disclosure can be checked", .{});
}
switch (gate1(in)) {
.unchanged => ctx.pass("schema-gate", "the querylog schema is unchanged since {s} (fingerprint {d})", .{
previous_tag, current_fingerprint,
}),
.migration_lane => ctx.pass("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and schema version {d} migrates to {d} in place", .{
previous_tag, old_fingerprint, current_fingerprint, prev_version, in.current_version,
}),
.break_lane => ctx.pass("schema-gate", "the querylog schema changed since {s} ({d} to {d}) as an explicit break to schema version {d}, and the `## [{s}]` section says so and says how to recover", .{
previous_tag, old_fingerprint, current_fingerprint, in.current_version, version,
}),
.no_lane => ctx.soft(
"schema-gate",
"the querylog schema changed since {s} ({d} to {d}) under neither lane. Either ship a migration (raise `current_version` above {d}, keeping `minimum_supported_version` at or below it, with a step per version) or declare an explicit break (`minimum_supported_version == current_version`) and give the `## [{s}]` section both the phrase '{s}' and a `{s}` section with recovery steps",
.{ previous_tag, old_fingerprint, current_fingerprint, prev_version, version, history_reset_phrase, restore_heading },
),
}
const problem = gate2(ctx.arena, in) orelse {
ctx.pass("schema-gate-metadata", "the migration metadata is consistent with {s}: schema versions {d}..{d}, {d} step(s), every released step and fixture untouched", .{
previous_tag, in.minimum_version, in.current_version, in.chain.len,
});
return;
};
const section = changelogSection(source, version) orelse "";
if (!disclosesHistoryReset(section)) {
ctx.soft(
"schema-gate",
"the querylog schema changed since {s} ({d} to {d}), so the first start after this release sets querylog.db aside and creates an empty one; say so in the `## [{s}]` section, which must contain the phrase '{s}'",
.{ previous_tag, old, current, version, history_reset_phrase },
);
return;
switch (problem.reason) {
.step_edited => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and this tree changes it; a released migration step is immutable, so add a new step instead", .{ problem.subject, previous_tag }),
.step_missing => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and is gone from this tree; a released migration step is immutable and every operator still below its target needs it", .{ problem.subject, previous_tag }),
.step_has_no_file => ctx.soft("schema-gate-metadata", "step {s} of the chain has no `{s}`; a step is a SQL file and nothing else, so inline SQL leaves the next release nothing to byte-compare and no operator a way to audit what ran", .{ problem.subject, problem.subject }),
.step_not_its_file => ctx.soft("schema-gate-metadata", "the chain's bytes for `{s}` are not that file's bytes; every step is the `@embedFile` of its own `v<from>.sql`, so rebuild the chain from the files rather than editing one side of the pair", .{problem.subject}),
.fixture_edited => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and this tree changes it; a released fixture is the file the next migration is proved against, so a new schema version ships a NEW pair", .{ problem.subject, previous_tag }),
.fixture_missing => ctx.soft("schema-gate-metadata", "`{s}` shipped in {s} and is gone from this tree; a released fixture is immutable", .{ problem.subject, previous_tag }),
.fixture_pair_absent => ctx.soft("schema-gate-metadata", "schema version {s} is supported but has no `{s}/querylog-v{s}-schema.sql` and `-data.sql` pair; every version in {d}..{d} needs one", .{ problem.subject, fixtures_dir, problem.subject, in.minimum_version, in.current_version }),
.legacy_fingerprint_edited => ctx.soft("schema-gate-metadata", "`legacy_fingerprint` is {d}, not the frozen {d}; it is the literal stamp the 0.0.12 and 0.0.13 binaries wrote, and changing it strands every such file that no migration-aware build has opened yet", .{ in.legacy_fingerprint, frozen_legacy_fingerprint }),
.version_regressed => ctx.soft("schema-gate-metadata", "`current_version` is {d} and {s} shipped {d}; the schema version never regresses", .{ in.current_version, previous_tag, prev_version }),
.minimum_regressed => ctx.soft("schema-gate-metadata", "`minimum_supported_version` is {d} and {s} shipped {d}; this build claims to migrate files the previous one could not, with no step to do it", .{ in.minimum_version, previous_tag, prev_minimum }),
.minimum_raised_without_break => ctx.soft("schema-gate-metadata", "`minimum_supported_version` rises from {d} to {d}, which drops support for schemas {s} could open. That is only releasable as the full explicit break: `minimum_supported_version == current_version`, a `current_version` above {d}, and a `## [{s}]` section carrying both '{s}' and a `{s}` section", .{ prev_minimum, in.minimum_version, previous_tag, prev_version, version, history_reset_phrase, restore_heading }),
.bump_without_step_or_break => ctx.soft("schema-gate-metadata", "`current_version` rises from {d} to {d} with no new step file and no explicit break; a version an operator's file cannot reach and is not refused for is a silent reset", .{ prev_version, in.current_version }),
.migration_undisclosed => ctx.soft("schema-gate-metadata", "this release migrates querylog.db from schema version {d} to {d}, so the `## [{s}]` section must contain the phrase '{s}'", .{ prev_version, in.current_version, version, migration_phrase }),
}
ctx.pass("schema-gate", "the querylog schema changed since {s} ({d} to {d}) and the `## [{s}]` section discloses it", .{
previous_tag, old, current, version,
});
}
/// The step and fixture files the previous tag froze, each paired with what
/// this tree did to it.
///
/// `git ls-tree` lists the tag's side; the tree's side is read off disk,
/// because a fixture added in this working copy is not in any index yet.
fn frozenFiles(ctx: *Ctx, object: []const u8, previous_tag: []const u8) ![]const ShippedFile {
const listing = try gitCapture(ctx, &.{
"git", "ls-tree", "-r", "--name-only", object, "--", migrations_dir, fixtures_dir,
}, git_local_timeout_s);
if (!listing.ok()) {
ctx.soft("schema-gate-metadata", "`git ls-tree {s}` for {s} exited {d}: {s}", .{
object, previous_tag, listing.code, std.mem.trimEnd(u8, listing.combined(ctx.arena), "\n"),
});
return CheckFailed;
}
var files: std.ArrayList(ShippedFile) = .empty;
var lines = std.mem.splitScalar(u8, listing.stdout, '\n');
while (lines.next()) |raw| {
const path = std.mem.trim(u8, raw, " \t\r");
if (path.len == 0) continue;
const kind: @FieldType(ShippedFile, "kind") = if (std.mem.startsWith(u8, path, migrations_dir ++ "/"))
.step
else if (fixtureVersionOf(std.fs.path.basename(path)) != null)
.fixture
else
// Anything else under `testdata/` belongs to some other test and
// carries no immutability promise.
continue;
const released = try gitCapture(ctx, &.{
"git", "show", ctx.fmt("{s}:{s}", .{ object, path }),
}, git_local_timeout_s);
if (!released.ok()) {
ctx.soft("schema-gate-metadata", "`git show {s}:{s}` exited {d}: {s}", .{
object, path, released.code, std.mem.trimEnd(u8, released.combined(ctx.arena), "\n"),
});
return CheckFailed;
}
const status: @FieldType(ShippedFile, "status") = blk: {
const current = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch
break :blk .missing;
break :blk if (std.mem.eql(u8, current, released.stdout)) .identical else .differs;
};
files.append(ctx.arena, .{ .kind = kind, .path = path, .status = status }) catch @panic("OOM");
}
return files.items;
}
/// The chain this build embedded, each step paired with the tree file it claims
/// to be. Reading the file is all this does; whether the two agree is Gate 2's
/// rule, and an unreadable file reads as absent so that the gate names the step
/// rather than the syscall.
fn treeChain(ctx: *Ctx) []const ChainStep {
var chain: std.ArrayList(ChainStep) = .empty;
for (querylog_versions.step_sql, 0..) |embedded, index| {
const from = querylog_versions.minimum + @as(i32, @intCast(index));
const path = ctx.fmt("{s}/v{d}.sql", .{ migrations_dir, from });
const on_disk = Io.Dir.cwd().readFileAlloc(ctx.io, path, ctx.arena, .limited(max_input_bytes)) catch null;
chain.append(ctx.arena, .{ .embedded = embedded, .on_disk = on_disk }) catch @panic("OOM");
}
return chain.items;
}
/// The versions this tree has BOTH halves of a fixture pair for, over the range
/// the metadata claims to support. Probing the range beats listing the
/// directory: the range is what the rule is about, and a stray `querylog-v9-`
/// file for some unsupported version proves nothing either way.
fn treeFixtureVersions(ctx: *Ctx) []const i32 {
var found: std.ArrayList(i32) = .empty;
var version = querylog_versions.minimum;
while (version <= querylog_versions.current) : (version += 1) {
const schema = ctx.fmt("{s}/querylog-v{d}-schema.sql", .{ fixtures_dir, version });
const data = ctx.fmt("{s}/querylog-v{d}-data.sql", .{ fixtures_dir, version });
_ = Io.Dir.cwd().readFileAlloc(ctx.io, schema, ctx.arena, .limited(max_input_bytes)) catch continue;
_ = Io.Dir.cwd().readFileAlloc(ctx.io, data, ctx.arena, .limited(max_input_bytes)) catch continue;
found.append(ctx.arena, version) catch @panic("OOM");
}
return found.items;
}
/// What to do about a `v<version>` tag that exists locally.
@@ -2673,3 +3109,360 @@ test "a published release is only reported from a payload that carries one" {
// A missing tag_name yields the empty string, which never equals a tag.
try testing.expectEqualStrings("", jsonString(no_assets.object, "id"));
}
// ---------------------------------------------------------------------------
// the two migration gates
// ---------------------------------------------------------------------------
/// A release with nothing to declare: the schema is unchanged, the metadata is
/// the previous release's, and every frozen file is where it was. Each test
/// below changes exactly the fields its rule is about, so what it is testing is
/// what it names.
fn baseGateInput() GateInput {
return .{
.ddl_changed = false,
.current_version = 1,
.minimum_version = 1,
.legacy_fingerprint = frozen_legacy_fingerprint,
.chain = &.{},
.prev_version = 1,
.prev_minimum = 1,
.shipped = &.{},
.fixture_versions = &.{1},
.changelog_section = "",
};
}
/// Two steps of plausible SQL, and the chains a correctly authored release
/// carries them in: the embedded bytes ARE the file's bytes.
const step_v1_sql = "ALTER TABLE domains RENAME TO domains_old;\n";
const step_v2_sql = "DROP VIEW recent_queries;\n";
const one_frozen_step: []const ChainStep = &.{
.{ .embedded = step_v1_sql, .on_disk = step_v1_sql },
};
const two_frozen_steps: []const ChainStep = &.{
.{ .embedded = step_v1_sql, .on_disk = step_v1_sql },
.{ .embedded = step_v2_sql, .on_disk = step_v2_sql },
};
const migration_section = "This release " ++ migration_phrase ++ ", so nothing is lost.\n";
const break_section = "This release " ++ history_reset_phrase ++ ".\n\n" ++
restore_heading ++ "\n\nStop the server and move the aside file back.\n";
/// A release that migrates schema version 1 to 2: one new step, one new fixture
/// pair, and the changelog phrase that discloses it.
fn migratingGateInput() GateInput {
var in = baseGateInput();
in.ddl_changed = true;
in.current_version = 2;
in.chain = one_frozen_step;
in.fixture_versions = &.{ 1, 2 };
in.changelog_section = migration_section;
return in;
}
/// A release that abandons schema version 1 instead of migrating it.
fn breakingGateInput() GateInput {
var in = baseGateInput();
in.ddl_changed = true;
in.current_version = 2;
in.minimum_version = 2;
in.chain = &.{};
in.fixture_versions = &.{2};
in.changelog_section = break_section;
return in;
}
fn expectGate2(in: GateInput, expected: ?Gate2Reason) !void {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const problem = gate2(arena_state.allocator(), in);
if (expected) |reason| {
try testing.expectEqual(reason, (problem orelse return error.GatePassed).reason);
} else {
if (problem) |actual| {
std.debug.print("unexpected gate 2 failure: {t} ({s})\n", .{ actual.reason, actual.subject });
return error.GateFailed;
}
}
}
test "a release that touches neither the schema nor the metadata passes both gates" {
const in = baseGateInput();
try testing.expectEqual(Gate1.unchanged, gate1(in));
try expectGate2(in, null);
}
test "a migration is released under the migration lane" {
const in = migratingGateInput();
try testing.expectEqual(Gate1.migration_lane, gate1(in));
try expectGate2(in, null);
}
test "an explicit break is released under the break lane" {
const in = breakingGateInput();
try testing.expectEqual(Gate1.break_lane, gate1(in));
try expectGate2(in, null);
}
test "a schema change under neither lane is refused" {
var in = baseGateInput();
in.ddl_changed = true;
// The v0.0.9 shape exactly: the DDL moved and nothing else did.
try testing.expectEqual(Gate1.no_lane, gate1(in));
}
test "break metadata cannot be released as a migration" {
var in = breakingGateInput();
// `minimum == current` means the previous release's files cannot reach the
// new version at all. Saying they migrate does not make them.
in.changelog_section = migration_section;
try testing.expectEqual(Gate1.no_lane, gate1(in));
}
test "a version bump whose chain does not span the supported range is refused" {
var in = migratingGateInput();
in.current_version = 3;
in.fixture_versions = &.{ 1, 2, 3 };
// One step cannot carry a file from 1 to 3.
try testing.expectEqual(Gate1.no_lane, gate1(in));
}
test "an edited or deleted released step is refused however the version moved" {
const path = migrations_dir ++ "/v1.sql";
for ([_]@FieldType(ShippedFile, "status"){ .differs, .missing }) |status| {
var in = migratingGateInput();
// A perfectly well-formed version append, which is exactly the case
// that must not launder an edit to a step already in operators' hands.
in.current_version = 3;
in.chain = two_frozen_steps;
in.fixture_versions = &.{ 1, 2, 3 };
in.shipped = &.{.{ .kind = .step, .path = path, .status = status }};
try testing.expectEqual(Gate1.migration_lane, gate1(in));
try expectGate2(in, if (status == .differs) .step_edited else .step_missing);
}
}
test "every step of the chain must be the frozen file at its own index" {
// Matching bytes are the whole rule, so start by proving they pass.
const frozen = migratingGateInput();
try expectGate2(frozen, null);
// A step written inline, with no `v1.sql` for the next release to compare
// against. Counting steps calls this chain complete; the byte comparison
// does not.
var inline_only = migratingGateInput();
inline_only.chain = &.{.{ .embedded = step_v1_sql, .on_disk = null }};
try testing.expectEqual(Gate1.migration_lane, gate1(inline_only));
try expectGate2(inline_only, .step_has_no_file);
// The file edited after the fact, so the binary runs SQL the audited file no
// longer contains.
var edited = migratingGateInput();
edited.chain = &.{.{ .embedded = step_v1_sql, .on_disk = step_v1_sql ++ "DROP TABLE domains;\n" }};
try expectGate2(edited, .step_not_its_file);
// And a chain listing its files out of order: index 0 must be `v1.sql`.
var reordered = migratingGateInput();
reordered.current_version = 3;
reordered.fixture_versions = &.{ 1, 2, 3 };
reordered.chain = &.{
.{ .embedded = step_v2_sql, .on_disk = step_v1_sql },
.{ .embedded = step_v1_sql, .on_disk = step_v2_sql },
};
try expectGate2(reordered, .step_not_its_file);
}
test "an edited or deleted released fixture is refused" {
const path = fixtures_dir ++ "/querylog-v1-data.sql";
for ([_]@FieldType(ShippedFile, "status"){ .differs, .missing }) |status| {
var in = migratingGateInput();
in.shipped = &.{.{ .kind = .fixture, .path = path, .status = status }};
try expectGate2(in, if (status == .differs) .fixture_edited else .fixture_missing);
}
}
test "a supported version with no fixture pair is refused" {
var in = migratingGateInput();
// The starting fixture is there; the version being released has none, so
// the migration it ships was never proved to land anywhere.
in.fixture_versions = &.{1};
try expectGate2(in, .fixture_pair_absent);
}
test "the schema version never regresses" {
var in = baseGateInput();
in.prev_version = 3;
in.prev_minimum = 1;
in.fixture_versions = &.{1};
try expectGate2(in, .version_regressed);
}
test "a version bump with neither a step nor a break is refused" {
var in = baseGateInput();
in.current_version = 2;
in.fixture_versions = &.{ 1, 2 };
in.changelog_section = migration_section;
try expectGate2(in, .bump_without_step_or_break);
}
test "a data-only migration must disclose itself even though the schema text held still" {
var in = migratingGateInput();
in.ddl_changed = false;
in.changelog_section = "";
// Gate 1 has nothing to say, which is the whole reason Gate 2 runs
// independently of it.
try testing.expectEqual(Gate1.unchanged, gate1(in));
try expectGate2(in, .migration_undisclosed);
in.changelog_section = migration_section;
try expectGate2(in, null);
}
test "the supported minimum never regresses" {
var in = baseGateInput();
in.prev_minimum = 2;
in.minimum_version = 1;
in.current_version = 2;
in.prev_version = 2;
in.fixture_versions = &.{ 1, 2 };
try expectGate2(in, .minimum_regressed);
}
test "raising the minimum is only releasable as the full explicit break" {
// Dropping support for a schema is the one change that silently discards an
// operator's history, so every half-measure below is refused — including the
// one where the schema text did not move at all.
var partial = breakingGateInput();
partial.changelog_section = migration_section;
try expectGate2(partial, .minimum_raised_without_break);
var no_heading = breakingGateInput();
no_heading.changelog_section = "This release " ++ history_reset_phrase ++ ".\n";
try expectGate2(no_heading, .minimum_raised_without_break);
var empty_heading = breakingGateInput();
empty_heading.changelog_section = "This release " ++ history_reset_phrase ++ ".\n\n" ++
restore_heading ++ "\n\n## [0.0.1] - 2020-01-01\n";
try expectGate2(empty_heading, .minimum_raised_without_break);
var same_version = breakingGateInput();
same_version.current_version = 1;
same_version.minimum_version = 1;
same_version.prev_minimum = 0;
same_version.fixture_versions = &.{1};
try expectGate2(same_version, .minimum_raised_without_break);
var unchanged_ddl = breakingGateInput();
unchanged_ddl.ddl_changed = false;
unchanged_ddl.changelog_section = migration_section;
try expectGate2(unchanged_ddl, .minimum_raised_without_break);
}
test "the legacy fingerprint is frozen" {
// Recomputing the anchor from a later DDL is the plausible way it gets
// edited, so the substitute is any other CRC-shaped number.
var in = baseGateInput();
in.legacy_fingerprint = 603440875;
try expectGate2(in, .legacy_fingerprint_edited);
// And the tree's own constant is the frozen one, which is what makes the
// rule above a check on this repository rather than on its own literal.
try testing.expectEqual(frozen_legacy_fingerprint, querylog_versions.legacy_fingerprint);
// The DDL has not moved since 0.0.12, so today the anchor and the schema
// fingerprint are the same number. They are not the same THING: the anchor
// is frozen at that value forever, and the fingerprint follows the schema.
try testing.expectEqual(frozen_legacy_fingerprint, querylog_schema.fingerprint);
}
test "this tree passes both gates against itself" {
// The state every cut starts from: nothing moved since the previous
// release. A tree that cannot pass this has a metadata bug, not a
// disclosure one.
var in = baseGateInput();
in.current_version = querylog_versions.current;
in.minimum_version = querylog_versions.minimum;
in.legacy_fingerprint = querylog_versions.legacy_fingerprint;
in.prev_version = querylog_versions.current;
in.prev_minimum = querylog_versions.minimum;
// The tree's own chain, each step paired with itself: reading the file off
// disk is `treeChain`'s job and needs an `Io` this test has no business
// holding. What this covers is the metadata — the chain's LENGTH against the
// supported range — which is the part a self-test can judge.
var chain: std.ArrayList(ChainStep) = .empty;
defer chain.deinit(testing.allocator);
for (querylog_versions.step_sql) |sql| {
try chain.append(testing.allocator, .{ .embedded = sql, .on_disk = sql });
}
in.chain = chain.items;
var versions: std.ArrayList(i32) = .empty;
defer versions.deinit(testing.allocator);
var version = querylog_versions.minimum;
while (version <= querylog_versions.current) : (version += 1) {
try versions.append(testing.allocator, version);
}
in.fixture_versions = versions.items;
try testing.expectEqual(Gate1.unchanged, gate1(in));
try expectGate2(in, null);
}
test "a previous tag without the versions module reads as schema version 1" {
// What `git show <old tag>:src/storage/querylog_versions.zig` hands back is
// nothing at all, and the driver answers 1 and 1 — every file such a release
// created is a version-1 file, which is what the legacy fingerprint stands
// for. This proves the extractor does not invent a number from a file that
// has no such declaration.
try testing.expect(extractVersionConst("pub const ddl = \"\";\n", "current_version") == null);
try testing.expect(extractVersionConst("", "minimum_supported_version") == null);
const in = baseGateInput();
try testing.expectEqual(@as(i32, 1), in.prev_version);
try testing.expectEqual(@as(i32, 1), in.prev_minimum);
try testing.expectEqual(Gate1.unchanged, gate1(in));
try expectGate2(in, null);
}
test "the version constants of the file on disk are the ones the gate compiled" {
// The same round trip the DDL extractor gets: `git show` will hand this
// text to `extractVersionConst`, so the parse has to agree with the
// compiler on the file it can check.
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const source = try Io.Dir.cwd().readFileAlloc(
threaded.io(),
querylog_versions_path,
arena_state.allocator(),
.limited(max_input_bytes),
);
try testing.expectEqual(querylog_versions.current, extractVersionConst(source, "current_version").?);
try testing.expectEqual(querylog_versions.minimum, extractVersionConst(source, "minimum_supported_version").?);
try testing.expectEqual(
querylog_versions.legacy_fingerprint,
extractVersionConst(source, "legacy_fingerprint").?,
);
}
test "a fixture name yields its version, and nothing else does" {
try testing.expectEqual(@as(i32, 1), fixtureVersionOf("querylog-v1-schema.sql").?);
try testing.expectEqual(@as(i32, 12), fixtureVersionOf("querylog-v12-data.sql").?);
try testing.expect(fixtureVersionOf("querylog-v1-notes.sql") == null);
try testing.expect(fixtureVersionOf("querylog-schema.sql") == null);
try testing.expect(fixtureVersionOf("config-v1-schema.sql") == null);
try testing.expect(fixtureVersionOf("querylog-vx-data.sql") == null);
}
test "restore instructions need a heading and something under it" {
try testing.expect(disclosesRestoreInstructions(break_section));
try testing.expect(!disclosesRestoreInstructions(restore_heading ++ "\n\n"));
try testing.expect(!disclosesRestoreInstructions(restore_heading ++ "\n\n### Something else\nbody\n"));
try testing.expect(!disclosesRestoreInstructions("### Restoring\nbody\n"));
try testing.expect(disclosesRestoreInstructions("intro\n" ++ restore_heading ++ "\n- move it back\n"));
}