diff --git a/frontend/src/components/AdminLogs.dom.test.tsx b/frontend/src/components/AdminLogs.dom.test.tsx index e06e6575..8e6891cd 100644 --- a/frontend/src/components/AdminLogs.dom.test.tsx +++ b/frontend/src/components/AdminLogs.dom.test.tsx @@ -197,6 +197,44 @@ describe("AdminLogs", () => { const reads = logsMock.mock.calls.length; await act(async () => vi.advanceTimersByTimeAsync(12000)); expect(logsMock.mock.calls.length).toBe(reads); + // The button says polling stopped, and pressing it reads again. + const liveButton = screen.getByRole("button", { name: "adminLogs.live" }); + expect(liveButton.getAttribute("aria-pressed")).toBe("false"); + fireEvent.click(liveButton); + await settle(); + expect(logsMock.mock.calls.length).toBe(reads + 1); + }); + + it("a failed older page says so, not that the list is out of date", async () => { + logsMock.mockImplementation(async (_q, before) => { + if (before !== null) throw new Error("500"); + return { items: [row(2)], accounts: {}, next_cursor: "c1" }; + }); + renderConsole(); + await waitFor(() => expect(lines()).toHaveLength(1)); + + fireEvent.click(screen.getByRole("button", { name: "adminLogs.older" })); + + expect((await screen.findByRole("alert")).textContent).toContain("adminLogs.olderFailed"); + }); + + it("offers no older page while a filter change loads", async () => { + let landErrors!: (page: LogPage) => void; + logsMock.mockImplementation((q) => + q.levels + ? new Promise((resolve) => (landErrors = resolve)) + : Promise.resolve({ items: [row(2)], accounts: {}, next_cursor: "c1" }), + ); + renderConsole(); + await waitFor(() => expect(lines()).toHaveLength(1)); + expect(screen.queryByRole("button", { name: "adminLogs.older" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.error/ })); + await waitFor(() => expect(landErrors).toBeTypeOf("function")); + + // The rows on screen are the previous filter's: their cursor is not this list's. + expect(screen.queryByRole("button", { name: "adminLogs.older" })).toBeNull(); + await act(async () => landErrors({ items: [row(1)], accounts: {}, next_cursor: null })); }); it("copies every loaded line, oldest first", async () => { diff --git a/frontend/src/components/AdminLogs.tsx b/frontend/src/components/AdminLogs.tsx index 9608491e..1f5754db 100644 --- a/frontend/src/components/AdminLogs.tsx +++ b/frontend/src/components/AdminLogs.tsx @@ -105,11 +105,26 @@ export default function AdminLogs() { placeholderData: keepPreviousData, refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS * 4 : false), }); - // What "Load older" continues from — read off the data, since `hasNextPage` is off while live. - const olderCursor = logs.data?.pages[logs.data.pages.length - 1]?.next_cursor ?? null; - // A read failed while rows (or counts) were already on screen: they are kept, and said to be old. - const stale = (logs.isError && !!logs.data) || facets.isError; - const retryStale = () => { + // A failed read stops the polling (the intervals above); the button shows that, and pressing it + // again reads now. + const polling = live && !logs.isError; + // What "Load older" continues from — read off the data, since `hasNextPage` is off while live; not + // off placeholder rows, which are the previous filter's. + const olderCursor = logs.isPlaceholderData + ? null + : (logs.data?.pages[logs.data.pages.length - 1]?.next_cursor ?? null); + // A read that failed while lines were on screen: they are kept, and the message says what the + // failure means for them. (With no lines, the list's own error state says it.) + const failure = !logs.data + ? null + : logs.isFetchNextPageError + ? "olderFailed" + : logs.isError || (facets.isError && facets.data) + ? "stale" + : facets.isError + ? "countsFailed" + : null; + const retryFailed = () => { if (logs.isFetchNextPageError) void logs.fetchNextPage(); else if (logs.isError) void logs.refetch(); if (facets.isError) void facets.refetch(); @@ -129,13 +144,17 @@ export default function AdminLogs() { id === "none" ? t("adminLogs.noAccount") : (accounts[id] ?? `#${id}`); const toggleLive = () => { - if (!live) { - // Watching the top: drop the older pages now; `liveRef` keeps each tick to the first page. - qc.setQueryData(key, (d) => - d ? { pages: d.pages.slice(0, 1), pageParams: d.pageParams.slice(0, 1) } : d, - ); + if (polling) { + setLive(false); + return; } - setLive(!live); + // Watching the top: drop the older pages now; `liveRef` keeps each tick to the first page. + qc.setQueryData(key, (d) => + d ? { pages: d.pages.slice(0, 1), pageParams: d.pageParams.slice(0, 1) } : d, + ); + setLive(true); + if (logs.isError) void logs.refetch(); + if (facets.isError) void facets.refetch(); }; const loadOlder = () => { @@ -256,10 +275,10 @@ export default function AdminLogs() { />