From 6096dd69273cbe0d76972f3610ce2632daf9191b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 04:51:42 +0200 Subject: [PATCH] fix(diagnostics): Back from a request's lines returns to the view it came from Every filter change replaced the history entry, so Back after a request or job pivot left the console altogether (the 0.90 notes promised otherwise). A pivot follows a link and is its own entry now; the console reads the landed entry back on popstate. The other filters still adjust in place. --- .../src/components/AdminLogs.dom.test.tsx | 36 +++++++++++++++++++ frontend/src/components/AdminLogs.tsx | 24 +++++++++++-- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/AdminLogs.dom.test.tsx b/frontend/src/components/AdminLogs.dom.test.tsx index 8e6891cd..eba9bec0 100644 --- a/frontend/src/components/AdminLogs.dom.test.tsx +++ b/frontend/src/components/AdminLogs.dom.test.tsx @@ -124,6 +124,42 @@ describe("AdminLogs", () => { expect([url.get("lreq"), url.get("lgrp"), url.get("lq")]).toEqual(["req-00000001", null, null]); }); + it("Back from a request's lines returns to the view the pivot was made from", async () => { + window.history.replaceState({ sfPage: "logs" }, "", "/?page=logs&lgrp=error&lq=boom"); + const depth = window.history.length; + logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null }); + renderConsole(); + await waitFor(() => expect(lines()).toHaveLength(1)); + fireEvent.click(lines()[0]!); + fireEvent.click(screen.getByRole("button", { name: "req-00000001" })); + await waitFor(() => + expect(screen.getByText("adminLogs.pivotRequest:req-00000001")).toBeTruthy(), + ); + expect(window.history.length).toBe(depth + 1); + + await act(async () => { + const landed = new Promise((resolve) => + window.addEventListener("popstate", resolve, { once: true }), + ); + window.history.back(); + await landed; + }); + + await waitFor(() => + expect(logsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + request_id: undefined, + levels: ["ERROR", "CRITICAL"], + q: "boom", + }), + null, + ), + ); + expect(screen.queryByText("adminLogs.pivotRequest:req-00000001")).toBeNull(); + const url = new URLSearchParams(window.location.search); + expect([url.get("lreq"), url.get("lgrp"), url.get("lq")]).toEqual([null, "error", "boom"]); + }); + it("keeps the account in use offered when the facets no longer count it", async () => { window.history.replaceState(null, "", "/?page=logs&lu=7"); logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null }); diff --git a/frontend/src/components/AdminLogs.tsx b/frontend/src/components/AdminLogs.tsx index 1f5754db..9ecd017e 100644 --- a/frontend/src/components/AdminLogs.tsx +++ b/frontend/src/components/AdminLogs.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, @@ -11,6 +11,7 @@ import { Copy, Radio, Search, X } from "lucide-react"; import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api"; import { copyText } from "../lib/clipboard"; import { formatDateTime } from "../lib/format"; +import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history"; import { LEVEL_GROUPS, type LevelGroup } from "../lib/log"; import { DEFAULT_LOGS_STATE, @@ -23,6 +24,7 @@ import { import { notify } from "../lib/notifications"; import { qk } from "../lib/queryKeys"; import { useDebounced } from "../lib/useDebounced"; +import { urlWithParams } from "../lib/urlState"; import { useUrlMirror } from "../lib/useUrlMirror"; import LogLine from "./LogLine"; import NavGrid from "./ui/NavGrid"; @@ -82,6 +84,15 @@ export default function AdminLogs() { const [open, setOpen] = useState(null); const q = useDebounced(state.q, 300); useUrlMirror(logsParamUpdates(state)); + // Back/Forward lands on another view of the console (a pivot pushed one, below): read it back. + useEffect(() => { + const onPop = () => { + setOpen(null); + setState(logsParamsToState(new URLSearchParams(window.location.search))); + }; + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, []); const update = (patch: Partial) => { setOpen(null); @@ -184,9 +195,18 @@ export default function AdminLogs() { return facets.data ? sum(levels.filter((f) => inGroup.includes(f.value))) : undefined; }; + // A pivot follows a link to another view, so it is its own history entry and Back returns to the + // view it was made from. The other filters are adjustments of the view on screen and replace it. const pivot = (patch: Pick) => { setLive(false); - update({ ...PIVOT_RESET, ...patch }); + const next = { ...state, ...PIVOT_RESET, ...patch }; + window.history.pushState( + { ...rememberNavPosition(), sfScroll: nextNavId() }, + "", + urlWithParams(logsParamUpdates(next)), + ); + notifyNav(); + update(next); }; // "Any", then what the facet counted. The value in use stays offered even at zero — under the