2 Commits
Author SHA1 Message Date
mokhtar 49c7da2381 changelog: 0.0.4 releases today
Release / guard (push) Successful in 26s
Gates / frontend (push) Successful in 1m26s
Gates / test (push) Successful in 1m44s
Gates / frontend (push) Successful in 1m6s
Gates / test-aarch64 (push) Successful in 6m36s
Gates / test (push) Successful in 1m33s
Gates / test-aarch64 (push) Successful in 5m50s
Gates / package (push) Successful in 5m5s
Gates / package (push) Successful in 30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 21m58s
Gates / container (push) Successful in 10s
Release / gates (push) Successful in 20m42s
Release / publish (push) Successful in 4m41s
2026-08-16 23:00:41 +02:00
mokhtar 794ea6541f admin: live and query log pages show client names 2026-08-16 23:00:31 +02:00
10 changed files with 272 additions and 29 deletions
+8
View File
@@ -6,6 +6,14 @@ Sections are written by hand. Nothing here is generated from commit messages: th
## [Unreleased]
## [0.0.4] - 2026-08-16
The names learned in 0.0.3 now show up where queries do: the live page and the query log name each client instead of printing its address.
### Added
- **Client names in the query tables.** The live page and the query log show each query's client by name, with the same precedence as the clients page: a hand-typed name wins, else the learned name (muted, tagged *learned*), else the bare address. When a name replaces the address, the address stays readable as the row's tooltip. Devices that appear mid-stream show their address first and pick up their name within half a minute.
## [0.0.3] - 2026-08-15
Devices name themselves: the clients table asks the router over reverse DNS instead of waiting for the operator to type every name. The CI container gate also moved from workflow shell into a compiled, tested tool, which fixed a latent temp-directory bug shared with the release tool.
+2 -19
View File
@@ -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 !== "" ? (
<span {...stylex.props(styles.dash)}>
<span {...stylex.props(shared.learnedName)}>
{client.learned_name}
<span {...stylex.props(styles.learnedTag)}>learned</span>
<span {...stylex.props(shared.learnedTag)}>learned</span>
</span>
) : (
<span {...stylex.props(styles.dash)}></span>
@@ -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<string, Pick<Client, "name" | "learned_name">>;
/**
* 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 <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.
if (client.name !== "") return <span title={ip}>{client.name}</span>;
return (
<span title={ip} {...stylex.props(shared.learnedName)}>
{client.learned_name}
<span {...stylex.props(shared.learnedTag)}>learned</span>
</span>
);
}
+114 -3
View File
@@ -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<LiveQueryEvent> = {}): { data: string } {
const payload: LiveQueryEvent = {
ts,
@@ -26,7 +67,11 @@ function renderPage() {
sources.push(es);
return es;
};
render(<LiveLogPage createEventSource={createEventSource} />);
render(
<QueryClientProvider client={createQueryClient()}>
<LiveLogPage createEventSource={createEventSource} />
</QueryClientProvider>,
);
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<Response>((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(() => {
+3 -1
View File
@@ -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 (
<section>
@@ -240,7 +242,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
<tbody>
{live.rows.map((row) => (
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
<QueryCells row={row} />
<QueryCells row={row} clientNames={clientNames} />
</tr>
))}
</tbody>
@@ -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> = {}): 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");
+7 -3
View File
@@ -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<QueryRow, "blocked" | "block_re
);
}
export function QueryCells({ row }: { row: Omit<QueryRow, "id"> }) {
export function QueryCells({ row, clientNames }: { row: Omit<QueryRow, "id">; clientNames: ClientNames }) {
return (
<>
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
<td {...stylex.props(styles.cell, styles.small, styles.breakAll, shared.mono)}>{row.domain}</td>
<td {...stylex.props(styles.cell, styles.small, styles.nowrap, shared.mono)}>{row.client_ip}</td>
<td {...stylex.props(styles.cell, styles.small, styles.nowrap)}>
<ClientName ip={row.client_ip} names={clientNames} />
</td>
<td {...stylex.props(styles.cell, styles.nowrap)}>{qtypeName(row.qtype)}</td>
<td {...stylex.props(styles.cell)}>
<BlockedCell row={row} />
@@ -212,6 +215,7 @@ export default function QueryLogPage() {
const [applied, setApplied] = useState<QueriesFilter>({});
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() {
<tbody>
{rows.map((row) => (
<tr key={row.id} {...stylex.props(styles.row)}>
<QueryCells row={row} />
<QueryCells row={row} clientNames={clientNames} />
</tr>
))}
</tbody>
+20
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
.{
.name = .nxdns,
.version = "0.0.3",
.version = "0.0.4",
.minimum_zig_version = "0.16.0",
.paths = .{""},
.fingerprint = 0x3307b311dded1d91,
+4
View File
@@ -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 `<ClientName>`, 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.