From 794ea6541f6d93b0868c62308b9498b210f2b1bf Mon Sep 17 00:00:00 2001 From: m5r Date: Sun, 16 Aug 2026 23:00:31 +0200 Subject: [PATCH] admin: live and query log pages show client names --- admin/src/features/clients/ClientsPage.tsx | 21 +--- admin/src/features/clients/clientNames.tsx | 49 ++++++++ admin/src/features/live/LiveLogPage.test.tsx | 117 +++++++++++++++++- admin/src/features/live/LiveLogPage.tsx | 4 +- .../features/queries/QueryLogPage.test.tsx | 66 +++++++++- admin/src/features/queries/QueryLogPage.tsx | 10 +- admin/src/ui/styles.ts | 20 +++ specs/milestone-25.md | 4 + 8 files changed, 263 insertions(+), 28 deletions(-) create mode 100644 admin/src/features/clients/clientNames.tsx diff --git a/admin/src/features/clients/ClientsPage.tsx b/admin/src/features/clients/ClientsPage.tsx index 788ce05..91de5a2 100644 --- a/admin/src/features/clients/ClientsPage.tsx +++ b/admin/src/features/clients/ClientsPage.tsx @@ -56,23 +56,6 @@ const styles = stylex.create({ dash: { color: colors.textMuted, }, - /** - * A learned name is runtime state, not something the operator typed, so it - * reads muted and carries an outlined "learned" tag. The tag is real text — - * a screen reader announces it — because colour alone is not an affordance. - */ - learnedTag: { - marginLeft: "0.5rem", - borderWidth: 1, - borderStyle: "solid", - borderColor: colors.border, - borderRadius: "0.25rem", - paddingInline: "0.375rem", - paddingBlock: "0.125rem", - fontSize: "0.75rem", - lineHeight: "1rem", - color: colors.textMuted, - }, badge: { marginLeft: "0.5rem", borderRadius: "0.25rem", @@ -153,9 +136,9 @@ export default function ClientsPage() { {client.name !== "" ? ( client.name ) : client.learned_name !== "" ? ( - + {client.learned_name} - learned + learned ) : ( diff --git a/admin/src/features/clients/clientNames.tsx b/admin/src/features/clients/clientNames.tsx new file mode 100644 index 0000000..1bb83d5 --- /dev/null +++ b/admin/src/features/clients/clientNames.tsx @@ -0,0 +1,49 @@ +/** + * The client column of the query tables reads as a name wherever one is known, + * with the same precedence the Clients page applies: a hand-typed `name` wins, + * the reverse-DNS `learned_name` stands in muted behind it, and an address with + * neither — including one the loaded list has never seen — stays bare. + */ + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import * as stylex from "@stylexjs/stylex"; +import { clientsQuery } from "@/lib/queries"; +import type { Client } from "@/lib/types"; +import { styles as shared } from "@/ui/styles"; + +export type ClientNames = ReadonlyMap>; + +/** + * The live stream names clients the loaded list has never seen. Polling folds + * them in on the next tick, which keeps the lookup a single cached query + * instead of a fetch fired per unknown address. + */ +const CLIENTS_POLL_MS = 30_000; + +export function useClientNames(): ClientNames { + const { data } = useQuery({ ...clientsQuery(), refetchInterval: CLIENTS_POLL_MS }); + return useMemo( + () => + new Map( + (data ?? []).map((client) => [client.ip, { name: client.name, learned_name: client.learned_name }]), + ), + [data], + ); +} + +export function ClientName({ ip, names }: { ip: string; names: ClientNames }) { + const client = names.get(ip); + if (client === undefined || (client.name === "" && client.learned_name === "")) { + return {ip}; + } + // The name replaces the address on screen, so the address stays reachable + // as the tooltip rather than disappearing from the row entirely. + if (client.name !== "") return {client.name}; + return ( + + {client.learned_name} + learned + + ); +} diff --git a/admin/src/features/live/LiveLogPage.test.tsx b/admin/src/features/live/LiveLogPage.test.tsx index cc0294f..d04761e 100644 --- a/admin/src/features/live/LiveLogPage.test.tsx +++ b/admin/src/features/live/LiveLogPage.test.tsx @@ -1,8 +1,49 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; -import type { LiveQueryEvent } from "@/lib/types"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createQueryClient } from "@/lib/queryClient"; +import type { Client, LiveQueryEvent } from "@/lib/types"; import { FakeEventSource } from "./fakeEventSource"; import LiveLogPage from "./LiveLogPage"; +function client(ip: string, name: string, learnedName: string): Client { + return { + id: Number(ip.split(".").pop()), + 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, + }; +} + +const CLIENTS: Client[] = [ + client("192.0.2.10", "Kitchen Pi", "pi.lan"), + client("192.0.2.11", "", "laptop.lan"), + client("192.0.2.12", "", ""), +]; + +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input) !== "/api/clients") { + return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); + } + return new Response(JSON.stringify({ clients: CLIENTS }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + function frame(ts: number, domain: string, overrides: Partial = {}): { data: string } { const payload: LiveQueryEvent = { ts, @@ -26,7 +67,11 @@ function renderPage() { sources.push(es); return es; }; - render(); + render( + + + , + ); return sources; } @@ -71,6 +116,72 @@ test("streams rows, flags blocked ones, and freezes the display", () => { expect(screen.getByText("later.example")).toBeTruthy(); }); +test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => { + const sources = renderPage(); + act(() => sources[0]!.emit("open")); + act(() => { + sources[0]!.emit("query", frame(1000, "named.example", { client_ip: "192.0.2.10" })); + sources[0]!.emit("query", frame(1001, "learned.example", { client_ip: "192.0.2.11" })); + sources[0]!.emit("query", frame(1002, "nameless.example", { client_ip: "192.0.2.12" })); + sources[0]!.emit("query", frame(1003, "stranger.example", { client_ip: "192.0.2.99" })); + }); + + // 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"); + expect(screen.queryByText("pi.lan")).toBeNull(); + + // The cell holds the learned name followed by the tag, so the match is on + // the containing span rather than on a bare text node. + const learned = screen.getByText( + (content, element) => element?.tagName === "SPAN" && content.startsWith("laptop.lan"), + ); + expect(learned.getAttribute("title")).toBe("192.0.2.11"); + // The affordance is text, not colour, so a screen reader announces it too. + expect(within(learned).getByText("learned")).toBeTruthy(); + + // 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. + const nameless = screen.getByText("192.0.2.12"); + expect(nameless.getAttribute("title")).toBeNull(); + const stranger = screen.getByText("192.0.2.99"); + expect(stranger.getAttribute("title")).toBeNull(); + expect(screen.getByText("stranger.example").closest("tr")?.textContent).toContain("192.0.2.99"); +}); + +test("rows stream in as bare IPs while the client list is still loading", async () => { + let releaseClients: () => void = () => {}; + vi.stubGlobal( + "fetch", + vi.fn( + (input: RequestInfo | URL) => + new Promise((resolve) => { + if (String(input) !== "/api/clients") { + resolve(new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 })); + return; + } + releaseClients = () => + resolve( + new Response(JSON.stringify({ clients: CLIENTS }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + }), + ), + ); + + const sources = renderPage(); + act(() => sources[0]!.emit("open")); + act(() => sources[0]!.emit("query", frame(1000, "named.example", { client_ip: "192.0.2.10" }))); + + expect(screen.getByText("192.0.2.10")).toBeTruthy(); + expect(screen.queryByText("Kitchen Pi")).toBeNull(); + + releaseClients(); + expect(await screen.findByText("Kitchen Pi")).toBeTruthy(); +}); + test("repeated connection failures show the viewer-cap state with a retry button", () => { const sources = renderPage(); act(() => { diff --git a/admin/src/features/live/LiveLogPage.tsx b/admin/src/features/live/LiveLogPage.tsx index e1d0b7b..36122c1 100644 --- a/admin/src/features/live/LiveLogPage.tsx +++ b/admin/src/features/live/LiveLogPage.tsx @@ -1,4 +1,5 @@ import * as stylex from "@stylexjs/stylex"; +import { useClientNames } from "@/features/clients/clientNames"; import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage"; import { RING_CAPACITY } from "./ringBuffer"; import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries"; @@ -168,6 +169,7 @@ function StatusPill({ status }: { status: StreamStatus }) { * filling; Resume shows the current buffer (anything pushed out meanwhile is gone). */ export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) { const live = useLiveQueries({ createEventSource }); + const clientNames = useClientNames(); return (
@@ -240,7 +242,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?: {live.rows.map((row) => ( - + ))} diff --git a/admin/src/features/queries/QueryLogPage.test.tsx b/admin/src/features/queries/QueryLogPage.test.tsx index 50fe391..9f864e3 100644 --- a/admin/src/features/queries/QueryLogPage.test.tsx +++ b/admin/src/features/queries/QueryLogPage.test.tsx @@ -1,9 +1,29 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClientProvider } from "@tanstack/react-query"; import { createQueryClient } from "@/lib/queryClient"; -import type { QueriesPage, QueryRow } from "@/lib/types"; +import type { Client, QueriesPage, QueryRow } from "@/lib/types"; import QueryLogPage from "./QueryLogPage"; +function client(id: number, ip: string, name: string, learnedName: string): Client { + return { + id, + 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, + }; +} + +const CLIENTS: Client[] = [ + client(1, "192.0.2.10", "Kitchen Pi", "pi.lan"), + client(2, "192.0.2.11", "", "laptop.lan"), + client(3, "192.0.2.12", "", ""), +]; + function row(id: number, domain: string, overrides: Partial = {}): QueryRow { return { id, @@ -48,6 +68,7 @@ beforeEach(() => { "fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); + if (url === "/api/clients") return json({ clients: CLIENTS }); const payload = PAGES[url]; if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 }); return new Response(JSON.stringify(payload), { @@ -90,6 +111,47 @@ test("renders the first page with type names, blocked badge, and formatted cells expect(screen.getByText(/Showing 2 queries/)).toBeTruthy(); }); +test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + 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, "named.example", { client_ip: "192.0.2.10" }), + row(19, "learned.example", { client_ip: "192.0.2.11" }), + row(18, "nameless.example", { client_ip: "192.0.2.12" }), + row(17, "stranger.example", { client_ip: "192.0.2.99" }), + ], + next_before: null, + } satisfies QueriesPage); + }), + ); + + renderPage(); + + // 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"); + expect(screen.queryByText("pi.lan")).toBeNull(); + + // The cell holds the learned name followed by the tag, so the match is on + // the containing span rather than on a bare text node. + const learned = screen.getByText( + (content, element) => element?.tagName === "SPAN" && content.startsWith("laptop.lan"), + ); + expect(learned.getAttribute("title")).toBe("192.0.2.11"); + // The affordance is text, not colour, so a screen reader announces it too. + expect(within(learned).getByText("learned")).toBeTruthy(); + + // 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(); +}); + test("load more appends the next page and stops at the end of the log", async () => { renderPage(); await screen.findByText("first.example"); diff --git a/admin/src/features/queries/QueryLogPage.tsx b/admin/src/features/queries/QueryLogPage.tsx index 86987f0..75f171a 100644 --- a/admin/src/features/queries/QueryLogPage.tsx +++ b/admin/src/features/queries/QueryLogPage.tsx @@ -5,6 +5,7 @@ import * as api from "@/lib/api"; import { formatMicros, formatTime } from "@/lib/format"; import { queriesInfiniteQuery } from "@/lib/queries"; import type { QueriesFilter, QueryRow } from "@/lib/types"; +import { ClientName, useClientNames, type ClientNames } from "@/features/clients/clientNames"; import { qtypeName } from "./qtype"; import Select from "@/ui/Select"; import { styles as shared } from "@/ui/styles"; @@ -162,12 +163,14 @@ export function BlockedCell({ row }: { row: Pick }) { +export function QueryCells({ row, clientNames }: { row: Omit; clientNames: ClientNames }) { return ( <> {formatTime(row.ts)} {row.domain} - {row.client_ip} + + + {qtypeName(row.qtype)} @@ -212,6 +215,7 @@ export default function QueryLogPage() { const [applied, setApplied] = useState({}); const base = useInfiniteQuery(queriesInfiniteQuery(applied)); + const clientNames = useClientNames(); const pages = base.data?.pages ?? []; const rows: QueryRow[] = pages.flatMap((page) => page.queries); @@ -336,7 +340,7 @@ export default function QueryLogPage() { {rows.map((row) => ( - + ))} diff --git a/admin/src/ui/styles.ts b/admin/src/ui/styles.ts index 5e1b5c6..a569a2e 100644 --- a/admin/src/ui/styles.ts +++ b/admin/src/ui/styles.ts @@ -167,6 +167,26 @@ export const styles = stylex.create({ tabularNums: { fontVariantNumeric: "tabular-nums", }, + /** + * A learned name is runtime state, not something the operator typed, so it + * reads muted and carries an outlined "learned" tag. The tag is real text — + * a screen reader announces it — because colour alone is not an affordance. + */ + learnedName: { + color: colors.textMuted, + }, + learnedTag: { + marginLeft: "0.5rem", + borderWidth: 1, + borderStyle: "solid", + borderColor: colors.border, + borderRadius: "0.25rem", + paddingInline: "0.375rem", + paddingBlock: "0.125rem", + fontSize: "0.75rem", + lineHeight: "1rem", + color: colors.textMuted, + }, /** For a value the operator reads character by character: a domain, an IP, a URL. */ mono: { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", diff --git a/specs/milestone-25.md b/specs/milestone-25.md index 02e9783..736b242 100644 --- a/specs/milestone-25.md +++ b/specs/milestone-25.md @@ -276,3 +276,7 @@ New files: `src/local/reverse_name.zig`, `src/server/client_names.zig`. Deleted - `src/web/openapi.yaml` was reviewed by hand against `ClientRow` (clients_repo.zig:312): the nine required fields match one-to-one and `name_attempt_after` is absent from the response, as intended. - Live smoke (scratch server, file mode, zone `127.in-addr.arpa` at a local stub PTR resolver): one real query materialised the client; the flush pass learned `smoke-host.lan` with no operator edit, visible in `GET /api/clients` and `nxdns_client_names_answered_total`. Removing the zone and re-arming `name_attempt_after` produced `no_zone` with zero packets to the stub and no pool movement. The learned name survived a restart's reconcile. A file-declared `name` won in the API after reconcile with the learned name still present as display-only state, and the named row left candidacy (`attempted` stayed 0 over a full flush interval). The database-mode hand-edit path runs the same candidacy SQL (`name IS NULL OR name = ''`) and is covered by the repo unit tests rather than a second live run. The UI half is covered by the ClientsPage rendering tests, not a live browser check. - The first smoke attempt polled a stale pre-milestone binary out of `zig-out/bin` — `zig build test` does not refresh the install step. Rebuild before any live check. + +## Addendum: names in the query tables (post-0.0.3) + +Operator request after running 0.0.3: the live page showed bare addresses while the Clients page had names. Frontend-only follow-up, no API change: `admin/src/features/clients/clientNames.tsx` owns `useClientNames()` (the same `["clients"]` query the Clients page uses, polled every 30 s so mid-stream unknown addresses fold in) and ``, which applies the ruling-10 precedence — hand-typed `name`, else `learned_name` muted with the "learned" tag, else the bare address — with the address kept as the tooltip when a name replaces it. Both query tables render it through the shared `QueryCells`, so the query log page got the same treatment as the live page. The learned-name styles moved from `ClientsPage.tsx` into `ui/styles.ts`. Three new tests (name wins, learned tag, bare fallback for unknown and unnamed addresses) were watched failing before the change.