admin: live and query log pages show client names

This commit is contained in:
2026-08-16 23:00:31 +02:00
parent ba037c5958
commit 794ea6541f
8 changed files with 263 additions and 28 deletions
+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>