Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 39 additions & 7 deletions app/src/components/audit/audit-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ const FACETS = [
export function AuditTable({
deploymentName = "this deployment",
events,
footer,
moreToLoad = false,
nameFor,
query,
storedNames = false,
Expand All @@ -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;
Expand Down Expand Up @@ -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[]) ?? [];
Expand Down Expand Up @@ -217,13 +224,24 @@ export function AuditTable({
</TableRow>
);
})}
{footer && rows.length > 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell
className="p-0"
colSpan={columns.length - UNDRAWN.size}
>
{footer}
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
{events.length > 0 && rows.length === 0 ? (
<div className="px-4">
<PageEmpty icon={IconSearch} title="No matches">
Nothing loaded matches this search and these filters.
</PageEmpty>
{footer}
</div>
) : null}
</>
Expand Down Expand Up @@ -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(
Expand Down
34 changes: 29 additions & 5 deletions app/src/components/manage/audit-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -211,14 +212,37 @@ export function AuditPage({
<div
className={children ? "flex h-[60vh] min-h-80 flex-col" : "contents"}
>
{events.isPending ? null : events.isError ? (
{events.isPending ? null : events.isError &&
!events.isFetchNextPageError ? (
<p className="px-4 text-destructive text-sm" role="alert">
Couldn't load these events. Reload the page.
</p>
) : (
<AuditTable
deploymentName={paths.deploymentName}
events={rows}
footer={
events.hasNextPage ? (
<div className="flex flex-col items-center gap-2 py-3">
{events.isFetchNextPageError ? (
<p className="text-destructive text-sm" role="alert">
Couldn't load older events. Try again.
</p>
) : null}
<Button
disabled={events.isFetchingNextPage}
onClick={() => events.fetchNextPage()}
size="sm"
variant="outline"
>
{events.isFetchingNextPage
? "Loading…"
: "Show older events"}
</Button>
</div>
) : null
}
moreToLoad={events.hasNextPage}
nameFor={nameFor}
query={query}
storedNames={edition !== "personal"}
Expand Down
40 changes: 32 additions & 8 deletions app/src/lib/audit/queries.ts
Original file line number Diff line number Diff line change
@@ -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<AuditEventsPage> => {
// `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,
});
}

Expand Down
143 changes: 143 additions & 0 deletions app/tests/activity-older-events.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <Page /> }),
history: createMemoryHistory({ initialEntries: ["/"] }),
});
const view = render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);

// 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(),
);
});
7 changes: 4 additions & 3 deletions app/tests/activity-page-name.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/*
Expand Down Expand Up @@ -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), []);
Expand Down
Loading
Loading