diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a96a5..8288be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does. +## [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. diff --git a/admin/package-lock.json b/admin/package-lock.json index 75029fb..89288df 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -8,7 +8,7 @@ "name": "nxdns-admin", "version": "0.0.0", "dependencies": { - "@phosphor-icons/react": "^2.1.10", + "@phosphor-icons/react": "2.1.10", "@stylexjs/stylex": "0.19.0", "@tanstack/react-query": "5.101.4", "@tanstack/react-router": "1.170.18", diff --git a/admin/package.json b/admin/package.json index 6757e25..a8729b8 100644 --- a/admin/package.json +++ b/admin/package.json @@ -25,7 +25,7 @@ "trailingComma": "all" }, "dependencies": { - "@phosphor-icons/react": "^2.1.10", + "@phosphor-icons/react": "2.1.10", "@stylexjs/stylex": "0.19.0", "@tanstack/react-query": "5.101.4", "@tanstack/react-router": "1.170.18", diff --git a/admin/src/features/activity/ActivityDetailPage.test.tsx b/admin/src/features/activity/ActivityDetailPage.test.tsx index 5b7b862..cca4c1f 100644 --- a/admin/src/features/activity/ActivityDetailPage.test.tsx +++ b/admin/src/features/activity/ActivityDetailPage.test.tsx @@ -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,7 +307,7 @@ 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", }); diff --git a/admin/src/features/activity/ActivityDetailPage.tsx b/admin/src/features/activity/ActivityDetailPage.tsx index 817d5dd..241eb4c 100644 --- a/admin/src/features/activity/ActivityDetailPage.tsx +++ b/admin/src/features/activity/ActivityDetailPage.tsx @@ -1,3 +1,4 @@ +import { ArrowLeft } from "@phosphor-icons/react"; 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 ( - ← Activity + + Activity ); } diff --git a/admin/src/features/activity/ActivityFilters.test.tsx b/admin/src/features/activity/ActivityFilters.test.tsx index e7c74d6..66e2877 100644 --- a/admin/src/features/activity/ActivityFilters.test.tsx +++ b/admin/src/features/activity/ActivityFilters.test.tsx @@ -157,7 +157,7 @@ function clientChips(): string[] { return within(screen.getByRole("group", { name: "Clients" })) .getAllByRole("button") .slice(1) - .map((chip) => chip.textContent?.replace("\u00d7", "") ?? ""); + .map((chip) => chip.textContent ?? ""); } /** @@ -368,7 +368,7 @@ test("the domain field is search-shaped, unspellchecked, and labelled without a expect(domain.getAttribute("autocomplete")).toBe("off"); expect(domain.getAttribute("placeholder")).toBe("Filter domains…"); // The magnifier is decoration over the field, never a second thing to read. - expect(document.querySelector("svg[aria-hidden='true']")).toBeTruthy(); + expect(domain.parentElement?.querySelector("[aria-hidden='true'] svg")).toBeTruthy(); }); test("a bound change does not reset the domain draft that is still being typed", () => { @@ -553,7 +553,7 @@ test("the menu ticks the clients the url names, not the ones last picked here", const ticked = screen .getAllByRole("menuitemcheckbox") .filter((item) => item.getAttribute("aria-checked") === "true"); - expect(ticked.map((item) => item.textContent?.replace("✓", ""))).toEqual(["laptop.lan — 192.0.2.11"]); + 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 () => { diff --git a/admin/src/features/activity/ActivityFilters.tsx b/admin/src/features/activity/ActivityFilters.tsx index 9e00932..2c520f9 100644 --- a/admin/src/features/activity/ActivityFilters.tsx +++ b/admin/src/features/activity/ActivityFilters.tsx @@ -29,6 +29,7 @@ * would promise filtering that is not happening. */ +import { MagnifyingGlass } from "@phosphor-icons/react"; import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react"; import * as stylex from "@stylexjs/stylex"; import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components"; @@ -76,6 +77,7 @@ const styles = stylex.create({ display: "flex", }, searchIcon: { + display: "inline-flex", position: "absolute", insetInlineStart: "0.5rem", top: "50%", @@ -502,19 +504,9 @@ export default function ActivityFilters({ applied, onApply, onClear }: Props) {
- + {option.label} @@ -353,7 +355,7 @@ export default function ClientFilter({ options, selected, onChange }: Props) { > {chipFor(ip, options)} ))} diff --git a/admin/src/features/clients/ClientDetailPage.test.tsx b/admin/src/features/clients/ClientDetailPage.test.tsx index 4ee6616..daa80b3 100644 --- a/admin/src/features/clients/ClientDetailPage.test.tsx +++ b/admin/src/features/clients/ClientDetailPage.test.tsx @@ -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 () => { diff --git a/admin/src/features/clients/ClientDetailPage.tsx b/admin/src/features/clients/ClientDetailPage.tsx index 4a04f34..652d179 100644 --- a/admin/src/features/clients/ClientDetailPage.tsx +++ b/admin/src/features/clients/ClientDetailPage.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { ArrowLeft } from "@phosphor-icons/react"; 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 ( - ← All clients + + All clients ); } diff --git a/admin/src/features/diagnostics/DiagnosticDetailPage.test.tsx b/admin/src/features/diagnostics/DiagnosticDetailPage.test.tsx index c30466c..d0e1053 100644 --- a/admin/src/features/diagnostics/DiagnosticDetailPage.test.tsx +++ b/admin/src/features/diagnostics/DiagnosticDetailPage.test.tsx @@ -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(); }); diff --git a/admin/src/features/diagnostics/DiagnosticDetailPage.tsx b/admin/src/features/diagnostics/DiagnosticDetailPage.tsx index 3955a8c..a7389e4 100644 --- a/admin/src/features/diagnostics/DiagnosticDetailPage.tsx +++ b/admin/src/features/diagnostics/DiagnosticDetailPage.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { ArrowLeft } from "@phosphor-icons/react"; 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 ( + + + All diagnostics + + ); +} + 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 (
- - ← All diagnostics - + void refetch()} />
); @@ -146,9 +162,7 @@ export default function DiagnosticDetailPage() { return (
- - ← All diagnostics - +

{copy.title}

diff --git a/admin/src/features/diagnostics/HealthStrip.tsx b/admin/src/features/diagnostics/HealthStrip.tsx index 61c5fbf..e0a383e 100644 --- a/admin/src/features/diagnostics/HealthStrip.tsx +++ b/admin/src/features/diagnostics/HealthStrip.tsx @@ -15,6 +15,8 @@ * a claim about the current state, until a poll succeeds again. */ +import type { ReactNode } from "react"; +import { Circle, Pause, Warning, X } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import * as stylex from "@stylexjs/stylex"; @@ -95,6 +97,9 @@ const styles = stylex.create({ danger: { color: colors.dangerText, }, + icon: { + display: "inline-flex", + }, message: { marginTop: "0.5rem", fontSize: "0.875rem", @@ -106,7 +111,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 = { ok: "●", notice: "‖", warn: "!", danger: "✕" }; +const ICONS: Record = { + ok: , + notice: , + warn: , + danger: , +}; function FactLinkAnchor({ link }: { link: FactLink }) { if (link.kind === "filter") { @@ -151,7 +161,7 @@ function FactLinkAnchor({ link }: { link: FactLink }) { function Fact({ fact }: { fact: HealthFact }) { return (
  • - {children} diff --git a/build.zig.zon b/build.zig.zon index a4cc93a..aefd13c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .nxdns, - .version = "0.0.14", + .version = "0.0.15", .minimum_zig_version = "0.16.0", .paths = .{""}, .fingerprint = 0x3307b311dded1d91, diff --git a/licenses/dependency-identity.txt b/licenses/dependency-identity.txt index 5c61c77..f173fb4 100644 --- a/licenses/dependency-identity.txt +++ b/licenses/dependency-identity.txt @@ -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 diff --git a/licenses/inventory.zon b/licenses/inventory.zon index 1a263d1..4ff59cf 100644 --- a/licenses/inventory.zon +++ b/licenses/inventory.zon @@ -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", diff --git a/licenses/licenses.zig b/licenses/licenses.zig index a498e60..fc3f885 100644 --- a/licenses/licenses.zig +++ b/licenses/licenses.zig @@ -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") }, diff --git a/licenses/phosphor-mit.txt b/licenses/phosphor-mit.txt new file mode 100644 index 0000000..e2eb0bd --- /dev/null +++ b/licenses/phosphor-mit.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.