fix(activity): an old audit/log link opens its tab, times read relative, one name for admin actions

Seen in the browser, not in the page's own tests: the navigation provider rewrites a retired page id
to `activity` before the page's chunk reads the URL, so `?page=audit` landed on the Runs tab. The
retired-page map now carries the params that open the successor's part (`at`), and the provider
writes them as it rewrites the page — on load and on Back alike.

A list time is relative with the exact second on hover (the DESIGN copy rule); a run moved over from
the audit log, whose start and end are one instant, shows no length instead of an invented "1 sec".
The audit log is called "admin actions" everywhere it is named (the clear button and its confirm, the
Configuration group, the cleanup job, which also prunes the run history its description left out).
This commit is contained in:
2026-10-18 04:00:38 +02:00
parent fe6308d844
commit c288ba147b
18 changed files with 133 additions and 92 deletions
+21 -4
View File
@@ -3,7 +3,7 @@
// The activity page around its run and audit lists (the server-log tab has its own file): the view
// is the URL — a link narrows the runs to one job, a status chip narrows them further; a run opens
// to its log lines and an admin action to its request's, each a new history entry that Back undoes;
// a link to the retired audit page opens the admin tab; the account filter reaches the quota tab; a
// the account filter reaches the quota tab; a
// failed read is an error with a retry, never an empty list.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
@@ -168,11 +168,10 @@ describe("Activity", () => {
expect(screen.getByText("quotaExceeded")).toBeTruthy();
});
it("a link to the retired audit page opens the admin tab, and an action opens its request's lines", async () => {
window.history.replaceState(null, "", "/?page=audit");
it("the admin tab lists the actions, and an action opens its request's lines", async () => {
window.history.replaceState(null, "", "/?page=activity&at=admin");
renderPage();
await waitFor(() => expect(screen.getByText("auditActions.scheduler_interval")).toBeTruthy());
expect(url().get("page")).toBe("activity");
expect(runsMock).not.toHaveBeenCalled();
fireEvent.click(rowButton(/auditActions\.scheduler_interval/));
@@ -197,6 +196,24 @@ describe("Activity", () => {
expect(Math.round(days)).toBe(30);
});
// The runs moved over from the audit log kept one instant for start and end: their length is
// unknown, and a "1 sec" would be invented.
it("a run whose start and end are one instant has no length", async () => {
window.history.replaceState(null, "", "/?page=activity");
runsMock.mockResolvedValue(
page([
{ ...RUN, id: 1, status: "ok", error: null, finished_at: RUN.started_at, summary: "moved" },
{ ...RUN, id: 2, status: "ok", error: null, summary: "timed" },
]),
);
renderPage();
await waitFor(() => expect(screen.getByText("moved")).toBeTruthy());
const cells = (text: string) =>
[...rowButton(new RegExp(text)).children].map((c) => c.textContent);
expect(cells("moved")).toContain("—");
expect(cells("timed")).not.toContain("—");
});
it("a failed read is an error with a retry, not an empty list", async () => {
window.history.replaceState(null, "", "/?page=activity");
runsMock.mockRejectedValue(new Error("502"));
+12 -7
View File
@@ -7,7 +7,7 @@ import { api, type AuditQuery, type AuditRow } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { sinceOf } from "../lib/activityUrlState";
import { auditActionLabel, auditText } from "../lib/auditText";
import { formatDateTime, formatDateTimeSec } from "../lib/format";
import { formatDateTimeSec } from "../lib/format";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { useHistoryList } from "../lib/useHistoryList";
@@ -15,6 +15,7 @@ import type { Shared } from "./ActivityRuns";
import { useConfirm } from "./confirmContext";
import HistoryList from "./HistoryList";
import HistoryRow from "./HistoryRow";
import { RelativeTime } from "./ui/RelativeTime";
import { Button } from "./ui/Button";
import FilterSelect from "./ui/FilterSelect";
@@ -134,17 +135,21 @@ export default function ActivityAudit({
cells={
<>
<span className="truncate font-medium">{auditActionLabel(t, row.action)}</span>
<span className="tabular-nums text-muted sm:hidden">
{formatDateTime(row.created_at, lang)}
</span>
<RelativeTime
at={row.created_at}
seconds
className="tabular-nums text-muted sm:hidden"
/>
<span
className={`hidden truncate sm:block ${who ? "text-muted" : "italic text-muted"}`}
>
{who ?? t("audit.system")}
</span>
<span className="hidden tabular-nums text-muted sm:block">
{formatDateTime(row.created_at, lang)}
</span>
<RelativeTime
at={row.created_at}
seconds
className="hidden tabular-nums text-muted sm:block"
/>
<span className="col-span-2 truncate text-muted sm:col-span-1">
{[head, sub].filter(Boolean).join(" · ")}
</span>
+11 -8
View File
@@ -9,12 +9,13 @@ import {
type ActivityUrlState,
type RunStatusFilter,
} from "../lib/activityUrlState";
import { formatDateTime, formatDateTimeSec, formatNumber, formatRuntime } from "../lib/format";
import { formatDateTimeSec, formatNumber, formatRuntime } from "../lib/format";
import { jobFailureText, jobResultText } from "../lib/jobResult";
import { qk } from "../lib/queryKeys";
import { useHistoryList } from "../lib/useHistoryList";
import HistoryList from "./HistoryList";
import HistoryRow from "./HistoryRow";
import { RelativeTime } from "./ui/RelativeTime";
import { StatusDot } from "./RunStatusDot";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
@@ -140,6 +141,8 @@ export default function ActivityRuns({
);
}
/** How long the run took; null while it runs, 0 when its start and end are one instant (the runs
* moved over from the audit log kept only when they were written — the length is unknown). */
function seconds(run: RunRow): number | null {
if (!run.finished_at) return null;
return (new Date(run.finished_at).getTime() - new Date(run.started_at).getTime()) / 1000;
@@ -175,18 +178,18 @@ function RunCells({ run, actor }: { run: RunRow; actor: string | null | undefine
{run.target_title && <span className="font-normal text-muted"> · {run.target_title}</span>}
</span>
{/* A phone has room for the name and the time only; the row opens to the rest. */}
<span className="tabular-nums text-muted sm:hidden">
{formatDateTime(run.started_at, lang)}
</span>
<RelativeTime at={run.started_at} seconds className="tabular-nums text-muted sm:hidden" />
<span className="hidden truncate text-muted sm:block">
{t(`scheduler.trigger.${run.trigger}`)}
{actor && ` · ${actor}`}
</span>
<RelativeTime
at={run.started_at}
seconds
className="hidden tabular-nums text-muted sm:block"
/>
<span className="hidden tabular-nums text-muted sm:block">
{formatDateTime(run.started_at, lang)}
</span>
<span className="hidden tabular-nums text-muted sm:block">
{secs === null ? "—" : formatRuntime(Math.max(1, secs), lang)}
{secs ? formatRuntime(Math.max(1, secs), lang) : "—"}
</span>
<span className="hidden tabular-nums text-muted sm:block">
{formatNumber(run.quota_units, lang)}
+4 -18
View File
@@ -2,10 +2,10 @@
//
// The activity page's server-log tab, rendered through the page (which owns the shared filters and
// the pivots). A request or run id narrows the list to that request or run (and says so, in the
// filter row and the URL), and Back returns to the view the pivot was made from; an old link to the
// retired log page opens this tab; the account in use stays pickable; "Live" watches 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.
// filter row and the URL), and Back returns to the view the pivot was made from; the account in use
// stays pickable; "Live" watches 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 { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -199,20 +199,6 @@ describe("AdminLogs", () => {
expect(new URLSearchParams(window.location.search).get("lrun")).toBe("77");
});
it("an old link to the server log page opens this tab with its filter", async () => {
window.history.replaceState(null, "", "/?page=logs&lreq=req-00000009");
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
renderConsole();
await waitFor(() =>
expect(logsMock).toHaveBeenCalledWith(
expect.objectContaining({ request_id: "req-00000009" }),
null,
),
);
expect(new URLSearchParams(window.location.search).get("page")).toBe("activity");
});
it("keeps the account in use offered when the account list does not have it", async () => {
window.history.replaceState(null, "", "/?page=activity&at=logs&au=7");
logsMock.mockResolvedValue({ items: [], accounts: {}, next_cursor: null });
@@ -219,6 +219,37 @@ describe("NavigationProvider · Back", () => {
expect(window.location.search).toBe("?page=files&keep=1");
});
// The audit log and the server log became the activity page's tabs: an old link, bookmark or entry
// opens that tab, with the filters it carried.
it("opens a retired page's tab on the page that replaced it", () => {
window.history.replaceState(null, "", "/?page=audit&keep=1");
const { result } = render();
expect(result.current.page).toBe("activity");
expect(window.location.search).toBe("?page=activity&keep=1&at=admin");
});
it("keeps a retired page's own filter as it opens its tab", () => {
window.history.replaceState(null, "", "/?page=logs&lreq=req-00000009");
render();
const url = new URLSearchParams(window.location.search);
expect([url.get("page"), url.get("at"), url.get("lreq")]).toEqual([
"activity",
"logs",
"req-00000009",
]);
});
it("opens a retired page's tab when Back lands on its entry", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
act(() => {
window.history.replaceState({ sfPage: "audit" }, "", "/?page=audit");
window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "audit" } }));
});
expect(result.current.page).toBe("activity");
expect(window.location.search).toBe("?page=activity&at=admin");
});
// lib/history's own listener takes a spent focus stamp off the entry it lands on; the rewrite must
// not put it back, or the next landing there (an overlay closing) would refocus a stale item.
it("does not put back the focus stamp the landing spent", () => {
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
import { useLatest } from "../lib/useLatest";
import { useTranslation } from "react-i18next";
import { useMe } from "../lib/useMe";
import { resolvePage, urlWithParams, type Page } from "../lib/urlState";
import { resolvePage, retiredParams, urlWithParams, type Page } from "../lib/urlState";
import { ACTIVITY_KEYS } from "../lib/activityUrlState";
import { pageTitleKey } from "../lib/pageMeta";
import { useDocumentObjectTitle } from "../lib/documentTitle";
@@ -193,7 +193,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
// ?chan / ?v / ?pl / filter params the loaders already read.
const url = urlWithParams({ page: mountPage === "feed" ? null : mountPage });
const url = urlWithParams({
// A retired page's link also opens its part of the page that replaced it (an `at` tab).
...retiredParams(new URLSearchParams(window.location.search).get("page")),
page: mountPage === "feed" ? null : mountPage,
});
if (st._yt && st._ytScrape) {
window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
} else {
@@ -213,7 +217,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
window.history.replaceState(
{ ...window.history.state, sfPage: p },
"",
urlWithParams({ page: p === "feed" ? null : p }),
urlWithParams({ ...retiredParams(sf), page: p === "feed" ? null : p }),
);
setPageState(p);
setAccountRaw(PAGE_KEY, p);