fix(activity): an ad-hoc run's row is one run's, a rollback keeps a run whole, Back reads one entry's filters

- The channel-backfill row says "running" from the runs this process has open, not from the table:
  a row whose outcome failed to write no longer reads as running until a restart. The run still
  going leads the row, so a newer run's outcome never sits under the older one's channel.
- Migration 0109's downgrade carries every run's trigger, spend and start in the audit row, and the
  upgrade restores them: a manual run whose admin was deleted stays manual.
- Back/Forward is read by the page's one popstate listener, both halves in one render; the server
  log adopts its half during that render, so no read goes out with one entry's filters and the
  other's range.
- The server log's failed facets use the other tabs' words (the menus may be short); the accounts
  message shows only when the menu really lacks them.
This commit is contained in:
2026-10-18 17:32:22 +02:00
parent fad656988a
commit 13b7577d9f
12 changed files with 188 additions and 72 deletions
+47 -2
View File
@@ -45,6 +45,7 @@ vi.mock("../lib/api", async (importOriginal) => {
};
});
import { qk } from "../lib/queryKeys";
import Activity from "./Activity";
import { ChoiceContext } from "./confirmContext";
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
@@ -90,8 +91,7 @@ const page = <T,>(items: T[]): HistoryPage<T> => ({
next_cursor: null,
});
function renderPage() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function renderPage(qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
return render(
<QueryClientProvider client={qc}>
{/* Every confirmation is answered yes. */}
@@ -259,6 +259,16 @@ describe("Activity", () => {
expect(usersMock).toHaveBeenCalledTimes(2);
});
it("accounts that could not be refreshed, still listed, are not called unloaded", async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0 } } });
qc.setQueryData(qk.adminUsers(), [{ id: 1, email: "admin@example.com" }]);
usersMock.mockRejectedValue(new Error("502"));
renderPage(qc);
await waitFor(() => expect(usersMock).toHaveBeenCalled());
await waitFor(() => expect(qc.getQueryState(qk.adminUsers())?.status).toBe("error"));
expect(screen.queryByText("activity.accountsFailed")).toBeNull();
});
it("clearing the admin actions reads the list again", async () => {
window.history.replaceState(null, "", "/?page=activity&at=admin");
renderPage();
@@ -285,6 +295,41 @@ describe("Activity", () => {
expect(after).toBe(live); // the same log, not a fresh one
});
it("Back/Forward on the server log reads only the filters of the entry it lands on", async () => {
// A browser runs the microtasks between two popstate listeners, so each listener's update
// renders on its own; a test dispatch does not. Each listener runs in its own act here.
const listeners: EventListener[] = [];
const addOther = window.addEventListener.bind(window);
const removeOther = window.removeEventListener.bind(window);
const add = vi.spyOn(window, "addEventListener").mockImplementation((type, l, o) => {
if (type === "popstate") listeners.push(l as EventListener);
else addOther(type, l, o);
});
const remove = vi.spyOn(window, "removeEventListener").mockImplementation((type, l, o) => {
if (type === "popstate") listeners.splice(listeners.indexOf(l as EventListener), 1);
else removeOther(type, l, o);
});
window.history.replaceState(null, "", "/?page=activity&at=logs&aw=24h");
renderPage();
await waitFor(() => expect(logsMock).toHaveBeenCalled());
logsMock.mockClear();
// Forward onto a pivot's entry: one run's lines over the log's whole retention.
window.history.pushState(null, "", "/?page=activity&at=logs&aw=90d&lrun=41");
for (const l of [...listeners]) {
await act(async () => l(new PopStateEvent("popstate")));
}
await waitFor(() =>
expect(logsMock).toHaveBeenLastCalledWith(expect.objectContaining({ run_id: 41 }), null),
);
const days = (since?: string) => (Date.now() - Date.parse(since ?? "")) / 86_400_000;
// Never one run's lines over a day, nor the whole log over 90 days.
expect(logsMock.mock.calls.map(([q]) => [q.run_id ?? null, days(q.since) > 30])).toEqual(
logsMock.mock.calls.map(() => [41, true]),
);
add.mockRestore();
remove.mockRestore();
});
it("an action whose request's lines are gone says so instead of linking to an empty list", async () => {
auditMock.mockResolvedValue(page([{ ...ACTION, log_lines: 0 }]));
window.history.replaceState(null, "", "/?page=activity&at=admin");
+18 -6
View File
@@ -15,7 +15,12 @@ import {
type ActivityWindow,
} from "../lib/activityUrlState";
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
import { DEFAULT_LOGS_STATE, logsParamUpdates } from "../lib/logsUrlState";
import {
DEFAULT_LOGS_STATE,
logsParamsToState,
logsParamUpdates,
type LogsUrlState,
} from "../lib/logsUrlState";
import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced";
import { urlWithParams } from "../lib/urlState";
@@ -55,17 +60,23 @@ export default function Activity() {
// text then applies at once instead of after the typing debounce.
const [jumps, setJumps] = useState(0);
// Bumped by a pivot only: the server log, already open, takes the pivot's filters from the address
// bar when it mounts again. Back/Forward it follows in place (AdminLogs) — a popstate is also a
// dialog closing, onto the same address, and a fresh log would drop its Live and its focus.
// bar when it mounts again. Back/Forward it follows in place — a popstate is also a dialog closing,
// onto the same address, and a fresh log would drop its Live and its focus.
const [pivots, setPivots] = useState(0);
// The log's half of the entry Back/Forward landed on, read here with this page's half: one listener,
// so both change in one render. Two listeners rendered in between (a browser runs the microtasks
// after each), and that render read the log with one entry's filters and the other's range.
const [landed, setLanded] = useState<{ logs: LogsUrlState } | null>(null);
const q = useDebounced(state.q, 300, jumps);
const panel = useTabPanel();
// `page` too: a link to a page this one replaced (audit, logs) is rewritten to this page's id.
useUrlMirror({ page: "activity", ...activityParamUpdates(state) });
useEffect(() => {
const onPop = () => {
const params = new URLSearchParams(window.location.search);
setJumps((n) => n + 1);
setState(activityParamsToState(new URLSearchParams(window.location.search)));
setState(activityParamsToState(params));
setLanded({ logs: logsParamsToState(params) });
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
@@ -106,7 +117,8 @@ export default function Activity() {
<SharedBar state={state} onChange={update} />
</div>
</PageToolbar>
{users.isError && (
{/* Only when the menu really lacks them: a refresh that failed keeps the list it had. */}
{users.isError && !users.data && (
<StateMessage tone="error" compact onRetry={() => void users.refetch()}>
{t("activity.accountsFailed")}
</StateMessage>
@@ -135,7 +147,7 @@ export default function Activity() {
) : state.tab === "logs" ? (
// Keyed by the pivots: a pivot hands the log its filters through the address bar, which it
// reads when it mounts.
<AdminLogs key={pivots} shared={shared} onPivot={pivot} />
<AdminLogs key={pivots} shared={shared} landed={landed} onPivot={pivot} />
) : (
<ActivityQuota shared={shared} />
)}
@@ -295,6 +295,14 @@ describe("AdminLogs", () => {
expect((await screen.findByRole("alert")).textContent).toContain("adminLogs.olderFailed");
});
it("facets that failed say the filter menus may be short, in the other tabs' words", async () => {
logsMock.mockResolvedValue({ items: [row(1)], accounts: {}, next_cursor: null });
facetsMock.mockRejectedValue(new Error("500"));
renderConsole();
await waitFor(() => expect(lines()).toHaveLength(1));
expect((await screen.findByRole("alert")).textContent).toContain("activity.filtersFailed");
});
it("offers no older page while a filter change loads", async () => {
let landErrors!: (page: LogPage) => void;
logsMock.mockImplementation((q) =>
+20 -18
View File
@@ -1,4 +1,4 @@
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
keepPreviousData,
@@ -57,9 +57,12 @@ export type LogPivot = Pick<LogsUrlState, "requestId" | "jobId" | "runId">;
export default function AdminLogs({
shared,
landed,
onPivot,
}: {
shared: Shared;
/** The filters of the entry Back/Forward landed on (the page reads the address bar, once). */
landed: { logs: LogsUrlState } | null;
/** Show only one request's, job's or run's lines (the page clears every other filter). */
onPivot: (pivot: LogPivot) => void;
}) {
@@ -84,18 +87,16 @@ export default function AdminLogs({
const [open, setOpen] = useState<number | null>(null);
useUrlMirror(logsParamUpdates(state));
// Back/Forward lands on another entry's filters; a dialog closing lands on these same ones, and
// changes nothing (Live and the open line stay).
const onLanded = useEffectEvent(() => {
const landed = logsParamsToState(new URLSearchParams(window.location.search));
if (JSON.stringify(landed) === JSON.stringify(state)) return;
setOpen(null);
setState(landed);
});
useEffect(() => {
const onPop = () => onLanded();
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
// changes nothing (Live and the open line stay). Adopted during the render that brings it, with
// the page's half.
const [seen, setSeen] = useState(landed);
if (landed !== seen) {
setSeen(landed);
if (landed && JSON.stringify(landed.logs) !== JSON.stringify(state)) {
setOpen(null);
setState(landed.logs);
}
}
const update = (patch: Partial<LogsUrlState>) => {
setOpen(null);
setState((s) => ({ ...s, ...patch }));
@@ -127,15 +128,16 @@ export default function AdminLogs({
? 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.)
// failure means for them. (With no lines, the list's own error state says it.) Without the facets
// the Source and Event menus offer only "Any", as on the other tabs, so the same words say it.
const failure = !logs.data
? null
: logs.isFetchNextPageError
? "olderFailed"
? "adminLogs.olderFailed"
: logs.isError || (facets.isError && facets.data)
? "stale"
? "adminLogs.stale"
: facets.isError
? "countsFailed"
? "activity.filtersFailed"
: null;
const retryFailed = () => {
if (logs.isFetchNextPageError)
@@ -229,7 +231,7 @@ export default function AdminLogs({
list={logs}
rowCount={rows.length}
rowsClassName="font-mono text-2xs"
failure={failure && { text: t(`adminLogs.${failure}`), onRetry: retryFailed }}
failure={failure && { text: t(failure), onRetry: retryFailed }}
older={
olderCursor !== null
? { label: t("adminLogs.older"), onClick: loadOlder, disabled: logs.isFetchingNextPage }