fix(diagnostics): say which read failed, and let Live restart

- "Load older" is not offered off a loading filter's placeholder rows,
  whose cursor belongs to the previous filter.
- A failed older page, stale rows and missing counts each get their own
  message (HU/EN).
- Live shows as off once a failed read stopped the polling; pressing it
  again reads now.
This commit is contained in:
2026-09-19 03:48:01 +02:00
parent 8f6f89832f
commit eb29f22eb7
4 changed files with 77 additions and 16 deletions
@@ -197,6 +197,44 @@ describe("AdminLogs", () => {
const reads = logsMock.mock.calls.length; const reads = logsMock.mock.calls.length;
await act(async () => vi.advanceTimersByTimeAsync(12000)); await act(async () => vi.advanceTimersByTimeAsync(12000));
expect(logsMock.mock.calls.length).toBe(reads); 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<LogPage>((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 () => { it("copies every loaded line, oldest first", async () => {
+35 -16
View File
@@ -105,11 +105,26 @@ export default function AdminLogs() {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
refetchInterval: (q) => (live && q.state.status !== "error" ? LIVE_MS * 4 : false), 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. // A failed read stops the polling (the intervals above); the button shows that, and pressing it
const olderCursor = logs.data?.pages[logs.data.pages.length - 1]?.next_cursor ?? null; // again reads now.
// A read failed while rows (or counts) were already on screen: they are kept, and said to be old. const polling = live && !logs.isError;
const stale = (logs.isError && !!logs.data) || facets.isError; // What "Load older" continues from — read off the data, since `hasNextPage` is off while live; not
const retryStale = () => { // 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(); if (logs.isFetchNextPageError) void logs.fetchNextPage();
else if (logs.isError) void logs.refetch(); else if (logs.isError) void logs.refetch();
if (facets.isError) void facets.refetch(); if (facets.isError) void facets.refetch();
@@ -129,13 +144,17 @@ export default function AdminLogs() {
id === "none" ? t("adminLogs.noAccount") : (accounts[id] ?? `#${id}`); id === "none" ? t("adminLogs.noAccount") : (accounts[id] ?? `#${id}`);
const toggleLive = () => { const toggleLive = () => {
if (!live) { if (polling) {
// Watching the top: drop the older pages now; `liveRef` keeps each tick to the first page. setLive(false);
qc.setQueryData<typeof logs.data>(key, (d) => return;
d ? { pages: d.pages.slice(0, 1), pageParams: d.pageParams.slice(0, 1) } : d,
);
} }
setLive(!live); // Watching the top: drop the older pages now; `liveRef` keeps each tick to the first page.
qc.setQueryData<typeof logs.data>(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 = () => { const loadOlder = () => {
@@ -256,10 +275,10 @@ export default function AdminLogs() {
/> />
<span className="ml-auto flex gap-1.5"> <span className="ml-auto flex gap-1.5">
<Button <Button
variant={live ? "outline-accent" : "outline"} variant={polling ? "outline-accent" : "outline"}
size="xs" size="xs"
icon={<Radio className="h-3.5 w-3.5" />} icon={<Radio className="h-3.5 w-3.5" />}
aria-pressed={live} aria-pressed={polling}
onClick={toggleLive} onClick={toggleLive}
> >
{t("adminLogs.live")} {t("adminLogs.live")}
@@ -296,14 +315,14 @@ export default function AdminLogs() {
</div> </div>
)} )}
{stale && ( {failure && (
<StateMessage <StateMessage
tone="error" tone="error"
compact compact
onRetry={retryStale} onRetry={retryFailed}
className="border-b border-border" className="border-b border-border"
> >
{t("adminLogs.stale")} {t(`adminLogs.${failure}`)}
</StateMessage> </StateMessage>
)} )}
@@ -27,6 +27,8 @@
"showJob": "Only this job's lines", "showJob": "Only this job's lines",
"empty": "No lines match the filters.", "empty": "No lines match the filters.",
"stale": "The last refresh failed; what you see may be out of date.", "stale": "The last refresh failed; what you see may be out of date.",
"olderFailed": "Couldn't load the older lines.",
"countsFailed": "The filter counts didn't load.",
"count_one": "{{count}} line loaded · newest first", "count_one": "{{count}} line loaded · newest first",
"count_other": "{{count}} lines loaded · newest first", "count_other": "{{count}} lines loaded · newest first",
"older": "Load older", "older": "Load older",
@@ -27,6 +27,8 @@
"showJob": "Csak ennek a jobnak a sorai", "showJob": "Csak ennek a jobnak a sorai",
"empty": "Nincs a szűrésnek megfelelő sor.", "empty": "Nincs a szűrésnek megfelelő sor.",
"stale": "Az utolsó frissítés nem sikerült, amit látsz, elavult lehet.", "stale": "Az utolsó frissítés nem sikerült, amit látsz, elavult lehet.",
"olderFailed": "Nem sikerült betölteni a régebbi sorokat.",
"countsFailed": "A szűrők számai nem töltődtek be.",
"count_one": "{{count}} sor betöltve · a legújabb felül", "count_one": "{{count}} sor betöltve · a legújabb felül",
"count_other": "{{count}} sor betöltve · a legújabb felül", "count_other": "{{count}} sor betöltve · a legújabb felül",
"older": "Régebbiek betöltése", "older": "Régebbiek betöltése",