fix(diagnostics): honest live console after review

- Live stops polling on a failed read and marks the list as possibly
  stale (with Retry), instead of re-raising the error dialog every tick.
- While live, a refetch reads the newest page only, whatever the cache
  holds (a late "Load older", a revisited filter set).
- Copy is disabled and the list dimmed while a filter change loads.
- Dates follow the UI language; the pivot chip uses the shared .chip.
- Tests: query-string wire test, Copy, every facet's skip key.
This commit is contained in:
2026-09-19 03:42:26 +02:00
parent 1c49870a7a
commit 8f6f89832f
7 changed files with 213 additions and 17 deletions
+78 -2
View File
@@ -2,9 +2,10 @@
//
// E22.6: the admin log console. A request id narrows the list to that request (and says so, in the
// filter row and the URL); a filter value the facets no longer count stays pickable; "Live" watches
// the newest page only; a failed read is an error, not an empty log.
// the newest page only, and stops on a failed read instead of re-raising it; a failed read is an
// error, not an empty log; Copy takes every loaded line, oldest first.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { LogFacets, LogPage, LogQuery, ServerLogRow } from "../lib/api";
@@ -35,6 +36,14 @@ vi.mock("../lib/api", async (importOriginal) => {
};
});
const copied: string[] = [];
vi.mock("../lib/clipboard", () => ({
copyText: (text: string) => {
copied.push(text);
return Promise.resolve(true);
},
}));
import AdminLogs from "./AdminLogs";
function row(id: number, extra: Partial<ServerLogRow> = {}): ServerLogRow {
@@ -75,6 +84,9 @@ function renderConsole() {
);
}
// react-query hands its updates to React on a (real) timer tick, after the promise that caused them.
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 20)));
const lines = () =>
within(screen.getByTestId("admin-logs-lines"))
.queryAllByRole("button")
@@ -85,6 +97,7 @@ beforeEach(() => {
facetsMock.mockResolvedValue(FACETS);
});
afterEach(() => {
vi.useRealTimers();
cleanup();
logsMock.mockReset();
facetsMock.mockReset();
@@ -142,6 +155,69 @@ describe("AdminLogs", () => {
).toBe("true");
});
it("a page that lands after Live went on is dropped at the next tick", async () => {
let landOlder!: (page: LogPage) => void;
logsMock.mockImplementation((_q, before) =>
before === null
? Promise.resolve({ items: [row(3), row(2)], accounts: {}, next_cursor: "c1" })
: new Promise<LogPage>((resolve) => (landOlder = resolve)),
);
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(2));
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
await waitFor(() => expect(landOlder).toBeTypeOf("function"));
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
await act(async () => landOlder({ items: [row(1)], accounts: {}, next_cursor: null }));
await settle();
expect(lines()).toHaveLength(3);
const olderReads = logsMock.mock.calls.filter(([, before]) => before !== null).length;
await act(async () => vi.advanceTimersByTimeAsync(4000));
await settle();
expect(lines()).toHaveLength(2);
expect(logsMock.mock.calls.filter(([, before]) => before !== null)).toHaveLength(olderReads);
});
it("a failed live read stops the polling and says the list may be old", async () => {
logsMock.mockResolvedValueOnce({ items: [row(1)], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
logsMock.mockRejectedValue(new Error("500"));
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
fireEvent.click(screen.getByRole("button", { name: "adminLogs.live" }));
await act(async () => vi.advanceTimersByTimeAsync(4000));
await settle();
expect(screen.getByRole("alert").textContent).toContain("adminLogs.stale");
expect(lines()).toHaveLength(1);
const reads = logsMock.mock.calls.length;
await act(async () => vi.advanceTimersByTimeAsync(12000));
expect(logsMock.mock.calls.length).toBe(reads);
});
it("copies every loaded line, oldest first", async () => {
logsMock.mockImplementation(async (_q, before) =>
before === null
? { items: [row(3), row(2)], accounts: {}, next_cursor: "c1" }
: { items: [row(1)], accounts: {}, next_cursor: null },
);
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(2));
fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" }));
await waitFor(() => expect(lines()).toHaveLength(3));
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "adminLogs.copy" }));
});
const ids = copied[copied.length - 1]!.split("\n").map((l) => JSON.parse(l).id);
expect(ids).toEqual([1, 2, 3]);
});
it("the keyboard can walk the lines and open one (NavGrid items)", async () => {
logsMock.mockResolvedValue({ items: [row(2), row(1)], accounts: {}, next_cursor: null });
renderConsole();