admin: live and query log pages show client names
This commit is contained in:
@@ -56,23 +56,6 @@ const styles = stylex.create({
|
|||||||
dash: {
|
dash: {
|
||||||
color: colors.textMuted,
|
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: {
|
badge: {
|
||||||
marginLeft: "0.5rem",
|
marginLeft: "0.5rem",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
@@ -153,9 +136,9 @@ export default function ClientsPage() {
|
|||||||
{client.name !== "" ? (
|
{client.name !== "" ? (
|
||||||
client.name
|
client.name
|
||||||
) : client.learned_name !== "" ? (
|
) : client.learned_name !== "" ? (
|
||||||
<span {...stylex.props(styles.dash)}>
|
<span {...stylex.props(shared.learnedName)}>
|
||||||
{client.learned_name}
|
{client.learned_name}
|
||||||
<span {...stylex.props(styles.learnedTag)}>learned</span>
|
<span {...stylex.props(shared.learnedTag)}>learned</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span {...stylex.props(styles.dash)}>—</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,49 @@
|
|||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import type { LiveQueryEvent } from "@/lib/types";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
|
import type { Client, LiveQueryEvent } from "@/lib/types";
|
||||||
import { FakeEventSource } from "./fakeEventSource";
|
import { FakeEventSource } from "./fakeEventSource";
|
||||||
import LiveLogPage from "./LiveLogPage";
|
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 } {
|
function frame(ts: number, domain: string, overrides: Partial<LiveQueryEvent> = {}): { data: string } {
|
||||||
const payload: LiveQueryEvent = {
|
const payload: LiveQueryEvent = {
|
||||||
ts,
|
ts,
|
||||||
@@ -26,7 +67,11 @@ function renderPage() {
|
|||||||
sources.push(es);
|
sources.push(es);
|
||||||
return es;
|
return es;
|
||||||
};
|
};
|
||||||
render(<LiveLogPage createEventSource={createEventSource} />);
|
render(
|
||||||
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
|
<LiveLogPage createEventSource={createEventSource} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
return sources;
|
return sources;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +116,72 @@ test("streams rows, flags blocked ones, and freezes the display", () => {
|
|||||||
expect(screen.getByText("later.example")).toBeTruthy();
|
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", () => {
|
test("repeated connection failures show the viewer-cap state with a retry button", () => {
|
||||||
const sources = renderPage();
|
const sources = renderPage();
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { useClientNames } from "@/features/clients/clientNames";
|
||||||
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
|
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
|
||||||
import { RING_CAPACITY } from "./ringBuffer";
|
import { RING_CAPACITY } from "./ringBuffer";
|
||||||
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
|
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). */
|
* filling; Resume shows the current buffer (anything pushed out meanwhile is gone). */
|
||||||
export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) {
|
export default function LiveLogPage({ createEventSource }: { createEventSource?: EventSourceFactory } = {}) {
|
||||||
const live = useLiveQueries({ createEventSource });
|
const live = useLiveQueries({ createEventSource });
|
||||||
|
const clientNames = useClientNames();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
@@ -240,7 +242,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
|
|||||||
<tbody>
|
<tbody>
|
||||||
{live.rows.map((row) => (
|
{live.rows.map((row) => (
|
||||||
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
|
<tr key={row.key} {...stylex.props(styles.row, row.blocked && styles.rowBlocked)}>
|
||||||
<QueryCells row={row} />
|
<QueryCells row={row} clientNames={clientNames} />
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</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 { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createQueryClient } from "@/lib/queryClient";
|
import { createQueryClient } from "@/lib/queryClient";
|
||||||
import type { QueriesPage, QueryRow } from "@/lib/types";
|
import type { Client, QueriesPage, QueryRow } from "@/lib/types";
|
||||||
import QueryLogPage from "./QueryLogPage";
|
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 {
|
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -48,6 +68,7 @@ beforeEach(() => {
|
|||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (input: RequestInfo | URL) => {
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
|
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||||
const payload = PAGES[url];
|
const payload = PAGES[url];
|
||||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||||
return new Response(JSON.stringify(payload), {
|
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();
|
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 () => {
|
test("load more appends the next page and stops at the end of the log", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText("first.example");
|
await screen.findByText("first.example");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as api from "@/lib/api";
|
|||||||
import { formatMicros, formatTime } from "@/lib/format";
|
import { formatMicros, formatTime } from "@/lib/format";
|
||||||
import { queriesInfiniteQuery } from "@/lib/queries";
|
import { queriesInfiniteQuery } from "@/lib/queries";
|
||||||
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
import type { QueriesFilter, QueryRow } from "@/lib/types";
|
||||||
|
import { ClientName, useClientNames, type ClientNames } from "@/features/clients/clientNames";
|
||||||
import { qtypeName } from "./qtype";
|
import { qtypeName } from "./qtype";
|
||||||
import Select from "@/ui/Select";
|
import Select from "@/ui/Select";
|
||||||
import { styles as shared } from "@/ui/styles";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<td {...stylex.props(styles.cell, styles.nowrap, styles.muted)}>{formatTime(row.ts)}</td>
|
<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.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, styles.nowrap)}>{qtypeName(row.qtype)}</td>
|
||||||
<td {...stylex.props(styles.cell)}>
|
<td {...stylex.props(styles.cell)}>
|
||||||
<BlockedCell row={row} />
|
<BlockedCell row={row} />
|
||||||
@@ -212,6 +215,7 @@ export default function QueryLogPage() {
|
|||||||
const [applied, setApplied] = useState<QueriesFilter>({});
|
const [applied, setApplied] = useState<QueriesFilter>({});
|
||||||
|
|
||||||
const base = useInfiniteQuery(queriesInfiniteQuery(applied));
|
const base = useInfiniteQuery(queriesInfiniteQuery(applied));
|
||||||
|
const clientNames = useClientNames();
|
||||||
|
|
||||||
const pages = base.data?.pages ?? [];
|
const pages = base.data?.pages ?? [];
|
||||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||||
@@ -336,7 +340,7 @@ export default function QueryLogPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.id} {...stylex.props(styles.row)}>
|
<tr key={row.id} {...stylex.props(styles.row)}>
|
||||||
<QueryCells row={row} />
|
<QueryCells row={row} clientNames={clientNames} />
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -167,6 +167,26 @@ export const styles = stylex.create({
|
|||||||
tabularNums: {
|
tabularNums: {
|
||||||
fontVariantNumeric: "tabular-nums",
|
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. */
|
/** For a value the operator reads character by character: a domain, an IP, a URL. */
|
||||||
mono: {
|
mono: {
|
||||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||||
|
|||||||
@@ -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.
|
- `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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user