diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a4028ddf..7e4c78be74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,8 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ### Smaller changes +- Settings > Activity and Admin > Audit reach past the newest 50 events: "Show older events" under + the last row loads the next page, and the count says "Newest 50 events" until nothing is older. - "OpenBot is running" opens on its heading again. Its Updates disclosure, and the line above it, sit under Show OpenBot and Stop OpenBot, as on the stopped screen, instead of at the top of the card with nothing above the line. diff --git a/app/src/components/audit/audit-table.tsx b/app/src/components/audit/audit-table.tsx index 988dcb972f..ba06f5d14c 100644 --- a/app/src/components/audit/audit-table.tsx +++ b/app/src/components/audit/audit-table.tsx @@ -97,6 +97,8 @@ const FACETS = [ export function AuditTable({ deploymentName = "this deployment", events, + footer, + moreToLoad = false, nameFor, query, storedNames = false, @@ -105,13 +107,18 @@ export function AuditTable({ /** What OpenBot itself is called in a row it started: "this deployment" in Admin. */ deploymentName?: string; events: AuditEvent[]; + /** Drawn under the last row, inside the scroll: where older events are asked for. */ + footer?: React.ReactNode; + /** Whether the server has older events than `events`, so the count says these are the newest. */ + moreToLoad?: boolean; nameFor: NameFor; query: string; /** Whether a row's What shows the trail's stored name on hover: Admin's, not a person's. */ storedNames?: boolean; /** - * Drawn above the table, given how many rows it shows ("3 events", "1 of 3 events") and the filter - * buttons to place beside the search. It stays put; only the rows under it scroll. + * Drawn above the table, given how many rows it shows ("3 events", "1 of 3 events", "Newest 50 + * events" while older ones are still to load) and the filter buttons to place beside the search. + * It stays put; only the rows under it scroll. */ toolbar: (parts: { count: string; @@ -144,7 +151,7 @@ export function AuditTable({ return ( <> {toolbar({ - count: countLabel(rows.length, events.length), + count: countLabel(rows.length, events.length, moreToLoad), filters: FACETS.map((facet) => { const column = table.getColumn(facet.id); const selected = (column?.getFilterValue() as string[]) ?? []; @@ -217,6 +224,16 @@ export function AuditTable({ ); })} + {footer && rows.length > 0 ? ( + + + {footer} + + + ) : null} {events.length > 0 && rows.length === 0 ? ( @@ -224,6 +241,7 @@ export function AuditTable({ Nothing loaded matches this search and these filters. + {footer} ) : null} @@ -315,10 +333,24 @@ function Facet({ ); } -/** How many rows the table is showing, for the toolbar. */ -function countLabel(shown: number, total: number): string { - const noun = total === 1 ? "event" : "events"; - return shown === total ? `${total} ${noun}` : `${shown} of ${total} ${noun}`; +/** + * How many rows the table is showing, for the toolbar. While the server has older events than those + * loaded, the count names them the newest, so it never reads as the size of the whole trail. + */ +function countLabel( + shown: number, + loaded: number, + moreToLoad: boolean, +): string { + const noun = loaded === 1 ? "event" : "events"; + if (moreToLoad) { + return shown === loaded + ? `Newest ${loaded} ${noun}` + : `${shown} of the newest ${loaded} ${noun}`; + } + return shown === loaded + ? `${loaded} ${noun}` + : `${shown} of ${loaded} ${noun}`; } function auditColumns( diff --git a/app/src/components/manage/audit-page.tsx b/app/src/components/manage/audit-page.tsx index 5acc77a62a..3ebd3cafb4 100644 --- a/app/src/components/manage/audit-page.tsx +++ b/app/src/components/manage/audit-page.tsx @@ -9,7 +9,7 @@ import { IconSearch, IconX, } from "@tabler/icons-react"; -import { useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { type ReactNode, useState } from "react"; import type { AuditEvent } from "@/components/audit/audit-cells"; import { AuditTable } from "@/components/audit/audit-table"; @@ -18,10 +18,10 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useBotNames } from "@/lib/agents/bot-names"; import { + CALLS_RECORDED_AS_CHANGES, COMPUTER_ACTION_EVENT_TYPES, DID_NOT_HAPPEN_EVENT_TYPES, eventTypeFilter, - CALLS_RECORDED_AS_CHANGES, OPENBOT_WORKING_EVENT_TYPES, oneRowPerAction, REFUSED_EVENT_TYPES, @@ -146,14 +146,15 @@ export function AuditPage({ ? withoutOpenBotWorking(withBot(view, bot)) : withBot(view, bot); const [query, setQuery] = useState(""); - const events = useQuery(auditEventsQueryOptions(search)); + // A page at a time, newest first; "Show older events" under the last row asks for the next. + const events = useInfiniteQuery(auditEventsQueryOptions(search)); // Activity names a skill by the title its page shows; the trail keeps only its slug. const skills = useQuery({ ...pluginsPageQueryOptions(), enabled: edition === "personal", }).data?.skills; const rows = withSkillTitles( - oneRowPerAction((events.data?.events ?? []) as AuditEvent[]), + oneRowPerAction(events.data?.pages.flatMap((page) => page.events) ?? []), skills, ); const nameFor = useBotNames(); @@ -211,7 +212,8 @@ export function AuditPage({
- {events.isPending ? null : events.isError ? ( + {events.isPending ? null : events.isError && + !events.isFetchNextPageError ? (

Couldn't load these events. Reload the page.

@@ -219,6 +221,28 @@ export function AuditPage({ + {events.isFetchNextPageError ? ( +

+ Couldn't load older events. Try again. +

+ ) : null} + +
+ ) : null + } + moreToLoad={events.hasNextPage} nameFor={nameFor} query={query} storedNames={edition !== "personal"} diff --git a/app/src/lib/audit/queries.ts b/app/src/lib/audit/queries.ts index 60ed55bc86..8658a8ce0e 100644 --- a/app/src/lib/audit/queries.ts +++ b/app/src/lib/audit/queries.ts @@ -1,17 +1,41 @@ -import { queryOptions } from "@tanstack/react-query"; +import { infiniteQueryOptions } from "@tanstack/react-query"; +import type { AuditEvent } from "@/components/audit/audit-cells"; import { client } from "@/lib/client"; -export const auditKeys = { all: ["audit-events"] as const }; +export const auditKeys = { + all: ["audit-events"] as const, + list: (search = "") => ["audit-events", "list", { search }] as const, +}; +/** One page of the trail as `GET /api/admin/audit-events` answers it, newest first. */ +export type AuditEventsPage = { + events: AuditEvent[]; + /** Where the next, older page starts. Absent on the last page. */ + nextCursor?: string; +}; + +/** + * The trail for one view (`search`, e.g. `?eventType=...`), a page at a time. + * + * Paged because the trail only grows. This used to ask once and never follow `nextCursor`, so the + * page showed the newest 50 events, called them the whole trail, and had no way to reach the rest. + */ export function auditEventsQueryOptions(search = "") { - return queryOptions({ - queryKey: [...auditKeys.all, search] as const, - queryFn: async () => { - const response = await client(`/api/admin/audit-events${search}`, { - fallback: "Could not load audit events", - }); + return infiniteQueryOptions({ + queryKey: auditKeys.list(search), + initialPageParam: "", + queryFn: async ({ pageParam }): Promise => { + // `search` is empty or starts with `?`; an older page carries the cursor after it. + const cursor = pageParam + ? `${search ? "&" : "?"}cursor=${encodeURIComponent(pageParam)}` + : ""; + const response = await client( + `/api/admin/audit-events${search}${cursor}`, + { fallback: "Could not load audit events" }, + ); return response.json(); }, + getNextPageParam: (page: AuditEventsPage) => page.nextCursor || undefined, }); } diff --git a/app/tests/activity-older-events.test.tsx b/app/tests/activity-older-events.test.tsx new file mode 100644 index 0000000000..6e8d20c061 --- /dev/null +++ b/app/tests/activity-older-events.test.tsx @@ -0,0 +1,143 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import type { ComponentType } from "react"; +import { agentKeys } from "@/lib/agents/queries"; +import { deploymentKeys } from "@/lib/deployment/queries"; +import { pluginKeys } from "@/lib/plugins/queries"; +import { Route as ActivityRoute } from "@/routes/_authed/settings/_personal/activity"; +import { settleReactWork } from "./settle-react-work"; + +/* + * FOUND ON A USED INSTALL (personal edition, 1.0 RC1 candidate, 298 events in the trail): Settings > + * Activity showed the newest 50 events as "49 events", as if that were everything, with no way to + * see an older one. The server answered `nextCursor` and the page never asked for it. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(() => cleanup()); + +const originalFetch = globalThis.fetch; +let asked: URL[] = []; + +afterAll(async () => { + globalThis.fetch = originalFetch; + await settleReactWork(); + await GlobalRegistrator.unregister(); +}); + +/** A row as `GET /api/admin/audit-events` answers it: the stored columns, `createdAt` as ISO. */ +function storedRow(n: number) { + return { + id: `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`, + actorUserId: "dev", + initiatorKind: "person", + initiatorId: null, + eventType: "computer.action_allowed", + targetType: "computer", + targetId: "personal-assistant", + payload: { + bot: "personal-assistant", + action: "computer_click", + element: { name: `Button ${n}`, role: "button" }, + }, + // Newest first, a minute apart: row 1 is the newest. + createdAt: new Date( + Date.UTC(2026, 10, 8, 12, 0) - n * 60_000, + ).toISOString(), + }; +} + +// The server's own cursor encoding (`encodeCursor` in server/src/audit.ts): the last row's time and id. +const CURSOR = Buffer.from( + JSON.stringify({ createdAt: storedRow(50).createdAt, id: storedRow(50).id }), +).toString("base64url"); + +/** The server's two pages: 50 rows and where the next starts, then the last 10 with no cursor. */ +function serve(url: URL) { + if (!url.searchParams.has("cursor")) { + return { + events: Array.from({ length: 50 }, (_, i) => storedRow(i + 1)), + nextCursor: CURSOR, + }; + } + // `nextCursor` is undefined on the last page, which `context.json` leaves out of the body. + return { events: Array.from({ length: 10 }, (_, i) => storedRow(i + 51)) }; +} + +test("Activity names what it shows as the newest events, and Show older events reaches the rest", async () => { + asked = []; + globalThis.fetch = Object.assign( + async (input: RequestInfo | URL) => { + const url = new URL(String(input), "http://127.0.0.1"); + if (url.pathname === "/api/admin/audit-events") { + asked.push(url); + return Response.json(serve(url)); + } + return Response.json({ error: "not served" }, { status: 404 }); + }, + { preconnect: originalFetch.preconnect }, + ); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, + }, + }); + queryClient.setQueryData(deploymentKeys.capabilities(), { + generativeUi: true, + selfHostBanner: false, + localEvaluation: true, + edition: "personal", + }); + queryClient.setQueryData(pluginKeys.page(), { + catalogue: [], + servers: [], + skills: [], + }); + queryClient.setQueryData(agentKeys.list(false), [ + { id: "personal-assistant", name: "Personal Assistant" }, + ]); + queryClient.setQueryData(agentKeys.list(true), []); + const Page = ActivityRoute.options.component as ComponentType; + const router = createRouter({ + routeTree: createRootRoute({ component: () => }), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + const view = render( + + + , + ); + + // The first page, and a count that does not pass itself off as the whole trail. + await view.findByText("Newest 50 events"); + expect(view.container.textContent).toContain("Button 50"); + expect(view.container.textContent).not.toContain("Button 51"); + + fireEvent.click(view.getByRole("button", { name: "Show older events" })); + + // The older page, asked for with the same view and the server's cursor, under the newer rows. + await view.findByText("60 events"); + expect(view.container.textContent).toContain("Button 60"); + expect(view.container.querySelectorAll("tbody tr[data-outcome]").length).toBe( + 60, + ); + expect(asked).toHaveLength(2); + expect(asked[1]?.searchParams.get("cursor")).toBe(CURSOR); + expect(asked[1]?.searchParams.get("excludeEventType")).toBe( + asked[0]?.searchParams.get("excludeEventType") ?? "missing", + ); + // The last page has no cursor, so there is nothing older to offer. + await waitFor(() => + expect( + view.queryByRole("button", { name: "Show older events" }), + ).toBeNull(), + ); +}); diff --git a/app/tests/activity-page-name.test.tsx b/app/tests/activity-page-name.test.tsx index 21297e9c72..a5959a7deb 100644 --- a/app/tests/activity-page-name.test.tsx +++ b/app/tests/activity-page-name.test.tsx @@ -12,11 +12,11 @@ import type { ComponentType } from "react"; import { withoutOpenBotWorking } from "@/components/manage/audit-page"; import { settingsSidebarItems } from "@/components/settings/settings-sidebar"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; import { deploymentKeys } from "@/lib/deployment/queries"; import { managePaths } from "@/lib/manage-paths"; import { Route as AdminAuditRoute } from "@/routes/_authed/admin/audit"; import { Route as ActivityRoute } from "@/routes/_authed/settings/_personal/activity"; +import { seedAuditPage } from "./seed-audit-page"; import { settleReactWork } from "./settle-react-work"; /* @@ -45,8 +45,9 @@ async function renderAs(edition: "team" | "personal", Page: ComponentType) { edition, }); // Activity asks without OpenBot's own working; Admin asks for everything. - queryClient.setQueryData( - [...auditKeys.all, edition === "personal" ? withoutOpenBotWorking("") : ""], + seedAuditPage( + queryClient, + edition === "personal" ? withoutOpenBotWorking("") : "", { events: [] }, ); queryClient.setQueryData(agentKeys.list(false), []); diff --git a/app/tests/activity-person-words.test.tsx b/app/tests/activity-person-words.test.tsx index b59e7f8b72..faaf3efcf0 100644 --- a/app/tests/activity-person-words.test.tsx +++ b/app/tests/activity-person-words.test.tsx @@ -8,15 +8,15 @@ import { RouterProvider, } from "@tanstack/react-router"; import { cleanup, fireEvent, render } from "@testing-library/react"; +import { ServerToolLine } from "@/components/channels/chat-transcript"; import { AuditPage, withoutOpenBotWorking, } from "@/components/manage/audit-page"; -import { ServerToolLine } from "@/components/channels/chat-transcript"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; import { deploymentKeys } from "@/lib/deployment/queries"; import { pluginKeys } from "@/lib/plugins/queries"; +import { seedAuditPage } from "./seed-audit-page"; import { settleReactWork } from "./settle-react-work"; /* @@ -137,8 +137,9 @@ async function renderAs(edition: "team" | "personal") { localEvaluation: edition === "personal", edition, }); - queryClient.setQueryData( - [...auditKeys.all, edition === "personal" ? withoutOpenBotWorking("") : ""], + seedAuditPage( + queryClient, + edition === "personal" ? withoutOpenBotWorking("") : "", { events: ROWS }, ); queryClient.setQueryData(pluginKeys.page(), { diff --git a/app/tests/audit-approval-outcomes.test.tsx b/app/tests/audit-approval-outcomes.test.tsx index d6581ae95c..6f9ff1f75f 100644 --- a/app/tests/audit-approval-outcomes.test.tsx +++ b/app/tests/audit-approval-outcomes.test.tsx @@ -16,7 +16,7 @@ import { eventTypeFilter, REFUSED_EVENT_TYPES, } from "@/lib/audit/outcome"; -import { auditKeys } from "@/lib/audit/queries"; +import { seedAuditPage } from "./seed-audit-page"; /** * Settings › Activity after a Bot was asked to write two files with "Ask before making changes" on, @@ -140,7 +140,7 @@ async function renderActivity() { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, }, }); - queryClient.setQueryData([...auditKeys.all, ""], { events: ROWS }); + seedAuditPage(queryClient, "", { events: ROWS }); queryClient.setQueryData(agentKeys.list(false), []); const router = createRouter({ routeTree: createRootRoute({ component: () => }), diff --git a/app/tests/audit-connect-app-rows.test.tsx b/app/tests/audit-connect-app-rows.test.tsx index 758a07b926..72a0603a27 100644 --- a/app/tests/audit-connect-app-rows.test.tsx +++ b/app/tests/audit-connect-app-rows.test.tsx @@ -11,10 +11,10 @@ import { import { cleanup, render } from "@testing-library/react"; import type { ComponentType } from "react"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; import { deploymentKeys } from "@/lib/deployment/queries"; import { Route as AdminAuditRoute } from "@/routes/_authed/admin/audit"; import { Route as ActivityRoute } from "@/routes/_authed/settings/_personal/activity"; +import { seedAuditPage } from "./seed-audit-page"; import { settleReactWork } from "./settle-react-work"; /* @@ -116,7 +116,7 @@ async function renderPage( edition, }); for (const [search, events] of Object.entries(audit)) - queryClient.setQueryData([...auditKeys.all, search], { events }); + seedAuditPage(queryClient, search, { events }); queryClient.setQueryData(agentKeys.list(false), [ { id: BOT, name: "Personal Assistant" }, ]); diff --git a/app/tests/audit-dry-run-label.test.tsx b/app/tests/audit-dry-run-label.test.tsx index b206fd330c..5447ef6509 100644 --- a/app/tests/audit-dry-run-label.test.tsx +++ b/app/tests/audit-dry-run-label.test.tsx @@ -9,8 +9,8 @@ import { } from "@tanstack/react-router"; import { cleanup, render, within } from "@testing-library/react"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; import { Route as AuditRoute } from "@/routes/_authed/admin/audit"; +import { seedAuditPage } from "./seed-audit-page"; beforeAll(() => GlobalRegistrator.register()); @@ -44,7 +44,7 @@ async function renderAuditRow(row: Record) { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, }, }); - queryClient.setQueryData([...auditKeys.all, ""], { events: [row] }); + seedAuditPage(queryClient, "", { events: [row] }); queryClient.setQueryData(agentKeys.list(false), []); const rootRoute = createRootRoute({ component: AuditRoute.options.component, diff --git a/app/tests/audit-event-words.test.tsx b/app/tests/audit-event-words.test.tsx index 9aaa3e7597..b8aa91f03d 100644 --- a/app/tests/audit-event-words.test.tsx +++ b/app/tests/audit-event-words.test.tsx @@ -15,8 +15,8 @@ import { } from "@/components/manage/audit-page"; import { agentKeys } from "@/lib/agents/queries"; import { OPENBOT_WORKING_EVENT_TYPES } from "@/lib/audit/outcome"; -import { auditKeys } from "@/lib/audit/queries"; import { deploymentKeys } from "@/lib/deployment/queries"; +import { seedAuditPage } from "./seed-audit-page"; import { settleReactWork } from "./settle-react-work"; /* @@ -123,10 +123,10 @@ async function renderAs(edition: "team" | "personal", rows = ROWS) { }); // Activity asks without OpenBot's own working, and the server answers without it. if (edition === "personal") - queryClient.setQueryData([...auditKeys.all, withoutOpenBotWorking("")], { + seedAuditPage(queryClient, withoutOpenBotWorking(""), { events: rows.filter((row) => !LEFT_OUT.has(row.eventType)), }); - else queryClient.setQueryData([...auditKeys.all, ""], { events: rows }); + else seedAuditPage(queryClient, "", { events: rows }); queryClient.setQueryData(agentKeys.list(false), [ { id: "personal-assistant", name: "Personal Assistant" }, ]); diff --git a/app/tests/audit-export-truncated.test.tsx b/app/tests/audit-export-truncated.test.tsx index 7111a1297e..7b4f9e6d52 100644 --- a/app/tests/audit-export-truncated.test.tsx +++ b/app/tests/audit-export-truncated.test.tsx @@ -9,8 +9,8 @@ import { } from "@tanstack/react-router"; import { cleanup, fireEvent, render } from "@testing-library/react"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; import { Route as AuditRoute } from "@/routes/_authed/admin/audit"; +import { seedAuditPage } from "./seed-audit-page"; beforeAll(() => GlobalRegistrator.register()); @@ -34,7 +34,7 @@ async function exportWith(headers: Record) { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, }, }); - queryClient.setQueryData([...auditKeys.all, ""], { events: [] }); + seedAuditPage(queryClient, "", { events: [] }); queryClient.setQueryData(agentKeys.list(false), []); const router = createRouter({ routeTree: createRootRoute({ component: AuditRoute.options.component }), diff --git a/app/tests/audit-folder-actions.test.tsx b/app/tests/audit-folder-actions.test.tsx index acdb49e9b7..32c85c31c7 100644 --- a/app/tests/audit-folder-actions.test.tsx +++ b/app/tests/audit-folder-actions.test.tsx @@ -13,8 +13,8 @@ import { withoutOpenBotWorking, } from "@/components/manage/audit-page"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; import { deploymentKeys } from "@/lib/deployment/queries"; +import { seedAuditPage } from "./seed-audit-page"; import { settleReactWork } from "./settle-react-work"; /* @@ -117,7 +117,7 @@ async function renderActivity() { localEvaluation: true, edition: "personal", }); - queryClient.setQueryData([...auditKeys.all, withoutOpenBotWorking("")], { + seedAuditPage(queryClient, withoutOpenBotWorking(""), { events: ROWS, }); queryClient.setQueryData(agentKeys.list(false), [ diff --git a/app/tests/audit-grant-and-routine-rows.test.tsx b/app/tests/audit-grant-and-routine-rows.test.tsx index 84402a0319..7d148dd7af 100644 --- a/app/tests/audit-grant-and-routine-rows.test.tsx +++ b/app/tests/audit-grant-and-routine-rows.test.tsx @@ -19,11 +19,11 @@ import { cleanup, render } from "@testing-library/react"; import type { ComponentType } from "react"; import { GALLERY as ACTIVITY } from "@/components/gallery/activity"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; import * as gallery from "@/lib/copilot/gallery-registry"; import { deploymentKeys } from "@/lib/deployment/queries"; import { Route as AdminAuditRoute } from "@/routes/_authed/admin/audit"; import { Route as ActivityRoute } from "@/routes/_authed/settings/_personal/activity"; +import { seedAuditPage } from "./seed-audit-page"; import { settleReactWork } from "./settle-react-work"; /* @@ -119,7 +119,7 @@ async function renderPage( edition, }); for (const [search, events] of Object.entries(audit)) - queryClient.setQueryData([...auditKeys.all, search], { events }); + seedAuditPage(queryClient, search, { events }); queryClient.setQueryData(agentKeys.list(false), [ { id: BOT, name: "Personal Assistant" }, ]); diff --git a/app/tests/audit-grant-rows.test.tsx b/app/tests/audit-grant-rows.test.tsx index b5c28c3852..811f5aa002 100644 --- a/app/tests/audit-grant-rows.test.tsx +++ b/app/tests/audit-grant-rows.test.tsx @@ -14,8 +14,8 @@ import { } from "@/components/manage/audit-page"; import { agentKeys } from "@/lib/agents/queries"; import { OPENBOT_WORKING_EVENT_TYPES } from "@/lib/audit/outcome"; -import { auditKeys } from "@/lib/audit/queries"; import { deploymentKeys } from "@/lib/deployment/queries"; +import { seedAuditPage } from "./seed-audit-page"; import { settleReactWork } from "./settle-react-work"; /* @@ -142,8 +142,9 @@ async function renderAs(edition: "team" | "personal") { edition, }); // Activity asks without OpenBot's own working; Admin asks for everything. - queryClient.setQueryData( - [...auditKeys.all, edition === "personal" ? withoutOpenBotWorking("") : ""], + seedAuditPage( + queryClient, + edition === "personal" ? withoutOpenBotWorking("") : "", // What the server answers each: Activity's query leaves a build's components out. { events: diff --git a/app/tests/audit-openbot-setup-rows.test.tsx b/app/tests/audit-openbot-setup-rows.test.tsx index 2ce4b514cb..a0efb45811 100644 --- a/app/tests/audit-openbot-setup-rows.test.tsx +++ b/app/tests/audit-openbot-setup-rows.test.tsx @@ -20,11 +20,11 @@ import type { ComponentType } from "react"; import { GALLERY as ACTIVITY } from "@/components/gallery/activity"; import { GALLERY as TABLE } from "@/components/gallery/table"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; import * as gallery from "@/lib/copilot/gallery-registry"; import { deploymentKeys } from "@/lib/deployment/queries"; import { Route as AdminAuditRoute } from "@/routes/_authed/admin/audit"; import { Route as ActivityRoute } from "@/routes/_authed/settings/_personal/activity"; +import { seedAuditPage } from "./seed-audit-page"; import { settleReactWork } from "./settle-react-work"; /* @@ -204,7 +204,7 @@ async function renderPage( edition, }); for (const [search, events] of Object.entries(audit)) - queryClient.setQueryData([...auditKeys.all, search], { events }); + seedAuditPage(queryClient, search, { events }); queryClient.setQueryData(agentKeys.list(false), [ { id: BOT, name: "Personal Assistant" }, ]); diff --git a/app/tests/audit-plumbing-rows.test.tsx b/app/tests/audit-plumbing-rows.test.tsx index 661c6f0fc9..d7bc17f9c0 100644 --- a/app/tests/audit-plumbing-rows.test.tsx +++ b/app/tests/audit-plumbing-rows.test.tsx @@ -11,10 +11,10 @@ import { cleanup, render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ComponentType } from "react"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; import { deploymentKeys } from "@/lib/deployment/queries"; import { Route as AdminAuditRoute } from "@/routes/_authed/admin/audit"; import { Route as ActivityRoute } from "@/routes/_authed/settings/_personal/activity"; +import { seedAuditPage } from "./seed-audit-page"; import { settleReactWork } from "./settle-react-work"; /* @@ -86,7 +86,7 @@ async function renderPage( edition, }); for (const [search, events] of Object.entries(audit)) - queryClient.setQueryData([...auditKeys.all, search], { events }); + seedAuditPage(queryClient, search, { events }); queryClient.setQueryData(agentKeys.list(false), [ { id: BOT, name: "Personal Assistant" }, ]); diff --git a/app/tests/audit-table-fits.test.tsx b/app/tests/audit-table-fits.test.tsx index acd5c3d974..a85d8ce2bd 100644 --- a/app/tests/audit-table-fits.test.tsx +++ b/app/tests/audit-table-fits.test.tsx @@ -11,7 +11,7 @@ import { cleanup, render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { AuditPage } from "@/components/manage/audit-page"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; +import { seedAuditPage } from "./seed-audit-page"; /** * The Audit page in the Desktop's default window, about 1000px wide with Settings' own column @@ -80,7 +80,7 @@ async function renderAudit() { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, }, }); - queryClient.setQueryData([...auditKeys.all, ""], { events: ROWS }); + seedAuditPage(queryClient, "", { events: ROWS }); queryClient.setQueryData(agentKeys.list(false), [ { id: "bot-a", name: "Claude Agent SDK" }, ]); diff --git a/app/tests/audit-table.test.tsx b/app/tests/audit-table.test.tsx index be5dfa6b35..70526b6621 100644 --- a/app/tests/audit-table.test.tsx +++ b/app/tests/audit-table.test.tsx @@ -17,7 +17,7 @@ import { } from "@/components/audit/audit-cells"; import { AuditPage } from "@/components/manage/audit-page"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; +import { seedAuditPage } from "./seed-audit-page"; /** * Admin → Audit as a log viewer: one window tall, the table across the whole pane and the only thing @@ -72,7 +72,7 @@ async function renderAudit(sections?: ReactNode) { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, }, }); - queryClient.setQueryData([...auditKeys.all, ""], { events: ROWS }); + seedAuditPage(queryClient, "", { events: ROWS }); queryClient.setQueryData(agentKeys.list(false), []); const router = createRouter({ routeTree: createRootRoute({ diff --git a/app/tests/bot-activity-actions.test.tsx b/app/tests/bot-activity-actions.test.tsx index f1d2fe65aa..e3c94bf88d 100644 --- a/app/tests/bot-activity-actions.test.tsx +++ b/app/tests/bot-activity-actions.test.tsx @@ -16,10 +16,10 @@ import { withoutOpenBotWorking, } from "@/components/manage/audit-page"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; import { authKeys } from "@/lib/auth/queries"; import { botLifecycleKeys } from "@/lib/bot-lifecycle/queries"; import { deploymentKeys } from "@/lib/deployment/queries"; +import { seedAuditPage } from "./seed-audit-page"; import { settleReactWork } from "./settle-react-work"; /** @@ -41,7 +41,7 @@ async function renderWith( options: { edition: "personal" | "team"; role: "admin" | "user"; - audit?: Record; + audit?: Record; ready?: string; }, ) { @@ -67,7 +67,7 @@ async function renderWith( { id: BOT, name: "Claude Agent SDK" }, ]); for (const [search, events] of Object.entries(options.audit ?? {})) - queryClient.setQueryData([...auditKeys.all, search], { events }); + seedAuditPage(queryClient, search, { events }); const router = createRouter({ routeTree: createRootRoute({ component: () => ui }), history: createMemoryHistory({ initialEntries: ["/"] }), diff --git a/app/tests/bot-id-never-shown.test.tsx b/app/tests/bot-id-never-shown.test.tsx index b9cde04147..1c91cd23a2 100644 --- a/app/tests/bot-id-never-shown.test.tsx +++ b/app/tests/bot-id-never-shown.test.tsx @@ -14,7 +14,7 @@ import { ChannelAvatar } from "@/components/channels/avatar"; import { AuditPage } from "@/components/manage/audit-page"; import { useBotNames } from "@/lib/agents/bot-names"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; +import { seedAuditPage } from "./seed-audit-page"; import { settleReactWork } from "./settle-react-work"; /* @@ -57,7 +57,7 @@ function everythingOn(root: Element): string { } async function renderActivity(queryClient: QueryClient) { - queryClient.setQueryData([...auditKeys.all, ""], { + seedAuditPage(queryClient, "", { events: [ { id: "e1", diff --git a/app/tests/personal-says-openbot.test.tsx b/app/tests/personal-says-openbot.test.tsx index 82da417c68..f2ec65b40e 100644 --- a/app/tests/personal-says-openbot.test.tsx +++ b/app/tests/personal-says-openbot.test.tsx @@ -15,7 +15,6 @@ import { } from "@/components/manage/audit-page"; import { PluginPage } from "@/components/manage/plugin-page"; import { agentKeys } from "@/lib/agents/queries"; -import { auditKeys } from "@/lib/audit/queries"; import { deploymentKeys } from "@/lib/deployment/queries"; import { managePaths } from "@/lib/manage-paths"; import { @@ -28,6 +27,7 @@ import { pluginKeys, } from "@/lib/plugins/queries"; import { Route as ActivityRoute } from "@/routes/_authed/settings/_personal/activity"; +import { seedAuditPage } from "./seed-audit-page"; import { settleReactWork } from "./settle-react-work"; /* @@ -133,8 +133,9 @@ function draw(edition: "team" | "personal", screen: () => ReactNode) { client.setQueryData(agentKeys.list(false), []); client.setQueryData(agentKeys.list(true), []); // Activity asks without OpenBot's own working; Admin asks for everything. - client.setQueryData( - [...auditKeys.all, edition === "personal" ? withoutOpenBotWorking("") : ""], + seedAuditPage( + client, + edition === "personal" ? withoutOpenBotWorking("") : "", { events: [startedByOpenBot] }, ); const router = createRouter({ diff --git a/app/tests/seed-audit-page.ts b/app/tests/seed-audit-page.ts new file mode 100644 index 0000000000..080339c213 --- /dev/null +++ b/app/tests/seed-audit-page.ts @@ -0,0 +1,18 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { auditKeys } from "@/lib/audit/queries"; + +/** + * One page of the trail in the cache, as `GET /api/admin/audit-events` answers it (`{ events }`, and + * `nextCursor` when older events remain), under the key the page asks for the view `search` with. + * The page reads it as the first page of its paged query. + */ +export function seedAuditPage( + queryClient: QueryClient, + search: string, + page: { events: unknown[]; nextCursor?: string }, +): void { + queryClient.setQueryData(auditKeys.list(search), { + pages: [page], + pageParams: [""], + }); +}